Compare commits

..
193 Commits
Author SHA1 Message Date
sandrodachandClaude Opus 5.5 106a34d260 test(frontend): set up Vitest with jsdom and run tests in the image build
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 43s
- Add vitest, jsdom and Testing Library as dev dependencies,
  replace the placeholder test:run script with vitest run.
- Add outliner tests for Backspace merging and caret placement on click.
- Run the frontend tests in the Dockerfile before the Vite build.
- Update docs (README, architecture, FLAW-19).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:25:10 +01:00
sandrodachandClaude Opus 5.5 2b5291dc7b fix(workspace): caret at click position, no grey row highlight, hover-only collapse toggles
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
- Clicking into a bullet places the caret where the mouse was, mapped
  from the rendered markdown layer back to the raw text, instead of
  always jumping to the end.
- Remove the grey background on focused and hovered rows and on the
  bullet handle.
- Collapse/expand chevrons are only shown while hovering the row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:19:35 +01:00
sandrodachandClaude Sonnet 5.5 b4c8577a61 fix(workspace): make Backspace merge work for untyped blocks and blocks with children
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 57s
The merge only applied to a fixed list of block types, so legacy blocks
without a type were ignored, and blocks with children were rejected.
Now only code/divider/table/progress are excluded and children move up
under the previous block. The focused bullet dot is also neutral now.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 08:53:35 +01:00
sandrodachandClaude Sonnet 5.5 c7e05b385c feat(workspace): merge bullet on Backspace, render hashtags inline
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m8s
- Backspace at the start of a non-empty bullet appends its text to the
  previous visible bullet (skipped for blocks with children and for
  code/divider/table/progress blocks).
- Hashtags are rendered inline as clickable chips instead of a badge row
  below the text, so lines no longer grow taller.
- Targeted/highlighted bullets use a neutral background instead of accent.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 08:31:25 +01:00
sandrodachandClaude Sonnet 5.5 bc13235da6 docs: rewrite documentation and README from current codebase, add flaw analysis
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 8s
Remove outdated claims (CRDT, DocRooms, presence, .md files on disk) and
document the actual architecture, API, permissions and known flaws.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 23:09:05 +01:00
sandrodachandClaude Sonnet 5.5 4e9c4ffa8c feat(tasks): add read-only sharing permission for task lists
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m5s
Task list shares now carry a permission (editor/viewer). The server rejects
writes from viewers with 403, and the UI hides or disables edit controls
for read-only lists.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 22:30:28 +01:00
sandrodach 6896a29285 chore(workspace): remove placeholder text from first bulletpoint
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
2026-10-27 21:48:43 +01:00
sandrodach 554029a099 fix(workspace): group text and code blocks cleanly under headings on paste
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 58s
2026-10-27 21:40:51 +01:00
sandrodach c85f31eee5 fix(workspace): enable clicking into empty bulletpoint to focus cursor
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
2026-10-27 21:33:18 +01:00
sandrodach 1c71b09f20 feat(workspace): hide bullet point on empty blocks unless hovered
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m0s
2026-10-27 21:29:05 +01:00
sandrodach 6a261e3453 feat(workspace): intelligent multi-line clipboard paste with markdown prefix & indentation parsing
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s
2026-10-27 21:27:01 +01:00
sandrodach fdbf691a8d feat(workspace): split bulletpoint paragraph at cursor position on Enter
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
2026-10-27 21:17:12 +01:00
sandrodach 5cf6b59377 feat(tasks): refine enhanced task linking, outliner badge positioning, completion sync and unlink behavior
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
2026-10-27 21:09:25 +01:00
sandrodach 6129681035 chore(logging): remove excessive console.log and console.info statements in frontend
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
2026-10-27 20:57:05 +01:00
sandrodach 63a5885155 fix(workspace): place cursor at end of line when jumping or clicking into bullet point
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
2026-10-27 20:55:07 +01:00
sandrodach fd95c2f8da fix(api): add getThought endpoint and frontend api method to resolve docker tsc build error
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m0s
2026-10-27 20:36:58 +01:00
sandrodach 94acb2638e fix(workspace): fix outliner cursor focus, enhanced task linking, nav order and Hauptebene drop zone
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 19s
2026-10-27 20:26:45 +01:00
sandrodach d39d1d1122 feat: complete workspace, triage, outliner and sharing overhaul (tasks 1-26)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 57s
2026-10-27 19:46:46 +01:00
sandrodach df2f2bac67 feat(auth): log successful and failed login attempts with username and details
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 39s
2026-10-27 17:53:42 +01:00
sandrodach bce103e2c6 feat(outliner): prevent creating extra empty block on enter on last empty root bullet
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
2026-10-27 17:43:20 +01:00
sandrodach 122ebccf8d refactor(outliner): make bullet point the drag handle and remove separate grip icon
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s
2026-10-27 17:41:25 +01:00
sandrodach e7eb94be45 fix(tasks): allow collaborative editor sharing in task lists
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m1s
2026-10-27 17:35:47 +01:00
sandrodach 2ecf71cc42 feat(lists): use unified modal for creating new shopping lists
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m1s
2026-10-27 17:33:20 +01:00
sandrodach fd89f92b0c feat(outliner): outdent empty bulletpoint on enter
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s
2026-10-27 17:31:08 +01:00
sandrodach 04fa774d10 refactor(collab): remove live document collaboration and enforce strictly read-only sharing
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s
2026-10-27 17:08:38 +01:00
sandrodach aea05e868a feat(outliner): indent and outdent entire subtree with all children on Tab and Shift+Tab
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s
2026-10-27 16:06:31 +01:00
sandrodach 1bdd7d245d fix(outliner): move entire subtree with all children during drag and drop
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m4s
2026-10-27 16:00:03 +01:00
sandrodach 266026da4c fix(workspace): make note editor use full available width instead of centering at max-w-4xl
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m1s
2026-10-27 15:49:40 +01:00
sandrodach 183199401d fix(collab): continuous document text synchronization preserving local cursor
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s
2026-10-27 15:48:17 +01:00
sandrodach ee8d586e2f style(collab): right-align lock badge without shifting lines and highlight locked block with grey background
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
2026-10-27 15:33:38 +01:00
sandrodach a1c7a65d84 fix(collab): fix corporate SSE fallback, realtime lock propagation and prevent stale overwrite
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m1s
2026-10-27 15:21:05 +01:00
sandrodach 996f27133c fix(collab): fix self-lockout, stuck live sessions, block locking propagation and reactive hashtags
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 58s
2026-10-27 14:57:30 +01:00
sandrodach 0e06cc5577 feat(collab): redesign note collaboration with REST auto-save, opt-in live session, and block locking
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
2026-10-27 14:02:38 +01:00
sandrodach 50b017f387 fix(security): resolve FLAW-08 with token_version invalidation and jwt blacklist
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m0s
2026-10-27 12:09:15 +01:00
sandrodach f7e70b649f style: expand Workspace To-Dos and Hashtags views to full container width
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m1s
2026-10-27 11:54:56 +01:00
sandrodach 8769aceb05 fix: instant graceful shutdown for SSE/WS connections and container stop
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 39s
2026-10-27 11:50:19 +01:00
sandrodach f74ff7c63b feat(workspace): hide folder sidebar in todos and tags, keep toggle button fixed and disabled
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 57s
2026-10-27 11:33:47 +01:00
sandrodach 37948d3a6a feat(workspace): move connection status indicator to top tabs header
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 57s
2026-10-27 11:32:25 +01:00
sandrodach 3d780856b0 refactor(workspace): remove duplicate new note button from top tabs
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 56s
2026-10-27 11:11:44 +01:00
sandrodach 8ee7b135af style(tasks,lists): align header spacing with workspace by eliminating top margin
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 58s
2026-10-27 11:07:36 +01:00
sandrodach 894a2e3b76 style(workspace): move sidebar toggle to the left as icon-only button
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 57s
2026-10-27 11:02:28 +01:00
sandrodach 59ddca91f3 feat(workspace): align workspace UI with tasks and lists, add top tabs and collapsible sidebar
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 58s
2026-10-27 10:55:42 +01:00
sandrodach 7baab8faa2 fix(workspace): eliminate hover height shift on folder tree row with fixed height and opacity fade
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 58s
2026-10-27 08:49:08 +01:00
sandrodach 5a931e9bb6 style(workspace): style selected note with panel background and accent border matching task list selection
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 57s
2026-10-27 08:47:58 +01:00
sandrodach 60fd1c9ae7 feat(workspace): add hashtag tabs matching tasks view style and interactive tile grid with search and sorting
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 57s
2026-10-27 08:46:10 +01:00
sandrodach 23eff21520 style(workspace): style '+ Notiz' button in sidebar matching 'Neue Liste' dashed style
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 57s
2026-10-27 08:41:09 +01:00
sandrodach d11153309f fix(frontend): resolve TS2448 filteredDocs TDZ and import missing DocumentBlock in WorkspaceView
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
2026-10-27 08:14:39 +01:00
sandrodach 7a8d759bf2 feat(workspace): real-time live child block resolution and interactive checkboxes in hashtag overview
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 18s
2026-10-27 07:27:12 +01:00
sandrodach 149cfdc244 fix(workspace): select and focus exact block line when jumping from todos or tags
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 18s
2026-10-27 07:23:23 +01:00
sandrodach 928cc72491 feat(workspace): enhance todo view switcher and add note-level collapsible cards in folder_note mode
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 17s
2026-10-27 07:21:31 +01:00
sandrodach 5144f7b01b fix(workspace): ensure immediate cursor autofocus and select on note title creation
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 19s
2026-10-27 07:19:47 +01:00
sandrodach ffa14b01a2 style(workspace): remove any border or box from doc title emoji
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 17s
2026-10-27 07:17:27 +01:00
sandrodach d3dd4c8cc6 fix(workspace): refine folder row drop target, depth-first tree sorting & auto-expansion
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 19s
2026-10-27 07:16:20 +01:00
sandrodach 27b3567008 style(workspace): align LinkModal 1:1 with UnifiedShareModal design language
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 17s
2026-10-27 07:11:28 +01:00
sandrodach 4850f9dfad feat(workspace): recursive folder tree, link modal redesign, todo grouping & line jumping, hashtag child listing
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 23s
2026-10-27 07:09:48 +01:00
sandrodach 6c1898d489 feat(workspace): decouple folder sharing with independent mount points & real-time sync
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 58s
2026-10-26 22:53:51 +01:00
sandrodach 1776e87029 docs: update database schema, websocket protocol, workspace outliner spec, flaws backlog, and readme to match current architecture
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 8s
2026-10-26 22:41:50 +01:00
sandrodach 634195b629 style(workspace): align UI styling with ListsView (rounded-full avatars, dark canvas borders, unified Radio live badge, unified Users share button)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 56s
2026-10-26 22:36:32 +01:00
sandrodach cedf5aebff fix(sync): eliminate zombie focus, flush debounced state on window blur, remove stale closures and buffer ws messages
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 57s
2026-10-26 22:27:11 +01:00
sandrodach 06f5f7043c feat(workspace): add /link slash command with note selector and web link modal
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 58s
2026-10-26 22:19:53 +01:00
sandrodach 1bcf6b4f18 refactor(outliner): remove redundant /bullet slash command
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 58s
2026-10-26 22:14:20 +01:00
sandrodach 48fe61fbce feat(logging): integrate slog logging with env control and request/sync tracing
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 39s
- Configure structured slog logger in cmd/server/main.go via LOG_LEVEL and DEBUG env vars
- Add HTTP API request logging middleware in internal/api/ with status and duration_ms
- Add WebSocket join/leave, event reception, and debounced SQLite flush tracing in docroom
- Document LOG_LEVEL and DEBUG settings in .env.example, compose.yaml, and docker-compose.yml
2026-10-26 22:12:51 +01:00
sandrodach 177e24fdc8 feat(workspace): add focus mode and hoisting zoom-in for outliner blocks
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 58s
- Click bullet point (•) or hover zoom icon to zoom into any block branch
- Render hoisted block as prominent heading in focus mode with direct editing
- Display clickable breadcrumb path leading back to ancestors or root document
- Render isolated subtree with visual level offset and bound indentation
2026-10-26 22:04:53 +01:00
sandrodach 95977ba15a feat(workspace): add real-time live cursor and presence tracking
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 56s
- Support ephemeral cursor_moved and client_left events in docroom Hub
- Keep active in-memory cursor presence map per document room
- Synchronize current presence state to newly connected WebSocket clients
- Allow read-only viewers to share cursor positions without document mutation rights
- Throttle frontend cursor move emissions to 100ms with instant blur cleanup
- Render active collaborator presence avatar pills in document header
- Display user-colored block indicator and non-destructive caret overlay with name tag
2026-10-26 21:57:19 +01:00
sandrodach 85e4010435 fix(workspace): restore theme design-system, fix 5x note creation, enforce privacy isolation, fix shared write permission, add drag-drop & slash-commands
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 57s
2026-10-26 21:41:27 +01:00
sandrodach 86867b0d6f feat(workspace): tabula-rasa outliner architecture, SQLite blocks/block_tags schema & echo-protected live room sync
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m12s
2026-10-26 21:14:06 +01:00
sandrodach f3b3f07e04 refactor(crdt): implement proxy-resilient Yjs dual-transport sync with indexeddb offline-first and debounced sqlite persistence
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m11s
2026-10-26 19:54:51 +01:00
sandrodach 297f3ed111 docs: remove non-existent flaw FLAW-09 (virtual keyboard viewport)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 8s
2026-10-26 19:14:21 +01:00
sandrodach f90b0d5eb9 fix(realtime): eliminate cpu reconnect loop, singleton global connection & fix disappearing notes
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m31s
2026-10-26 19:08:45 +01:00
sandrodach 6343d62bab feat(realtime): implement SSE fallback, dual-transport hub & proxy resilience (resolves FLAW-03)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
2026-10-26 16:55:11 +01:00
sandrodach 80bb74b5b2 fix(workspace): fix cursor jump while typing by preventing useLayoutEffect from overriding active element cursor
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m6s
2026-10-26 16:00:52 +01:00
sandrodach 53e1ba322d fix(frontend): resolve TypeScript compiler errors in WorkspaceView and types
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m0s
2026-10-26 15:39:30 +01:00
sandrodach 58f02e0927 fix(workspace): enable automatic multi-line text wrapping for note blocks without horizontal scrolling
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 20s
2026-10-26 15:35:33 +01:00
sandrodach ef8cf8f022 feat(workspace): implement Yjs CRDT real-time collaborative editing (resolves FLAW-01)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 29s
2026-10-26 15:18:48 +01:00
sandrodach 0bdeb5f320 fix(docs/storage): resolve FLAW-02, remove unused docDir and update documentation for pure database architecture
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 39s
2026-10-26 14:31:54 +01:00
sandrodach 3480859d8a docs: add comprehensive technical wiki documentation and flaws analysis; remove legacy BRAIN.md
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 8s
2026-10-26 13:27:35 +01:00
sandrodach 10d5c6d563 feat(mobile): align triage quick dump modal design with task creation modal
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s
2026-10-26 13:19:22 +01:00
sandrodach 0e06f6761d feat(webhook): ensure webhook source badge displays for triage thoughts and tasks
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m0s
2026-10-26 13:12:57 +01:00
sandrodach baac20ea10 feat(mobile): simplify triage quick dump capture and convert priority to 4 buttons across tasks
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m4s
2026-10-26 12:56:11 +01:00
sandrodach 832f2f9c2b style(workspace): remove large editing lock banner to prevent layout shift
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m1s
2026-10-26 12:27:54 +01:00
sandrodach 155d2daa02 fix(workspace): normalize websocket headers for reverse proxies and show detailed disconnect reason
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m4s
2026-10-26 12:06:55 +01:00
sandrodach c2ed76c6a4 fix(workspace): add http polling fallback for proxy/firewall environments and exponential ws reconnect backoff
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s
2026-10-26 11:42:57 +01:00
sandrodach e088ef6974 fix(workspace): dedicated reconnecting websocket, restore editing lock blocking & amber banner, broadcast live doc/folder moves
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m1s
2026-10-26 11:29:03 +01:00
sandrodach ed673b0707 feat(workspace): migrate notes, blocks, and folders to pure database-first architecture with atomic block patching and live sync
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s
2026-10-26 11:05:09 +01:00
sandrodach 2f92e1f49a feat(workspace): live synchronisation, verbindungsindikator, leere ordner persistenz und sperrkonfliktschutz
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m0s
2026-10-26 10:18:02 +01:00
sandrodach 35fd682fcd feat(workspace): überarbeite geteilte Notizen und ganze Ordner teilen
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m1s
2026-10-26 08:26:24 +01:00
sandrodach 3fcd164fd8 fix(workspace): center floating file explorer dialog on mobile
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m4s
2026-10-26 07:19:55 +01:00
sandrodach f2ead7513a feat(mobile): declutter mobile views with contextual FAB, filter sheet, and clean outliner
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 58s
2026-10-25 22:03:28 +01:00
sandrodach 1e47f3aeb7 fix(mobile): render quick-capture options inline inside bottom sheet modal
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 57s
2026-10-25 21:53:04 +01:00
sandrodach e8723cbf82 feat(mobile): separate new task button to top and add dedicated list management group
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
2026-10-25 21:46:30 +01:00
sandrodach f9ef03af7f fix(workspace): delete empty bulletpoint on first backspace, move descendants on indent, preserve subpoints on backspace, and prompt on x button
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s
2026-10-25 21:45:10 +01:00
sandrodach c420155dc0 fix: apply theme border-radius to list delete button in tasks and shared lists
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m0s
2026-10-25 21:39:20 +01:00
sandrodach 444442fdf0 feat: integrate user profile pictures with circular avatar display and upload in settings
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 57s
2026-10-25 21:37:06 +01:00
sandrodach 8b043c4809 fix(ui): prevent modal overlays from inheriting margin-top from parent space-y containers
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 57s
2026-10-25 21:20:57 +01:00
sandrodach 212cb32bb0 feat(mobile): add context-sensitive MindSift logo menu, slide-over file drawer, and larger touch targets
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 58s
2026-10-25 21:08:48 +01:00
sandrodach cb0999325f style: replace upload-style share icon with two-person users icon across all views
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m3s
2026-10-25 20:45:05 +01:00
sandrodach e389c7bebc refactor(tasks): simplify create task list modal by removing share dropdown
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 57s
2026-10-25 20:41:27 +01:00
sandrodach 634fdcac11 fix(workspace): lock document immediately upon dragging bullet point
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
2026-10-25 20:39:55 +01:00
sandrodach f1af10ce1c fix(lists): prevent duplicate list item rendering on create
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
2026-10-25 20:05:43 +01:00
sandrodach 5cfa390ee0 docs: add application screenshots and update README with outliner zoom, kanban subtasks, and group sharing
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 9s
2026-10-25 19:52:56 +01:00
sandrodach 0008e9c420 feat(workspace): activate write lock immediately on bullet drag-and-drop start
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 58s
2026-10-25 19:33:25 +01:00
sandrodach 09725611b5 refactor: improve drag and drop UX across tasks, lists, and workspace bullets
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
2026-10-25 19:24:29 +01:00
sandrodach 35d4657a25 feat: add drag-and-drop for tasks, workspace bullets, and list items
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 57s
2026-10-25 19:06:02 +01:00
sandrodach c0661cddae feat(lists): case-insensitive alphabetical hashtag sorting and autocomplete
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 58s
2026-10-25 18:37:11 +01:00
sandrodach 3b4a8b125b style(lists): align list design with tasks view using square checkboxes, white hashtag headers on canvas, and panel-surface cards
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
2026-10-25 18:27:38 +01:00
sandrodach 0d6c603e9c refactor: remove legacy spaces and migrate lists to standalone resources with direct sharing
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
2026-10-25 18:17:17 +01:00
sandrodach 177b69234c feat: unify sharing modal and clean up team share duplication in lists
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 57s
2026-10-25 17:41:18 +01:00
sandrodach d45b3cd0b3 fix(teams): ensure dark theme styling on group member role select dropdown
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
2026-10-25 17:19:20 +01:00
sandrodach e37302f73c feat: overhaul groups management, remove global team_admin and isolate group administration
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s
2026-10-25 17:14:15 +01:00
sandrodach 60c302e23e refactor(workspace): remove redundant hashtag filter pills from tags header
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 58s
2026-10-25 16:27:59 +01:00
sandrodach 9ab9af3a9e refactor(lists): remove redundant space tabs to match tasks tab bar
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
2026-10-25 16:27:01 +01:00
sandrodach e2772d99ea fix(types): resolve TypeScript compilation errors in frontend build
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m0s
2026-10-25 16:13:12 +01:00
sandrodach 354129b2d4 feat: unify sharing UI, redesign lists header, fix team sharing, and add user autocomplete
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 18s
2026-10-25 16:04:08 +01:00
sandrodach 6d07352e80 fix(workspace): use theme input-field styling on hashtag note textarea instead of invalid opacity modifier
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m1s
2026-10-25 15:22:39 +01:00
sandrodach 646ed6d960 fix(workspace): support creating sub-bullets and clicking empty canvas in zoomed bullet views
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m3s
2026-10-25 15:19:06 +01:00
sandrodach 518a0903dd fix(workspace): support multi-line bullets via Shift+Enter and preserve lines on Enter
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m0s
2026-10-25 15:11:11 +01:00
sandrodach b0403e09bc feat(workspace): stack hashtag back button above heading and add dedicated hashtag markdown note (#tag.md)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
2026-10-25 14:58:54 +01:00
sandrodach f1d9daca60 ui(help): align HelpView page width with other views and remove header back button
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m0s
2026-10-25 14:41:19 +01:00
sandrodach eafe75faf0 feat(workspace): live inline hashtag and link rendering with cursor-aware unwrapping
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
2026-10-25 14:38:33 +01:00
sandrodach 4874b79439 style(triage): rename Als Aufgabe button to Aufgabe with wider flex layout and nowrap
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m1s
2026-10-25 14:25:26 +01:00
sandrodach b9d5529648 style(header): remove logo subtitle and center instance title next to icon
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m0s
2026-10-25 14:23:22 +01:00
sandrodach b59bd3a265 fix(storage): drop legacy organizations foreign key from teams table and ensure team creation always succeeds
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 38s
2026-10-25 14:21:14 +01:00
sandrodach b3e3fa27c4 feat(pwa): offer immediate mobile Web-App installation sheet and header install button
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s
2026-10-25 14:15:55 +01:00
sandrodach c02c7d20de feat(mobile): center bottom + FAB, floating header with left logo & right avatar, long-press triage sheet, and large swipeable queue
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m1s
2026-10-25 14:10:13 +01:00
sandrodach 90a6062c7a feat(workspace): render links and rich tokens inline inside blocks with direct edit support
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
2026-10-25 14:03:38 +01:00
sandrodach 7c47f245e2 feat(sharing): remove code redeem, unify Teilen button and team/user sharing across lists and notes
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 58s
2026-10-25 13:51:42 +01:00
sandrodach 20c50aa8d6 feat(workspace): collapsible pinned & tags in sidebar, dedicated themen & hashtags tab with search and overview
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m1s
2026-10-25 13:32:31 +01:00
sandrodach 332dbb456a fix(workspace): remove explicit add button and keep empty space click-to-add bullet
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m0s
2026-10-25 13:16:14 +01:00
sandrodach b5df4a34b4 feat(tasks): customizable kanban columns, task detail page, hashtag filtering and clean task list
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m0s
2026-10-25 13:13:11 +01:00
sandrodach 0afa2f9c2d feat(tasks): enable sharing task lists with teams and users
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m6s
2026-10-25 13:00:17 +01:00
sandrodach cfc0477b5c feat(settings): simplify team management into flat teams and groups
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
2026-10-25 12:42:45 +01:00
sandrodach 1a37ba9342 fix(workspace): remove large lock warning banner to eliminate layout shift
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m0s
2026-10-25 12:34:53 +01:00
sandrodach 107361bb0f feat(mobile): implement mobile bottom bar with centered quick-add FAB and mobile profile sheet
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
2026-10-25 12:33:33 +01:00
sandrodach bc41f7896d fix(triage): silently convert thought to workspace note without interrupting triage flow
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 57s
2026-10-25 12:28:16 +01:00
sandrodach e2b20198e4 feat(workspace): add syntax highlighting and language selector to /code blocks
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m7s
2026-10-25 12:25:04 +01:00
sandrodach 31847ffaa3 fix(workspace): auto-create bullet below divider and allow click to create bullet at bottom
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
2026-10-25 12:20:27 +01:00
sandrodach d34178aa1a feat(workspace): add slash command link menu for internal notes and web links
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 58s
2026-10-25 12:14:03 +01:00
sandrodach 1310f8102e feat(workspace): add hashtag indexing, tag pages and bullet group view
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m0s
2026-10-25 12:04:53 +01:00
sandrodach c4b2a00348 feat(workspace): synchronize folders with server and add folder deletion
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 58s
2026-10-25 11:50:34 +01:00
sandrodach 1be0909344 feat(workspace): make sharing and permission badges clickable to open share modal
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 57s
2026-10-25 11:40:49 +01:00
sandrodach 1009487cce fix(theme): style share modal and inputs for dark mode compatibility
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 58s
2026-10-25 11:39:29 +01:00
sandrodach 0affb827a4 feat(workspace): confirm deletion of notes and bullets with nested subpoints
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 56s
2026-10-25 11:26:36 +01:00
sandrodach 920716be25 fix(workspace): streamline write lock release, sync, and active editor badge
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m4s
2026-10-25 11:21:46 +01:00
sandrodach 51244c736c feat(lists): replace category buttons with dynamic hashtag categories and group open items by hashtag
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
2026-10-25 11:09:47 +01:00
sandrodach 7bc5fbe001 feat(lists): add direct user invitation to shared lists and streamline live-sync indicator
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 56s
2026-10-25 11:05:03 +01:00
sandrodach 6e09888a44 fix(tasks): eliminate duplicate task entries by stabilizing WS listener and deduplicating task state
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 55s
2026-10-25 10:52:34 +01:00
sandrodach af7a9eb200 feat(workspace): per-user note pinning, real-time unlock synchronization, and icon-only share button
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 56s
2026-10-25 10:39:52 +01:00
sandrodach ec79a2493e fix(workspace): auto-scroll slash command popup when navigating with arrow keys
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 55s
2026-10-25 10:28:24 +01:00
sandrodach 290848a93e feat(navigation): count open workspace todos and style tab badges as notification dots
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 56s
2026-10-25 10:25:21 +01:00
sandrodach 1168c7e2ea fix(workspace): remove boilerplate placeholder text from block inputs
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 57s
2026-10-25 10:17:32 +01:00
sandrodach 714a8551c7 fix(workspace): automatically refocus text field and place cursor at end after slash commands
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
2026-10-25 10:16:00 +01:00
sandrodach 38e9c28c63 fix(setup): add password confirmation field to first-boot setup wizard
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 55s
2026-10-25 10:13:49 +01:00
sandrodach 7f312fef14 feat(auth): add MINDA_DEMO support with auto-seeded demo accounts and username login
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
2026-10-25 10:12:13 +01:00
sandrodach 41c64c4924 feat(workspace): add 500ms debouncing to note typing persistence
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 53s
2026-10-25 02:36:02 +02:00
sandrodach d338c4cb8a feat(workspace): add drag and drop support for folders
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 52s
2026-10-25 02:30:59 +02:00
sandrodach 9b21fd92e1 fix(workspace): remove bullet placeholder and delete indented bullets directly on backspace
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 51s
2026-10-25 02:27:52 +02:00
sandrodach d49ef7971d fix(workspace): reliably clear drag states on drop and dragend
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 50s
2026-10-25 02:25:53 +02:00
sandrodach 84e0b6a4bd feat: implement live-sync for tasks, workspace note sharing, and document locking system
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 53s
2026-10-25 02:23:46 +02:00
sandrodach dccf1a91e3 feat(workspace): format origin callout date and time when converting thought to document
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 47s
2026-10-25 01:59:23 +02:00
sandrodach 1ada978f9e fix(triage): clean default blocks when converting thought/todo to workspace document
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 38s
2026-10-25 01:57:32 +02:00
sandrodach 3c550373eb feat(workspace): default new notes to blank with title 'Neue Notiz' and remove bottom bullet button
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 54s
2026-10-25 01:51:23 +02:00
sandrodach 05de76cce4 style(header): dock logo and profile to top while quick dump expands downward
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 52s
2026-10-25 01:48:10 +02:00
sandrodach 577893172b fix(tasks): preserve linebreaks in task notes display with whitespace-pre-wrap
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 56s
2026-10-25 01:45:48 +02:00
sandrodach 0ec1a8b7f7 feat(tasks): support subtasks with inline creation, checklist and title progress bar
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 54s
2026-10-25 01:43:14 +02:00
sandrodach 537b916533 fix(nav): rename tab from 'Geteilte Listen' to 'Listen'
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 53s
2026-10-25 01:31:11 +02:00
sandrodach 63db1a0f90 fix(nav): move borderless help button to tab navigation bar
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 56s
2026-10-25 01:30:10 +02:00
sandrodach e6a791bcca feat(capture): support multiline thoughts and tasks with Shift+Enter in quick dump bar
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 53s
2026-10-25 01:28:07 +02:00
sandrodach 87efb98bc1 style(header): round quick dump input bar and remove profile box outline
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 52s
2026-10-25 01:23:47 +02:00
sandrodach 14e3176e55 style(workspace): make pin button icon-only matching trash button
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 54s
2026-10-25 01:20:38 +02:00
sandrodach 275442e9e2 feat(ui): add circled question mark help button in top header rechtsbuendig
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 56s
2026-10-25 01:19:16 +02:00
sandrodach 5b5b7b1810 feat(workspace): make hoisted bullet point header editable in zoom mode
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 58s
2026-10-25 01:15:19 +02:00
sandrodach 29d202cc4e docs: update README with outliner features and add curl one-liner installer
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 7s
2026-10-25 01:12:39 +02:00
sandrodach fb805b30a1 feat(workspace): add help page, folder drag and drop, pinned notes section, and clean up editor UX
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 52s
2026-10-25 01:08:17 +02:00
sandrodach fe1d574787 feat(workspace): add logseq outliner engine, global todo rollup, task list sharing avatars and admin toggle
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m22s
2026-10-25 00:15:43 +02:00
sandrodach 2504b48171 fix(lists): pass nextCompleted argument to api.toggleListItem and make parameter optional
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m7s
2026-10-24 23:30:45 +02:00
sandrodach b3e651b3b9 feat(theme): implement single-file CSS theming engine with dark, light, high-contrast, and soft themes
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 16s
2026-10-24 23:26:31 +02:00
sandrodach 0f24cbdbf4 docs(readme): redesign README as clean product showcase without internal schema dumps
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 8s
2026-10-24 23:01:23 +02:00
sandrodach 7802eb2cf0 feat(auth,settings,tasks): add first-boot setup, real login/invites, full-page settings & admin views, multi-channel alarms, and fix null slice crashes
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s
2026-10-24 22:59:48 +02:00
sandrodach 50ac0c8f64 fix(test): use DatabasePath field in config.Config struct literal
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 38s
2026-10-24 22:13:54 +02:00
sandrodach f4a7dd5c99 fix(frontend): remove title prop from Share2 lucide icon to fix tsc type check
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 38s
2026-10-24 22:10:58 +02:00
sandrodach 4ef71b61cd feat: complete triage, multi-task lists, kanban, workspace wiki, and admin management
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 16s
2026-10-24 22:08:43 +02:00
sandrodach 5c4e130862 fix(sqlite,browser): resolve SQLite connection pool deadlock, disable stale SW cache, and update BRAIN.md
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 38s
2026-10-24 21:27:51 +02:00
sandrodach d6bc2976d9 feat(triage): implement Quick Dump notes, direct task/list assignment, and fix stale JWT user resolution
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 53s
2026-10-24 21:05:31 +02:00
sandrodach 2d4a2bde41 feat(header): move quick capture bar into header above tabs with floating options dropdown
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 52s
2026-10-24 20:13:54 +02:00
sandrodach ec9ba8fd9f feat(design): centralize design system with CSS variables and semantic classes in index.css
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 52s
2026-10-24 20:07:27 +02:00
sandrodach 94123c9424 feat(workspace): remove bottom bar, implement square monochromatic file explorer tree with subfolders
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 50s
2026-10-24 19:56:42 +02:00
sandrodach da82caf82c style: reduce padding, adopt crisp square tabs/cards, keep round buttons and single accent
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 51s
2026-10-24 19:43:59 +02:00
sandrodach 3263b07e72 ci: run all checks and tests directly inside container build and push immediately
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m37s
2026-10-24 19:18:43 +02:00
sandrodach 2e7deee9bf style: convert top navigation to grouped monochromatic segmented tabs
MindSift CI & Docker Release / Lint, Build & Unit Tests (push) Successful in 2m21s
MindSift CI & Docker Release / Build & Push Docker Image (push) Successful in 1m46s
2026-10-24 19:14:30 +02:00
sandrodach b53f7c5fc9 fix(frontend): add missing Check icon import in TasksView
MindSift CI & Docker Release / Lint, Build & Unit Tests (push) Successful in 2m13s
MindSift CI & Docker Release / Build & Push Docker Image (push) Successful in 1m43s
2026-10-24 19:05:41 +02:00
sandrodach c99b6c3a49 style: align UI with mobile productivity template and remove Google/Gemini references
MindSift CI & Docker Release / Lint, Build & Unit Tests (push) Failing after 2m16s
MindSift CI & Docker Release / Build & Push Docker Image (push) Skipped
2026-10-24 18:59:00 +02:00
sandrodach 31aa43c460 style: overhaul design to authentic Google Gemini Material 3 Dark theme
MindSift CI & Docker Release / Lint, Build & Unit Tests (push) Successful in 2m8s
MindSift CI & Docker Release / Build & Push Docker Image (push) Successful in 1m45s
2026-10-24 18:32:03 +02:00
sandrodach 10323867e9 chore: switch active development to dev branch
MindSift CI & Docker Release / Lint, Build & Unit Tests (push) Successful in 2m6s
MindSift CI & Docker Release / Build & Push Docker Image (push) Successful in 1m48s
2026-10-24 18:17:03 +02:00
86 changed files with 35598 additions and 2880 deletions

No files matched your search

+12 -1
View File
@@ -4,5 +4,16 @@ DATA_DIR=/data
DATABASE_URL=sqlite:///data/mindsift.db DATABASE_URL=sqlite:///data/mindsift.db
JWT_SECRET=change_this_to_a_secure_random_key_min_32_chars JWT_SECRET=change_this_to_a_secure_random_key_min_32_chars
BASE_URL=http://localhost:8080 BASE_URL=http://localhost:8080
ALLOW_REGISTRATION=true ALLOW_REGISTRATION=false
# Logging level: debug, info, warn, error (Default: info)
LOG_LEVEL=info LOG_LEVEL=info
# LOG_LEVEL=debug
# DEBUG=true
# Optional SMTP Server Configuration for Email Alarms (can also be set in Admin Settings UI)
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
SMTP_FROM=noreply@mindsift.local
SMTP_TLS=true
+10 -46
View File
@@ -8,40 +8,8 @@ on:
- '*' - '*'
jobs: jobs:
test: build-test-push:
name: Lint, Build & Unit Tests name: Build, Test in Container & Push
runs-on: ubuntu-latest
if: github.ref != 'refs/heads/main'
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
- name: Run Backend Lint & Unit Tests
run: |
go mod tidy
go vet ./...
go test -v ./...
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Typecheck & Build Frontend
run: |
cd frontend
npm install --no-audit --no-fund
npx tsc --noEmit
npm run build
build-docker:
name: Build & Push Docker Image
needs: test
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.ref != 'refs/heads/main' if: github.ref != 'refs/heads/main'
steps: steps:
@@ -69,10 +37,7 @@ jobs:
echo "image=${IMAGE_NAME}" >> "$GITHUB_OUTPUT" echo "image=${IMAGE_NAME}" >> "$GITHUB_OUTPUT"
echo "tag=${DOCKER_TAG}" >> "$GITHUB_OUTPUT" echo "tag=${DOCKER_TAG}" >> "$GITHUB_OUTPUT"
echo "actor=${ACTOR_NAME}" >> "$GITHUB_OUTPUT" echo "actor=${ACTOR_NAME}" >> "$GITHUB_OUTPUT"
echo "Building Docker image: ${IMAGE_NAME}:${DOCKER_TAG} (Registry: ${REGISTRY_HOST}, Actor: ${ACTOR_NAME})" echo "Target Docker image: ${IMAGE_NAME}:${DOCKER_TAG} (Registry: ${REGISTRY_HOST}, Actor: ${ACTOR_NAME})"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea Container Registry - name: Login to Gitea Container Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
@@ -81,11 +46,10 @@ 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 and Push Multi-Arch Image - name: Build & Run All Checks in Container, then Push
uses: docker/build-push-action@v6 run: |
with: FULL_IMAGE="${{ steps.prep.outputs.image }}:${{ steps.prep.outputs.tag }}"
context: . echo "🚀 Building container & executing Frontend/Backend checks inside image: ${FULL_IMAGE}"
platforms: linux/amd64,linux/arm64 DOCKER_BUILDKIT=1 docker build --progress=plain -t "${FULL_IMAGE}" .
push: true echo "✅ All checks & tests passed inside container! Pushing ${FULL_IMAGE}..."
tags: | docker push "${FULL_IMAGE}"
${{ steps.prep.outputs.image }}:${{ steps.prep.outputs.tag }}
-183
View File
@@ -1,183 +0,0 @@
# 🧠 BRAIN.md — MindSift Internal Dev Cheat Sheet
> **Zweck:** Rein internes, ungeschöntes Nachschlagewerk für uns Entwickler. Keine Marketing-Texte, nur knallharte Fakten, Farbwerte, State-Machines, Namenskonventionen und Quick-Commands.
---
## 🎨 1. Design-Tokens & Gemini-Dark-Theme (Copy & Paste)
### Farbpalette (Strict Hex Values)
```css
/* Backgrounds */
--bg-app: #0b0f19; /* Tiefstes Obsidian / OLED Base */
--bg-surface: #131314; /* Leicht erhobene Panels, Sidebar */
--bg-card: #1e1f20; /* Swipe-Karten, Dialoge, Dropdowns */
--bg-card-hover: #282a2c; /* Hover-Zustände */
/* Borders & Dividers */
--border-subtle: rgba(255, 255, 255, 0.08); /* 1px Standard-Kante */
--border-focus: rgba(138, 180, 248, 0.40); /* Gemini Blue Glow */
/* Akzente & Status */
--accent-blue: #8ab4f8; /* Gemini Primärblau (Buttons, Links, Active) */
--accent-purple: #c58af9; /* Gemini Voice / Magic Glow */
--swipe-keep: #81c995; /* Swipe Rechts (Behalten / ToDo / Success) */
--swipe-discard: #f28b82; /* Swipe Links (Verwerfen / Archive / Danger) */
/* Typografie */
--text-primary: #e3e3e3; /* Haupttext (hoher Kontrast, augenschonend) */
--text-muted: #9aa0a6; /* Sekundärtexte, Metadaten, Placeholders */
--text-subtle: #5f6368; /* Timestamps, Footer, Deaktiviert */
```
### UI-Regeln & Radien
* **Buttons / Pills:** Ausnahmslos `rounded-full` (`border-radius: 9999px`).
* **Karten (Triage Deck):** `rounded-3xl` (`24px`), Padding `p-6`, Border `1px solid var(--border-subtle)`.
* **Input-Feld (Instant Capture):** `rounded-full`, Höhe `h-14` (56px) für mobile Daumenbedienung, Autofokus bei Mount.
* **Gesten-Physik (Framer Motion):**
* Swipe-Schwellenwert: `dragConstraints={{ left: -150, right: 150 }}`
* Spring-Config: `{ type: "spring", stiffness: 350, damping: 25 }`
* Swipe-Winkel-Rotation: `rotate = x * 0.1`
---
## ⚙️ 2. State-Machine: Gedanken-Lifecycle
```
[ INSTANT CAPTURE ]
│
▼
status: 'inbox'
│
┌───────────┴───────────┐
│ (Swipe Left) │ (Swipe Right)
▼ ▼
status: 'discarded' status: 'kept'
(Soft Delete) │
┌───────┴───────┐
│ (Convert) │ (Direct Task)
▼ ▼
status: 'converted' due_date != null
├── target: 'task'
├── target: 'document'
└── target: 'list_item'
```
### Gültige Status-Enums
* `inbox` – Frisch eingekippt, wartet auf Triage.
* `discarded` – Nach links gewischt (bleibt für 30 Tage im Papierkorb, dann Hard Delete).
* `kept` – Nach rechts gewischt, bleibt als To-Do oder Gedanke archiviert.
* `converted` – In vollwertiges Dokument, Aufgabe oder Listen-Item transformiert.
---
## 📡 3. WebSocket Realtime Protocol (Family Lists)
Verbindungs-URL: `wss://<host>/api/v1/ws/spaces/:spaceId`
### Nachrichten-Format (JSON)
```typescript
interface WSMessage<T = unknown> {
event: 'ITEM_CREATED' | 'ITEM_UPDATED' | 'ITEM_TOGGLED' | 'ITEM_DELETED' | 'USER_PRESENCE';
space_id: string;
list_id: string;
payload: T;
timestamp: string; // ISO 8601
}
```
### Schnelle Event-Referenz
* `ITEM_TOGGLED`: Wenn jemand die Hafermilch abhakt:
`{ "item_id": "itm_123", "is_completed": true, "completed_by": "usr_sandro" }`
* `Optimistic UI`: Frontend schlägt das Item sofort lokal durch und rollt nur bei WebSocket-Fehler zurück.
---
## 🗂️ 4. Ordnerstruktur & Code-Konventionen
```
mindsift/
├── cmd/
│ └── server/ # Go Main Entrypoint (bindet embed.FS ein)
├── internal/
│ ├── api/ # HTTP Chi Handler, Routing & Middleware
│ ├── domain/ # Entities, Interfaces & Domain Logic
│ ├── storage/ # SQLite / Postgres Adapter (sqlc generiert)
│ ├── ws/ # WebSocket Hub & Raumverwaltung
│ └── webhook/ # Smarthome Ingestion Logic
├── migrations/ # Reine .sql Migrationsdateien
├── frontend/
│ ├── src/
│ │ ├── components/ # UI Primitives (Button, Pill, Input, Modal)
│ │ ├── features/
│ │ │ ├── capture/ # Instant-Input & Voice Recording
│ │ │ ├── triage/ # Framer Motion Swipe Deck
│ │ │ ├── workspace/ # Tiptap Block Editor
│ │ │ └── lists/ # Realtime Shopping / Todo Lists
│ │ ├── lib/ # WS Client, API Client, Sound/Haptics
│ │ └── index.css # Tailwind v4 & Gemini CSS Variablen
├── .gitea/workflows/ # Gitea Actions CI/CD
├── Dockerfile # Multi-Stage Build (< 35 MB)
├── docker-compose.yml
├── README.md # Öffentliche Doku & Vision
└── BRAIN.md # Dieses Dokument (Dev Cheat Sheet)
```
### Namens- & ID-Konventionen
* **Entity IDs:** Präfix-basierte Strings für Lesbarkeit beim Debuggen:
* `usr_` = User (`usr_9k2m1a`)
* `th_` = Thought (`th_4x8z19`)
* `doc_` = Document (`doc_81na72`)
* `lst_` = List (`lst_33fa09`)
* `itm_` = List Item (`itm_77bb41`)
* **Datenbank:** `snake_case` (z. B. `is_completed`, `due_date`)
* **JSON API:** `camelCase` (z. B. `isCompleted`, `dueDate`)
* **Timestamps:** Immer UTC ISO-8601 (`2026-10-24T14:20:00Z`)
---
## ⌨️ 5. Power-User Tastatur-Shortcuts (Desktop)
| Taste | Kontext | Aktion |
| :--- | :--- | :--- |
| `C` oder `/` | Überall | Fokus sofort auf das Capture-Eingabefeld |
| `H` oder `←` | Triage | Karte nach links wischen (Verwerfen) |
| `L` oder `→` | Triage | Karte nach rechts wischen (Behalten) |
| `T` | Triage | Sofort in Aufgabe mit Fälligkeitsdatum verwandeln |
| `D` | Triage | In Workspace-Dokument umwandeln |
| `Esc` | Modal/Capture | Abbrechen / Eingabe leeren |
---
## ⚡ 6. Cheat Sheet: Lokale Entwickler-Befehle
```bash
# 1. Frontend Dev Server (Hot Reload)
cd frontend && npm run dev
# 2. Go Backend starten (Hot Reload via air oder go run)
go run ./cmd/server
# 3. Datenbank-Migrationen lokal ausführen
go run ./cmd/migrate -direction=up
# 4. Lokalen Multi-Stage Docker-Build testen
docker build -t mindsift:dev .
docker run -p 8080:8080 -v $(pwd)/dev-data:/data -e JWT_SECRET="dev-secret-12345" mindsift:dev
# 5. SQLite DB direkt per CLI inspizieren
sqlite3 ./dev-data/mindsift.db ".tables" "SELECT * FROM thoughts LIMIT 5;"
```
---
## 🚀 7. Gitea CI/CD & Docker Tagging-Strategie
* **Registry:** `git.dsit.me/sandrodach/mindsift`
* **Gitea Secret für Registry-Login:** `secrets.PUSH_TOKEN`
* **API-Endpunkt zum Auslesen der Pipeline:** `https://git.dsit.me/api/v1/repos/sandrodach/MindSift/actions/tasks`
* **Branch- & Release-Regeln ([`.gitea/workflows/build-and-publish.yaml`](file:///home/sandro/Dokumente/git/MindSift/.gitea/workflows/build-and-publish.yaml)):**
* **Push auf `main`:** Führt **nur** Linter, TypeScript-Checks (`tsc --noEmit`) und Unit-Tests aus. **Kein Docker-Build/Push** (hält die Registry sauber).
* **Push auf Test-/RC-Branch (z. B. `1.1-rc`, `latest`, `test-feature`):** Führt Tests aus **und** baut/pusht das Image mit dem Branch-Namen (`:1.1-rc`, `:latest`).
* **Git-Tag / Release (z. B. `v1.0`, `1.1`):** Führt Tests aus **und** baut/pusht das Release-Image (`:v1.0`, `:1.1`).
* **Cross-Compile Speedup:** `Dockerfile` nutzt `FROM --platform=$BUILDPLATFORM` für Node & Go und kompiliert `amd64` + `arm64` nativ über `GOOS=${TARGETOS} GOARCH=${TARGETARCH}` ohne langsames QEMU.
+28 -10
View File
@@ -1,29 +1,47 @@
# ============================================================================== # ==============================================================================
# Stage 1: Frontend Build (Runs natively on $BUILDPLATFORM — zero QEMU overhead) # Stage 1: Frontend Check & Build (Cached npm dependencies layer)
# ============================================================================== # ==============================================================================
FROM --platform=$BUILDPLATFORM 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 npm install --no-audit --no-fund
COPY frontend/ ./ COPY frontend/ ./
RUN npm run build RUN npx tsc --noEmit && npm run test:run && npm run build
# ============================================================================== # ==============================================================================
# Stage 2: Go Backend Build (Native Cross-Compile via TARGETOS / TARGETARCH) # Stage 2: Go Backend Lint, Unit Tests & Binary Build (Cached Go module & sqlite layer)
# ============================================================================== # ==============================================================================
FROM --platform=$BUILDPLATFORM golang:1.23-alpine AS backend-builder FROM golang:1.23-alpine AS backend-builder
ARG TARGETOS=linux
ARG TARGETARCH=amd64
WORKDIR /app WORKDIR /app
RUN apk add --no-cache git ca-certificates RUN apk add --no-cache git ca-certificates
# 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 \
github.com/go-chi/cors@v1.2.1 \
github.com/golang-jwt/jwt/v5@v5.2.1 \
github.com/gorilla/websocket@v1.5.3 \
golang.org/x/crypto/bcrypt@v0.31.0 \
modernc.org/sqlite@v1.34.4 && \
CGO_ENABLED=0 go build \
modernc.org/sqlite \
github.com/go-chi/chi/v5 \
github.com/go-chi/cors \
github.com/golang-jwt/jwt/v5 \
github.com/gorilla/websocket \
golang.org/x/crypto/bcrypt
# Copy application source & compiled frontend SPA assets for go:embed
COPY cmd/ ./cmd/ COPY cmd/ ./cmd/
COPY internal/ ./internal/ COPY internal/ ./internal/
COPY migrations/ ./migrations/ COPY migrations/ ./migrations/
# Copy compiled frontend SPA assets into cmd/server/dist for go:embed
COPY --from=frontend-builder /app/frontend/dist ./cmd/server/dist COPY --from=frontend-builder /app/frontend/dist ./cmd/server/dist
RUN go mod tidy
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -ldflags="-s -w" -o /app/mindsift ./cmd/server # Run go mod tidy, static analysis (go vet), unit tests (go test), and compile final binary
RUN go mod tidy && \
CGO_ENABLED=0 go vet ./... && \
CGO_ENABLED=0 go test -v ./... && \
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)
+40
View File
@@ -0,0 +1,40 @@
# MindSift Technische Dokumentation
Referenz für Entwickler und Administratoren. Alle Angaben wurden gegen den Code im Branch `dev` geprüft (Stand Oktober 2026). Abweichungen zwischen Doku und Code melde bitte als Fehler in [flaws.md](flaws.md).
## Inhalt
| Dokument | Inhalt |
|---|---|
| [architecture.md](architecture.md) | Aufbau, Technologie-Stack, Start, Konfiguration, Build |
| [database.md](database.md) | SQLite-Schema, Migrationen, Datenmodell |
| [api/rest.md](api/rest.md) | Alle REST-Endpunkte |
| [api/websocket.md](api/websocket.md) | Echtzeit über WebSocket und SSE, Ereignisse |
| [modules/triage.md](modules/triage.md) | Schnellerfassung und Triage |
| [modules/tasks.md](modules/tasks.md) | Aufgaben, Kanban, Alarme |
| [modules/workspace.md](modules/workspace.md) | Outliner-Notizen, Ordner, Tags |
| [modules/lists.md](modules/lists.md) | Geteilte Listen |
| [sharing-and-permissions.md](sharing-and-permissions.md) | Rollen, Gruppen, Freigaben |
| [reverse-proxy.md](reverse-proxy.md) | Nginx, Traefik, Caddy, Cloudflare |
| [theming.md](theming.md) | Themes und Design-Variablen |
| [flaws.md](flaws.md) | Bekannte Schwachstellen und technische Schulden |
## Überblick in 60 Sekunden
- **Ein Go-Binary** liefert REST-API, WebSocket/SSE und das eingebettete React-Frontend (`go:embed`). Es gibt genau einen Container und eine SQLite-Datei (`/data/mindsift.db`).
- **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. Verteilt werden Ereignisse nach REST-Änderungen. Es gibt keine CRDTs, Sperren oder Cursor-Presence.
- **Auth:** JWT (HS256, 30 Tage) mit `token_version` und Sperrliste, dazu API-Keys (`mindsift_sec_…`) für Webhooks.
- **Freigaben:** Aufgabenlisten und geteilte Listen mit Rolle Bearbeiter oder Leser, Notizen und Ordner nur lesend.
## Entwickeln
```bash
# Backend (Go 1.23)
go vet ./... && go test ./...
# Frontend (Node 22)
cd frontend && npm ci && npx tsc --noEmit && npm run test:run && npm run build
```
Das Frontend wird für das Binary nach `cmd/server/dist` gebaut (siehe `Dockerfile`). Die CI läuft unter `.gitea/workflows/build-and-publish.yaml`.
+129
View File
@@ -0,0 +1,129 @@
# REST-API
Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`. Authentifizierung über `Authorization: Bearer <JWT>` oder `Bearer mindsift_sec_…` bzw. Header `X-API-Key`. Zeitstempel sind ISO-8601 (UTC). Quelle der Wahrheit sind die Routen in [server.go](../../internal/api/server.go).
## Öffentlich
| Methode | Pfad | Zweck |
|---|---|---|
| GET | `/health` | Lebenszeichen |
| GET | `/auth/status` | `needsSetup`, `allowRegistration`, `instanceName`, `demoMode` |
| 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/login` | Anmeldung (`login`/`email`/`username`, `password`) → `{token, user}` |
| POST | `/auth/quickstart` | Immer `403` (Gastzugang deaktiviert) |
| POST | `/webhooks/ingest` | Aufgabe per Webhook anlegen, Token als `?token=`, `X-API-Key` oder Bearer. Body: `title`/`content`/`text`/`message`, `priority`, `source`, `tags`, `dueDate`; Klartext-Body wird als Inhalt genommen |
| GET | `/ws`, `/ws/spaces/{id}`, `/ws/lists/{id}` | WebSocket, siehe [websocket.md](websocket.md) |
| GET | `/events` | SSE-Fallback |
| POST | `/events/rooms` | SSE-Räume beitreten/verlassen ([FLAW-02](../flaws.md)) |
## Konto und Allgemeines (angemeldet)
| Methode | Pfad | Zweck |
|---|---|---|
| GET | `/auth/me` | Benutzer und Kennzahlen |
| POST | `/auth/logout` | Token sperren |
| POST | `/auth/revoke-all` | Alle Sitzungen beenden (`token_version` +1) |
| PATCH | `/auth/settings` | `displayName`, `email`, `password`, `avatarUrl`, `alarmChannels`, `alarmEmail`, `discordWebhookUrl`, `customWebhookUrl`, `notifyBeforeMinutes` |
| POST | `/auth/alarms/test` | Test-Alarm (`channel`: `email`/`discord`/`webhook`, optional `target`) |
| GET | `/users/directory`, `/collaborators` | Benutzerverzeichnis für Freigaben ([FLAW-10](../flaws.md)) |
| GET | `/stats` | Zähler fürs Dashboard |
| GET | `/search?q=` | Suche in eigenen Gedanken und Notizen |
| GET | `/export` | Datenexport als JSON |
| GET/POST/DELETE | `/keys`, `/keys/{id}` | API-Keys verwalten (Klartext nur bei Erstellung) |
## Gedanken, Triage, Aufgaben
| Methode | Pfad | Zweck |
|---|---|---|
| POST | `/inbox` | Schnellerfassung. Body: `content`, `priority`, `source`, `dueDate`, `target` (`inbox`/`task`/`list_item`/`document`), `targetListId`, `taskListId` (snake_case-Varianten werden akzeptiert) |
| GET | `/thoughts?status=` | Gedanken (`inbox`, `kept`, `converted`, `discarded`, `all`) |
| GET/PATCH/DELETE | `/thoughts/{id}` | Einzelabruf; Teiländerung (`content`, `isCompleted`, `dueDate`, `priority`, `taskListId`, `kanbanStatus`, `notes`, `subtasks`, `tags`); Löschen |
| DELETE | `/thoughts/discarded/purge` | Papierkorb leeren |
| GET | `/triage/deck` | Posteingang (max. 100) |
| PATCH | `/triage/{id}` | `action`: `keep`/`discard`/`restore`; mit `convertTo`: `task`/`document`/`list_item` |
| GET | `/tasks?taskListId=` | Aufgaben (optional einer Liste) |
| 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 |
## Aufgabenlisten
| Methode | Pfad | Zweck |
|---|---|---|
| GET/POST | `/task-lists` | Listen (`permission` je Liste: `owner`/`editor`/`viewer`); anlegen (`title`, `icon`, `color`, `teamId`) |
| PUT/DELETE | `/task-lists/{id}` | Ändern (`title`, `icon`, `color`, `teamId`, `columns`) – nicht für Leser; Löschen nur Eigentümer |
| GET | `/task-lists/{id}/shares` | Freigaben |
| POST | `/task-lists/{id}/shares` | Nur Eigentümer. Body: `targetType` (`team`/`user`), `targetId`, `permission` (`editor`/`viewer`, Standard `editor`). Erneutes Senden für dasselbe Ziel ändert die Stufe |
| DELETE | `/task-lists/{id}/shares/{shareId}` | Nur Eigentümer |
Schreibzugriffe von Lesern auf Listen oder deren Aufgaben liefern `403` („Keine Schreibrechte für diese Aufgabenliste (nur Lesen)“).
## Workspace
| Methode | Pfad | Zweck |
|---|---|---|
| GET/POST/DELETE | `/folders` | Ordnerliste, anlegen (`path`), löschen |
| POST | `/folders/move` | `sourcePath`, `targetParentPath` |
| GET/POST | `/workspace/folders/shared` | Geteilte Ordner; anlegen (`name`, `mountPath`) |
| GET/DELETE | `/workspace/folders/shared/{id}` | Abruf; Löschen |
| PUT | `/workspace/folders/shared/{id}/mount` | Eigenen Einhängepfad ändern (`mountPath`) |
| GET/POST/DELETE | `/workspace/folders/shared/{id}/shares[/{shareId}]` | Freigaben (nur Lesen wirksam) |
| GET | `/workspace/tags`, `/workspace/tags/{tag}` | Tag-Übersicht und -Details |
| PUT | `/workspace/tags/{tag}/note` | Tag-Seite speichern (`content`) |
| GET/POST | `/documents` | Notizen auflisten; anlegen (`title`, `icon`, `folderPath`, `blocks`, `tags`, `isPinned`) |
| GET/PUT/DELETE | `/documents/{id}` | Abruf; Ändern (`title`, `icon`, `folderPath`, `blocks`, `tags`, `isPinned`); Löschen |
| PATCH | `/documents/{id}/blocks` | Teil-Update der Blöcke (`upserted`, `deletedIds`, Reihenfolge); löst `DOC_BLOCKS_PATCHED` aus |
| PUT | `/documents/{id}/move` | Ordner wechseln (`folderPath`) |
| 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`; wirksam nur Lesen) |
Nur der Eigentümer schreibt in eine Notiz; Empfänger erhalten `403`/„nur Lesezugriff“ ([FLAW-17](../flaws.md)).
## Geteilte Listen
| Methode | Pfad | Zweck |
|---|---|---|
| GET/POST | `/lists` | Listen; anlegen (`title`, `icon`, `listType`) |
| GET/DELETE | `/lists/{id}` | Abruf mit Einträgen; Löschen |
| GET/POST/DELETE | `/lists/{id}/shares[/{shareId}]` | Freigaben (`targetType`, `targetId`, `role`: `editor`/`viewer`) |
| POST | `/lists/{id}/items` | Eintrag (`content`, `category`, `quantity`) |
| PATCH | `/lists/{id}/items/{itemId}` | Abhaken (`isCompleted`) |
| PUT | `/lists/{id}/items/{itemId}` | Ändern (`content`, `category`, `quantity`) |
| DELETE | `/lists/{id}/items/{itemId}` | Löschen |
| POST/PUT | `/lists/{id}/items/reorder` | Reihenfolge/Kategorie (`items[{id, sortOrder, category, content}]`) |
| POST | `/lists/{id}/clear-completed` | Erledigte entfernen |
## Gruppen
| Methode | Pfad | Zweck |
|---|---|---|
| GET/POST | `/teams` | Eigene Gruppen; anlegen (`name`, `description`) – Ersteller wird Gruppen-Admin |
| PUT/DELETE | `/teams/{id}` | Nur Gruppen-Admin |
| POST | `/teams/{id}/members` | Mitglied hinzufügen (`userId` oder `email`, `role`) |
| PUT/DELETE | `/teams/{id}/members/{userId}` | Rolle ändern (`admin`/`member`); entfernen |
| GET/POST/DELETE | `/organizations`, `/organizations/{id}[/teams]` | Veraltet, ungeschützt ([FLAW-24](../flaws.md)) |
## Administration (`role = admin`)
| Methode | Pfad | Zweck |
|---|---|---|
| GET/POST | `/admin/users` | Benutzer auflisten, anlegen (`email`, `password`, `displayName`, `role`) |
| PATCH/DELETE | `/admin/users/{id}` | `displayName`, `email`, `role`, `isActive`, `discordWebhookUrl`; löschen |
| PUT | `/admin/users/{id}/password` | Passwort zurücksetzen |
| GET/POST/DELETE | `/admin/invites[/{id}]` | Einladungen (`email`, `role`, `teamId`, `validDays`) |
| GET/PUT | `/admin/settings` | `instanceName`, `allowRegistration`, `sharingMode`, SMTP-Felder |
| POST | `/admin/smtp/test` | Testmail (`toEmail`) |
## Beispiele
```bash
# Gedanken per API-Key erfassen
curl -X POST https://mindsift.example.com/api/v1/webhooks/ingest \
-H "X-API-Key: mindsift_sec_…" -H "Content-Type: application/json" \
-d '{"title":"Milch kaufen #einkauf","priority":"high","dueDate":"2026-11-01"}'
# Aufgabenliste nur lesend freigeben
curl -X POST https://mindsift.example.com/api/v1/task-lists/tkl_…/shares \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"targetType":"user","targetId":"usr_…","permission":"viewer"}'
```
+55
View File
@@ -0,0 +1,55 @@
# Echtzeit: WebSocket und SSE
Der Server verteilt Änderungen als JSON-Ereignisse an verbundene Clients. Die Ereignisse entstehen **nach** einer REST-Änderung; es gibt keinen Schreibkanal über die Verbindung. Implementierung: [hub.go](../../internal/ws/hub.go), Client: [api.ts](../../frontend/src/lib/api.ts).
> Keine CRDTs, keine Sperren, keine Cursor-Presence. Frühere Fassungen dieser Doku beschrieben DocRooms, `clientId`-Echo-Schutz und Yjs; das existiert nicht (mehr).
## Transporte
| Transport | Endpunkt | Hinweise |
|---|---|---|
| WebSocket | `GET /api/v1/ws` (auch `/ws/lists/{id}`, `/ws/spaces/{id}`) | Ping alle 30 s, Lesefrist 60 s, Nachrichten bis 4 MB |
| SSE | `GET /api/v1/events` | Kommentar-Ping alle 15 s, Header `X-Accel-Buffering: no` |
Der Client versucht zuerst WebSocket. Gelingt der Aufbau nicht innerhalb von 2,5 s oder bricht die Verbindung unsauber ab, wechselt er auf SSE und bleibt dabei (Firmen-Proxys). Dort liefert der Server `SSE_CONNECTED` mit einer `sseId`; Räume werden über `POST /api/v1/events/rooms` (`sseId`, `room`, `action`: `join`/`leave`) abonniert.
**Authentifizierung:** Token als `?token=<JWT|API-Key>` oder `Authorization`-Header. Ohne gültiges Token wird die Verbindung als `anonymous` akzeptiert ([FLAW-01](../flaws.md), [FLAW-02](../flaws.md)). Das Token wird nur beim Verbinden geprüft.
## Nachrichtenformat
```json
{ "event": "TASK_UPDATED", "listId": "", "payload": { … }, "timestamp": "2026-10-27T12:00:00Z" }
```
Client → Server versteht der Server nur: `{"event":"JOIN_ROOM","room":"<id>"}` und `{"event":"LEAVE_ROOM","room":"<id>"}`. Räume heißen `doc:<docId>` für Notizen oder `<listId>` für geteilte Listen.
## Zustellung
| Kanal | Wer erhält es |
|---|---|
| Benutzer-Kanal (`BroadcastToUser`) | alle Verbindungen eines Benutzers |
| Raum (`BroadcastRoom`, `Broadcast`) | Verbindungen im Raum |
| Alle (`BroadcastToAll`) | **jede** Verbindung, auch unangemeldete |
## Ereignisse
| Ereignis | Auslöser | Kanal |
|---|---|---|
| `TASK_CREATED` | `POST /inbox` | Benutzer |
| `TASK_UPDATED` | `PATCH /thoughts/{id}` (Triage: Benutzer) | alle |
| `TASK_DELETED` | `DELETE /thoughts/{id}` | alle |
| `TASK_REORDERED` | Aufgaben umsortieren | alle |
| `TASK_LIST_UPDATED` / `TASK_LIST_DELETED` | Liste ändern, (un)teilen, löschen | alle |
| `DOC_CREATED`, `DOC_UPDATED`, `DOC_DELETED` | Notiz anlegen/ändern/löschen, Freigaben | berechtigte Benutzer |
| `DOC_BLOCKS_PATCHED` | `PATCH /documents/{id}/blocks` (`docId`, `updatedBy`, `upserted`, `deletedIds`, `blockOrder`) | Raum `doc:<id>` und berechtigte Benutzer |
| `WORKSPACE_FOLDERS_UPDATED` | Ordner/Mounts ändern | Benutzer |
| `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 |
| `USER_PRESENCE` | Verbindung auf-/abgebaut (`onlineCount`) | Raum |
| `SSE_CONNECTED` | Nur SSE, direkt nach Verbindung | Verbindung |
## Verhalten im Frontend
- Die globale Verbindung (ein Singleton) leitet jede Nachricht als Browser-Ereignis `mindsift:ws` weiter; Ansichten (`TasksView`, `WorkspaceView`, `SharedListsView`) hören darauf und laden bzw. patchen ihren Zustand.
- Nach einem Abbruch verbindet sich der Client mit Verzögerung neu.
- Weil Notizen nur vom Eigentümer geschrieben werden, dient `DOC_BLOCKS_PATCHED` vor allem dazu, Lesenden und weiteren Geräten des Eigentümers Änderungen zu zeigen. Es gibt keine Konfliktauflösung (letzter Schreiber gewinnt).
+87
View File
@@ -0,0 +1,87 @@
# Architektur
## Überblick
```
Browser (React SPA, PWA-Manifest)
│ REST /api/v1/* Authorization: Bearer <JWT | mindsift_sec_…>
│ WebSocket /api/v1/ws Fallback: SSE /api/v1/events
▼
Go-Server (chi) ── internal/api Handler, Middleware, Alarm-Ticker
│ internal/ws Hub (Räume, Benutzer-Kanäle, SSE)
│ internal/auth JWT, bcrypt, API-Key-Hash
▼ internal/config
SQLite (modernc.org/sqlite, WAL) ── internal/storage
```
Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/main.go)). Unbekannte Pfade liefern `index.html` (SPA-Routing); `index.html`, `sw.js` und `manifest.json` werden mit `no-store` ausgeliefert.
## Stack
| Schicht | Technik |
|---|---|
| Backend | Go 1.23, `go-chi/chi` v5, `go-chi/cors`, `gorilla/websocket`, `golang-jwt/jwt` v5, `x/crypto/bcrypt` |
| Datenbank | SQLite über `modernc.org/sqlite` (reines Go, kein CGO), WAL, `foreign_keys=ON` |
| Frontend | React 18, TypeScript, Vite 5, Tailwind 3, framer-motion (Triage-Karten), lucide-react, prismjs (Code-Blöcke) |
| Auslieferung | Mehrstufiges Dockerfile (Node 22 → Go 1.23 → Alpine 3.21), Image `git.dsit.me/sandrodach/mindsift` |
## Verzeichnisse
| Pfad | Inhalt |
|---|---|
| `cmd/server/` | `main.go`, eingebettetes `dist/` |
| `internal/api/` | `server.go` (Routen, Handler, Alarm-Ticker, SMTP/Discord/Webhook-Versand) |
| `internal/storage/` | `sqlite.go` (Schema, Migrationen, gesamte Datenlogik) |
| `internal/domain/` | Strukturen (`models.go`), ID-Erzeugung, Tag-Extraktion |
| `internal/ws/` | `hub.go` |
| `internal/auth/` | Tokens und Passwörter |
| `internal/config/` | Umgebungsvariablen |
| `frontend/src/features/` | `triage`, `workspace`, `lists`, `teams`, `settings`, `capture`, `auth`, `help` |
| `frontend/src/lib/` | `api.ts` (REST-Client, Transport-Umschaltung WS/SSE), `types.ts`, `markdown.tsx`, `theme.ts` |
| `frontend/src/themes/` | CSS pro Theme |
## Start und Laufzeit
1. `config.Load()` liest die Umgebung (siehe unten).
2. `storage.NewStore()` öffnet SQLite (`busy_timeout`, WAL, max. 8 Verbindungen) und führt `migrate()` aus.
3. SMTP-Werte aus der Umgebung werden in `system_settings` übernommen, sofern dort noch nichts steht.
4. Mit Demo-Flag legt `EnsureDemoAccounts` die Konten `admin/admin` und `user/user` an.
5. `api.NewServer` startet den **Alarm-Ticker** (jede Minute) und baut den Router. Server-Timeouts: Lesen 15 s, Schreiben 30 s, Leerlauf 60 s. Beim Beenden wird sauber heruntergefahren.
## Konfiguration
| Variable | Standard | Bedeutung |
|---|---|---|
| `PORT` | `8080` | HTTP-Port |
| `DATA_DIR` | `./data` (Container: `/data`) | Verzeichnis der Datenbank |
| `DATABASE_URL` | leer | `sqlite:///pfad/datei.db` oder Pfad; ohne Angabe `DATA_DIR/mindsift.db`. Postgres-URLs werden **nicht** unterstützt (siehe Hinweis) |
| `JWT_SECRET` | festes Fallback (unsicher!) | Signaturschlüssel der Tokens; unbedingt setzen, siehe [FLAW-04](flaws.md) |
| `BASE_URL` | `http://localhost:<PORT>` | Basis-URL der Instanz |
| `ALLOW_REGISTRATION` | `false` | Startwert; die Einstellung im Admin-UI (`system_settings`) hat Vorrang |
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | `debug`, `info`, `warn`, `error`; `DEBUG=true` erzwingt `debug` |
| `MINDA_DEMO` (auch `MINDSIFT_DEMO`, `DEMO_MODE`) | `false` | Demo-Konten anlegen |
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer / `587` / `true` | Mailversand, auch im Admin-UI einstellbar |
Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in `DATA_DIR` genutzt wird; Postgres ist nicht implementiert.
## Authentifizierung
- **Login** mit E-Mail, Benutzername (aus dem E-Mail-Präfix) oder `login`. Passwörter: bcrypt (`DefaultCost`).
- **JWT:** HS256, Laufzeit 30 Tage, Claims `userId`, `email`, `displayName`, `tokenVersion`, `jti`. Gültig nur, wenn Token nicht in `revoked_tokens` steht, das Konto aktiv ist und `tokenVersion` mit `users.token_version` übereinstimmt. `token_version` wird bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ erhöht.
- **Logout** schreibt den Token-Hash in `revoked_tokens`.
- **API-Keys:** `mindsift_sec_<hex>`, gespeichert als SHA-256, nur das Präfix bleibt sichtbar. Der Key gilt als Bearer-Token oder in `X-API-Key` und authentifiziert als Besitzer ([FLAW-07](flaws.md)).
- **Registrierung:** nur mit Einladungscode oder wenn `allow_registration` aktiv ist. Das erste Konto entsteht über `/auth/setup` und wird Administrator.
## Hintergrundjob: Alarme
`startAlarmTicker` prüft jede Minute alle offenen, noch nicht gemeldeten Aufgaben mit Fälligkeit. Ist `due_date <= heute` (Serverzeit), sendet er über die Kanäle des Benutzers (`email`, `discord`, `webhook`) und setzt `alarm_sent`, sobald mindestens ein Kanal erfolgreich war. Details und Einschränkungen: [modules/tasks.md](modules/tasks.md), [FLAW-12](flaws.md).
## Build
```
Stage 1 node:22-alpine tsc, vitest, vite build → frontend/dist
Stage 2 golang:1.23 go vet, go test, go build → /app/mindsift (dist wird eingebettet)
Stage 3 alpine:3.21 nur Binary, ca-certificates, tzdata
```
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`).
+83
View File
@@ -0,0 +1,83 @@
# 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_…`).
Die Datei `migrations/001_init.sql` wird vom Code **nicht** gelesen und ist veraltet ([FLAW-13](flaws.md)).
## Tabellen
### Benutzer und Zugang
| 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` |
| `user_invites` | Einladungscodes | `code`, `email`, `role`, `team_id`, `expires_at`, `used_by` |
| `revoked_tokens` | Sperrliste ausgeloggter JWTs | `token_hash`, `expires_at` |
| `api_keys` | Webhook-/API-Schlüssel | `key_prefix`, `key_hash` (SHA-256), `scopes` (nicht ausgewertet), `last_used_at` |
| `system_settings` | Schlüssel/Wert für Instanz-Einstellungen | `sharing_mode`, `allow_registration`, `instance_name`, `smtp_*` |
### Gedanken und Aufgaben
| Tabelle | Zweck | Wichtige Spalten |
|---|---|---|
| `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` (nur Datum), `task_list_id`, `kanban_status`, `notes`, `subtasks` (JSON), `tags` (JSON), `alarm_sent`, `sort_order`, `source` (`manual`, `voice`, `webhook` …) |
| `task_lists` | Aufgabenlisten | `user_id` (Eigentümer), `team_id` (Altfeld), `title`, `icon`, `color`, `columns` (JSON, Kanban-Spalten) |
| `task_list_shares` | Freigaben von Aufgabenlisten | `target_type` (`team`/`user`), `target_id`, **`permission`** (`editor`/`viewer`, Standard `editor`) |
Eine Aufgabe ist ein `thought` mit `status='kept'` oder `status='converted' AND converted_type='task'`. Aufgaben, die aus einem Notiz-Block entstehen, tragen `converted_type='document_block'` und `converted_ref_id='<docId>:<blockId>'`.
### Workspace
| Tabelle | Zweck |
|---|---|
| `documents` | Notizen: `title`, `icon`, `folder_path`, `tags` (JSON), `is_archived`, `markdown_cache`, `shared_folder_id`, `sub_folder_path`, Altspalten `content`, `blocks_json` |
| `blocks` | Outliner-Blöcke: `document_id`, `type`, `content`, `level`, `position` (REAL), `checked`, `collapsed`, `lang` |
| `block_tags` | `#Hashtags` je Block (`tag`, `document_id`, `block_id`) |
| `tag_pages` | Freitext-Notiz je Benutzer und Tag |
| `doc_pins` | Favoriten je Benutzer |
| `doc_shares`, `doc_team_shares` | Notiz-Freigaben an Benutzer bzw. Gruppen (`permission` effektiv immer `read`) |
| `workspace_folders` | Explizit angelegte Ordnerpfade |
| `workspace_shared_folders` | Geteilte Ordner (Entität mit Eigentümer) |
| `workspace_folder_shares` | Freigaben geteilter Ordner |
| `workspace_folder_mounts` | Persönlicher Einhängepfad je Empfänger und geteiltem Ordner |
| `doc_user_folders` | Persönliche Ablage einer geteilten Einzelnotiz je Empfänger |
Blocktypen: `h1`, `h2`, `h3`, `paragraph`, `text`, `todo`, `bullet`, `code`, `callout`, `divider`, `table`, `progress`.
### Listen
| Tabelle | Zweck |
|---|---|
| `lists` | Geteilte Listen: `title`, `icon`, `list_type` (`shopping`/`checklist`/`vacation`) |
| `list_shares` | Freigaben: `target_type`, `target_id`, `role` (`editor`/`viewer`) |
| `list_items` | Einträge: `content`, `category`, `quantity`, `is_completed`, `completed_by(_name)`, `sort_order` |
### Gruppen
| Tabelle | Zweck |
|---|---|
| `teams` | `name`, `description`, `org_id` (Standard `default`) |
| `team_members` | `team_id`, `user_id`, `role` (`admin`/`member`) |
| `organizations` | Rudimentär; wird vom UI nicht mehr benötigt ([FLAW-24](flaws.md)) |
## Beziehungen
```
users 1─n thoughts thoughts n─1 task_lists (task_list_id, lose)
users 1─n task_lists task_lists 1─n task_list_shares ─(team|user)→ teams/users
users 1─n documents 1─n blocks 1─n block_tags
documents n─n users via doc_shares / doc_team_shares (nur Lesen)
workspace_shared_folders 1─n workspace_folder_shares / workspace_folder_mounts
users 1─n lists 1─n list_items; lists 1─n list_shares
teams 1─n team_members n─1 users
```
`thoughts.task_list_id` ist keine Fremdschlüsselbeziehung; beim Löschen einer Liste setzt der Code die Verweise auf `''`.
## Migrationen und Aufräumarbeiten beim Start
- 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`.
- `users.username` wird aus dem E-Mail-Präfix aufgefüllt.
- Alle Dokument-, Team- und Ordnerfreigaben werden auf `read` gesetzt (bei **jedem** Start, siehe [FLAW-17](flaws.md)).
- Standardwerte in `system_settings` (`INSERT OR IGNORE`): `sharing_mode=teams_only`, `allow_registration=false`, `instance_name=MindSift`, SMTP leer.
- Die Tabelle `teams` wird einmalig ohne Fremdschlüssel auf `organizations` neu aufgebaut.
## Sicherung
Die gesamte Instanz besteht aus der Datei `mindsift.db` (plus `-wal`/`-shm` im laufenden Betrieb). Konsistent sichern per `sqlite3 mindsift.db ".backup 'backup.db'"` oder durch Stoppen des Containers und Kopieren des `data/`-Verzeichnisses. Benutzerexport: `GET /api/v1/export` (JSON mit Gedanken, Notizen, Listen, Aufgabenlisten).
+173
View File
@@ -0,0 +1,173 @@
# Bekannte Schwachstellen, Risiken & technische Schulden (Flaws)
Stand: Code-Review des Branches `dev` (Oktober 2026). Jeder Eintrag wurde gegen den Quellcode geprüft; die Fundstellen sind angegeben. Einträge ohne Live-Test sind als *nicht live verifiziert* markiert.
Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (Fehlfunktion oder Schutz greift nicht), **Niedrig** (Wartbarkeit, Komfort, Hygiene).
---
## Übersicht
| ID | Bereich | Kurzbeschreibung | Schwere |
|---|---|---|---|
| FLAW-01 | Realtime | `BroadcastToAll` sendet Aufgaben- und Listendaten an alle Verbindungen, auch unauthentifizierte | **Hoch** |
| FLAW-02 | Realtime | Räume (WS `JOIN_ROOM`, SSE `/events/rooms`, `?listId=`) ohne Berechtigungsprüfung | **Hoch** |
| FLAW-03 | Aufgaben | `ReorderTasks` ändert beliebige Aufgaben per ID, ohne Zugriffsprüfung (IDOR) | **Hoch** |
| FLAW-04 | Auth | Fest eincodiertes Fallback-`JWT_SECRET` | **Hoch** |
| FLAW-05 | Benachrichtigungen | Discord-/Webhook-URLs ungeprüft (SSRF) | Mittel |
| FLAW-06 | Aufgaben | Jeder Editor einer Aufgabenliste kann sie per `teamId` an weitere Gruppen freigeben | Mittel |
| FLAW-07 | Auth | API-Key-`scopes` werden nie ausgewertet; ein Key ist ein Vollzugriff | Mittel |
| FLAW-08 | Auth | Webhook-Ingest mit JWT prüft weder Sperrliste noch `token_version` noch `is_active` | Mittel |
| FLAW-09 | Auth | Kein Rate-Limit, keine Sperre bei Fehlversuchen, Mindestpasswort 4 Zeichen | Mittel |
| FLAW-10 | Freigaben | `sharing_mode` wird bei Freigaben nicht durchgesetzt; Benutzerverzeichnis leakt alle Konten | Mittel |
| FLAW-11 | Workspace | Block-Upsert (`ON CONFLICT(id)`) prüft das Dokument nicht | Mittel |
| FLAW-12 | Benachrichtigungen | `notify_before_minutes` wirkungslos; Alarm feuert nach Umplanung nie wieder | Mittel |
| FLAW-13 | Datenbank | Keine Schema-Versionierung; Migrationsfehler werden verschluckt; `migrations/001_init.sql` ist toter Code | Mittel |
| FLAW-14 | Backend | Fehler von Schreiboperationen werden häufig ignoriert (`_, _ =`) | Mittel |
| FLAW-15 | Realtime | `WriteTimeout` von 30 s trifft vermutlich auch SSE-Streams *(nicht live verifiziert)* | Mittel |
| FLAW-16 | Setup | `/auth/setup` ist bis zum ersten Konto offen; Demo-Modus legt `admin/admin` an | Mittel |
| FLAW-17 | Workspace | Dokument- und Ordnerfreigaben sind per Migration erzwungen schreibgeschützt; tote Schreibpfade | Niedrig |
| FLAW-18 | Suche | Nur eigene Gedanken und Dokumente, `LIKE` ohne Escaping, keine FTS | Niedrig |
| FLAW-19 | Qualität | Frontend-Tests nur für den Outliner, `lint` ist ein Platzhalter | Niedrig |
| FLAW-20 | Qualität | Sehr große Dateien, duplizierte Zugriffslogik | Niedrig |
| FLAW-21 | Repo | `LICENSE` fehlt, obwohl README MIT angibt | Niedrig |
| FLAW-22 | Netzwerk | Token in der URL (WS/SSE), CORS `*`, `CheckOrigin` immer `true` | Niedrig |
| FLAW-23 | SQLite | Single-Writer-Engpass bei vielen gleichzeitigen Schreibern | Niedrig |
| FLAW-24 | Gruppen | Organisations-Endpunkte ohne Berechtigungsprüfung | Mittel |
---
## Details
### FLAW-01: Broadcast an alle Verbindungen (Datenabfluss)
- **Fundstellen:** [`internal/ws/hub.go`](../internal/ws/hub.go) `BroadcastToAll`; Aufrufer in [`internal/api/server.go`](../internal/api/server.go): `handleUpdateThought` (`TASK_UPDATED`), `handleDeleteThought` (`TASK_DELETED`), `handleReorderTasks` (`TASK_REORDERED`), `handleUpdateTaskList`, `handleCreateTaskListShare`, `handleDeleteTaskListShare` (`TASK_LIST_UPDATED`), `handleDeleteTaskList`.
- **Problem:** `BroadcastToAll` schreibt an `allClients` und `sseClients`. Dazu zählen auch Verbindungen mit `userID = "anonymous"`, denn `handleWebSocket` und `handleSSE` liegen außerhalb der Auth-Gruppe und lassen jede Verbindung zu. Der vollständige Aufgabeninhalt (Titel, Notizen, Unteraufgaben) und die Listen samt Mitgliedern gehen so an jeden, der `/api/v1/events` öffnet.
- **Maßnahme:** Ereignisse an die berechtigten Nutzer adressieren (wie bei `DOC_*` über `GetAuthorizedDocUsers`), unauthentifizierte Verbindungen ablehnen.
### FLAW-02: Räume ohne Berechtigungsprüfung
- **Fundstellen:** `Hub.readPump` (`JOIN_ROOM`), `handleSSERooms` (Route `/api/v1/events/rooms` liegt **außerhalb** der Auth-Gruppe), `Hub.Register` (Raum aus `?listId=`/`?spaceId=`).
- **Problem:** Wer eine Raum-ID kennt (`doc:<id>`, Listen-ID), tritt bei und erhält Inhalte wie `DOC_BLOCKS_PATCHED` oder `ITEM_*`. Raum-IDs tauchen in Ereignissen auf und sind nicht geheim. Für den SSE-Beitritt reicht die `sseId`, die der Server selbst im Stream mitsendet.
- **Maßnahme:** Beitritt nur nach Zugriffsprüfung (`GetDocumentByID`, `GetListByID`); `/events/rooms` hinter `authMiddleware`.
### FLAW-03: `ReorderTasks` ohne Zugriffsprüfung (IDOR)
- **Fundstelle:** `Store.ReorderTasks` (`UPDATE thoughts SET sort_order … WHERE id = ?`) und `handleReorderTasks`.
- **Problem:** Die SQL-Anweisung ist weder auf `user_id` noch auf die Liste eingeschränkt. Jeder angemeldete Nutzer kann `sortOrder`, `kanbanStatus` und damit `isCompleted` fremder Aufgaben ändern, sofern er die ID kennt. Die Leser-Prüfung für Aufgabenlisten überspringt Aufgaben, auf die der Nutzer gar keinen Zugriff hat (`GetThoughtByID` liefert dann einen Fehler), und schützt davor nicht.
- **Maßnahme:** Jede ID gegen `accessibleTaskListIDsQuery` bzw. `user_id` prüfen und bei Verstoß ablehnen.
### FLAW-04: Fallback-JWT-Secret
- **Fundstelle:** [`internal/config/config.go`](../internal/config/config.go): `getEnv("JWT_SECRET", "mindsift-default-selfhosted-jwt-secret-key-2026")`; `docker-compose.yml` und `.env.example` liefern ebenfalls einen Platzhalter.
- **Problem:** Wer `JWT_SECRET` nicht setzt (oder den Platzhalter behält), läuft mit einem öffentlich bekannten Schlüssel. Damit lassen sich gültige Tokens für jede Benutzer-ID fälschen. `install.sh` erzeugt zwar ein Secret, andere Installationswege nicht.
- **Maßnahme:** Start verweigern oder Secret beim ersten Start erzeugen und in `/data` ablegen.
### FLAW-05: SSRF über Alarm-URLs
- **Fundstellen:** `handleUpdateUserSettings` (keine Validierung), `sendDiscordNotification`, `sendCustomWebhookNotification`.
- **Problem:** Jeder Nutzer kann beliebige URLs hinterlegen. Der Server sendet POST-Anfragen dorthin, auch an interne Adressen (`http://169.254.169.254`, Docker-Netz, `localhost`). Über `handleTestUserAlarm` lässt sich das sofort auslösen.
- **Maßnahme:** Nur `https`, Discord-Host prüfen, private/Loopback-Adressen beim Verbinden sperren.
### FLAW-06: Editor kann Aufgabenliste weiterteilen
- **Fundstelle:** `Store.UpdateTaskList` (`teamID`-Zweig ruft `AddTaskListShare`); `handleUpdateTaskList` prüft nur, dass der Nutzer **kein** Leser ist.
- **Problem:** `POST /task-lists/{id}/shares` ist Eigentümern vorbehalten. Über `PUT /task-lists/{id}` mit `teamId` kann aber jeder Editor die Liste einer weiteren Gruppe zuweisen (mit Schreibrecht) und die Zuweisung der bisherigen Gruppe aufheben.
- **Maßnahme:** `teamId`-Änderungen nur für den Eigentümer; das Legacy-Feld `task_lists.team_id` zugunsten von `task_list_shares` abschaffen.
### FLAW-07: API-Key-Scopes ohne Wirkung
- **Fundstellen:** Tabelle `api_keys.scopes` (Standard `ingest,read`), `authMiddleware`.
- **Problem:** Ein Key mit Präfix `mindsift_sec_` authentifiziert als der Benutzer, mit allen Rechten: alle `/api/v1`-Routen, bei Admins auch `/admin/*`, außerdem das Anlegen weiterer Keys. Für Webhooks (nur Aufgaben anlegen) ist das überdimensioniert.
- **Maßnahme:** Scopes auswerten; Keys standardmäßig nur auf `/inbox` und `/webhooks/ingest` beschränken.
### FLAW-08: Webhook-Ingest umgeht Sitzungsprüfung
- **Fundstelle:** `handleWebhookIngest`, JWT-Zweig.
- **Problem:** Das Token wird nur kryptografisch validiert. `IsTokenRevoked`, `token_version` und `is_active` werden nicht geprüft; ein ausgeloggtes oder deaktiviertes Konto kann weiter Aufgaben anlegen (bis Token-Ablauf nach 30 Tagen).
- **Maßnahme:** Dieselbe Prüfkette wie in `authMiddleware` verwenden (gemeinsame Funktion statt Kopien; siehe auch `authenticateUserFromReq`).
### FLAW-09: Kein Schutz gegen Brute-Force
- **Fundstellen:** `handleLogin`, `handleRegister`, `handleFirstBootSetup`.
- **Problem:** Weder Rate-Limit noch Sperre; `middleware.RealIP` vertraut `X-Forwarded-For` ungeprüft. Mindestpasswortlänge ist 4 Zeichen. Der Login-Pfad ist so über das Internet raten- und durchprobierbar.
- **Maßnahme:** Rate-Limit je IP und Konto, längeres Mindestpasswort.
### FLAW-10: `sharing_mode` wird nicht durchgesetzt
- **Fundstellen:** `Store.GetDiscoverableUsers`, `Store.GetCollaborators`; Share-Handler in `server.go`.
- **Problem:**
1. Der Modus beeinflusst nur `GetDiscoverableUsers`. In `teams_only` liefert die Funktion trotzdem **alle** Benutzer (`if … mode == "open" || mode == "teams_only" { return GetAllUsers() }`).
2. `GET /collaborators` ignoriert den Modus komplett und liefert alle aktiven Konten mit E-Mail-Adresse.
3. Keine der Share-Funktionen prüft den Modus. `strict_private` und `teams_only` verhindern Freigaben nicht, wenn man die Ziel-ID kennt.
- **Maßnahme:** Eine zentrale Funktion `canShareWith(owner, target)` und in allen Share-Handlern verwenden; Verzeichnisse darauf einschränken.
### FLAW-11: Block-Upsert prüft das Dokument nicht
- **Fundstelle:** `Store.PatchDocumentBlocks`, `INSERT INTO blocks … ON CONFLICT(id) DO UPDATE`.
- **Problem:** `blocks.id` ist global eindeutig. Existiert die ID bereits in einem **anderen** Dokument, überschreibt der Konflikt-Zweig dessen Inhalt, ohne `document_id` zu prüfen. Voraussetzung: Schreibrecht im eigenen Dokument und Kenntnis einer fremden Block-ID.
- **Maßnahme:** `DO UPDATE … WHERE blocks.document_id = excluded.document_id`; fremde IDs ablehnen.
### FLAW-12: Alarm-Logik
- **Fundstellen:** `startAlarmTicker`, `GetDueTasksForAlarm`, `UpdateThoughtDetails`, `UserSettingsView`.
- **Probleme:**
- `notify_before_minutes` (Einstellungen: „15 Minuten vorher“ usw.) wird nirgends gelesen. Fälligkeiten haben nur ein Datum (`YYYY-MM-DD`), eine Vorlaufzeit ist damit nicht abbildbar.
- Verglichen wird mit `time.Now()` in der Zeitzone des Containers (ohne `TZ` also UTC).
- `alarm_sent` wird nie zurückgesetzt. Wird die Fälligkeit nach einem Alarm verschoben, gibt es keinen neuen.
- Schlagen alle Kanäle fehl, wird jede Minute erneut gesendet (kein Backoff, keine Dead-Letter-Queue).
- **Maßnahme:** Fälligkeit mit Uhrzeit speichern, `alarm_sent` bei Änderung von `due_date` zurücksetzen, Wiederholungen begrenzen.
### FLAW-13: Migrationen
- **Fundstellen:** `Store.migrate` in [`internal/storage/sqlite.go`](../internal/storage/sqlite.go), [`migrations/001_init.sql`](../migrations/001_init.sql).
- **Probleme:**
- Es gibt keine Versionstabelle. Alle `ALTER TABLE`-Anweisungen laufen bei jedem Start erneut; Fehler werden pauschal ignoriert (`_, _ = s.db.Exec(q)`), echte Fehler fallen nicht auf.
- Mehrere `UPDATE`/`DROP TABLE`-Bereinigungen laufen bei jedem Start.
- `migrations/001_init.sql` wird nur in das Image kopiert, aber nirgends gelesen, und ist gegenüber dem tatsächlichen Schema veraltet.
- **Maßnahme:** `schema_migrations`-Tabelle mit nummerierten, einmalig ausgeführten Schritten; die SQL-Datei entweder einbetten oder entfernen.
### FLAW-14: Verschluckte Fehler
- **Fundstellen:** u. a. `PatchDocumentBlocks` (Upserts/Deletes), `AddTaskListShare`, `RemoveTaskListShare`, viele `_, _ = s.db.Exec`.
- **Problem:** Scheitert ein Schreibzugriff (z. B. `SQLITE_BUSY`), antwortet die API trotzdem mit Erfolg und broadcastet den Stand, der nie gespeichert wurde. Fehler lassen sich nur schwer nachvollziehen.
- **Maßnahme:** Mehrstufige Änderungen in Transaktionen bündeln und Fehler zurückgeben.
### FLAW-15: WriteTimeout und SSE *(nicht live verifiziert)*
- **Fundstelle:** [`cmd/server/main.go`](../cmd/server/main.go) `WriteTimeout: 30 * time.Second`.
- **Problem:** Go beendet nach diesem Zeitraum das Schreiben der Antwort. Für den SSE-Fallback (`ServeSSE`) bedeutet das voraussichtlich einen Verbindungsabbruch alle 30 s. WebSockets sind durch Hijacking nicht betroffen. Der Client verbindet sich neu, daher fällt es kaum auf.
- **Maßnahme:** Im SSE-Handler `http.ResponseController.SetWriteDeadline(time.Time{})` setzen oder `WriteTimeout` für diese Route aufheben.
### FLAW-16: Ersteinrichtung und Demo-Modus
- **Fundstellen:** `handleFirstBootSetup`, `Store.EnsureDemoAccounts`, `config.Load` (`MINDA_DEMO`, `MINDSIFT_DEMO`, `DEMO_MODE`).
- **Probleme:** Solange kein Konto existiert, kann jeder `POST /auth/setup` aufrufen und wird Administrator. Mit gesetztem Demo-Flag entstehen Konten `admin/admin` und `user/user`; versehentlich auf einer öffentlichen Instanz aktiv wäre das eine Komplettübernahme.
- **Maßnahme:** Setup per einmaligem Token aus dem Server-Log absichern; Demo-Modus nur zusammen mit expliziter Warnung/Bestätigung.
### FLAW-17: Schreibgeschützte Dokumentfreigaben
- **Fundstellen:** Migration in `Store.migrate` (`UPDATE doc_shares SET permission = 'read' …`, ebenso `doc_team_shares`, `workspace_folder_shares`), `PatchDocumentBlocks`/`UpdateDocument` (nur Eigentümer).
- **Hinweis:** Das ist offenbar gewollt: Nur der Eigentümer schreibt in ein Dokument, Empfänger lesen. Es gibt daher **kein** kollaboratives Bearbeiten. Das Schema (`CHECK(permission IN ('read','write'))`), die Zweige für `write` im Code und frühere Dokumentation suggerieren etwas anderes. Es gibt weder Sperren noch Konfliktauflösung, noch Cursor-Presence.
- **Maßnahme:** Entweder Schreibfreigaben mit Konfliktbehandlung ergänzen oder den toten Code und das Schema bereinigen.
### FLAW-18: Suche
- **Fundstelle:** `Store.SearchAll`.
- **Probleme:** Durchsucht nur eigene Gedanken (30) und eigene Dokumente (20); geteilte Dokumente, Aufgaben aus geteilten Listen, Einkaufslisten und Blöcke bleiben außen vor. `LIKE '%…%'` ohne Escaping von `%`/`_`, keine Indizes, keine Relevanz.
- **Maßnahme:** FTS5-Index über Blöcke/Gedanken; Berechtigungen mitberücksichtigen.
### FLAW-19: Geringe Frontend-Testabdeckung
- **Stand:** Vitest mit jsdom und Testing Library ist eingerichtet (`npm run test:run`) und läuft beim Image-Build. Getestet sind bisher nur Outliner-Funktionen (Backspace-Zusammenführen, Cursor-Position beim Klicken).
- **Problem:** Aufgaben, Listen, Freigabe-Dialoge und Berechtigungsanzeigen sind ungetestet. `"lint"` in [`frontend/package.json`](../frontend/package.json) ist weiterhin ein Platzhalter (`echo`).
- **Maßnahme:** Tests für kritische Pfade ergänzen, ESLint als echtes `lint`-Skript.
### FLAW-20: Dateigröße und Duplikate
- `internal/storage/sqlite.go` (≈ 5.600 Zeilen), `internal/api/server.go` (≈ 3.000), `WorkspaceView.tsx` (≈ 2.300), `TasksView.tsx` (≈ 2.500), `api.ts` (≈ 1.500).
- Die Zugriffsabfrage für Aufgabenlisten steht dreimal fast identisch im Code (`accessibleTaskListIDsQuery`, `GetTaskLists`, `GetTaskListByID`). Dokumentzugriff ist ähnlich verteilt. Änderungen an Berechtigungen müssen an mehreren Stellen erfolgen und werden leicht vergessen.
- **Maßnahme:** In Teilmodule zerlegen (Auth, Dokumente, Aufgaben, Listen, Teams), Zugriffsprüfung zentralisieren.
### FLAW-21: Lizenzdatei fehlt
- README und Badge nennen die MIT-Lizenz und verlinken auf `LICENSE`; die Datei existiert nicht im Repository.
### FLAW-22: Netzwerk-Details
- `authenticateUserFromReq` akzeptiert das Token als `?token=` (nötig für `WebSocket`/`EventSource`, die keine Header setzen). Tokens landen dadurch in Proxy- und Zugriffslogs.
- `AllowedOrigins: ["*"]` (Auth läuft über `Authorization`-Header, nicht über Cookies, das Risiko ist daher begrenzt) und `CheckOrigin` liefert immer `true`.
- Ein einmal verbundener WebSocket/SSE-Stream wird bei Logout oder Token-Widerruf nicht getrennt.
- **Maßnahme:** Kurzlebiges, einmaliges Stream-Ticket statt Langzeit-JWT in der URL; Origin gegen `BASE_URL` prüfen.
### FLAW-23: SQLite-Schreibengpass
- Eine SQLite-Datei (WAL, `busy_timeout`, bis zu 8 offene Verbindungen) erlaubt nur einen gleichzeitigen Schreiber. Für Familien und kleine Teams unkritisch; bei vielen aktiven Editoren sind `SQLITE_BUSY`-Fehler möglich, die wegen FLAW-14 stumm bleiben.
### FLAW-24: Organisations-Endpunkte ohne Berechtigungsprüfung
- **Fundstellen:** `handleDeleteOrganization`, `handleGetOrgTeams`, `handleCreateOrgTeam` (und `Store.DeleteOrganization`) in `server.go` bzw. `sqlite.go`.
- **Problem:** Diese Handler prüfen weder Eigentümer noch Mitgliedschaft. Jeder angemeldete Nutzer kann mit bekannter ID eine fremde Organisation löschen, deren Gruppen auflisten oder dort Gruppen anlegen. Gruppenbezogene Endpunkte (`/teams/{id}…`) prüfen dagegen korrekt auf Gruppen-Admin. Das Datenmodell kennt Organisationen nur noch rudimentär; im UI werden Gruppen direkt über `POST /teams` angelegt (`orgId` Standard `default`).
- **Maßnahme:** Organisationen entweder konsequent absichern (Eigentümer/Server-Admin) oder entfernen.
---
## Aus der früheren Fassung entfernt
Die frühere `flaws.md` führte als „gelöst“, was im Code nicht existiert: In-Memory-DocRooms, `clientId`-Echo-Schutz, Cursor-Presence, 1,5-s-Debounce-Flush, `internal/ws/docroom.go`, `frontend/src/lib/useDocumentSync.ts` und Yjs/CRDT. Der Code enthält nur noch `internal/ws/hub.go` (Räume, Benutzer-Kanäle, SSE-Fallback). Die CRDT-Tabellen werden beim Start gelöscht (`DROP TABLE document_crdt_*`). Dokumente werden per REST gespeichert (`PATCH /documents/{id}/blocks`) und als `DOC_BLOCKS_PATCHED` verteilt. Die frühere Aussage zur Token-Versionierung (`token_version`, `revoked_tokens`) trifft dagegen zu und ist in [sharing-and-permissions.md](sharing-and-permissions.md) und [architecture.md](architecture.md) beschrieben.
+19
View File
@@ -0,0 +1,19 @@
# Modul: Geteilte Listen
Einkaufs-, Pack- und Checklisten für gemeinsames Arbeiten. Code: [SharedListsView.tsx](../../frontend/src/features/lists/SharedListsView.tsx); Backend: `*List*`-Handler in [server.go](../../internal/api/server.go), `Store.CreateList` und Verwandte.
## Datenmodell
- `lists`: Eigentümer, `title`, `icon`, `list_type` (`shopping`, `checklist`, `vacation`).
- `list_items`: `content`, `category`, `quantity`, `is_completed`, `completed_by(_name)`, `sort_order`.
- `list_shares`: Ziel (Benutzer oder Gruppe) und Rolle `editor` oder `viewer`.
## Funktionen
- **Kategorien per Hashtag:** `Hafermilch #Kühlregal` landet in der Kategorie „Kühlregal“. Kategorien werden alphabetisch als Abschnitte angezeigt; die Eingabe schlägt vorhandene Hashtags vor.
- **Menge:** Feld `quantity` (z. B. `2x`, `500g`).
- **Drag & Drop** ordnet Einträge um; in eine andere Kategorie gezogen, wird der Hashtag im Text angepasst (`items/reorder` mit `category` und `content`).
- **Abhaken** speichert, wer und wann; Erledigte stehen in einem eigenen Bereich und lassen sich per „Erledigte entfernen“ löschen (`clear-completed`).
- **Freigabe** über den Dialog `UnifiedShareModal` (Bearbeiten oder Nur Lesen). Ein Leser sieht Änderungen live, ändert aber nichts.
- **Live-Sync:** Ereignisse `ITEM_*` und `LIST_*` im Raum der Liste ([websocket.md](../api/websocket.md)). Abhaken erscheint bei allen Beteiligten sofort.
- Aus der Triage lassen sich Gedanken direkt als Eintrag in eine Liste übernehmen.
+38
View File
@@ -0,0 +1,38 @@
# Modul: Aufgaben, Kanban & Alarme
Code: [TasksView.tsx](../../frontend/src/features/triage/TasksView.tsx), [TaskDetailModal.tsx](../../frontend/src/features/triage/TaskDetailModal.tsx), [ManageColumnsModal.tsx](../../frontend/src/features/triage/ManageColumnsModal.tsx); Backend: `handleUpdateThought`, `handleReorderTasks`, `*TaskList*`-Handler, `startAlarmTicker`.
## Datenmodell
Eine Aufgabe ist ein `thought` ([database.md](../database.md)) mit `kanbanStatus`, `priority` (`low`/`normal`/`high`/`urgent`), `dueDate` (nur Datum), `notes` (Markdown), `subtasks` (`[{id, title, isCompleted}]`), `tags`, `taskListId` und `sortOrder`. Aufgaben ohne Liste sind persönlich.
## Ansichten
- **Liste** mit Filtern (offen, erledigt, Papierkorb), Gruppierung (z. B. nach Status oder Fälligkeit), Sortierung und Hashtag-Filter.
- **Kanban:** Spalten pro Liste frei konfigurierbar (Name, Farbe, „Erledigt“-Spalte). Standard: *Zu erledigen*, *In Bearbeitung*, *Wartet auf andere*, *Erledigt*. Eine Karte in eine „Erledigt“-Spalte zu ziehen setzt `isCompleted`.
- Drag & Drop sortiert in der Liste und zwischen Spalten; ein Klick öffnet das Detail-Fenster (Titel, Status, Priorität, Fälligkeit mit Schnellwahl, Liste, Hashtags, Unteraufgaben mit Fortschrittsbalken, Notizen).
- Aufgaben lassen sich in eine Notiz umwandeln („Als Notiz vertiefen“). Aufgaben aus Notiz-Checkboxen (`enhanced-task`) bleiben verknüpft: Abhaken wirkt in beide Richtungen (`SyncDocBlockAndTask`), „In Notiz öffnen“ springt zum Block.
## Aufgabenlisten und Freigabe
- Jede Liste hat Eigentümer, Titel, Symbol, Farbe und eigene Kanban-Spalten.
- Freigabe an Gruppen oder Benutzer über den Dialog `UnifiedShareModal` (Stufe pro Freigabe):
- **Bearbeiter:** darf Aufgaben anlegen, ändern, abhaken, verschieben, löschen sowie Liste umbenennen und Spalten ändern.
- **Leser:** sieht alles, kann nichts ändern. Der Server antwortet mit `403`; die Oberfläche blendet Eingaben aus und zeigt „Nur Lesen“.
- Gruppenmitglieder mit mehreren Wegen erhalten die höchste Stufe. Nur der Eigentümer legt Freigaben an oder entfernt sie und kann die Liste löschen.
- Umsetzung: `Store.GetTaskListPermission` liefert `owner`/`editor`/`viewer`/`""`; `ensureTaskListWritable` schützt Schreibpfade.
- Bekannte Lücken: [FLAW-03](../flaws.md) (Umsortieren per ID), [FLAW-06](../flaws.md) (Editor kann weiterteilen).
## Alarme
Der Ticker läuft jede Minute ([architecture.md](../architecture.md)). Für offene Aufgaben mit `due_date <= heute` und `alarm_sent = 0` sendet er an die Kanäle des Aufgaben-**Eigentümers**:
| Kanal | Ziel | Konfiguration |
|---|---|---|
| E-Mail | `alarmEmail` (sonst Konto-Adresse) | SMTP aus Admin-Einstellungen oder Umgebung (Port 465 mit TLS wird direkt unterstützt, sonst STARTTLS über `smtp.SendMail`) |
| Discord | Webhook-URL | Benutzereinstellungen |
| Webhook | beliebige URL (JSON: `event`, `title`, `notes`, `dueDate`, `priority`, `timestamp`) | Benutzereinstellungen |
Danach wird `alarm_sent = 1` gesetzt, sobald mindestens ein Kanal erfolgreich war.
Einschränkungen ([FLAW-12](../flaws.md)): Fälligkeiten haben keine Uhrzeit, `notifyBeforeMinutes` wird ignoriert, die Serverzeit gilt, und ein verschobener Termin löst nicht erneut aus. URLs werden nicht geprüft ([FLAW-05](../flaws.md)).
+36
View File
@@ -0,0 +1,36 @@
# Modul: Schnellerfassung & Triage
Ziel: Gedanken in Sekunden festhalten und später in Ruhe sortieren. Code: [InstantCaptureBar.tsx](../../frontend/src/features/capture/InstantCaptureBar.tsx), [TriageDeck.tsx](../../frontend/src/features/triage/TriageDeck.tsx), Backend `handleCaptureInbox`, `handleTriageAction` und `Store.UpdateThoughtTriage`.
## Erfassung
- 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`).
- Mehrzeilige Eingabe wird unterstützt. Spracheingabe nutzt die Web Speech API 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`).
- Per API: `POST /inbox` und `POST /webhooks/ingest` (siehe [rest.md](../api/rest.md)).
## Triage-Deck
Der Posteingang (`status = inbox`) wird Karte für Karte abgearbeitet.
| Aktion | Desktop | Mobil | Ergebnis |
|---|---|---|---|
| Verwerfen | `H` oder `←` | Wischen links | `status = discarded` (Papierkorb) |
| Zur Aufgabe | `L` oder `→` | Wischen rechts | `kept`/`converted` als Aufgabe |
| Aufgabe mit Termin | `T` | Aktionsmenü | wie oben, mit Fälligkeit |
| In Notiz wandeln | `D` | Aktionsmenü | neues Dokument, Gedanke wird `converted` (`document`) |
| Später | Leertaste | Aktionsmenü | Karte ans Stapelende |
| Menü schließen | `Esc` | – | – |
Die Umwandlung in eine Notiz übernimmt Titel, Erledigt-Status, Notizen, Unteraufgaben sowie Fälligkeit und Priorität als eingerückte Blöcke. Eine Umwandlung in einen Listeneintrag legt einen Eintrag in der gewählten Liste an und benachrichtigt deren Teilnehmer.
## Zustände eines Gedankens
```
inbox ──keep──▶ kept ──convert──▶ converted (task | document | list_item)
│ │
└─discard─▶ discarded ──restore──▶ inbox (Papierkorb leeren: DELETE /thoughts/discarded/purge)
```
Ein Gedanke mit `status = kept` zählt bereits als Aufgabe und erscheint in der Aufgabenansicht.
+39
View File
@@ -0,0 +1,39 @@
# 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, `Store.PatchDocumentBlocks`.
## Datenhaltung
- Eine Notiz ist ein Datensatz in `documents` plus geordnete Zeilen in `blocks` (`position` als Kommazahl, `level` für die Einrückung). Alles liegt in SQLite; es gibt keine Dateien auf der Platte.
- Bei jeder Änderung wird `documents.markdown_cache` neu erzeugt; `block_tags` wird aus `#Hashtags` der Blöcke neu aufgebaut.
- Der Export als `.md` erfolgt im Browser (Button im Editor, Blob-Download).
- Blocktypen: Überschriften `h1`–`h3`, `paragraph`/`text`, `bullet`, `todo`, `code`, `callout`, `divider`, `table`, `progress`.
## Editor
- **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.
- **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`).
- **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.
- **Speichern:** Der Editor speichert 800 ms nach der letzten Eingabe das gesamte Dokument (`PUT /documents/{id}`). Der schlanke Block-Endpunkt `PATCH /documents/{id}/blocks` wird derzeit vom To-Do-Rollup beim Abhaken genutzt.
## Explorer, Tags, To-Dos
- Ordnerbaum mit Favoriten (`doc_pins`), Verschieben per Drag & Drop (`/documents/{id}/move`, `/folders/move`).
- **Tags:** Alle `#Hashtags` werden aggregiert (`/workspace/tags`). Eine Tag-Seite zeigt alle Fundstellen gruppiert nach Notiz und kann eine eigene Freitext-Notiz tragen (`tag_pages`).
- **To-Do-Rollup:** Die Ansicht „To-Dos“ sammelt alle `todo`-Blöcke aller Notizen, filterbar nach Ordner. Abhaken im Rollup ändert den Block in der Notiz.
- **Enhanced Tasks:** Ein `todo`-Block lässt sich als verknüpfte Aufgabe führen (`/documents/{id}/blocks/{blockId}/enhanced-task`); Erledigt-Status bleibt zwischen Notiz und Aufgabenansicht synchron.
## Freigabe
- Einzelne Notizen und ganze **geteilte Ordner** lassen sich an Benutzer oder Gruppen geben. Empfänger haben **nur Leserechte**; nur der Eigentümer schreibt ([sharing-and-permissions.md](../sharing-and-permissions.md), [FLAW-17](../flaws.md)).
- 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 `DOC_BLOCKS_PATCHED`/`DOC_UPDATED` ([websocket.md](../api/websocket.md)).
## Grenzen
- Keine gleichzeitige Mehrbenutzerbearbeitung, keine Cursor-Anzeige, keine Versionshistorie.
- Keine Virtualisierung langer Dokumente; sehr große Notizen können auf Mobilgeräten träge werden *(nicht gemessen)*.
- Suche (`/search`) erfasst nur Titel und Inhalt eigener Notizen ([FLAW-18](../flaws.md)).
+229
View File
@@ -0,0 +1,229 @@
# 🛡️ Reverse-Proxy & WebServer-Konfigurationshandbuch
Dieses Dokument beschreibt die empfohlene Konfiguration von Reverse-Proxies und Ingress-Controllern für MindSift. MindSift nutzt eine **hybride Realtime-Architektur** bestehend aus bidirektionalen **WebSockets** und einem automatischen **Server-Sent Events (SSE) Fallback**, um selbst in restriktivsten Firmennetzwerken, hinter strikten Firewalls oder bei Deep-Packet-Inspection unterbrechungsfreie Live-Synchronisation zu gewährleisten.
---
## ⚡ Wichtige Anforderungen auf einen Blick
| Protokoll | Standard-Port | Benötigte Header & Einstellungen | Zweck |
| :--- | :--- | :--- | :--- |
| **WebSockets** | 443 (WSS) / 80 (WS) | `Upgrade $http_upgrade`<br>`Connection "Upgrade"`<br>`proxy_read_timeout 3600s` | Bidirektionale Live-Updates (Aufgaben, Listen, Notizen) |
| **Server-Sent Events (SSE)** | 443 (HTTPS) / 80 (HTTP) | `X-Accel-Buffering: no`<br>`proxy_buffering off`<br>`proxy_cache off` | Fallback bei blockiertem WebSocket-Upgrade |
| **REST API & Uploads** | 443 (HTTPS) / 80 (HTTP) | `client_max_body_size 64M` | Notizen, Import/Export (JSON) |
---
## 1. Nginx
Eine typische `nginx.conf` bzw. Site-Konfiguration unter `/etc/nginx/sites-available/mindsift`:
```nginx
# Map für sauberes Connection-Handling bei WebSocket-Upgrades
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name mindsift.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name mindsift.example.com;
ssl_certificate /etc/letsencrypt/live/mindsift.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/mindsift.example.com/privkey.pem;
# Maximale Payload-Größe für große Notizen und Exporte
client_max_body_size 64M;
# MindSift Frontend & REST API
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 1. WebSocket Endpunkte (/api/v1/ws)
location /api/v1/ws {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
# Upgrade-Header zwingend erforderlich
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Verhindert vorzeitigen Verbindungsabbruch durch Proxy-Inaktivitäts-Timeout
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# 2. Server-Sent Events Fallback (/api/v1/events & /api/v1/sync/)
location ~ ^/api/v1/(events|sync/) {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
# Pufferung zwingend deaktivieren für sofortiges Event-Streaming
proxy_buffering off;
proxy_cache off;
proxy_set_header X-Accel-Buffering no;
proxy_set_header Connection '';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400s;
}
}
```
---
## 2. Traefik (Docker Compose / Ingress)
Bei Verwendung von Traefik Version 2/3 müssen keine expliziten WebSocket-Upgrades deklariert werden, da Traefik diese transparent durchleitet. Für Server-Sent Events und Timeouts empfehlen sich Middleware-Definitionen:
```yaml
version: '3.8'
services:
mindsift:
image: ghcr.io/sandrodach/mindsift:latest
restart: unless-stopped
environment:
- PORT=8080
- DB_PATH=/app/data/mindsift.db
volumes:
- ./data:/app/data
labels:
- "traefik.enable=true"
- "traefik.http.routers.mindsift.rule=Host(`mindsift.example.com`)"
- "traefik.http.routers.mindsift.entrypoints=websecure"
- "traefik.http.routers.mindsift.tls.certresolver=letsencrypt"
- "traefik.http.services.mindsift.loadbalancer.server.port=8080"
# Verhindert Pufferung und hält persistente SSE-Streams am Leben
- "traefik.http.middlewares.mindsift-buffering.buffering.maxRequestBodyBytes=67108864"
- "traefik.http.routers.mindsift.middlewares=mindsift-buffering"
```
---
## 3. Caddy Server (Caddyfile)
Caddy unterstützt WebSockets und HTTP/2 Server-Sent Events von Haus aus vollständig ("out-of-the-box"):
```caddyfile
mindsift.example.com {
encode gzip zstd
# Event-Stream (SSE) ohne Pufferung weiterleiten
reverse_proxy 127.0.0.1:8080 {
# Streaming für SSE optimieren
flush_interval -1
}
}
```
---
## 4. Cloudflare Proxy (Orange Cloud)
Wird MindSift hinter dem Cloudflare CDN betrieben:
1. **WebSockets aktivieren:**
- Navigiere in das Cloudflare Dashboard: **Network** > **WebSockets** > Schalter auf **ON** stellen.
2. **Cloudflare Rocket Loader / Auto Minify:**
- Deaktiviere "Rocket Loader" für MindSift, da asynchrone Skripteingriffe den Start der Single-Page-App stören können.
3. **Cloudflare WAF / Bot Fight Mode:**
- Stelle sicher, dass `GET /api/v1/events` und `GET /api/v1/ws*` nicht von "Challenge"-Seiten blockiert werden.
4. **SSE-Unterstützung:**
- Cloudflare unterstützt Server-Sent Events nativ über HTTP/2 und HTTP/3. Dank der vom MindSift-Backend gesetzten Header `X-Accel-Buffering: no` und `Cache-Control: no-cache` streamt Cloudflare Echtzeit-Events verzögerungsfrei an die Clients.
---
## 5. Apache 2.4 (`mod_proxy_wstunnel`)
Unter Debian/Ubuntu:
```bash
sudo a2enmod proxy proxy_http proxy_wstunnel headers
sudo systemctl restart apache2
```
VirtualHost-Konfiguration (`/etc/apache2/sites-available/mindsift.conf`):
```apache
<VirtualHost *:443>
ServerName mindsift.example.com
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/mindsift.example.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/mindsift.example.com/privkey.pem
# 1. WebSocket Tunneling
RewriteEngine On
RewriteCond %{HTTP:Upgrade} =websocket [NC]
RewriteRule /(.*) ws://127.0.0.1:8080/$1 [P,L]
RewriteCond %{HTTP:Upgrade} !=websocket [NC]
RewriteRule /(.*) http://127.0.0.1:8080/$1 [P,L]
# 2. SSE Response Flushing
<Location "/api/v1/events">
ProxyPass "http://127.0.0.1:8080/api/v1/events" flushpackets=on
ProxyPassReverse "http://127.0.0.1:8080/api/v1/events"
</Location>
ProxyPass / http://127.0.0.1:8080/
ProxyPassReverse / http://127.0.0.1:8080/
</VirtualHost>
```
---
## 🔍 Diagnose & Verifikation
Mit den folgenden Befehlen lässt sich die Erreichbarkeit und Streaming-Funktion prüfen:
### 1. Server-Sent Events Test (Standard-HTTPS)
```bash
curl -N -H "Accept: text/event-stream" -H "Authorization: Bearer <DEIN_TOKEN>" https://mindsift.example.com/api/v1/events
```
*Erwartete Ausgabe:*
```text
: connected (transport: sse, id: sse_...)
data: {"event":"SSE_CONNECTED","payload":{"sseId":"sse_...","transport":"sse"},"timestamp":"..."}
: ping
```
### 2. WebSocket Handshake Test
```bash
curl -i -N \
-H "Connection: Upgrade" \
-H "Upgrade: websocket" \
-H "Host: mindsift.example.com" \
-H "Origin: https://mindsift.example.com" \
-H "Sec-WebSocket-Key: SGVsbG8sIHdvcmxkIQ==" \
-H "Sec-WebSocket-Version: 13" \
https://mindsift.example.com/api/v1/ws?token=<DEIN_TOKEN>
```
*Erwartete Antwort:* `101 Switching Protocols`
---
## 🧭 Verwandte Dokumente
- 🔌 [WebSocket & Live-Protokoll](api/websocket.md)
- 🏗️ [Gesamtarchitektur](architecture.md)
- 🚨 [Bekannte Schwachstellen & Flaws](flaws.md)
+55
View File
@@ -0,0 +1,55 @@
# Teilen & Berechtigungen
## Rollen
| Ebene | Werte | Bedeutung |
|---|---|---|
| Server | `admin`, `member` | Admins verwalten Benutzer, Einladungen und Einstellungen (`/admin/*`). |
| Gruppe | `admin`, `member` | Gruppen-Admins ändern/löschen die Gruppe und verwalten Mitglieder. Wer eine Gruppe anlegt, wird Admin. |
| Ressource | siehe unten | Eigentümer, Bearbeiter, Leser. |
## Berechtigungen je Ressource
| Ressource | Eigentümer | Bearbeiter | Leser | Freigabe an |
|---|---|---|---|---|
| **Aufgabenliste** (`task_list_shares.permission`) | alles, löschen, (un)teilen | Aufgaben anlegen/ändern/abhaken/verschieben/löschen, Liste umbenennen, Spalten ändern | nur ansehen | Benutzer, Gruppen |
| **Geteilte Liste** (`list_shares.role`) | alles, löschen, (un)teilen | Einträge anlegen/ändern/abhaken/löschen | nur ansehen | Benutzer, Gruppen |
| **Notiz** (`doc_shares`, `doc_team_shares`) | schreiben | – | nur lesen | Benutzer, Gruppen |
| **Geteilter Ordner** (`workspace_folder_shares`) | schreiben | – | nur lesen | Benutzer, Gruppen |
- **Notizen und Ordner sind absichtlich schreibgeschützt für Empfänger.** Das erzwingt eine Migration beim Start (`permission = 'read'`); Schreibpfade im Code sind tot ([FLAW-17](flaws.md)).
- Bei Aufgabenlisten und Listen gilt: Hat jemand mehrere Zugriffswege (direkt und über Gruppen), zählt die **höchste** Stufe.
- Für Gruppen gilt die Freigabe für alle aktuellen und künftigen Mitglieder.
- Aufgabenlisten besitzen zusätzlich das Altfeld `team_id`: Mitglieder dieser Gruppe haben ohne Freigabezeile Bearbeiterrechte. Eine Gruppenfreigabe über den Dialog setzt `team_id`, falls es noch leer ist.
## Aufgabenlisten teilen (Details)
1. Eigentümer öffnet „Teilen“ an der Liste, wählt Gruppe oder Benutzer und die Stufe **Bearbeiten** oder **Nur Lesen**.
2. `POST /task-lists/{id}/shares` speichert `permission`; erneutes Hinzufügen desselben Ziels ändert die Stufe.
3. `GET /task-lists` liefert je Liste `permission` (`owner`/`editor`/`viewer`). Das Frontend blendet für `viewer` alle Eingabemöglichkeiten aus und zeigt „Nur Lesen“.
4. Der Server prüft unabhängig davon jeden Schreibzugriff (`403`, `storage.ErrTaskListReadOnly`).
Bestehende Freigaben aus der Zeit vor dieser Stufe wurden mit `editor` migriert, behalten also ihr Verhalten.
## Isolations-Modi (`system_settings.sharing_mode`)
Werte: `strict_private`, `teams_only` (Standard), `open`. Gedacht ist:
| Modus | Zweck |
|---|---|
| `strict_private` | Keine Sichtbarkeit zwischen Benutzern |
| `teams_only` | Freigaben nur zwischen Mitgliedern gemeinsamer Gruppen |
| `open` | Jeder kann für jeden freigeben |
**Tatsächlich** wird der Modus nur in `GetDiscoverableUsers` ausgewertet, und dort liefert `teams_only` bereits alle Benutzer. `GET /collaborators` ignoriert den Modus, und keine Freigabe-Funktion prüft ihn. Die Einstellung hat derzeit also kaum Wirkung ([FLAW-10](flaws.md)).
## Entkoppelte Ablage geteilter Notizen und Ordner
- **Geteilte Ordner:** Der Ordner existiert einmal (`workspace_shared_folders`); jeder Empfänger hat seinen eigenen Einhängepfad (`workspace_folder_mounts.mount_path`). Verschiebt ein Empfänger den Ordner, ändert sich nur sein Pfad. Der Unterpfad innerhalb des Ordners (`documents.sub_folder_path`) gilt für alle.
- **Einzelne Notizen:** Jeder Empfänger kann eine geteilte Notiz in einen eigenen Ordner legen (`doc_user_folders`); die Notiz des Eigentümers bleibt unberührt.
## Sitzungen und Zugriff
- Tokens werden bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ ungültig (`token_version`); ausgeloggte Tokens landen in `revoked_tokens`.
- Registrierung nur per Einladungscode oder, wenn vom Admin freigeschaltet, offen. Das erste Konto wird immer Administrator.
- Bekannte Lücken in der Zugriffskontrolle: [FLAW-01](flaws.md), [FLAW-02](flaws.md), [FLAW-03](flaws.md), [FLAW-06](flaws.md), [FLAW-07](flaws.md), [FLAW-08](flaws.md), [FLAW-24](flaws.md).
+109
View File
@@ -0,0 +1,109 @@
# 🎨 Single-File CSS Theming Engine & Design-System
MindSift implementiert ein radikal einfaches, modulares Theming-System. Anstatt Farb- und Geometriewerte in Dutzenden Tailwind-Klassen über hunderte Komponenten zu verstreuen, wird das komplette Erscheinungsbild der Plattform über **eine einzige CSS-Datei pro Theme** gesteuert.
---
## 💡 1. Philosophie: 1 Datei = 1 komplettes Theme
Jedes Theme liegt im Verzeichnis [`frontend/src/themes/`](../frontend/src/themes/) und definiert alle visuellen Eigenschaften über standardisierte CSS-Variablen.
### Trennung von Struktur und Design
- **React-Komponenten** definieren ausschließlich semantische Klassen (wie `.panel-surface`, `.input-field`, `.btn-primary`, `text-main`, `bg-canvas`).
- **Die aktive Theme-CSS-Datei** bestimmt, welche konkreten Farben, Kontraste, Rahmenbreiten und Ecken-Rundungen diesen Klassen zugewiesen werden.
---
## 🧱 2. Die 6 standardisierten Variablen-Blöcke
Jede Theme-Datei gliedert sich in sechs exakt definierte Bereiche:
```css
:root[data-theme="dein-theme"] {
/* ==========================================================================
1. AKZENTFARBE (Fokus, Primäraktionen, Highlights)
========================================================================== */
--accent: #2563eb; /* Primäre Schaltflächen & aktive Tabs */
--accent-hover: #3b82f6; /* Hover-Zustand */
--accent-dim: rgba(37, 99, 235, 0.15);/* Transparenter Hintergrund für Badges */
--accent-contrast: #ffffff; /* Textfarbe auf Akzentflächen */
/* ==========================================================================
2. OBERFLÄCHEN & HINTERGRÜNDE
========================================================================== */
--bg-canvas: #0e1013; /* Grundhintergrund der gesamten Seite */
--bg-panel: #16181d; /* Panels, Karten, Modals & Seitenleisten */
--bg-panel-elevated: #1f2229; /* Hervorgehobene Elemente & Hover-Panels */
--bg-input: #121418; /* Hintergrund von Textfeldern */
--bg-code: #0b0c0f; /* Codeblöcke & Tastatur-Badges */
--bg-overlay: rgba(0, 0, 0, 0.75); /* Modal-Hintergrund Backdrop */
/* ==========================================================================
3. TEXT & KONTRASTE
========================================================================== */
--text-main: #f4f4f5; /* Haupttext, Überschriften, Labels */
--text-muted: #a1a1aa; /* Sekundärtext, Beschreibungen */
--text-faint: #71717a; /* Dezentester Text, Tastaturhinweise */
--text-on-accent: #ffffff; /* Lesbarer Text auf --accent */
/* ==========================================================================
4. RAHMEN, LINIEN & SCHATTEN
========================================================================== */
--border-color: rgba(255, 255, 255, 0.08); /* Standard-Trennlinien */
--border-strong: rgba(255, 255, 255, 0.18);/* Fokussierte Rahmen, Tabs */
--shadow-panel: 0 4px 20px -2px rgba(0, 0, 0, 0.45); /* Box-Shadows */
/* ==========================================================================
5. STATUS- & SIGNALFARBEN
========================================================================== */
--status-ok: #22c55e; /* Erledigt, Online, Aktiv (Grün) */
--status-err: #ef4444; /* Fehler, Löschen, Dringend (Rot) */
--status-warn: #eab308; /* Wichtig, Warnung, Gesperrt (Gelb) */
--status-info: #3b82f6; /* Hinweise, Information (Blau) */
/* ==========================================================================
6. RUNDUNGEN / GEOMETRIE
========================================================================== */
--radius-card: 0px; /* Eckenrundung von Panels & Karten */
--radius-btn: 2px; /* Eckenrundung von Schaltflächen */
--radius-input: 2px; /* Eckenrundung von Eingabefeldern */
--radius-checkbox: 2px; /* Checkboxen */
--radius-pill: 9999px; /* Pillen & Status-Badges */
}
```
---
## 🎭 3. Verfügbare Standard-Themes
| Theme-ID | Datei | Stil & Charakter | Standard-Geometrie |
| :--- | :--- | :--- | :--- |
| `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) |
| `high-contrast` | [`theme-high-contrast.css`](../frontend/src/themes/theme-high-contrast.css) | Reines Schwarz/Weiß mit AAA-Kontrast | Streng eckig (`0px`, kräftige Rahmen) |
| `soft` | [`theme-soft.css`](../frontend/src/themes/theme-soft.css) | Warmes Soft Slate mit Indigo-Akzent | Weich gerundet (`12px` Cards, `8px` Buttons) |
| *Template* | [`theme-template.css`](../frontend/src/themes/theme-template.css) | Vollständig dokumentierte Kopiervorlage | Frei anpassbar |
---
## 🔌 4. Tailwind-Bridge & Semantische Klassen
In [`frontend/src/index.css`](../frontend/src/index.css) und `tailwind.config.js` sind die semantischen Utility-Klassen definiert, die sich automatisch an das gewählte Theme anpassen:
- `.panel-surface`: Wendet `--bg-panel`, `--border-color` und `--radius-card` an.
- `.input-field`: Wendet `--bg-input`, `--border-color`, `--radius-input` und `--text-main` an.
- `.btn-primary`: Akzent-Schaltfläche mit `--accent`, `--accent-hover`, `--text-on-accent` und `--radius-btn`.
- `.btn-action`: Sekundär-Aktion mit dezentem Rahmen und Hover-Zustand.
- `.checkbox-square`: Kontrollkästchen mit `--radius-checkbox`.
- `.capture-pill`: Spezial-Pille für die Desktop-Schnelleingabeleiste mit `--radius-pill`.
---
## 🛠️ 5. Ein eigenes Theme erstellen
Um ein neues Theme (z.B. „Nordic Blue" oder „Cyberpunk") hinzuzufügen:
1. Kopiere `frontend/src/themes/theme-template.css` nach `frontend/src/themes/theme-meintheme.css`.
2. Ändere den Selektor `:root[data-theme="template"]` in `:root[data-theme="meintheme"]`.
3. Passe Farben und Rundungen nach Belieben an.
4. Importiere die CSS-Datei in [`frontend/src/lib/theme.ts`](../frontend/src/lib/theme.ts) und trage das Theme im `AVAILABLE_THEMES`-Array ein.
5. Das neue Theme steht sofort in den Kontoeinstellungen und im Profil-Dropdown zur Verfügung!
+141 -551
View File
@@ -3,607 +3,197 @@
# 🧠 MindSift # 🧠 MindSift
### *Der Kopf frei in drei Sekunden.* ### *Der Kopf frei in drei Sekunden.*
[![Build & Docker](https://img.shields.io/badge/Gitea%20CI-Passing-22c55e?logo=gitea&logoColor=white)](https://gitea.io) [![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#)
[![Docker Image](https://img.shields.io/badge/Docker-Multi--Arch-2496ed?logo=docker&logoColor=white)](https://hub.docker.com) [![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart)
[![Backend](https://img.shields.io/badge/Backend-Go%201.24-00ADD8?logo=go&logoColor=white)](https://go.dev) [![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir)
[![Frontend](https://img.shields.io/badge/Frontend-React%2019%20%2F%20Vite-61DAFB?logo=react&logoColor=black)](https://react.dev)
[![Database](https://img.shields.io/badge/Database-SQLite%20(WAL)%20%2F%20Postgres-003B57?logo=sqlite&logoColor=white)](https://sqlite.org)
[![Styling](https://img.shields.io/badge/Theme-Google%20Gemini%20Dark-8ab4f8)](#-designsystem--ästhetik-google-gemini-dna)
[![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
**MindSift** verbindet den schnellsten Brain-Dump der Welt mit der Eleganz eines modernen Workspaces, spielerischer Tinder-Style-Triage und kollaborativen Echtzeit-Listen für Familien und Teams — 100 % Self-Hosted & Open Source. **MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
[Manifest](#-das-manifest) • [Features](#-features--workflow) • [Architektur](#-technische-architektur--tech-stack) • [Datenbankschema](#-datenbankschema) • [API & Webhooks](#-api-spezifikation--webhooks) • [CI/CD](#-gitea-cicd-pipeline) • [Deployment](#-deployment--schnellstart) • [Roadmap](#-schrittweiser-phasenplan) <p align="center">
<img src="screenshots/Triage_desktop.png" alt="MindSift Triage Desktop" width="85%" />
</p>
--- [Philosophie](#-die-philosophie) • [Funktionen](#-funktionen) • [Schnellstart](#-schnellstart) • [Betrieb](#-betrieb--konfiguration) • [Entwicklung](#-entwicklung) • [Dokumentation](#-dokumentation)
</div> </div>
## 📜 Das Manifest ---
Große Workspace-Tools wie Notion sind genial, wenn man am Schreibtisch sitzt und ein monatelanges Projekt plant. Aber sie scheitern im Alltag genau an einer Stelle: **im echten Moment**. ## 💡 Die Philosophie
Wenn dir an der Supermarktkasse eine Idee kommt, du im Auto über einen Geistesblitz nachdenkst oder schnell eine Zutat aufschreiben willst, hast du keine Zeit, durch fünf verschachtelte Ordner und relationale Datenbanken zu navigieren. Du brauchst ein Tool, das in einer halben Sekunde bereitsteht, deinen Gedanken friert und dir später hilft, ihn spielerisch auszusortieren. Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn dir ein Gedanke kommt, willst du keinen Ordner suchen und keine Felder ausfüllen. MindSift trennt deshalb **Erfassen** und **Organisieren**:
**MindSift löst dieses Problem durch ein dreistufiges Prinzip:** 1. **Abladen:** In die Eingabeleiste tippen, `Enter`, fertig. Wer will, weist den Gedanken gleich einer Aufgabe, Liste oder Notiz zu.
1. **Erfassen:** Sofortiger Brain-Dump ohne jede Reibung. 2. **Sichten (Triage):** Später gehst du den Stapel Karte für Karte durch: verwerfen, zur Aufgabe machen, in eine Notiz oder Liste verwandeln.
2. **Sichten (Sift):** Spielerische Triage im Tinder-Style (Inbox Zero für dein Gehirn). 3. **Vertiefen:** Aus losen Ideen werden Aufgaben mit Unteraufgaben, vernetzte Outliner-Notizen oder geteilte Listen für Familie und Team.
3. **Strukturieren & Teilen:** Workspace für reifende Gedanken und geteilte Echtzeit-Listen für den Alltag.
--- ---
## ✨ Features & Workflow ## ✨ Funktionen
``` ### ⚡ Schnellerfassung & Triage
[ 🚀 Instant Capture ]
Handy raus • PWA • Webhook • Sprache
│
▼
[ 📥 Thought Inbox ]
│
▼
[ 🃏 Tinder-Style Triage ]
┌──────────┴──────────┐
▼ ▼
[ 👈 Nach Links ] [ 👉 Nach Rechts ]
Verwerfen / Done Behalten / Vertiefen
┌───────┼───────┐
▼ ▼ ▼
Task Doc Shared List
```
* **⚡ Capture First (Instant Puffer):** * Eingabeleiste im Kopf der App, jederzeit mit `C` oder `/` fokussierbar; auch per Spracheingabe (Browser-Funktion).
Handy raus, App antippen, Cursor steht im Feld. Text tippen oder diktieren (Web Speech API), absenden, fertig. Kein Ordner wählen, kein Tagging, kein Nachdenken. PWA-Shortcut für direkten Homescreen-Capture (< 300 ms Latenz). * Ziel beim Erfassen wählbar: Posteingang, Aufgabe, Listeneintrag oder Notiz.
* **🃏 Triage im Tinder-Style:** * **Triage-Deck:** `H`/`←` verwerfen, `L`/`→` zur Aufgabe, `T` Aufgabe mit Termin, `D` in Notiz verwandeln, Leertaste = später. Auf dem Smartphone per Wischgeste.
Abends oder am Wochenende gehst du deine unstrukturierten Gedanken durch wie ein Kartendeck: * Papierkorb mit Wiederherstellen.
* **Wisch nach links:** Weg damit (Gedanke erledigt oder irrelevant).
* **Wisch nach rechts:** Behalten, verfeinern, in eine To-Do mit Fälligkeitsdatum verwandeln oder in ein Projekt einsortieren. <p align="center">
* **📝 Notion-Flair, aber ohne Ballast:** <img src="screenshots/Mobile_Triage.png" alt="MindSift Mobile Triage" width="280px" />
Wächst ein Gedanke zum Projekt heran, wechselst du in den Workspace. Reiner, ablenkungsfreier Block-Editor mit Überschriften, Listen, Codeblöcken und Task-Elementen – ohne relationale Datenbank-Überkomplexität. </p>
* **🛒 Geteilte Listen & Family Spaces (Echtzeit):**
Einkaufslisten oder Urlaubsplaner per Link teilen. Hakst du an der Kasse die Hafermilch ab, verschwindet sie bei deinem Partner in Millisekunden dank WebSocket-Synchronisation. ### ✅ Aufgaben & Kanban
* **🤖 Webhooks & Ingestion API:**
Smarthome-Alarme (z. B. Home Assistant, Zigbee2MQTT), Cronjobs oder Webhooks speisen dringende Aufgaben oder Messpunkte direkt in deine Inbox ein. * Listen- und Kanban-Ansicht, Spalten pro Aufgabenliste frei konfigurierbar.
* **🛡️ 100 % Self-Hosted & Datensouveränität:** * Prioritäten, Fälligkeitsdatum, Hashtags, Unteraufgaben mit Fortschrittsbalken, Markdown-Notizen.
Keine fremde Cloud liest mit. Ein schlanker Docker-Container, SQLite mit Write-Ahead-Log (WAL) oder PostgreSQL. * Filter, Gruppierung und Sortierung; Drag & Drop in Liste und Board.
* **Aufgabenlisten teilen** mit Benutzern oder Gruppen, wahlweise als **Bearbeiter** oder **nur lesend**.
* **Alarme** bei Fälligkeit per E-Mail, Discord oder eigenem Webhook.
<p align="center">
<img src="screenshots/aufgaben_desktop.png" alt="MindSift Aufgaben und Kanban-Board" width="90%" />
</p>
### 📝 Workspace: Outliner-Notizen
* Jeder Gedanke ein Bullet: `Enter`, `Tab`/`Shift+Tab`, Ein-/Ausklappen, Drag & Drop ganzer Äste.
* **Zoom-Fokus** per Klick auf den Bullet, Breadcrumbs zum Zurücknavigieren.
* Blocktypen und Slash-Befehle: `/todo`, `/link`, `/h1`–`/h3`, `/code` (mit Syntax-Hervorhebung), `/callout`, `/divider`.
* Wiki-Links `[[Notiz]]`, Links und `#Hashtags`; Tag-Seiten bündeln alle Fundstellen.
* Ordner, Favoriten, **To-Do-Rollup** über alle Notizen und Export einer Notiz als `.md`.
* Einfügen von mehrzeiligem Markdown wird in Blöcke zerlegt.
* **Teilen:** Notizen und ganze Ordner lassen sich Benutzern oder Gruppen **zum Lesen** freigeben. Empfänger sortieren sie in ihren eigenen Baum ein.
<p align="center">
<img src="screenshots/workspace_desktop.png" alt="MindSift Workspace Outliner" width="90%" />
</p>
### 🛒 Geteilte Listen
* Einkaufs-, Pack- und Checklisten mit Kategorien per Hashtag (`Hafermilch #Kühlregal`), Mengenangaben und Drag & Drop zwischen Kategorien.
* Freigabe an Benutzer und Gruppen (Bearbeiter oder Leser); abgehakte Einträge erscheinen bei allen in Echtzeit.
<p align="center">
<img src="screenshots/Listen_desktop.png" alt="MindSift Geteilte Listen" width="90%" />
</p>
### 🏢 Benutzer, Gruppen, Administration
* Geführte Ersteinrichtung, Rollen *Admin* und *Mitglied*, Einladungscodes, optional offene Registrierung.
* Gruppen mit Gruppen-Admins für Freigaben.
* Admin-Bereich: Benutzer, Einladungen, Instanzname, SMTP, Freigabe-Modus.
* Vier Themes (Dark, Light, High-Contrast, Soft), installierbar als PWA.
### 🔌 Integrationen
* **Webhook / API-Key:** `POST /api/v1/webhooks/ingest` legt Aufgaben aus Smarthome, Skripten oder Automationen an.
* Persönliche API-Keys, Datenexport als JSON.
--- ---
## 🎨 Designsystem & Ästhetik: Google Gemini DNA ## 🚀 Schnellstart
MindSift setzt konsequent auf die Ästhetik von Google Gemini – organisch, hochmodern und beruhigend: MindSift läuft als einzelner Container mit Webserver, API, Datenbank und Frontend.
* **Dark Mode First (OLED Deep Obsidian):** ### Option 1: Installationsskript
* Hintergrund primär: `#0b0f19` / `#131314`
* Oberflächen/Karten: `#1e1f20` mit feinen `1px` Konturen (`rgba(255, 255, 255, 0.08)`)
* Akzente & Glows: Gemini Blue (`#8ab4f8`) und sanftes Violett (`#c58af9`)
* **Pill-Shapes & Soft Curves:** Stark abgerundete Eingabefelder (`rounded-full`), fließende Kapselbuttons und weiche Kanten.
* **Zurückhaltende Eleganz:** Großzügiger Weißraum (*Spreading*), subtile Ambient-Glows bei Fokus und flüssige 60fps-Gestenphysik für die Karten-Triage.
* **Haptik & Touch-Optimierung:** Große Interaktionsradien (mind. 48px Touch-Targets), Vibrations-Feedback via Web Vibration API bei Karten-Swipes.
--- ```bash
curl -fsSL https://git.dsit.me/sandrodach/MindSift/raw/branch/dev/install.sh | bash
## 🏗️ Technische Architektur & Tech-Stack
Als Lead-Architektur wurde ein **Single-Binary / Single-Container** Paradigma gewählt. Das Go-Backend kompiliert das React-Frontend direkt in ein einziges Executable ein (`embed.FS`).
```
┌─────────────────────────────────────────────────────────────────────────┐
│ Client (Mobile PWA / Desktop) │
│ React 19 • Vite • Tailwind CSS v4 • Framer Motion • Tiptap Block-Editor │
└───────────────────────────────┬─────────────────────────────────────────┘
│ HTTP/2 / REST & WebSockets (WSS)
┌───────────────────────────────▼─────────────────────────────────────────┐
│ MindSift Single Container (Go 1.24) │
│ │
│ ┌─────────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Embedded Assets │ │ Chi HTTP Mux │ │ WebSocket Hub │ │
│ │ (React SPA Dist) │ │ REST / Webhook │ │ (Realtime Sync) │ │
│ └─────────────────────┘ └────────┬────────┘ └────────┬────────┘ │
│ │ │ │
│ ┌───────▼─────────────────────▼────────┐ │
│ │ Business Service Layer │ │
│ │ (Capture, Triage, Docs, Lists) │ │
│ └──────────────────┬───────────────────┘ │
│ │ │
│ ┌──────────────────▼───────────────────┐ │
│ │ Data Access Layer (sqlc / WAL) │ │
│ └──────────────────┬───────────────────┘ │
└─────────────────────────────────────────────────┼───────────────────────┘
│
┌──────────────────▼───────────────────┐
│ SQLite 3 (WAL Mode, zero-config) │
│ *Optional: PostgreSQL Driver │
└──────────────────────────────────────┘
``` ```
### Stack-Entscheidungen & Rationale Das Skript legt ein Verzeichnis an, lädt `docker-compose.yml` und `.env.example`, erzeugt ein `JWT_SECRET` und startet den Container.
| Schicht | Technologie | Begründung & Architekturentscheidung | ### Option 2: Docker Compose
| :--- | :--- | :--- |
| **Backend** | **Go 1.24+ (Chi Router)** | Extrem geringer Speicherverbrauch (~15–25 MB RAM idle), Kaltstart in < 5 ms, überlegene Concurrency für WebSockets, erzeugt ein statisches Binary. |
| **Frontend** | **React 19 + TypeScript + Vite** | Bietet mit **Framer Motion** die beste Gesten- und Physik-Engine für flüssige Tinder-Swipes und mit **Tiptap** den industrieweit führenden blockbasierten Rich-Text-Editor. |
| **Styling** | **Tailwind CSS v4** | Höchste Performance, null CSS-Runtime-Overhead, perfekte Abbildung des Gemini Dark-Themas mit Design-Tokens. |
| **Realtime** | **Go WebSockets (`nhooyr/websocket`)** | Schlanke, native WebSocket-Rooms für kollaborative Einkaufslisten mit Sub-10ms-Latenz und automatischem Reconnect. |
| **Datenbank** | **SQLite (WAL-Modus) / sqlc** | Standardmäßig Zero-Config mit Write-Ahead-Logging, ACID-sicher und als einzelne Datei einfachst per Backup sicherbar. Optional umschaltbar auf PostgreSQL per `DATABASE_URL`. |
| **PWA / Offline** | **Workbox / IndexedDB** | App startet auch im Funkloch sofort; Gedanken werden lokal vorgehalten und beim nächsten Verbindungsaufbau transparent synchronisiert. |
| **Container** | **Distroless / Alpine Scratch** | Minimalistisches Multi-Stage Dockerfile. End-Image-Größe < 35 MB. Keine Sicherheitsrisiken durch überflüssige System-Pakete. |
--- ```bash
mkdir -p mindsift && cd mindsift
## 🗄️ Datenbankschema curl -fsSL https://git.dsit.me/sandrodach/MindSift/raw/branch/dev/docker-compose.yml -o docker-compose.yml
curl -fsSL https://git.dsit.me/sandrodach/MindSift/raw/branch/dev/.env.example -o .env
Das Schema ist relational strukturiert, normalisiert und auf Lese- sowie Schreibgeschwindigkeit im Sub-Millisekundenbereich optimiert. # In .env: JWT_SECRET durch einen langen Zufallswert ersetzen!
docker compose up -d
```mermaid
erDiagram
USERS ||--o{ THOUGHTS : owns
USERS ||--o{ DOCUMENTS : authors
USERS ||--o{ SPACE_MEMBERS : participates
USERS ||--o{ API_KEYS : possesses
SPACES ||--o{ SPACE_MEMBERS : contains
SPACES ||--o{ LISTS : hosts
LISTS ||--o{ LIST_ITEMS : contains
DOCUMENTS ||--o{ DOCUMENT_BLOCKS : contains
USERS {
uuid id PK
string email UK
string password_hash
string display_name
timestamp created_at
}
THOUGHTS {
uuid id PK
uuid user_id FK
text content
string voice_transcript
string status "inbox | kept | discarded | converted"
string converted_type "task | document | list_item"
uuid converted_ref_id
timestamp due_date
timestamp created_at
timestamp triaged_at
}
DOCUMENTS {
uuid id PK
uuid user_id FK
uuid space_id FK
string title
string icon
boolean is_archived
timestamp created_at
timestamp updated_at
}
DOCUMENT_BLOCKS {
uuid id PK
uuid document_id FK
string block_type "paragraph | heading | task | code"
jsonb content
int sort_order
timestamp updated_at
}
SPACES {
uuid id PK
string name
string icon
boolean is_personal
timestamp created_at
}
SPACE_MEMBERS {
uuid space_id PK,FK
uuid user_id PK,FK
string role "owner | editor | viewer"
timestamp joined_at
}
LISTS {
uuid id PK
uuid space_id FK
string title
string list_type "shopping | checklist"
timestamp updated_at
}
LIST_ITEMS {
uuid id PK
uuid list_id FK
string content
boolean is_completed
uuid completed_by FK
timestamp completed_at
int sort_order
}
API_KEYS {
uuid id PK
uuid user_id FK
string name
string key_hash UK
string scopes
timestamp last_used_at
}
``` ```
### SQL DDL Definitionen (Auszug SQLite / Postgres kompatibel) Minimal:
```sql
-- Aktivierung von WAL & Foreign Keys für SQLite
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
PRAGMA busy_timeout = 5000;
-- Gedanken & Instant Inbox Puffer
CREATE TABLE IF NOT EXISTS thoughts (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
content TEXT NOT NULL,
status TEXT CHECK(status IN ('inbox', 'kept', 'discarded', 'converted')) DEFAULT 'inbox',
converted_type TEXT CHECK(converted_type IN ('task', 'document', 'list_item', NULL)),
converted_ref_id TEXT,
due_date DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
triaged_at DATETIME,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX idx_thoughts_user_status ON thoughts(user_id, status, created_at DESC);
-- Kollaborative Listen (z.B. Familie & Einkaufen)
CREATE TABLE IF NOT EXISTS list_items (
id TEXT PRIMARY KEY,
list_id TEXT NOT NULL,
content TEXT NOT NULL,
is_completed BOOLEAN NOT NULL DEFAULT 0,
completed_by TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
completed_at DATETIME,
FOREIGN KEY(list_id) REFERENCES lists(id) ON DELETE CASCADE
);
CREATE INDEX idx_list_items_sync ON list_items(list_id, is_completed, sort_order ASC);
```
---
## 🔌 API-Spezifikation & Webhooks
Alle Endpunkte antworten mit `application/json` und nutzen Standard-HTTP-Statuscodes. Die Authentifizierung erfolgt über `Bearer <JWT>` oder dedizierte Ingestion-Keys.
### 1. Instant Capture & Ingestion (Sub-Second Entry)
```http
POST /api/v1/inbox
Authorization: Bearer <token>
Content-Type: application/json
{
"content": "Zündkerzen für den Rasenmäher bestellen #garage",
"due_date": "2026-10-25T18:00:00Z" // Optional
}
```
**Response (201 Created - < 30ms):**
```json
{
"id": "th_9f8a3c21",
"status": "inbox",
"created_at": "2026-10-24T16:25:00Z"
}
```
### 2. Smarthome & Externe Webhook Ingestion
Für Home Assistant, Node-RED, n8n oder Shell-Skripte:
```http
POST /api/v1/webhooks/ingest?token=mindsift_sec_789xyz
Content-Type: application/json
{
"title": "Kellerfeuchtigkeit über 70%",
"priority": "urgent",
"tags": ["smarthome", "keller"]
}
```
### 3. Triage & Tinder-Style Swipe Deck
* **Gedanken-Deck laden:** `GET /api/v1/triage/deck?limit=20`
* **Gedanke wischen (Swipe Action):**
```http
PATCH /api/v1/triage/th_9f8a3c21
Authorization: Bearer <token>
Content-Type: application/json
{
"action": "keep", // oder "discard"
"convert_to": "task", // optional: "task" | "document" | "list_item"
"target_list_id": "lst_shopping_123", // wenn convert_to = "list_item"
"due_date": "2026-10-28T09:00:00Z"
}
```
### 4. Realtime WebSocket Protocol (Kollaborative Listen)
Client verbindet sich zu `wss://mindsift.local/api/v1/ws/spaces/:space_id`.
* **Event: Item hinzugefügt/aktualisiert**
```json
{
"event": "ITEM_UPDATED",
"payload": {
"list_id": "lst_shopping_123",
"item_id": "itm_456",
"content": "Hafermilch (Barista)",
"is_completed": true,
"user_name": "Sandro"
}
}
```
---
## 🚀 Gitea CI/CD Pipeline
Das Projekt ist für den Betrieb mit **Gitea Actions** optimiert. Bei jedem Push auf `main` oder einem Release-Tag baut die Pipeline automatisch ein Multi-Arch-Docker-Image (`linux/amd64`, `linux/arm64`) und schiebt es in die Gitea Container Registry.
### Workflow-Datei: `.gitea/workflows/build-and-publish.yaml`
```yaml
name: MindSift Multi-Arch Docker Build
on:
push:
branches:
- main
tags:
- 'v*'
pull_request:
branches:
- main
jobs:
test:
name: Lint & Unit Tests
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
cache: true
- name: Run Backend Tests
run: |
go vet ./...
go test -v -race ./...
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Run Frontend Tests
run: |
cd frontend
npm ci
npm run lint
npm run test:run
build-docker:
name: Build & Push Docker Image
needs: test
runs-on: ubuntu-latest
if: github.event_name == 'push'
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Set up QEMU (Multi-Arch)
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ gitea.server_url }}
username: ${{ gitea.actor }}
password: ${{ secrets.GITEA_TOKEN }}
- name: Extract Metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ gitea.server_url }}/${{ gitea.repository }}
tags: |
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
type=semver,pattern={{version}}
- name: Build and Push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
```
### Multi-Stage Dockerfile (`Dockerfile`)
```dockerfile
# Stage 1: Frontend Build
FROM node:22-alpine AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
# Stage 2: Go Backend Build
FROM golang:1.24-alpine AS backend-builder
WORKDIR /app
RUN apk add --no-cache build-base git
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Kopiere gebaute Frontend Assets in das embed.FS-Verzeichnis
COPY --from=frontend-builder /app/frontend/dist ./server/dist
RUN CGO_ENABLED=1 GOOS=linux go build -ldflags="-s -w -extldflags '-static'" -o mindsift ./cmd/server
# Stage 3: Minimal Production Image
FROM alpine:3.21
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=backend-builder /app/mindsift /app/mindsift
ENV PORT=8080
ENV DATA_DIR=/data
VOLUME ["/data"]
EXPOSE 8080
USER 1000:1000
ENTRYPOINT ["/app/mindsift"]
```
---
## 🐳 Deployment & Schnellstart
### 1. Schnellstart mit Docker Compose (Empfohlen)
Erstelle eine `docker-compose.yml`:
```yaml ```yaml
services: services:
mindsift: mindsift:
image: gitea.your-domain.com/user/mindsift:latest image: git.dsit.me/sandrodach/mindsift:dev
container_name: mindsift
restart: unless-stopped restart: unless-stopped
ports: ports: ["8080:8080"]
- "8080:8080" volumes: ["./data:/data"]
volumes:
- ./data:/data
environment: environment:
- PORT=8080 - JWT_SECRET=hier-ein-langer-zufaelliger-wert
- DATA_DIR=/data - BASE_URL=https://mindsift.example.com
- JWT_SECRET=change_this_to_a_secure_random_key_min_32_chars
- BASE_URL=https://mindsift.local
- LOG_LEVEL=info
``` ```
Starte den Service: ### Ersteinrichtung
```bash
docker compose up -d
```
Die Weboberfläche steht sofort unter `http://localhost:8080` bereit.
### 2. Standalone Docker Run Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch das Anlegen des Administrator-Kontos.
```bash > ⚠️ **Setze `JWT_SECRET`.** Ohne Angabe nutzt der Server einen öffentlich bekannten Standardwert, mit dem sich Anmelde-Tokens fälschen lassen. Rufe die Instanz außerdem direkt nach dem Start auf, damit kein Fremder das erste Konto anlegt.
docker run -d \
--name mindsift \
--restart unless-stopped \
-p 8080:8080 \
-v $(pwd)/mindsift-data:/data \
-e JWT_SECRET="dein-super-sicheres-geheimnis" \
gitea.your-domain.com/user/mindsift:latest
```
### Umgebungsvariablen
| Variable | Standardwert | Beschreibung |
| :--- | :--- | :--- |
| `PORT` | `8080` | Interner HTTP/WebSocket Port |
| `DATA_DIR` | `/data` | Verzeichnis für SQLite DB, Uploads und Cache |
| `DATABASE_URL` | `sqlite:///data/mindsift.db` | Optional: PostgreSQL DSN (`postgres://user:pass@host:5432/db`) |
| `JWT_SECRET` | *Pflichtfeld* | Secret für Authentifizierungs-Tokens (mind. 32 Zeichen) |
| `BASE_URL` | `http://localhost:8080` | Externe URL für Einladungslinks und Webhooks |
| `ALLOW_REGISTRATION`| `true` | Erstregistrierung erlauben (`false` nach Admin-Erstellung) |
--- ---
## 🗺️ Schrittweiser Phasenplan ## ⚙️ Betrieb & Konfiguration
``` | Variable | Standard | Zweck |
Phase 1: Foundation ────► Phase 2: Triage Deck ────► Phase 3: Workspace |---|---|---|
(Go + PWA + DB) (Framer Physics) (Tiptap Blocks) | `PORT` | `8080` | HTTP-Port |
│ | `DATA_DIR` | `/data` (Container) | Ablage der Datenbank `mindsift.db` |
Phase 6: Hardening ◄──── Phase 5: Webhooks ◄──── Phase 4: Realtime | `JWT_SECRET` | unsicherer Standard | **Pflicht in Produktion**, Signaturschlüssel |
(CI/CD + Release) (Smarthome Ingest) (Family Lists) | `BASE_URL` | `http://localhost:8080` | Öffentliche URL |
``` | `ALLOW_REGISTRATION` | `false` | Offene Registrierung (im Admin-UI änderbar) |
| `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) |
| `MINDA_DEMO` | `false` | Legt Demo-Konten `admin/admin`, `user/user` an. **Nie auf öffentlichen Servern!** |
### Phase 1: Core Foundation & Instant Capture (Sprint 1) * **Reverse-Proxy:** WebSocket-Upgrade und ungepufferte SSE-Streams müssen durchgereicht werden. Beispiele für Nginx, Traefik, Caddy und Cloudflare: [Doku/reverse-proxy.md](Doku/reverse-proxy.md).
- [x] Repository-Initialisierung & Architektur-Design * **Datensicherung:** Alle Daten liegen in `data/mindsift.db` (+ `-wal`/`-shm`). Sichere sie mit `sqlite3 mindsift.db ".backup 'backup.db'"` oder bei gestopptem Container. Zusätzlich gibt es einen JSON-Export pro Benutzer.
- [x] Go Backend-Grundgerüst (Chi Router, Konfigurations-Management, Healthcheck) * **Updates:** Neues Image ziehen und Container neu starten (`docker compose pull && docker compose up -d`). Das Schema wird beim Start automatisch angepasst.
- [x] SQLite Storage-Layer mit WAL-Modus und automatischer Migration
- [x] JWT-Authentifizierung & User-Management
- [x] Mobile-First PWA Setup (Vite, React, Tailwind CSS, Gemini Dark Theme)
- [x] **Instant-Capture-Interface:** Autofokus-Eingabemaske, Sprachaufnahme (Web Speech API), Latenz < 300 ms
### Phase 2: Tinder-Style Triage & Task Engine (Sprint 2)
- [x] Interaktives Swipe-Deck mit **Framer Motion** (Gesten, Rotation, elastische Rückfederung)
- [x] Triage-Status-Engine (`inbox` -> `kept` / `discarded` / `converted`)
- [x] Quick-Convert: Wisch nach rechts transformiert Gedanken direkt in Aufgaben mit Due-Date, Projekt-Notizen oder Listen-Einträge
- [x] Tastatur-Shortcuts für Power-User am Desktop (`H`/`L` oder Pfeiltasten, `C`/`/` für Capture)
- [x] Haptisches Feedback auf Mobilgeräten (Web Vibration API)
### Phase 3: Notion-Style Workspace (Sprint 3)
- [x] Block-Editor (Headings, Task-Checkboxes, Code-Blöcke, Callouts, Listen)
- [x] Dokumenten-Verwaltung für vertiefte Gedanken inkl. Pinning & Icon-Picker
- [x] Tagging & Volltextsuche über Gedanken und Dokumente
- [x] Markdown Export (`.md`)
### Phase 4: Realtime Spaces & Family Sync (Sprint 4)
- [x] Multi-User Space-Architektur (Familien- und Team-Räume mit Invite-Codes)
- [x] Go WebSocket Hub mit Raum-Multiplexing
- [x] Echtzeit-Einkaufslisten: Sofortige Synchronisation von Checked-States über alle Endgeräte
- [x] Optimistische UI-Updates für verzögerungsfreies Offline-/Online-Erlebnis
### Phase 5: Smarthome Webhooks & External Ecosystem (Sprint 5)
- [x] Ingestion-API mit API-Token-Management (`mindsift_sec_...`)
- [x] Webhook-Endpunkte für Home Assistant, Zigbee2MQTT, n8n und Cronjobs
- [x] Interaktiver Live-Webhook-Simulator in der UI
- [x] Backup- und Export-Endpunkt (kompletter JSON-Export)
### Phase 6: Production Hardening, CI/CD & v1.0 (Sprint 6)
- [x] Gitea Actions Multi-Arch Workflow (`amd64`, `arm64`)
- [x] Single-Container Packaging (`embed.FS`) & Multi-Stage Dockerfile
- [x] Automatisierte Backend-Unit-Tests (`internal/storage/sqlite_test.go`)
- [ ] End-to-End Tests (Playwright) auf Ziel-Cluster
- [x] Erstellung der offiziellen Dokumentation & v1.0 Release-Bereitschaft
--- ---
## 🔒 Sicherheit & Datenschutz ## 🛠 Entwicklung
* **Zero Tracking:** Keine Analytics, keine externen CDNs, keine Telemetrie. Voraussetzungen: Go 1.23, Node 22.
* **Content Security Policy (CSP):** Strikte Header; alle Assets und Fonts werden lokal ausgeliefert.
* **Sichere Passwörter:** Argon2id Passwort-Hashing nach OWASP-Empfehlung. ```bash
* **Isolierte Container:** Der Container läuft standardmäßig unter einer Non-Root-UID (`1000:1000`). # Backend: Vet und Tests
go vet ./... && go test ./...
# Frontend
cd frontend
npm ci
npm run dev # Vite-Entwicklungsserver
npx tsc --noEmit # Typprüfung
npm run test:run # Komponententests (Vitest, jsdom)
npm run build # Produktions-Build
```
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.
---
## 📚 Dokumentation
Die technische Doku liegt unter [Doku/](Doku/README.md): Architektur, Datenbank, REST- und Echtzeit-API, Module, Freigaben und Berechtigungen, Themes. Bekannte Schwachstellen und offene Punkte stehen in [Doku/flaws.md](Doku/flaws.md).
---
## 🔒 Deine Daten gehören dir
* Selbst gehostet, keine Telemetrie, keine Fremddienste. Ausgehende Verbindungen gibt es nur zu den Alarm-Zielen, die du selbst einrichtest (SMTP, Discord, Webhook).
* Alle Inhalte liegen in einer einzigen SQLite-Datei; Notizen lassen sich als Markdown exportieren, alle Daten eines Benutzers als JSON.
--- ---
## 📄 Lizenz ## 📄 Lizenz
Dieses Projekt ist lizenziert unter der **MIT-Lizenz** – siehe [LICENSE](LICENSE) für Details. Im Repository liegt derzeit keine `LICENSE`-Datei; die Lizenz ist damit nicht festgelegt.
---
<div align="center"> <div align="center">
<sub>Designed with ❤️ for clarity, speed and digital sovereignty.</sub> <sub>Gebaut für Klarheit, Geschwindigkeit und digitale Souveränität.</sub>
</div> </div>
+38 -12
View File
@@ -4,7 +4,8 @@ import (
"context" "context"
"embed" "embed"
"io/fs" "io/fs"
"log" "log/slog"
"net"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
@@ -23,40 +24,60 @@ var embeddedDist embed.FS
func main() { func main() {
cfg := config.Load() cfg := config.Load()
log.Printf("🧠 Starting MindSift Server on port %s (DB: %s)", cfg.Port, cfg.DatabasePath) level := cfg.SlogLevel()
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
slog.SetDefault(logger)
slog.Info("MindSift Server starting", "port", cfg.Port, "log_level", level.String())
store, err := storage.NewStore(cfg.DatabasePath) store, err := storage.NewStore(cfg.DatabasePath)
if err != nil { if err != nil {
log.Fatalf("Failed to initialize storage: %v", err) slog.Error("Failed to initialize storage", "error", err)
os.Exit(1)
} }
defer store.Close() defer store.Close()
// Ensure initial default workspace exists on fresh self-hosted boot // Seed SMTP defaults from environment variables into DB if not yet configured in DB
if _, err := store.EnsureDefaultUser(); err != nil { store.SeedSMTPDefaultsFromEnv(cfg.SMTPHost, cfg.SMTPPort, cfg.SMTPUser, cfg.SMTPPass, cfg.SMTPFrom, cfg.SMTPTLS)
log.Printf("Warning: could not seed default user: %v", err)
if cfg.DemoMode {
slog.Info("🧪 Demo-Modus aktiv (MINDA_DEMO=true): Richte Demo-Benutzer admin/admin und user/user ein...")
if err := store.EnsureDemoAccounts(); err != nil {
slog.Warn("⚠️ Fehler beim Anlegen der Demo-Accounts", "error", err)
} else {
slog.Info("✅ Demo-Accounts bereit: admin / admin (Admin-Rolle) und user / user (Standard-Rolle)")
}
} }
wsHub := ws.NewHub() wsHub := ws.NewHub()
staticFS, err := fs.Sub(embeddedDist, "dist") staticFS, err := fs.Sub(embeddedDist, "dist")
if err != nil { if err != nil {
log.Fatalf("Failed to mount embedded dist filesystem: %v", err) slog.Error("Failed to mount embedded dist filesystem", "error", err)
os.Exit(1)
} }
srv := api.NewServer(cfg, store, wsHub, staticFS) srv := api.NewServer(cfg, store, wsHub, staticFS)
serverCtx, serverCancel := context.WithCancel(context.Background())
defer serverCancel()
httpServer := &http.Server{ httpServer := &http.Server{
Addr: ":" + cfg.Port, Addr: ":" + cfg.Port,
Handler: srv.Router(), Handler: srv.Router(),
BaseContext: func(l net.Listener) context.Context {
return serverCtx
},
ReadTimeout: 15 * time.Second, ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second, IdleTimeout: 60 * time.Second,
} }
go func() { go func() {
log.Printf("✨ MindSift ready at http://0.0.0.0:%s", cfg.Port) slog.Info("✨ MindSift ready", "url", "http://0.0.0.0:"+cfg.Port)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("HTTP server error: %v", err) slog.Error("HTTP server error", "error", err)
os.Exit(1)
} }
}() }()
@@ -64,8 +85,13 @@ func main() {
signal.Notify(stop, os.Interrupt, syscall.SIGTERM) signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
<-stop <-stop
log.Println("Shutting down MindSift server gracefully...") slog.Info("Shutting down MindSift server gracefully...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) serverCancel()
wsHub.Close()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel() defer cancel()
_ = httpServer.Shutdown(ctx) if err := httpServer.Shutdown(ctx); err != nil {
slog.Warn("HTTP server shutdown finished with error", "error", err)
}
} }
+28
View File
@@ -0,0 +1,28 @@
services:
mindsift:
image: git.dsit.me/sandrodach/mindsift:dev
container_name: mindsift
restart: unless-stopped
ports:
- "${PORT:-8080}:8080"
volumes:
- ./data:/data
environment:
- PORT=8080
- DATA_DIR=/data
- JWT_SECRET=${JWT_SECRET:-change_this_to_a_secure_random_key_min_32_chars}
- BASE_URL=${BASE_URL:-http://localhost:8080}
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-false}
- LOG_LEVEL=${LOG_LEVEL:-info}
# Set LOG_LEVEL=debug or DEBUG=true for detailed request and sync logging
# - LOG_LEVEL=debug
# - DEBUG=true
# Demo mode: if true, automatically seeds demo accounts admin/admin and user/user
- MINDA_DEMO=${MINDA_DEMO:-false}
# Optional SMTP / Mail Configuration (can also be configured in Admin UI)
- SMTP_HOST=${SMTP_HOST:-}
- SMTP_PORT=${SMTP_PORT:-587}
- SMTP_USER=${SMTP_USER:-}
- SMTP_PASS=${SMTP_PASS:-}
- SMTP_FROM=${SMTP_FROM:-}
- SMTP_TLS=${SMTP_TLS:-true}
+14 -5
View File
@@ -1,9 +1,6 @@
services: services:
mindsift: mindsift:
build: image: git.dsit.me/sandrodach/mindsift:dev
context: .
dockerfile: Dockerfile
image: git.dsit.me/sandrodach/mindsift:latest
container_name: mindsift container_name: mindsift
restart: unless-stopped restart: unless-stopped
ports: ports:
@@ -15,5 +12,17 @@ services:
- DATA_DIR=/data - DATA_DIR=/data
- 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:-true} - ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-false}
- LOG_LEVEL=${LOG_LEVEL:-info} - LOG_LEVEL=${LOG_LEVEL:-info}
# Set LOG_LEVEL=debug or DEBUG=true for detailed request and sync logging
# - LOG_LEVEL=debug
# - DEBUG=true
# Demo mode: if true, automatically seeds demo accounts admin/admin and user/user
- MINDA_DEMO=${MINDA_DEMO:-false}
# Optional SMTP / Mail Configuration (can also be configured in Admin UI)
- SMTP_HOST=${SMTP_HOST:-}
- SMTP_PORT=${SMTP_PORT:-587}
- SMTP_USER=${SMTP_USER:-}
- SMTP_PASS=${SMTP_PASS:-}
- SMTP_FROM=${SMTP_FROM:-}
- SMTP_TLS=${SMTP_TLS:-true}
+16 -2
View File
@@ -4,17 +4,31 @@
<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" />
<meta name="theme-color" content="#0b0f19" /> <meta name="theme-color" content="#0b0f19" />
<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-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="description" content="MindSift — Der Kopf frei in drei Sekunden. Instant Capture, Tinder-Style Triage, Workspace & Echtzeit-Listen." /> <meta name="description" content="MindSift — Der Kopf frei in drei Sekunden. Instant Capture, Tinder-Style Triage, Workspace & Echtzeit-Listen." />
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='26' fill='%23131314'/><circle cx='50' cy='50' r='32' fill='none' stroke='%238ab4f8' stroke-width='7'/><path d='M36 52 L46 62 L66 38' fill='none' stroke='%23c58af9' stroke-width='8' stroke-linecap='round' stroke-linejoin='round'/></svg>" /> <link rel="icon" type="image/svg+xml" href="/icon.svg" />
<title>MindSift — Der Kopf frei in drei Sekunden</title> <link rel="apple-touch-icon" href="/icon.svg" />
<title>MindSift</title>
<script>
window.__deferredPwaPrompt = null;
window.addEventListener('beforeinstallprompt', function (e) {
e.preventDefault();
window.__deferredPwaPrompt = e;
window.dispatchEvent(new CustomEvent('mindsift:pwa-ready'));
});
</script>
</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]">
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
<script> <script>
if ('caches' in window) {
caches.keys().then((keys) => keys.forEach((k) => caches.delete(k))).catch(() => {});
}
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
window.addEventListener('load', () => { window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {}); navigator.serviceWorker.register('/sw.js').catch(() => {});
+4056
View File
File diff suppressed because it is too large. Load diff
+9 -2
View File
@@ -8,22 +8,29 @@
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"lint": "echo 'Linting passed'", "lint": "echo 'Linting passed'",
"test:run": "echo 'Frontend checks passed'" "test": "vitest",
"test:run": "vitest run"
}, },
"dependencies": { "dependencies": {
"@types/prismjs": "^1.26.6",
"framer-motion": "^11.15.0", "framer-motion": "^11.15.0",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"prismjs": "^1.30.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/dom": "^10.4.2",
"@testing-library/react": "^16.3.3",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"jsdom": "^25.0.1",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"typescript": "^5.6.3", "typescript": "^5.6.3",
"vite": "^5.4.11" "vite": "^5.4.11",
"vitest": "^2.1.9"
} }
} }
+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<rect width="512" height="512" rx="128" fill="#8ab4f8"/>
<g transform="translate(80, 80) scale(14.666)" fill="none" stroke="#0b0f19" stroke-width="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: 851 B

+11 -3
View File
@@ -1,18 +1,26 @@
{ {
"id": "/",
"name": "MindSift — Der Kopf frei in drei Sekunden", "name": "MindSift — Der Kopf frei in drei Sekunden",
"short_name": "MindSift", "short_name": "MindSift",
"description": "Instant Brain-Dump, Tinder-Style Triage, Workspace & Echtzeit-Familienlisten.", "description": "Instant Brain-Dump, Tinder-Style Triage, Workspace & Echtzeit-Familienlisten.",
"start_url": "/?mode=capture", "start_url": "/",
"scope": "/",
"display": "standalone", "display": "standalone",
"background_color": "#0b0f19", "background_color": "#0b0f19",
"theme_color": "#0b0f19", "theme_color": "#0b0f19",
"orientation": "portrait-primary", "orientation": "portrait-primary",
"icons": [ "icons": [
{ {
"src": "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='26' fill='%23131314'/><circle cx='50' cy='50' r='32' fill='none' stroke='%238ab4f8' stroke-width='7'/><path d='M36 52 L46 62 L66 38' fill='none' stroke='%23c58af9' stroke-width='8' stroke-linecap='round' stroke-linejoin='round'/></svg>", "src": "/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/icon.svg",
"sizes": "192x192 512x512", "sizes": "192x192 512x512",
"type": "image/svg+xml", "type": "image/svg+xml",
"purpose": "any maskable" "purpose": "maskable"
} }
], ],
"shortcuts": [ "shortcuts": [
+8 -23
View File
@@ -1,33 +1,18 @@
const CACHE_NAME = 'mindsift-pwa-v1'; // Network-first / cache-clearing service worker to ensure fresh builds across all browsers
const ASSETS = ['/', '/index.html', '/manifest.json']; self.addEventListener('install', () => {
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(ASSETS))
);
self.skipWaiting(); self.skipWaiting();
}); });
self.addEventListener('activate', (event) => { self.addEventListener('activate', (event) => {
event.waitUntil( event.waitUntil(
caches.keys().then((keys) => caches
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))) .keys()
) .then((keys) => Promise.all(keys.map((k) => caches.delete(k))))
.then(() => self.clients.claim())
); );
self.clients.claim();
}); });
self.addEventListener('fetch', (event) => { self.addEventListener('fetch', () => {
if (event.request.method !== 'GET' || event.request.url.includes('/api/')) { // Always use native browser network fetch so API calls and fresh Vite bundles are never intercepted by stale caches
return; return;
}
event.respondWith(
fetch(event.request)
.then((res) => {
const clone = res.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
return res;
})
.catch(() => caches.match(event.request).then((cached) => cached || caches.match('/')))
);
}); });
+1342 -241
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,140 @@
import React from 'react';
import { Check, X } from 'lucide-react';
import { useMobileActions } from '../context/MobileActionContext';
import { triggerHaptic } from '../lib/api';
interface MobileHeaderMenuProps {
activeTabLabel: string;
}
export const MobileHeaderMenu: React.FC<MobileHeaderMenuProps> = ({ activeTabLabel }) => {
const { groups, isMenuOpen, closeMenu } = useMobileActions();
if (!isMenuOpen) return null;
return (
<div className="sm:hidden fixed inset-0 !m-0 z-50">
{/* Backdrop */}
<div
className="fixed inset-0 !m-0 bg-black/60 backdrop-blur-xs animate-in fade-in duration-150"
onClick={() => {
triggerHaptic(8);
closeMenu();
}}
/>
{/* Menu Sheet below Header */}
<div className="fixed top-14 left-2.5 right-2.5 max-h-[80vh] overflow-y-auto bg-panel border border-border rounded-2xl shadow-2xl p-3 space-y-3.5 z-50 animate-in fade-in slide-in-from-top-2 duration-150">
{/* Menu Header */}
<div className="flex items-center justify-between px-2 pb-2 border-b border-border">
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-accent"></span>
<span className="text-xs font-bold uppercase tracking-wider text-muted">
{activeTabLabel} — Optionen & Aktionen
</span>
</div>
<button
type="button"
onClick={() => {
triggerHaptic(8);
closeMenu();
}}
className="w-9 h-9 rounded-xl flex items-center justify-center text-muted hover:text-main hover:bg-canvas active:scale-95 transition-all"
title="Menü schließen"
>
<X className="w-4 h-4" />
</button>
</div>
{groups.length === 0 ? (
<div className="py-6 text-center text-xs text-muted">
Keine spezifischen Optionen für diese Ansicht verfügbar.
</div>
) : (
<div className="space-y-4">
{groups.map((group) => (
<div key={group.id} className="space-y-1.5">
{group.title && (
<div className="px-2 text-[11px] font-bold uppercase tracking-wider text-faint">
{group.title}
</div>
)}
<div className="space-y-1">
{group.items.map((item) => {
const isAccent = item.variant === 'accent';
const isDanger = item.variant === 'danger';
const isActive = item.active;
let btnClass =
'bg-canvas/70 hover:bg-canvas text-main border border-border/60';
if (isAccent) {
btnClass =
'bg-accent text-on-accent border border-accent font-semibold shadow-sm';
} else if (isDanger) {
btnClass =
'bg-status-err/10 text-status-err border border-status-err/30 font-medium';
} else if (isActive) {
btnClass =
'bg-accent/15 text-accent border border-accent/40 font-semibold';
}
return (
<button
key={item.id}
type="button"
onClick={() => {
triggerHaptic(12);
closeMenu();
item.onClick();
}}
className={`w-full min-h-[46px] px-3.5 py-2.5 rounded-xl flex items-center justify-between gap-3 text-sm transition-all active:scale-[0.98] cursor-pointer ${btnClass}`}
>
<div className="flex items-center gap-3 min-w-0 flex-1 text-left">
{item.icon && (
<span
className={`flex-shrink-0 flex items-center justify-center w-5 h-5 ${
isAccent
? 'text-on-accent'
: isDanger
? 'text-status-err'
: isActive
? 'text-accent'
: 'text-muted'
}`}
>
{item.icon}
</span>
)}
<span className="truncate">{item.label}</span>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{item.badge !== undefined && item.badge !== '' && (
<span
className={`px-2 py-0.5 rounded-full text-xs font-mono font-semibold ${
isAccent
? 'bg-black/20 text-on-accent'
: isActive
? 'bg-accent/20 text-accent'
: 'bg-panel text-muted border border-border'
}`}
>
{item.badge}
</span>
)}
{isActive && !isAccent && (
<Check className="w-4 h-4 text-accent flex-shrink-0" />
)}
</div>
</button>
);
})}
</div>
</div>
))}
</div>
)}
</div>
</div>
);
};
@@ -0,0 +1,393 @@
import React, { useState } from 'react';
import {
Users,
User as UserIcon,
X,
Plus,
Trash2,
Lock,
Check,
} from 'lucide-react';
import { User, Team, UnifiedShare } from '../lib/types';
import { UserAutocomplete } from '../features/UserAutocomplete';
import { UserAvatar } from './UserAvatar';
export interface UnifiedShareModalProps {
isOpen: boolean;
onClose: () => void;
title?: string;
resourceName: string;
resourceIcon?: string | React.ReactNode;
isOwner: boolean;
currentUserId?: string;
shares: UnifiedShare[];
teams: Team[];
collaborators: User[];
allowRoleSelection?: boolean; // Default true (editor vs viewer)
fixedPermission?: 'editor' | 'viewer'; // When allowRoleSelection is false, force fixedPermission (default 'viewer' for documents, 'editor' for tasks/lists)
onAddShare: (targetType: 'team' | 'user', targetId: string, permission: 'editor' | 'viewer') => Promise<void>;
onRemoveShare: (shareId: string) => Promise<void>;
}
export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
isOpen,
onClose,
title = 'Freigeben & Berechtigungen',
resourceName,
resourceIcon,
isOwner,
currentUserId,
shares,
teams,
collaborators,
allowRoleSelection = true,
fixedPermission,
onAddShare,
onRemoveShare,
}) => {
const effectiveFixedPerm = fixedPermission || 'viewer';
const [shareType, setShareType] = useState<'team' | 'user'>('team');
const [selectedTeamId, setSelectedTeamId] = useState('');
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [selectedPermission, setSelectedPermission] = useState<'editor' | 'viewer'>(
allowRoleSelection ? 'editor' : effectiveFixedPerm
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
if (!isOpen) return null;
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
const targetId = shareType === 'team' ? selectedTeamId : selectedUser?.id;
if (!targetId) {
setError(shareType === 'team' ? 'Bitte eine Gruppe auswählen' : 'Bitte einen Benutzer auswählen');
return;
}
setLoading(true);
try {
const permToUse = allowRoleSelection ? selectedPermission : effectiveFixedPerm;
await onAddShare(shareType, targetId, permToUse);
setSelectedTeamId('');
setSelectedUser(null);
} catch (err: any) {
setError(err?.message || 'Fehler beim Hinzufügen der Freigabe');
} finally {
setLoading(false);
}
};
const handleRemove = async (shareId: string) => {
if (!window.confirm('Möchtest du diese Freigabe wirklich aufheben?')) return;
setError(null);
setLoading(true);
try {
await onRemoveShare(shareId);
} catch (err: any) {
setError(err?.message || 'Fehler beim Entfernen der Freigabe');
} finally {
setLoading(false);
}
};
const excludedUserIds = [
...(currentUserId ? [currentUserId] : []),
...shares.filter((s) => s.targetType === 'user').map((s) => s.targetId),
];
const availableTeams = teams.filter(
(t) => !shares.some((s) => s.targetType === 'team' && s.targetId === t.id)
);
return (
<div
className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="panel-surface w-full max-w-lg p-5 shadow-panel rounded-card border border-border space-y-4 animate-in fade-in zoom-in-95 duration-150">
{/* Header */}
<div className="flex items-center justify-between pb-3 border-b border-border">
<div className="flex items-center gap-2.5 min-w-0">
<div className="w-8 h-8 rounded-btn bg-accent/15 text-accent flex items-center justify-center shrink-0">
{resourceIcon ? (
typeof resourceIcon === 'string' ? (
<span className="text-base">{resourceIcon}</span>
) : (
resourceIcon
)
) : (
<Users className="w-4 h-4" />
)}
</div>
<div className="min-w-0">
<h3 className="text-sm font-bold text-main truncate">{title}</h3>
<p className="text-[11px] text-muted truncate">
Objekt: <span className="font-semibold text-main">{resourceName}</span>
</p>
</div>
</div>
<button
type="button"
onClick={onClose}
className="p-1.5 text-muted hover:text-main rounded-btn hover:bg-panel transition-colors cursor-pointer"
title="Schließen"
>
<X className="w-4 h-4" />
</button>
</div>
{error && (
<div className="p-2.5 bg-status-danger/10 border border-status-danger/30 text-status-danger rounded text-xs">
{error}
</div>
)}
{/* Add Share Section (Only if owner / manager) */}
{isOwner ? (
<form onSubmit={handleAdd} className="space-y-3 p-3 bg-panel-elevated border border-border rounded-card">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-semibold text-main">Freigabe hinzufügen:</span>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => {
setShareType('team');
setSelectedUser(null);
setError(null);
}}
className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1.5 border transition-colors cursor-pointer ${
shareType === 'team'
? 'bg-panel border-accent text-accent font-semibold shadow-xs'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
<Users className="w-3.5 h-3.5" />
<span>Gruppe</span>
</button>
<button
type="button"
onClick={() => {
setShareType('user');
setSelectedTeamId('');
setError(null);
}}
className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1.5 border transition-colors cursor-pointer ${
shareType === 'user'
? 'bg-panel border-accent text-accent font-semibold shadow-xs'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
<UserIcon className="w-3.5 h-3.5" />
<span>Benutzer</span>
</button>
</div>
</div>
{shareType === 'team' ? (
<div className="flex flex-col sm:flex-row gap-2">
<select
value={selectedTeamId}
onChange={(e) => setSelectedTeamId(e.target.value)}
className="input-field bg-panel h-8 px-2.5 text-xs flex-1 cursor-pointer"
disabled={loading}
>
<option value="" className="bg-panel text-muted">-- Gruppe auswählen --</option>
{availableTeams.map((t) => (
<option key={t.id} value={t.id} className="bg-panel text-main">
👥 {t.name} ({t.members?.length || 1} Mitglieder)
</option>
))}
</select>
{allowRoleSelection ? (
<select
value={selectedPermission}
onChange={(e) => setSelectedPermission(e.target.value as 'editor' | 'viewer')}
className="input-field bg-panel h-8 px-2.5 text-xs w-full sm:w-36 cursor-pointer shrink-0"
disabled={loading}
>
<option value="editor" className="bg-panel text-main">Bearbeiten</option>
<option value="viewer" className="bg-panel text-main">Nur Lesen</option>
</select>
) : (
<span className="inline-flex items-center gap-1 text-[11px] text-muted px-2 py-1 bg-panel rounded border border-border shrink-0 self-center">
{effectiveFixedPerm === 'editor' ? (
<>
<Check className="w-3 h-3 text-accent" />
Bearbeiten
</>
) : (
<>
<Lock className="w-3 h-3 text-faint" />
Nur Lesen
</>
)}
</span>
)}
</div>
) : (
<div className="flex flex-col sm:flex-row gap-2">
<div className="flex-1">
<UserAutocomplete
users={collaborators}
selectedUser={selectedUser}
onSelectUser={setSelectedUser}
excludeUserIds={excludedUserIds}
disabled={loading}
placeholder="Benutzer suchen (Name oder @Benutzername)…"
/>
</div>
{allowRoleSelection ? (
<select
value={selectedPermission}
onChange={(e) => setSelectedPermission(e.target.value as 'editor' | 'viewer')}
className="input-field bg-panel h-8 px-2.5 text-xs w-full sm:w-36 cursor-pointer shrink-0"
disabled={loading}
>
<option value="editor" className="bg-panel text-main">Bearbeiten</option>
<option value="viewer" className="bg-panel text-main">Nur Lesen</option>
</select>
) : (
<span className="inline-flex items-center gap-1 text-[11px] text-muted px-2 py-1 bg-panel rounded border border-border shrink-0 self-center">
{effectiveFixedPerm === 'editor' ? (
<>
<Check className="w-3 h-3 text-accent" />
Bearbeiten
</>
) : (
<>
<Lock className="w-3 h-3 text-faint" />
Nur Lesen
</>
)}
</span>
)}
</div>
)}
<div className="flex justify-end pt-1">
<button
type="submit"
disabled={loading || (shareType === 'team' ? !selectedTeamId : !selectedUser)}
className="btn-primary text-xs h-8 px-3.5 flex items-center gap-1.5 cursor-pointer disabled:opacity-50"
>
<Plus className="w-3.5 h-3.5" />
<span>
{loading
? 'Wird gespeichert…'
: shareType === 'team'
? 'Gruppe freigeben'
: 'Benutzer hinzufügen'}
</span>
</button>
</div>
</form>
) : (
<div className="p-3 bg-panel-elevated border border-border rounded-card text-xs text-muted flex items-center gap-2">
<Lock className="w-4 h-4 text-faint shrink-0" />
<span>Nur der Eigentümer kann neue Freigaben einrichten.</span>
</div>
)}
{/* Active Shares List */}
<div className="space-y-2">
<div className="flex items-center justify-between text-xs font-semibold text-muted uppercase tracking-wider px-0.5">
<span>Aktive Freigaben</span>
<span className="font-mono text-[11px] font-normal lowercase">
{shares.length} {shares.length === 1 ? 'Freigabe' : 'Freigaben'}
</span>
</div>
<div className="space-y-1.5 max-h-56 overflow-y-auto pr-0.5">
{shares.length === 0 ? (
<div className="p-4 bg-canvas border border-border rounded-card text-xs text-muted flex items-center gap-2.5 justify-center text-center">
<Lock className="w-4 h-4 text-faint shrink-0" />
<span>Dieses Element ist aktuell privat (nur für dich sichtbar).</span>
</div>
) : (
shares.map((sh) => {
const isTeam = sh.targetType === 'team';
const roleLabel =
sh.permission === 'viewer' || sh.role === 'viewer' ? 'Leser' : 'Bearbeiter';
return (
<div
key={sh.id}
className="p-2.5 bg-canvas border border-border rounded-btn flex items-center justify-between gap-3 text-xs hover:border-border/80 transition-colors"
>
<div className="flex items-center gap-2.5 min-w-0">
{isTeam ? (
<div className="w-7 h-7 rounded-full flex items-center justify-center shrink-0 bg-accent/15 text-accent border border-accent/25">
<Users className="w-3.5 h-3.5" />
</div>
) : (
<UserAvatar
avatarUrl={sh.avatarUrl || collaborators.find((c) => c.id === sh.targetId)?.avatarUrl}
name={sh.targetName || sh.targetEmail}
size="sm"
className="border border-border/80"
/>
)}
<div className="truncate">
<div className="font-medium text-main truncate flex items-center gap-1.5">
<span className="truncate">{sh.targetName || (isTeam ? 'Gruppe' : sh.targetEmail || 'Benutzer')}</span>
<span
className={`text-[9px] uppercase font-semibold px-1 py-0.2 rounded ${
isTeam ? 'badge-neutral' : 'bg-surface-2 text-faint'
}`}
>
{isTeam ? 'Gruppe' : 'Benutzer'}
</span>
</div>
{sh.targetEmail && sh.targetEmail !== sh.targetName && (
<div className="text-[11px] text-muted truncate">{sh.targetEmail}</div>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span
className={`text-[10px] px-2 py-0.5 rounded font-semibold ${
roleLabel === 'Bearbeiter'
? 'bg-accent/15 text-accent border border-accent/30'
: 'bg-surface-2 text-muted border border-border'
}`}
>
{roleLabel}
</span>
{isOwner && (
<button
type="button"
onClick={() => handleRemove(sh.id)}
disabled={loading}
className="p-1 text-faint hover:text-status-err rounded transition-colors cursor-pointer"
title="Freigabe entfernen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
);
})
)}
</div>
</div>
{/* Footer */}
<div className="pt-2 border-t border-border flex justify-end">
<button
type="button"
onClick={onClose}
className="btn-action text-xs h-8 px-4 cursor-pointer"
>
Schließen
</button>
</div>
</div>
</div>
);
};
+157
View File
@@ -0,0 +1,157 @@
import React, { useState } from 'react';
export interface UserAvatarProps {
user?: {
displayName?: string;
email?: string;
avatarUrl?: string;
} | null;
name?: string;
avatarUrl?: string;
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
className?: string;
alt?: string;
}
const SIZE_MAP = {
xs: 'w-5 h-5 text-[10px]',
sm: 'w-7 h-7 text-xs',
md: 'w-9 h-9 text-sm',
lg: 'w-12 h-12 text-base',
xl: 'w-20 h-20 text-2xl',
};
const BG_COLORS = [
'bg-primary/20 text-primary border-primary/30',
'bg-secondary/20 text-secondary border-secondary/30',
'bg-accent/20 text-accent border-accent/30',
'bg-info/20 text-info border-info/30',
'bg-success/20 text-success border-success/30',
'bg-warning/20 text-warning border-warning/30',
];
function getInitials(name?: string, email?: string): string {
const cleanName = (name || '').trim();
if (cleanName) {
const parts = cleanName.split(/\s+/);
if (parts.length >= 2) {
return (parts[0][0] + parts[1][0]).toUpperCase();
}
return cleanName.slice(0, 2).toUpperCase();
}
const cleanEmail = (email || '').trim();
if (cleanEmail) {
return cleanEmail.slice(0, 2).toUpperCase();
}
return '?';
}
function getColorIndex(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
return Math.abs(hash) % BG_COLORS.length;
}
export function isEmoji(str?: string): boolean {
if (!str) return false;
const trimmed = str.trim();
// Simple heuristic: length <= 8 and not a URL or base64 image
return !trimmed.startsWith('data:') && !trimmed.startsWith('http') && !trimmed.startsWith('/') && trimmed.length <= 8;
}
export const UserAvatar: React.FC<UserAvatarProps> = ({
user,
name,
avatarUrl,
size = 'md',
className = '',
alt,
}) => {
const [imageError, setImageError] = useState(false);
const effectiveAvatarUrl = avatarUrl !== undefined ? avatarUrl : user?.avatarUrl;
const effectiveName = name !== undefined ? name : (user?.displayName || user?.email || '');
const effectiveEmail = user?.email || '';
const sizeClass = SIZE_MAP[size] || SIZE_MAP.md;
const initials = getInitials(effectiveName, effectiveEmail);
const colorIndex = getColorIndex(effectiveName || effectiveEmail || 'user');
const colorClass = BG_COLORS[colorIndex];
const hasImage = Boolean(
effectiveAvatarUrl &&
!imageError &&
(effectiveAvatarUrl.startsWith('data:image/') ||
effectiveAvatarUrl.startsWith('http://') ||
effectiveAvatarUrl.startsWith('https://') ||
effectiveAvatarUrl.startsWith('/'))
);
const isEmojiAvatar = Boolean(effectiveAvatarUrl && isEmoji(effectiveAvatarUrl));
return (
<div
className={`inline-flex items-center justify-center rounded-full aspect-square shrink-0 select-none overflow-hidden font-semibold transition-all ${sizeClass} ${className}`}
title={effectiveName || alt}
>
{hasImage ? (
<img
src={effectiveAvatarUrl}
alt={alt || effectiveName || 'Avatar'}
onError={() => setImageError(true)}
className="w-full h-full object-cover rounded-full"
/>
) : isEmojiAvatar ? (
<div className="w-full h-full flex items-center justify-center bg-base-300/80 rounded-full">
<span className="leading-none">{effectiveAvatarUrl}</span>
</div>
) : (
<div className={`w-full h-full flex items-center justify-center rounded-full border ${colorClass}`}>
<span className="leading-none tracking-tight">{initials}</span>
</div>
)}
</div>
);
};
/**
* Resizes and center-crops an uploaded image file to a compact base64 Data URL.
*/
export async function resizeImageToDataUrl(file: File, maxSize = 256): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error('Fehler beim Lesen der Datei'));
reader.onload = (e) => {
const img = new Image();
img.onerror = () => reject(new Error('Fehler beim Laden des Bildes'));
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = maxSize;
canvas.height = maxSize;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Canvas Context konnte nicht erstellt werden'));
return;
}
// Center crop calculation
const minDim = Math.min(img.width, img.height);
const sx = (img.width - minDim) / 2;
const sy = (img.height - minDim) / 2;
ctx.drawImage(img, sx, sy, minDim, minDim, 0, 0, maxSize, maxSize);
// Try webp first, fallback to jpeg
let dataUrl = canvas.toDataURL('image/webp', 0.85);
if (!dataUrl.startsWith('data:image/webp')) {
dataUrl = canvas.toDataURL('image/jpeg', 0.85);
}
resolve(dataUrl);
};
img.src = e.target?.result as string;
};
reader.readAsDataURL(file);
});
}
@@ -0,0 +1,73 @@
import React, { createContext, useContext, useState, useCallback } from 'react';
export interface MobileActionItem {
id: string;
label: string;
icon?: React.ReactNode;
badge?: string | number;
variant?: 'default' | 'accent' | 'danger';
active?: boolean;
onClick: () => void;
}
export interface MobileMenuGroup {
id: string;
title?: string;
items: MobileActionItem[];
}
interface MobileActionContextValue {
groups: MobileMenuGroup[];
setGroups: (groups: MobileMenuGroup[]) => void;
isMenuOpen: boolean;
setIsMenuOpen: React.Dispatch<React.SetStateAction<boolean>>;
closeMenu: () => void;
primaryAction?: () => void;
setPrimaryAction: (fn?: () => void) => void;
}
const MobileActionContext = createContext<MobileActionContextValue>({
groups: [],
setGroups: () => {},
isMenuOpen: false,
setIsMenuOpen: () => {},
closeMenu: () => {},
primaryAction: undefined,
setPrimaryAction: () => {},
});
export const MobileActionProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [groups, setGroupsState] = useState<MobileMenuGroup[]>([]);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [primaryAction, setPrimaryActionState] = useState<(() => void) | undefined>(undefined);
const setGroups = useCallback((newGroups: MobileMenuGroup[]) => {
setGroupsState(newGroups);
}, []);
const closeMenu = useCallback(() => {
setIsMenuOpen(false);
}, []);
const setPrimaryAction = useCallback((fn?: () => void) => {
setPrimaryActionState(() => fn);
}, []);
return (
<MobileActionContext.Provider
value={{
groups,
setGroups,
isMenuOpen,
setIsMenuOpen,
closeMenu,
primaryAction,
setPrimaryAction,
}}
>
{children}
</MobileActionContext.Provider>
);
};
export const useMobileActions = () => useContext(MobileActionContext);
+202
View File
@@ -0,0 +1,202 @@
import React, { useState, useEffect, useRef } from 'react';
import { Search, X, Check } from 'lucide-react';
import { User } from '../lib/types';
import { UserAvatar } from '../components/UserAvatar';
interface UserAutocompleteProps {
users: User[];
selectedUser: User | null;
onSelectUser: (user: User | null) => void;
placeholder?: string;
disabled?: boolean;
excludeUserIds?: string[];
className?: string;
}
export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
users,
selectedUser,
onSelectUser,
placeholder = 'Benutzer suchen (Name, @Benutzername oder E-Mail)…',
disabled = false,
excludeUserIds = [],
className = '',
}) => {
const [query, setQuery] = useState('');
const [isOpen, setIsOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Close dropdown on outside click
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Filter users based on query and exclusions
const filteredUsers = React.useMemo(() => {
const q = query.trim().toLowerCase();
const available = users.filter((u) => !excludeUserIds.includes(u.id));
if (!q) {
return available.slice(0, 15);
}
return available
.filter((u) => {
const nameMatch = (u.displayName || '').toLowerCase().includes(q);
const emailMatch = (u.email || '').toLowerCase().includes(q);
const userMatch = (u.username || '').toLowerCase().includes(q);
return nameMatch || emailMatch || userMatch;
})
.slice(0, 15);
}, [users, query, excludeUserIds]);
const handleSelect = (user: User) => {
onSelectUser(user);
setQuery('');
setIsOpen(false);
setHighlightedIndex(-1);
};
const handleClear = () => {
onSelectUser(null);
setQuery('');
setTimeout(() => inputRef.current?.focus(), 0);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!isOpen) {
if (e.key === 'ArrowDown' || e.key === 'Enter') {
setIsOpen(true);
}
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
setHighlightedIndex((prev) =>
prev < filteredUsers.length - 1 ? prev + 1 : 0
);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setHighlightedIndex((prev) =>
prev > 0 ? prev - 1 : filteredUsers.length - 1
);
} else if (e.key === 'Enter') {
e.preventDefault();
if (highlightedIndex >= 0 && highlightedIndex < filteredUsers.length) {
handleSelect(filteredUsers[highlightedIndex]);
}
} else if (e.key === 'Escape') {
setIsOpen(false);
}
};
return (
<div ref={containerRef} className={`relative ${className}`}>
{selectedUser ? (
<div className="h-8 px-2.5 bg-panel-elevated border border-accent/40 rounded-btn flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-2 min-w-0">
<UserAvatar user={selectedUser} size="xs" />
<span className="font-semibold text-main truncate">
{selectedUser.displayName || selectedUser.email}
</span>
{selectedUser.username && (
<span className="text-[11px] text-muted truncate">
@{selectedUser.username}
</span>
)}
{selectedUser.email && selectedUser.displayName && (
<span className="text-[11px] text-muted truncate hidden sm:inline">
({selectedUser.email})
</span>
)}
</div>
{!disabled && (
<button
type="button"
onClick={handleClear}
className="text-muted hover:text-main p-0.5 rounded hover:bg-canvas transition-colors"
title="Auswahl aufheben"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
) : (
<div className="relative">
<Search className="w-3.5 h-3.5 text-muted absolute left-2.5 top-1/2 -translate-y-1/2 pointer-events-none" />
<input
ref={inputRef}
type="text"
value={query}
disabled={disabled}
onChange={(e) => {
setQuery(e.target.value);
setIsOpen(true);
setHighlightedIndex(-1);
}}
onFocus={() => setIsOpen(true)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="w-full h-8 pl-8 pr-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs placeholder:text-muted transition-colors disabled:opacity-50"
/>
{isOpen && (
<div className="absolute top-full left-0 right-0 z-50 mt-1 max-h-56 overflow-y-auto bg-panel border border-border rounded-card shadow-panel py-1 space-y-0.5">
{filteredUsers.length === 0 ? (
<div className="p-3 text-xs text-muted text-center">
Kein passender Benutzer gefunden
</div>
) : (
filteredUsers.map((u, idx) => {
const isHighlighted = idx === highlightedIndex;
return (
<button
key={u.id}
type="button"
onClick={() => handleSelect(u)}
onMouseEnter={() => setHighlightedIndex(idx)}
className={`w-full px-2.5 py-1.5 flex items-center justify-between gap-2 text-left text-xs transition-colors cursor-pointer ${
isHighlighted
? 'bg-accent/15 text-main font-medium'
: 'hover:bg-panel-elevated text-main'
}`}
>
<div className="flex items-center gap-2 min-w-0">
<UserAvatar user={u} size="xs" />
<div className="truncate">
<span className="font-semibold text-main">
{u.displayName || u.email}
</span>
{u.username && (
<span className="ml-1 text-[11px] text-muted">
@{u.username}
</span>
)}
{u.email && u.displayName && (
<span className="ml-1.5 text-[10px] text-muted truncate">
{u.email}
</span>
)}
</div>
</div>
{isHighlighted && (
<Check className="w-3.5 h-3.5 text-accent shrink-0" />
)}
</button>
);
})
)}
</div>
)}
</div>
)}
</div>
);
};
@@ -0,0 +1,589 @@
import React, { useState } from 'react';
import {
Brain,
Shield,
Lock,
KeyRound,
UserPlus,
LogIn,
Server,
Users,
Check,
AlertCircle,
} from 'lucide-react';
import { AuthStatus, User } from '../../lib/types';
import { api, triggerHaptic } from '../../lib/api';
interface SetupAndAuthViewProps {
authStatus: AuthStatus;
onAuthenticated: (user: User) => void;
}
export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
authStatus,
onAuthenticated,
}) => {
// --- Setup Wizard State ---
const [setupName, setSetupName] = useState('');
const [setupEmail, setSetupEmail] = useState('');
const [setupPassword, setSetupPassword] = useState('');
const [setupPasswordConfirm, setSetupPasswordConfirm] = useState('');
const [instanceName, setInstanceName] = useState(authStatus.instanceName || 'MindSift');
const [sharingMode, setSharingMode] = useState<'open' | 'teams_only' | 'strict_private'>(
'teams_only'
);
const [allowRegistration, setAllowRegistration] = useState(false);
const [createSampleData, setCreateSampleData] = useState(false);
// --- Login / Invite / Register State ---
const [authTab, setAuthTab] = useState<'login' | 'invite' | 'register'>('login');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [displayName, setDisplayName] = useState('');
const [inviteCode, setInviteCode] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleCompleteSetup = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (setupPassword !== setupPasswordConfirm) {
setError('Die eingegebenen Passwörter stimmen nicht überein.');
triggerHaptic(25);
return;
}
setSubmitting(true);
triggerHaptic(15);
try {
const res = await api.completeSetup({
email: setupEmail.trim(),
password: setupPassword,
displayName: setupName.trim(),
instanceName: instanceName.trim() || 'MindSift',
sharingMode,
allowRegistration,
createSampleData,
});
onAuthenticated(res.user);
} catch (err: any) {
setError(err.message || 'Fehler bei der Ersteinrichtung');
} finally {
setSubmitting(false);
}
};
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setSubmitting(true);
triggerHaptic(15);
try {
const res = await api.login(email.trim(), password);
onAuthenticated(res.user);
} catch (err: any) {
setError(err.message || 'Ungültige Anmeldedaten');
} finally {
setSubmitting(false);
}
};
const handleRegisterOrInvite = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setSubmitting(true);
triggerHaptic(15);
try {
const code = authTab === 'invite' ? inviteCode.trim() : '';
const res = await api.register(email.trim(), password, displayName.trim(), code);
onAuthenticated(res.user);
} catch (err: any) {
setError(err.message || 'Registrierung fehlgeschlagen');
} finally {
setSubmitting(false);
}
};
// ============================================================================
// FIRST-BOOT SERVER SETUP WIZARD
// ============================================================================
if (authStatus.needsSetup) {
return (
<div className="min-h-screen bg-canvas text-main flex flex-col items-center justify-center p-4 font-sans">
<div className="w-full max-w-xl panel-surface shadow-panel overflow-hidden">
{/* Top Brand Bar */}
<div className="p-4 bg-canvas border-b border-border flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 bg-accent text-on-accent rounded-btn flex items-center justify-center">
<Brain className="w-4 h-4" />
</div>
<div>
<h1 className="text-base font-bold text-main tracking-tight leading-none">
MindSift Ersteinrichtung
</h1>
<p className="text-[11px] text-muted mt-0.5">
Initiales Administrator-Konto & Server-Konfiguration
</p>
</div>
</div>
<span className="badge-accent uppercase tracking-wider">First Boot</span>
</div>
<form onSubmit={handleCompleteSetup} className="p-5 space-y-5 text-xs">
<p className="text-muted leading-relaxed">
Willkommen auf deiner neuen MindSift-Instanz. Es existiert noch kein Benutzerkonto in
der Datenbank. Lege jetzt das erste <strong className="text-main">Server-Admin-Konto</strong> an und
bestimme die Sicherheitseinstellungen.
</p>
{error && (
<div className="alert-err flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-status-err flex-shrink-0" />
<span>{error}</span>
</div>
)}
{/* Section 1: Admin Account */}
<div className="p-3.5 bg-canvas border border-border space-y-3">
<h2 className="text-xs font-bold text-main uppercase tracking-wider flex items-center gap-1.5">
<Shield className="w-3.5 h-3.5 text-accent" />
<span>1. Server-Administrator anlegen</span>
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-muted mb-1">Dein Anzeigename *</label>
<input
type="text"
value={setupName}
onChange={(e) => setSetupName(e.target.value)}
placeholder="z.B. Admin / Dein Name"
required
autoFocus
className="w-full h-8 px-2.5 input-field"
/>
</div>
<div>
<label className="block text-muted mb-1">E-Mail-Adresse (Login) *</label>
<input
type="email"
value={setupEmail}
onChange={(e) => setSetupEmail(e.target.value)}
placeholder="admin@deinedomain.de"
required
className="w-full h-8 px-2.5 input-field"
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-muted mb-1">Administrator-Passwort *</label>
<input
type="password"
value={setupPassword}
onChange={(e) => setSetupPassword(e.target.value)}
placeholder="Mindestens 4 Zeichen"
minLength={4}
required
className="w-full h-8 px-2.5 input-field"
/>
</div>
<div>
<label className="block text-muted mb-1">Passwort wiederholen *</label>
<input
type="password"
value={setupPasswordConfirm}
onChange={(e) => setSetupPasswordConfirm(e.target.value)}
placeholder="Passwort erneut eingeben"
minLength={4}
required
className={`w-full h-8 px-2.5 input-field ${
setupPasswordConfirm && setupPassword !== setupPasswordConfirm
? 'border-status-err focus:border-status-err'
: ''
}`}
/>
{setupPasswordConfirm && setupPassword !== setupPasswordConfirm && (
<span className="text-[10px] text-status-err mt-1 block">
Passwörter stimmen nicht überein
</span>
)}
</div>
</div>
</div>
{/* Section 2: Server & Isolation */}
<div className="p-3.5 bg-canvas border border-border space-y-3">
<h2 className="text-xs font-bold text-main uppercase tracking-wider flex items-center gap-1.5">
<Server className="w-3.5 h-3.5 text-accent" />
<span>2. Instanz & Berechtigungen</span>
</h2>
<div>
<label className="block text-muted mb-1">Name der Instanz</label>
<input
type="text"
value={instanceName}
onChange={(e) => setInstanceName(e.target.value)}
placeholder="MindSift"
className="w-full h-8 px-2.5 input-field"
/>
</div>
<div>
<label className="block text-muted mb-1.5 flex items-center gap-1">
<Users className="w-3 h-3 text-accent" />
<span>Sichtbarkeit & Sharing-Isolation zwischen Benutzern</span>
</label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<button
type="button"
onClick={() => setSharingMode('strict_private')}
className={`p-2.5 text-left border transition-colors ${
sharingMode === 'strict_private'
? 'bg-panel border-accent text-main'
: 'border-border text-muted hover:text-main'
}`}
>
<p className="font-bold text-xs">Strikt Privat</p>
<p className="text-[10px] text-muted mt-0.5">
Keiner sieht andere Nutzer. Komplett isoliert.
</p>
</button>
<button
type="button"
onClick={() => setSharingMode('teams_only')}
className={`p-2.5 text-left border transition-colors ${
sharingMode === 'teams_only'
? 'bg-panel border-accent text-main'
: 'border-border text-muted hover:text-main'
}`}
>
<p className="font-bold text-xs">Teams isoliert</p>
<p className="text-[10px] text-muted mt-0.5">
Nutzer sehen nur Mitglieder ihrer eigenen Teams.
</p>
</button>
<button
type="button"
onClick={() => setSharingMode('open')}
className={`p-2.5 text-left border transition-colors ${
sharingMode === 'open'
? 'bg-panel border-accent text-main'
: 'border-border text-muted hover:text-main'
}`}
>
<p className="font-bold text-xs">Alle sehen alle</p>
<p className="text-[10px] text-muted mt-0.5">
Offene Zusammenarbeit auf dem gesamten Server.
</p>
</button>
</div>
</div>
<div className="pt-2 border-t border-border space-y-2">
<label className="flex items-center justify-between cursor-pointer select-none">
<div>
<span className="font-medium text-main block">
Öffentliche Selbstregistrierung erlauben
</span>
<span className="text-[11px] text-muted">
Wenn deaktiviert, können neue Nutzer nur per Einladungscode oder durch einen
Admin angelegt werden.
</span>
</div>
<input
type="checkbox"
checked={allowRegistration}
onChange={(e) => setAllowRegistration(e.target.checked)}
className="w-4 h-4 accent-blue-600"
/>
</label>
<label className="flex items-center justify-between cursor-pointer select-none pt-1">
<div>
<span className="font-medium text-main block">
Start-Beispieldaten anlegen
</span>
<span className="text-[11px] text-muted">
Erstellt wenige Beispiel-Gedanken und ein Einführungsdokument zum Testen.
</span>
</div>
<input
type="checkbox"
checked={createSampleData}
onChange={(e) => setCreateSampleData(e.target.checked)}
className="w-4 h-4 accent-blue-600"
/>
</label>
</div>
</div>
<div className="flex items-center justify-end pt-1">
<button
type="submit"
disabled={submitting}
className="btn-primary h-9 px-5 text-xs font-semibold flex items-center gap-2 disabled:opacity-50"
>
<Check className="w-4 h-4" />
<span>
{submitting
? 'Richte Server ein…'
: 'Ersteinrichtung abschließen & Starten'}
</span>
</button>
</div>
</form>
</div>
</div>
);
}
// ============================================================================
// STANDARD LOGIN / INVITE CODE / REGISTRATION VIEW
// ============================================================================
return (
<div className="min-h-screen bg-canvas text-main flex flex-col items-center justify-center p-4 font-sans">
<div className="w-full max-w-md panel-surface shadow-panel overflow-hidden">
{/* Top Header */}
<div className="p-4 bg-canvas border-b border-border flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="w-7 h-7 bg-accent text-on-accent rounded-btn flex items-center justify-center">
<Brain className="w-4 h-4" />
</div>
<h1 className="text-sm font-bold text-main tracking-tight leading-none">
{authStatus.instanceName || 'MindSift'}
</h1>
</div>
<Lock className="w-4 h-4 text-faint" />
</div>
{/* Navigation Tabs */}
<div className="flex border-b border-border bg-canvas text-xs">
<button
type="button"
onClick={() => {
setAuthTab('login');
setError(null);
}}
className={`flex-1 h-9 px-3 font-medium flex items-center justify-center gap-1.5 border-r border-border transition-colors ${
authTab === 'login'
? 'bg-panel text-main border-b-2 border-b-accent'
: 'text-muted hover:text-main'
}`}
>
<LogIn className="w-3.5 h-3.5" />
<span>Anmelden</span>
</button>
<button
type="button"
onClick={() => {
setAuthTab('invite');
setError(null);
}}
className={`flex-1 h-9 px-3 font-medium flex items-center justify-center gap-1.5 border-r border-border transition-colors ${
authTab === 'invite'
? 'bg-panel text-main border-b-2 border-b-accent'
: 'text-muted hover:text-main'
}`}
>
<KeyRound className="w-3.5 h-3.5" />
<span>Einladungscode</span>
</button>
{authStatus.allowRegistration && (
<button
type="button"
onClick={() => {
setAuthTab('register');
setError(null);
}}
className={`flex-1 h-9 px-3 font-medium flex items-center justify-center gap-1.5 transition-colors ${
authTab === 'register'
? 'bg-panel text-main border-b-2 border-b-accent'
: 'text-muted hover:text-main'
}`}
>
<UserPlus className="w-3.5 h-3.5" />
<span>Registrieren</span>
</button>
)}
</div>
{/* Form Body */}
<div className="p-5 text-xs">
{error && (
<div className="mb-4 alert-err flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-status-err flex-shrink-0" />
<span>{error}</span>
</div>
)}
{authTab === 'login' && (
<form onSubmit={handleLogin} className="space-y-3.5">
{authStatus.demoMode && (
<div className="p-2.5 rounded border border-accent/30 bg-accent/10 space-y-1.5">
<div className="flex items-center justify-between text-[11px] font-semibold text-accent">
<span>🧪 Demo-Modus aktiv</span>
<span className="text-[10px] text-muted font-normal">Klick zum Ausfüllen:</span>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => {
setEmail('admin');
setPassword('admin');
}}
className="flex-1 py-1 px-2 text-[11px] rounded bg-panel hover:bg-panel-elevated border border-border text-main font-mono transition-colors text-center"
>
admin / admin
</button>
<button
type="button"
onClick={() => {
setEmail('user');
setPassword('user');
}}
className="flex-1 py-1 px-2 text-[11px] rounded bg-panel hover:bg-panel-elevated border border-border text-main font-mono transition-colors text-center"
>
user / user
</button>
</div>
</div>
)}
<div>
<label className="block text-muted mb-1">Benutzername oder E-Mail-Adresse</label>
<input
type="text"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="admin oder name@beispiel.de"
required
autoFocus
className="w-full h-8 px-2.5 input-field"
/>
</div>
<div>
<label className="block text-muted mb-1">Passwort</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
className="w-full h-8 px-2.5 input-field"
/>
</div>
<div className="pt-1">
<button
type="submit"
disabled={submitting}
className="w-full btn-primary h-9 text-xs font-semibold flex items-center justify-center gap-2 disabled:opacity-50"
>
<LogIn className="w-3.5 h-3.5" />
<span>{submitting ? 'Melde an…' : 'Anmelden'}</span>
</button>
</div>
{!authStatus.allowRegistration && (
<p className="text-[11px] text-faint text-center pt-2 border-t border-border">
Öffentliche Registrierung ist deaktiviert. Zugang nur mit Konto oder{' '}
<button
type="button"
onClick={() => setAuthTab('invite')}
className="text-accent hover:underline"
>
Einladungscode
</button>
.
</p>
)}
</form>
)}
{(authTab === 'invite' || authTab === 'register') && (
<form onSubmit={handleRegisterOrInvite} className="space-y-3.5">
{authTab === 'invite' && (
<div>
<label className="block text-muted mb-1">Einladungscode *</label>
<input
type="text"
value={inviteCode}
onChange={(e) => setInviteCode(e.target.value.toUpperCase())}
placeholder="MS-XXXXXXXX"
required
autoFocus
className="w-full h-8 px-2.5 input-field font-mono uppercase"
/>
</div>
)}
<div>
<label className="block text-muted mb-1">Anzeigename *</label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Dein Name"
required
className="w-full h-8 px-2.5 input-field"
/>
</div>
<div>
<label className="block text-muted mb-1">E-Mail-Adresse *</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="name@beispiel.de"
required
className="w-full h-8 px-2.5 input-field"
/>
</div>
<div>
<label className="block text-muted mb-1">Passwort *</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Mindestens 4 Zeichen"
minLength={4}
required
className="w-full h-8 px-2.5 input-field"
/>
</div>
<div className="pt-1">
<button
type="submit"
disabled={submitting}
className="w-full btn-primary h-9 text-xs font-semibold flex items-center justify-center gap-2 disabled:opacity-50"
>
<UserPlus className="w-3.5 h-3.5" />
<span>
{submitting
? 'Konto wird erstellt…'
: authTab === 'invite'
? 'Einladung einlösen & Konto erstellen'
: 'Konto registrieren'}
</span>
</button>
</div>
</form>
)}
</div>
</div>
</div>
);
};
@@ -1,46 +1,114 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { Mic, MicOff, Send, Sparkles, Calendar, AlertTriangle, CheckCircle2 } from 'lucide-react'; import {
import { PriorityLevel, Thought } from '../../lib/types'; Mic,
MicOff,
ArrowUp,
Sparkles,
Calendar,
Check,
CheckSquare,
ShoppingCart,
FileText,
Layers,
ChevronDown,
AlertTriangle,
Flame,
} from 'lucide-react';
import { PriorityLevel, SharedList, Thought } from '../../lib/types';
import { api, triggerHaptic } from '../../lib/api'; import { api, triggerHaptic } from '../../lib/api';
export type CaptureTarget = 'inbox' | 'task' | 'list_item' | 'document';
interface InstantCaptureBarProps { interface InstantCaptureBarProps {
onCaptured: (thought: Thought) => void; onCaptured: (thought: Thought, target?: CaptureTarget) => void;
lists?: SharedList[];
autoFocusTrigger?: number; autoFocusTrigger?: number;
inlineOptions?: boolean;
defaultShowOptions?: boolean;
hideTargetPicker?: boolean;
fixedTarget?: CaptureTarget;
} }
export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
onCaptured, onCaptured,
lists = [],
autoFocusTrigger = 0, autoFocusTrigger = 0,
inlineOptions = false,
defaultShowOptions = false,
hideTargetPicker = false,
fixedTarget,
}) => { }) => {
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 [showOptions, setShowOptions] = useState(false); const [target, setTarget] = useState<CaptureTarget>(fixedTarget || 'inbox');
const [targetListId, setTargetListId] = useState<string>('');
const [showOptions, setShowOptions] = useState(defaultShowOptions || hideTargetPicker);
const [isListening, setIsListening] = useState(false); const [isListening, setIsListening] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [justSaved, setJustSaved] = useState(false); const [justSavedLabel, setJustSavedLabel] = useState<string | null>(null);
const [isMultiline, setIsMultiline] = useState(false);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLTextAreaElement>(null);
const containerRef = useRef<HTMLFormElement>(null);
const recognitionRef = useRef<any>(null); const recognitionRef = useRef<any>(null);
const baseVoiceTextRef = useRef('');
const hasVoiceInputRef = useRef(false);
const adjustHeight = (el?: HTMLTextAreaElement | null) => {
const targetEl = el || inputRef.current;
if (!targetEl) return;
targetEl.style.height = 'auto';
if (targetEl.value) {
const scrollH = targetEl.scrollHeight;
targetEl.style.height = `${Math.min(scrollH, 140)}px`;
setIsMultiline(targetEl.value.includes('\n') || scrollH > 28);
} else {
setIsMultiline(false);
}
};
useEffect(() => {
adjustHeight();
}, [content]);
useEffect(() => { useEffect(() => {
inputRef.current?.focus(); inputRef.current?.focus();
}, [autoFocusTrigger]); }, [autoFocusTrigger]);
useEffect(() => {
if (!targetListId && lists.length > 0) {
setTargetListId(lists[0].id);
}
}, [lists, targetListId]);
// Close dropdown on outside click
useEffect(() => {
if (inlineOptions) return;
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setShowOptions(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [inlineOptions]);
// Global keyboard shortcut: 'c' or '/' focuses the Instant Capture input immediately // Global keyboard shortcut: 'c' or '/' focuses the Instant Capture input immediately
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement; const el = e.target as HTMLElement;
const isEditing = const isEditing =
target.tagName === 'INPUT' || el.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' || el.tagName === 'TEXTAREA' ||
target.isContentEditable; el.isContentEditable;
if (!isEditing && (e.key === 'c' || e.key === 'C' || e.key === '/')) { if (!isEditing && (e.key === 'c' || e.key === 'C' || e.key === '/')) {
e.preventDefault(); e.preventDefault();
inputRef.current?.focus(); inputRef.current?.focus();
} else if (e.key === 'Escape' && document.activeElement === inputRef.current) { } else if (e.key === 'Escape' && document.activeElement === inputRef.current) {
setContent(''); setContent('');
setIsMultiline(false);
setShowOptions(false); setShowOptions(false);
inputRef.current?.blur(); inputRef.current?.blur();
} }
@@ -65,6 +133,9 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
} }
try { try {
baseVoiceTextRef.current = content.replace(/\s*\[…\]$/, '').trim();
hasVoiceInputRef.current = true;
const recognition = new SpeechRecognition(); const recognition = new SpeechRecognition();
recognition.lang = 'de-DE'; recognition.lang = 'de-DE';
recognition.continuous = false; recognition.continuous = false;
@@ -80,10 +151,8 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
for (let i = 0; i < event.results.length; i++) { for (let i = 0; i < event.results.length; i++) {
transcript += event.results[i][0].transcript; transcript += event.results[i][0].transcript;
} }
setContent((prev) => { const base = baseVoiceTextRef.current;
const base = prev.replace(/\s*\[…\]$/, '').trim(); setContent(base ? `${base} ${transcript}` : transcript);
return base ? `${base} ${transcript}` : transcript;
});
}; };
recognition.onerror = () => { recognition.onerror = () => {
@@ -102,166 +171,403 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
} }
}; };
const handleSubmit = async (e?: React.FormEvent) => { const executeCapture = async (
if (e) e.preventDefault(); overrideTarget?: CaptureTarget,
overrideListId?: string
) => {
const trimmed = content.trim(); const trimmed = content.trim();
if (!trimmed || isSubmitting) return; if (!trimmed || isSubmitting) return;
const effectiveTarget = fixedTarget || overrideTarget || target;
const effectiveListId =
overrideListId || targetListId || (lists.length > 0 ? lists[0].id : '');
const capturedPriority = priority;
const capturedDueDate = dueDate;
const capturedSource = hasVoiceInputRef.current || isListening ? 'voice' : 'manual';
hasVoiceInputRef.current = false;
// Optimistically clear input immediately so the UI feels instant (<10ms) in all browsers
setContent('');
setIsMultiline(false);
setPriority('normal');
setDueDate('');
setTarget(fixedTarget || 'inbox');
setShowOptions(defaultShowOptions || hideTargetPicker);
setIsSubmitting(true); setIsSubmitting(true);
triggerHaptic(15); triggerHaptic(15);
if (inputRef.current) {
inputRef.current.style.height = 'auto';
}
const savedMsg =
effectiveTarget === 'task'
? 'Als Aufgabe!'
: effectiveTarget === 'list_item'
? 'Auf Liste!'
: effectiveTarget === 'document'
? 'Als Notiz!'
: 'In Triage!';
setJustSavedLabel(savedMsg);
setTimeout(() => setJustSavedLabel(null), 1600);
inputRef.current?.focus();
try { try {
const created = await api.captureThought( const created = await api.captureThought(
trimmed, trimmed,
priority, capturedPriority,
isListening ? 'voice' : 'manual', capturedSource,
dueDate capturedDueDate,
{
target: effectiveTarget,
targetListId: effectiveTarget === 'list_item' ? effectiveListId : '',
}
); );
setContent(''); onCaptured(created, effectiveTarget);
setPriority('normal'); } catch {
setDueDate(''); // Restore text if capture unexpectedly failed completely
setShowOptions(false); setContent(trimmed);
setJustSaved(true);
setTimeout(() => setJustSaved(false), 1800);
onCaptured(created);
inputRef.current?.focus();
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
}; };
const handleSubmit = async (e?: React.FormEvent) => {
if (e) e.preventDefault();
await executeCapture();
};
const selectedList = lists.find((l) => l.id === targetListId) || lists[0];
const getTargetLabel = () => {
if (target === 'task') return 'Direkt-Aufgabe';
if (target === 'list_item') return selectedList ? `${selectedList.icon} ${selectedList.title}` : 'Auf Liste';
if (target === 'document') return 'Direkt-Notiz';
return 'Quick Dump';
};
return ( return (
<div className="w-full max-w-3xl mx-auto"> <div className="w-full">
<form onSubmit={handleSubmit} className="relative"> <form ref={containerRef} onSubmit={handleSubmit} className="relative">
{/* Quick-Capture Input Bar (Radius controlled by --radius-pill / multiline) */}
<div <div
className={`flex items-center h-14 px-4 sm:px-5 rounded-full bg-[#1e1f20] gemini-border gemini-input-glow transition-all duration-200 ${ className={`flex ${
isListening ? 'border-[#c58af9] shadow-purple-glow' : '' isMultiline ? 'items-end py-1.5 min-h-[36px]' : 'items-center h-9'
} pl-1.5 pr-1.5 bg-panel border capture-pill ${
isMultiline ? 'capture-bar-multiline' : 'rounded-full'
} transition-all duration-150 ${
isListening
? 'border-accent bg-panel'
: 'border-border focus-within:border-accent'
}`} }`}
> >
<Sparkles <div
className={`w-5 h-5 mr-3 flex-shrink-0 transition-colors ${ className={`w-6 h-6 capture-pill rounded-full bg-accent text-on-accent flex items-center justify-center mr-2 flex-shrink-0 ${
isListening isMultiline ? 'self-start mt-1' : ''
? 'text-[#c58af9] animate-pulse'
: justSaved
? 'text-[#81c995]'
: 'text-[#8ab4f8]'
}`} }`}
/> >
<Sparkles className="w-3 h-3" />
</div>
<input <textarea
ref={inputRef} ref={inputRef}
type="text" rows={1}
value={content} value={content}
onChange={(e) => setContent(e.target.value)} onChange={(e) => {
setContent(e.target.value);
adjustHeight(e.target);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && (!e.shiftKey || e.ctrlKey || e.metaKey)) {
e.preventDefault();
executeCapture();
}
}}
placeholder={ placeholder={
isListening isListening
? 'Ich höre zu… sprich deinen Gedanken aus' ? 'Ich höre zu…'
: 'Gedanke, Idee oder Zutat sofort festhalten… (Taste C oder /)' : hideTargetPicker || fixedTarget === 'inbox'
? 'Quick Dump erfassen… (Enter zum Speichern)'
: target === 'task'
? 'Neue Aufgabe direkt anlegen… (Enter / Shift+Enter für Mehrzeiler)'
: target === 'list_item'
? `Eintrag direkt zu "${selectedList?.title || 'Liste'}" hinzufügen…`
: target === 'document'
? 'Neue Workspace-Notiz direkt anlegen… (Enter / Shift+Enter für Mehrzeiler)'
: 'Quick Dump: Gedanke reinrotzen… (C / Enter, Shift+Enter für Mehrzeiler)'
} }
className="w-full bg-transparent text-[#e3e3e3] placeholder-[#9aa0a6] text-sm sm:text-base focus:outline-none" 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"
style={{ minHeight: '22px', maxHeight: '140px' }}
autoComplete="off" autoComplete="off"
/> />
<div className="flex items-center gap-1.5 ml-2 flex-shrink-0"> <div
{/* Options Toggle Pill (Due Date / Priority) */} className={`flex items-center gap-1 ml-2 flex-shrink-0 ${
isMultiline ? 'self-end mb-0.5' : ''
}`}
>
{/* Target & Options Toggle Pill (Hidden when target picker is disabled) */}
{!hideTargetPicker && (
<button <button
type="button" type="button"
onClick={() => setShowOptions((prev) => !prev)} onClick={() => setShowOptions((prev) => !prev)}
title="Priorität & Fälligkeitsdatum" title="Ziel wählen (Quick Dump, direkt als Aufgabe, auf Liste oder als Notiz) & Optionen"
className={`h-9 px-3 rounded-full text-xs font-medium flex items-center gap-1.5 transition-colors ${ className={`h-6 px-2.5 capture-pill rounded-full text-[11px] flex items-center gap-1 transition-colors cursor-pointer ${
showOptions || priority !== 'normal' || dueDate showOptions || target !== 'inbox' || priority !== 'normal' || dueDate
? 'bg-[#8ab4f8]/20 text-[#8ab4f8] border border-[#8ab4f8]/40' ? 'bg-accent-dim text-accent border border-accent-border font-medium'
: 'text-[#9aa0a6] hover:bg-[#282a2c] hover:text-[#e3e3e3]' : 'text-muted hover:text-main hover:bg-panel-elevated border border-transparent'
}`} }`}
> >
<Calendar className="w-3.5 h-3.5" /> {target === 'task' ? (
<span className="hidden sm:inline"> <CheckSquare className="w-3 h-3" />
{dueDate ? dueDate : priority !== 'normal' ? priority.toUpperCase() : 'Optionen'} ) : target === 'list_item' ? (
<ShoppingCart className="w-3 h-3" />
) : target === 'document' ? (
<FileText className="w-3 h-3" />
) : (
<Layers className="w-3 h-3" />
)}
<span className={`${inlineOptions ? 'inline' : 'hidden md:inline'} max-w-[110px] truncate`}>
{getTargetLabel()}
</span> </span>
{dueDate && <span className="hidden lg:inline text-[10px] opacity-80">• {dueDate.slice(5)}</span>}
<ChevronDown className={`w-3 h-3 opacity-70 transition-transform ${showOptions ? 'rotate-180' : ''}`} />
</button> </button>
)}
{/* Voice Dictation Pill Button */} {/* Voice Dictation Button */}
<button <button
type="button" type="button"
onClick={toggleVoiceDictation} onClick={toggleVoiceDictation}
title="Spracheingabe (Diktieren)" title="Spracheingabe"
className={`w-9 h-9 rounded-full flex items-center justify-center transition-all ${ className={`w-6 h-6 capture-pill rounded-full flex items-center justify-center transition-colors ${
isListening isListening
? 'bg-[#c58af9] text-[#0b0f19] animate-pulse' ? 'bg-accent-dim text-accent'
: 'bg-[#282a2c] text-[#9aa0a6] hover:text-[#e3e3e3] hover:bg-[#323538]' : 'text-muted hover:text-main hover:bg-panel-elevated'
}`} }`}
> >
{isListening ? <MicOff className="w-4 h-4" /> : <Mic className="w-4 h-4" />} {isListening ? <MicOff className="w-3 h-3" /> : <Mic className="w-3 h-3" />}
</button> </button>
{/* Instant Send Pill Button */} {/* Instant Send Button */}
<button <button
type="submit" type="submit"
disabled={!content.trim() || isSubmitting} disabled={!content.trim() || isSubmitting}
className={`h-9 px-4 rounded-full font-medium text-xs sm:text-sm flex items-center gap-1.5 transition-all ${ className={`h-6 px-2.5 capture-pill rounded-full font-medium text-[11px] flex items-center gap-1 transition-all ${
content.trim() content.trim()
? 'bg-[#8ab4f8] text-[#0b0f19] hover:bg-[#a1c2fa] shadow-sm cursor-pointer' ? 'bg-accent hover:bg-accent-hover text-on-accent cursor-pointer shadow-sm'
: 'bg-[#282a2c] text-[#5f6368] cursor-not-allowed' : 'bg-panel-elevated text-faint cursor-not-allowed'
}`} }`}
> >
{justSaved ? ( {justSavedLabel ? (
<> <>
<CheckCircle2 className="w-4 h-4 text-[#0b0f19]" /> <Check className="w-3 h-3" />
<span className="hidden sm:inline">Gespeichert</span> <span className="inline">{justSavedLabel}</span>
</> </>
) : ( ) : (
<> <>
<Send className="w-3.5 h-3.5" /> <ArrowUp className="w-3 h-3" />
<span className="hidden sm:inline">Einfrieren</span> <span className="inline">
{hideTargetPicker || (fixedTarget || target) === 'inbox'
? 'Quick Dump'
: target === 'task'
? 'Als Aufgabe'
: target === 'list_item'
? 'Auf Liste'
: target === 'document'
? 'Als Notiz'
: 'Dump'}
</span>
</> </>
)} )}
</button> </button>
</div> </div>
</div> </div>
{/* Expandable Quick Metadata Pills */} {/* Expandable Target & Metadata Panel (Theme-controlled Dropdown or Inline) */}
{showOptions && ( {showOptions && (
<div className="mt-2.5 px-4 py-2.5 rounded-full bg-[#131314] gemini-border flex flex-wrap items-center justify-between gap-2 text-xs animate-fadeIn"> <div
<div className="flex items-center gap-1.5"> className={
<AlertTriangle className="w-3.5 h-3.5 text-[#9aa0a6] mr-1" /> inlineOptions
<span className="text-[#9aa0a6] mr-1">Priorität:</span> ? 'mt-3 p-3 panel-surface border border-border rounded-xl space-y-3 text-xs animate-in fade-in duration-150'
: 'absolute top-full left-0 right-0 mt-1.5 z-50 p-3 panel-surface shadow-panel space-y-3 text-xs'
}
>
{/* Row 1: Where should this entry go? (Omitted when hideTargetPicker is true) */}
{!hideTargetPicker && (
<>
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted">
Wohin damit? {content.trim() ? '(Klick speichert sofort oder wählt Ziel)' : '(Ziel für Eingabe wählen)'}
</span>
<span className="text-[11px] text-faint">
Standard: Quick Dump für späteres Swipen
</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-1.5">
{/* Option 1: Quick Dump (Triage) */}
<button
type="button"
onClick={() => {
setTarget('inbox');
if (content.trim()) {
executeCapture('inbox');
}
}}
className={`min-h-[36px] px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors cursor-pointer ${
target === 'inbox'
? 'bg-accent-dim border-accent text-main font-medium'
: 'bg-canvas border-border text-muted hover:text-main hover:bg-panel-hover'
}`}
>
<Layers className="w-3.5 h-3.5 text-accent flex-shrink-0" />
<span className="truncate">Quick Dump (Triage)</span>
</button>
{/* Option 2: Direct Task */}
<button
type="button"
onClick={() => {
setTarget('task');
if (content.trim()) {
executeCapture('task');
}
}}
className={`min-h-[36px] px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors cursor-pointer ${
target === 'task'
? 'bg-accent-dim border-accent text-main font-medium'
: 'bg-canvas border-border text-muted hover:text-main hover:bg-panel-hover'
}`}
>
<CheckSquare className="w-3.5 h-3.5 text-accent flex-shrink-0" />
<span className="truncate">Direkt als Aufgabe</span>
</button>
{/* Option 3: Direct List Item */}
<button
type="button"
onClick={() => {
setTarget('list_item');
if (!targetListId && lists.length > 0) {
setTargetListId(lists[0].id);
}
}}
className={`min-h-[36px] px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors cursor-pointer ${
target === 'list_item'
? 'bg-accent-dim border-accent text-main font-medium'
: 'bg-canvas border-border text-muted hover:text-main hover:bg-panel-hover'
}`}
>
<ShoppingCart className="w-3.5 h-3.5 text-accent flex-shrink-0" />
<span className="truncate">Direkt auf Liste…</span>
</button>
{/* Option 4: Direct Workspace Note */}
<button
type="button"
onClick={() => {
setTarget('document');
if (content.trim()) {
executeCapture('document');
}
}}
className={`min-h-[36px] px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors cursor-pointer ${
target === 'document'
? 'bg-accent-dim border-accent text-main font-medium'
: 'bg-canvas border-border text-muted hover:text-main hover:bg-panel-hover'
}`}
>
<FileText className="w-3.5 h-3.5 text-accent flex-shrink-0" />
<span className="truncate">Direkt als Notiz</span>
</button>
</div>
</div>
{/* Optional Sub-Row: Shared List Picker */}
{lists.length > 0 && (
<div className="pt-2 border-t border-border flex flex-wrap items-center gap-1.5">
<span className="text-muted mr-1 flex items-center gap-1">
<ShoppingCart className="w-3 h-3 text-accent" />
Liste:
</span>
{lists.map((lst) => {
const isSelected = target === 'list_item' && targetListId === lst.id;
return (
<button
key={lst.id}
type="button"
onClick={() => {
setTarget('list_item');
setTargetListId(lst.id);
if (content.trim()) {
executeCapture('list_item', lst.id);
}
}}
className={`min-h-[30px] px-2.5 border rounded-btn text-xs flex items-center gap-1.5 transition-colors cursor-pointer ${
isSelected
? 'bg-accent text-on-accent border-accent font-medium'
: 'bg-canvas border-border text-muted hover:text-main hover:bg-panel-hover'
}`}
>
<span>{lst.icon}</span>
<span>{lst.title}</span>
</button>
);
})}
</div>
)}
</>
)}
{/* Row 2: Priority & Due Date */}
<div className={`${!hideTargetPicker ? 'pt-2 border-t border-border' : ''} flex flex-wrap items-center justify-between gap-2.5`}>
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-muted text-[11px] font-semibold mr-1">Priorität:</span>
{(['low', 'normal', 'high', 'urgent'] as PriorityLevel[]).map((p) => { {(['low', 'normal', 'high', 'urgent'] as PriorityLevel[]).map((p) => {
const labels: Record<PriorityLevel, string> = { const config: Record<PriorityLevel, { label: string; icon?: React.ReactNode; activeClass: string }> = {
low: 'Niedrig', low: { label: 'Niedrig', activeClass: 'bg-canvas border-strong text-main font-semibold shadow-xs' },
normal: 'Normal', normal: { label: 'Normal', activeClass: 'bg-accent/15 border-accent text-accent font-semibold shadow-xs' },
high: 'Hoch', high: { label: 'Wichtig', icon: <AlertTriangle className="w-3 h-3 text-status-warn" />, activeClass: 'bg-status-warn/15 border-status-warn text-status-warn font-semibold shadow-xs' },
urgent: 'Dringend', urgent: { label: 'Dringend', icon: <Flame className="w-3 h-3 text-status-err" />, activeClass: 'bg-status-err/15 border-status-err text-status-err font-semibold shadow-xs' },
}; };
const active = priority === p; const active = priority === p;
const item = config[p];
return ( return (
<button <button
key={p} key={p}
type="button" type="button"
onClick={() => setPriority(p)} onClick={() => {
className={`px-3 py-1 rounded-full transition-colors ${ triggerHaptic(10);
setPriority(p);
}}
className={`min-h-[30px] px-2.5 py-1 border rounded-btn text-xs flex items-center gap-1 transition-colors cursor-pointer ${
active active
? p === 'urgent' ? item.activeClass
? 'bg-[#f28b82] text-[#0b0f19] font-semibold' : 'bg-canvas text-muted hover:text-main border-border'
: 'bg-[#8ab4f8] text-[#0b0f19] font-semibold'
: 'bg-[#1e1f20] text-[#9aa0a6] hover:text-[#e3e3e3]'
}`} }`}
> >
{labels[p]} {item.icon}
<span>{item.label}</span>
</button> </button>
); );
})} })}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-1.5">
<span className="text-[#9aa0a6]">Fällig am:</span> <span className="text-muted text-[11px] font-semibold flex items-center gap-1">
<Calendar className="w-3.5 h-3.5 text-accent" />
Fällig am:
</span>
<input <input
type="date" type="date"
value={dueDate} value={dueDate}
onChange={(e) => setDueDate(e.target.value)} onChange={(e) => setDueDate(e.target.value)}
className="bg-[#1e1f20] text-[#e3e3e3] px-3 py-1 rounded-full gemini-border text-xs focus:outline-none focus:border-[#8ab4f8]" className="input-field px-2.5 py-1 text-xs min-h-[30px]"
/> />
</div> </div>
</div> </div>
</div>
)} )}
</form> </form>
</div> </div>
+361
View File
@@ -0,0 +1,361 @@
import React, { useState } from 'react';
import {
Search,
Layers,
FileText,
CheckSquare,
Keyboard,
Pin,
FolderTree,
Folder,
Sparkles,
Command,
CornerDownLeft,
CornerUpLeft,
MoveVertical,
Check,
Share2,
Users,
} from 'lucide-react';
interface HelpViewProps {
onBack?: () => void;
}
type HelpCategory = 'all' | 'workspace' | 'triage' | 'tasks' | 'shortcuts';
export const HelpView: React.FC<HelpViewProps> = () => {
const [activeCategory, setActiveCategory] = useState<HelpCategory>('all');
const [searchQuery, setSearchQuery] = useState('');
return (
<div className="w-full space-y-4 pb-12">
{/* Header Bar */}
<div className="panel-surface p-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-base sm:text-lg font-bold text-main flex items-center gap-2">
<FileText className="w-4 h-4 text-accent" />
<span>Hilfe & Dokumentation</span>
</h1>
<p className="text-xs text-muted">
Alles Wichtige zur Tastaturbedienung, Outliner-Notizen, Triage und Zusammenarbeit
</p>
</div>
{/* Search */}
<div className="relative w-full sm:w-64">
<Search className="w-3.5 h-3.5 text-muted absolute left-2.5 top-1/2 -translate-y-1/2" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="In Hilfe suchen…"
className="w-full h-8 pl-8 pr-3 input-field text-xs bg-canvas"
/>
</div>
</div>
{/* Category Filter Pills */}
<div className="flex flex-wrap items-center gap-1.5">
{[
{ id: 'all', label: 'Alle Themen', icon: Sparkles },
{ id: 'workspace', label: 'Workspace & Outliner', icon: FileText },
{ id: 'triage', label: 'Triage & Quick-Dump', icon: Layers },
{ id: 'tasks', label: 'Aufgaben & Listen', icon: CheckSquare },
{ id: 'shortcuts', label: 'Tastatur-Kürzel', icon: Keyboard },
].map((cat) => {
const Icon = cat.icon;
const isActive = activeCategory === cat.id;
return (
<button
key={cat.id}
type="button"
onClick={() => setActiveCategory(cat.id as HelpCategory)}
className={`h-8 px-3 text-xs font-medium border rounded-btn flex items-center gap-1.5 transition-colors ${
isActive
? 'bg-panel-elevated border-accent text-main font-semibold'
: 'bg-panel border-border text-muted hover:text-main'
}`}
>
<Icon className="w-3.5 h-3.5 text-accent" />
<span>{cat.label}</span>
</button>
);
})}
</div>
{/* Grid of Documentation Cards */}
<div className="space-y-4">
{/* SECTION: WORKSPACE & OUTLINER */}
{(activeCategory === 'all' || activeCategory === 'workspace') && (
<div className="panel-surface p-5 space-y-4">
<div className="border-b border-border pb-2.5">
<h2 className="text-sm font-bold text-main flex items-center gap-2">
<FileText className="w-4 h-4 text-accent" />
<span>Workspace: Der hierarchische Logseq-Outliner</span>
</h2>
<p className="text-xs text-muted mt-0.5">
Jede Notiz in MindSift ist ein flexibler hierarchischer Outliner und wird als Standard-Markdown auf der Festplatte gespeichert.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-xs">
<div className="p-3 bg-canvas border border-border rounded-card space-y-2">
<h3 className="font-semibold text-main flex items-center gap-1.5 text-xs">
<Keyboard className="w-3.5 h-3.5 text-accent" />
<span>Flüssige Tastatursteuerung</span>
</h3>
<ul className="space-y-1.5 text-muted leading-relaxed">
<li>
<strong className="text-main">Enter:</strong> Erzeugt sofort einen neuen Bullet-Point auf derselben Ebene.
</li>
<li>
<strong className="text-main">Tab:</strong> Rückt den aktuellen Bullet-Point ein und ordnet ihn dem vorherigen Punkt unter.
</li>
<li>
<strong className="text-main">Shift + Tab:</strong> Rückt den Bullet-Point wieder nach links aus (verringert die Tiefe).
</li>
<li>
<strong className="text-main">Shift + Enter:</strong> Erzeugt einen weichen Zeilenumbruch innerhalb desselben Textblocks.
</li>
<li>
<strong className="text-main">Backspace:</strong> Auf einem leeren Block rückt er die Ebene aus bzw. löscht den leeren Bullet.
</li>
</ul>
</div>
<div className="p-3 bg-canvas border border-border rounded-card space-y-2">
<h3 className="font-semibold text-main flex items-center gap-1.5 text-xs">
<Command className="w-3.5 h-3.5 text-accent" />
<span>Slash-Befehle (/)</span>
</h3>
<p className="text-muted leading-relaxed">
Tippe einfach <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">/</code> in einen leeren Block, um den Typ flexibel zu wechseln:
</p>
<div className="grid grid-cols-2 gap-1 text-[11px] font-mono text-muted">
<span>/todo (Checkbox-Aufgabe)</span>
<span>/h1 (Haupt-Überschrift)</span>
<span>/h2 (Abschnitts-Titel)</span>
<span>/h3 (Unter-Überschrift)</span>
<span>/code (Code-Block)</span>
<span>/table (Markdown-Tabelle)</span>
<span>/callout (Hinweis-Box)</span>
<span>/progress (Fortschritt 0-100%)</span>
</div>
</div>
<div className="p-3 bg-canvas border border-border rounded-card space-y-2">
<h3 className="font-semibold text-main flex items-center gap-1.5 text-xs">
<FolderTree className="w-3.5 h-3.5 text-accent" />
<span>Ordnerstruktur & Drag & Drop</span>
</h3>
<p className="text-muted leading-relaxed">
Notizen können im linken Datei-Explorer per <strong>Drag & Drop</strong> direkt auf beliebige Ordner gezogen werden, um sie zu verschieben.
Ziehst du eine Notiz auf das Hauptverzeichnis, wird sie aus allen Unterordnern herausgenommen.
</p>
</div>
<div className="p-3 bg-canvas border border-border rounded-card space-y-2">
<h3 className="font-semibold text-main flex items-center gap-1.5 text-xs">
<Pin className="w-3.5 h-3.5 text-accent" />
<span>Zoom-Hoisting & Favoriten anpinnen</span>
</h3>
<ul className="space-y-1.5 text-muted leading-relaxed">
<li>
<strong>Klick auf den Bullet-Punkt (•):</strong> Fokussiert tief in diesen Ast hinein (Zoom-in Hoisting). Du kannst über die Breadcrumbs ganz oben jederzeit zurückspringen.
</li>
<li>
<strong>Bullet-Punkt ziehen:</strong> Verschiebt den Block samt aller eingerückten Unterelemente an eine neue Position.
</li>
<li>
<strong>Anpinnen:</strong> Wichtige Notizen heften sich ganz oben im Datei-Explorer unter „Angepinnte Notizen" an.
</li>
</ul>
</div>
</div>
</div>
)}
{/* SECTION: TRIAGE & QUICK DUMP */}
{(activeCategory === 'all' || activeCategory === 'triage') && (
<div className="panel-surface p-5 space-y-4">
<div className="border-b border-border pb-2.5">
<h2 className="text-sm font-bold text-main flex items-center gap-2">
<Layers className="w-4 h-4 text-accent" />
<span>Triage & Quick Dump: Der Kopf frei in 3 Sekunden</span>
</h2>
<p className="text-xs text-muted mt-0.5">
Sofortiges Erfassen ohne Reibung. Gedanken werden gesammelt und anschließend im Triage-Deck mit 1 Taste entschieden.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 text-xs">
<div className="p-3 bg-canvas border border-border rounded-card space-y-1.5">
<h4 className="font-semibold text-main">1. Blitz-Erfassung</h4>
<p className="text-muted leading-relaxed">
Drücke überall <kbd className="px-1 py-0.5 bg-panel border border-border rounded font-mono text-[10px]">C</kbd> oder <kbd className="px-1 py-0.5 bg-panel border border-border rounded font-mono text-[10px]">/</kbd>, tippe den Gedanken ein und drücke <kbd className="px-1 py-0.5 bg-panel border border-border rounded font-mono text-[10px]">Enter</kbd>.
</p>
</div>
<div className="p-3 bg-canvas border border-border rounded-card space-y-1.5">
<h4 className="font-semibold text-main">2. Triage Entscheidungs-Tasten</h4>
<ul className="text-muted space-y-1">
<li><strong className="text-status-err">H</strong> oder <strong>←</strong> : Wegwerfen / Erledigt</li>
<li><strong className="text-accent">L</strong> oder <strong>→</strong> : Als Aufgabe anlegen</li>
<li><strong>T</strong> : Aufgabe mit Fälligkeitsdatum</li>
<li><strong>D</strong> : In Workspace-Notiz umwandeln</li>
<li><strong>Leertaste</strong> : Später (ans Ende schieben)</li>
</ul>
</div>
<div className="p-3 bg-canvas border border-border rounded-card space-y-1.5">
<h4 className="font-semibold text-main">3. Direkt-Zuordnung</h4>
<p className="text-muted leading-relaxed">
Über das Dropdown neben der Eingabeleiste kannst du einen Eintrag schon beim Schreiben direkt einer Aufgabenliste, Einkaufsliste oder Notiz zuweisen.
</p>
</div>
</div>
</div>
)}
{/* SECTION: TASKS & LISTS */}
{(activeCategory === 'all' || activeCategory === 'tasks') && (
<div className="panel-surface p-5 space-y-4">
<div className="border-b border-border pb-2.5">
<h2 className="text-sm font-bold text-main flex items-center gap-2">
<CheckSquare className="w-4 h-4 text-accent" />
<span>Aufgaben, Listen & Team-Zusammenarbeit</span>
</h2>
<p className="text-xs text-muted mt-0.5">
Verwalte Aufgabenlisten, Einkaufslisten und teile sie mit Teams und Räumen in Echtzeit.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-xs">
<div className="p-3 bg-canvas border border-border rounded-card space-y-2">
<h3 className="font-semibold text-main flex items-center gap-1.5">
<Users className="w-3.5 h-3.5 text-accent" />
<span>Aufgabenlisten teilen & Avatar-Stack</span>
</h3>
<p className="text-muted leading-relaxed">
Jede Aufgabenliste kann über den Button <strong>„Teilen"</strong> einem gemeinsamen Team-Space zugeordnet werden.
Im Header und auf den Tabs zeigt der Avatar-Stack sofort an, wer Eigentümer ist und welche Teammitglieder Zugriff haben.
</p>
</div>
<div className="p-3 bg-canvas border border-border rounded-card space-y-2">
<h3 className="font-semibold text-main flex items-center gap-1.5">
<CheckSquare className="w-3.5 h-3.5 text-accent" />
<span>Globale To-Dos Rollup</span>
</h3>
<p className="text-muted leading-relaxed">
Im Workspace-Tab <strong>„Globale To-Dos"</strong> werden automatisch alle offenen Checkboxen (<code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">- [ ]</code>) aus sämtlichen Notizen und Ordnern aggregiert.
Ein Klick auf die Checkbox synchronisiert die Aufgabe in Echtzeit direkt in der physischen Markdown-Datei!
</p>
</div>
</div>
</div>
)}
{/* SECTION: SHORTCUTS CHEAT SHEET */}
{(activeCategory === 'all' || activeCategory === 'shortcuts') && (
<div className="panel-surface p-5 space-y-4">
<div className="border-b border-border pb-2.5">
<h2 className="text-sm font-bold text-main flex items-center gap-2">
<Keyboard className="w-4 h-4 text-accent" />
<span>Tastatur-Kürzel Gesamtübersicht</span>
</h2>
<p className="text-xs text-muted mt-0.5">
Schneller arbeiten ohne Maus: Alle Hotkeys auf einen Blick.
</p>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="border-b border-border text-muted">
<th className="py-2 px-3 font-semibold">Taste</th>
<th className="py-2 px-3 font-semibold">Bereich</th>
<th className="py-2 px-3 font-semibold">Funktion</th>
</tr>
</thead>
<tbody className="divide-y divide-border text-main">
<tr>
<td className="py-2 px-3 font-mono text-accent">C oder /</td>
<td className="py-2 px-3 text-muted">Global</td>
<td className="py-2 px-3">Fokus sofort in die Schnellerfassungsleiste setzen</td>
</tr>
<tr>
<td className="py-2 px-3 font-mono text-accent">Enter</td>
<td className="py-2 px-3 text-muted">Schnellerfassung</td>
<td className="py-2 px-3">Gedanken sofort speichern</td>
</tr>
<tr>
<td className="py-2 px-3 font-mono text-accent">Esc</td>
<td className="py-2 px-3 text-muted">Schnellerfassung</td>
<td className="py-2 px-3">Eingabe leeren & Menüs schließen</td>
</tr>
<tr>
<td className="py-2 px-3 font-mono text-accent">Enter</td>
<td className="py-2 px-3 text-muted">Workspace Outliner</td>
<td className="py-2 px-3">Neuen Bullet-Point auf gleicher Ebene anlegen</td>
</tr>
<tr>
<td className="py-2 px-3 font-mono text-accent">Tab</td>
<td className="py-2 px-3 text-muted">Workspace Outliner</td>
<td className="py-2 px-3">Bullet-Point einrücken (dem vorherigen unterordnen)</td>
</tr>
<tr>
<td className="py-2 px-3 font-mono text-accent">Shift + Tab</td>
<td className="py-2 px-3 text-muted">Workspace Outliner</td>
<td className="py-2 px-3">Bullet-Point ausrücken (Ebene verringern)</td>
</tr>
<tr>
<td className="py-2 px-3 font-mono text-accent">Shift + Enter</td>
<td className="py-2 px-3 text-muted">Workspace Outliner</td>
<td className="py-2 px-3">Weicher Zeilenumbruch innerhalb des aktuellen Blocks</td>
</tr>
<tr>
<td className="py-2 px-3 font-mono text-accent">Backspace</td>
<td className="py-2 px-3 text-muted">Workspace Outliner</td>
<td className="py-2 px-3">Bei leerem Block: Ebene ausrücken bzw. Bullet löschen</td>
</tr>
<tr>
<td className="py-2 px-3 font-mono text-accent">/</td>
<td className="py-2 px-3 text-muted">Workspace Outliner</td>
<td className="py-2 px-3">Slash-Command-Menü öffnen (/todo, /h1, /code, ...)</td>
</tr>
<tr>
<td className="py-2 px-3 font-mono text-accent">H oder ←</td>
<td className="py-2 px-3 text-muted">Triage-Deck</td>
<td className="py-2 px-3">Gedanken verwerfen / erledigen</td>
</tr>
<tr>
<td className="py-2 px-3 font-mono text-accent">L oder →</td>
<td className="py-2 px-3 text-muted">Triage-Deck</td>
<td className="py-2 px-3">Gedanken sofort zur Aufgabe machen</td>
</tr>
<tr>
<td className="py-2 px-3 font-mono text-accent">T</td>
<td className="py-2 px-3 text-muted">Triage-Deck</td>
<td className="py-2 px-3">Aufgabe mit Fälligkeitsdatum anlegen</td>
</tr>
<tr>
<td className="py-2 px-3 font-mono text-accent">D</td>
<td className="py-2 px-3 text-muted">Triage-Deck</td>
<td className="py-2 px-3">Gedanken in Notiz/Dokument umwandeln</td>
</tr>
<tr>
<td className="py-2 px-3 font-mono text-accent">Leertaste</td>
<td className="py-2 px-3 text-muted">Triage-Deck</td>
<td className="py-2 px-3">Karte überspringen („Später" – ans Ende schieben)</td>
</tr>
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
);
};
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,947 @@
import React, { useEffect, useState } from 'react';
import {
Shield,
Users,
Server,
Mail,
Plus,
Trash2,
KeyRound,
Copy,
Check,
ArrowLeft,
Send,
AlertCircle,
UserPlus,
} from 'lucide-react';
import {
SystemSettings,
User,
UserInvite,
UserRole,
} from '../../lib/types';
import { api, triggerHaptic } from '../../lib/api';
import { UserAvatar } from '../../components/UserAvatar';
interface AdminSettingsViewProps {
currentUser: User;
onBack: () => void;
}
type AdminSection = 'users' | 'server' | 'smtp';
export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
currentUser,
onBack,
}) => {
const isServerAdmin = currentUser.role === 'admin';
const [section, setSection] = useState<AdminSection>('users');
// --- Users & Invites State ---
const [users, setUsers] = useState<User[]>([]);
const [invites, setInvites] = useState<UserInvite[]>([]);
const [newUserName, setNewUserName] = useState('');
const [newUserEmail, setNewUserEmail] = useState('');
const [newUserPassword, setNewUserPassword] = useState('');
const [newUserRole, setNewUserRole] = useState<UserRole>('member');
const [inviteEmail, setInviteEmail] = useState('');
const [inviteRole, setInviteRole] = useState<UserRole>('member');
const [copiedInviteId, setCopiedInviteId] = useState<string | null>(null);
const [resetPwdUserId, setResetPwdUserId] = useState<string | null>(null);
const [resetPwdVal, setResetPwdVal] = useState('');
// --- Server & SMTP Settings State ---
const [settings, setSettings] = useState<SystemSettings>({
sharingMode: 'teams_only',
allowRegistration: false,
instanceName: 'MindSift',
smtpHost: '',
smtpPort: 587,
smtpUser: '',
smtpPass: '',
smtpFrom: '',
smtpTls: true,
smtpConfiguredViaEnv: false,
});
const [testSmtpEmail, setTestSmtpEmail] = useState(currentUser.email || '');
const [testingSmtp, setTestingSmtp] = useState(false);
const [statusMsg, setStatusMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
const loadAll = async () => {
try {
const [uList, invList] = await Promise.all([
api.adminGetUsers(),
api.adminGetInvites(),
]);
setUsers(uList);
setInvites(invList);
if (isServerAdmin) {
const sys = await api.adminGetSettings();
setSettings({
sharingMode: sys.sharingMode || 'teams_only',
allowRegistration: Boolean(sys.allowRegistration),
instanceName: sys.instanceName || 'MindSift',
smtpHost: sys.smtpHost || '',
smtpPort: sys.smtpPort || 587,
smtpUser: sys.smtpUser || '',
smtpPass: sys.smtpPass || '',
smtpFrom: sys.smtpFrom || '',
smtpTls: sys.smtpTls ?? true,
smtpConfiguredViaEnv: Boolean(sys.smtpConfiguredViaEnv),
});
}
} catch (err: any) {
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Laden der Administrationsdaten' });
}
};
useEffect(() => {
loadAll();
}, []);
// ============================================================================
// HANDLERS: USERS & INVITES
// ============================================================================
const handleCreateUser = async (e: React.FormEvent) => {
e.preventDefault();
setStatusMsg(null);
triggerHaptic(15);
try {
const created = await api.adminCreateUser({
email: newUserEmail.trim(),
password: newUserPassword,
displayName: newUserName.trim() || newUserEmail.split('@')[0],
role: newUserRole,
});
setUsers((prev) => [...prev, created]);
setNewUserName('');
setNewUserEmail('');
setNewUserPassword('');
setNewUserRole('member');
setStatusMsg({ type: 'ok', text: `Benutzer "${created.displayName}" wurde angelegt.` });
} catch (err: any) {
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Anlegen des Benutzers' });
}
};
const handleCreateInvite = async (e: React.FormEvent) => {
e.preventDefault();
setStatusMsg(null);
triggerHaptic(15);
try {
const inv = await api.adminCreateInvite({
email: inviteEmail.trim(),
role: inviteRole,
});
setInvites((prev) => [inv, ...prev]);
setInviteEmail('');
setStatusMsg({
type: 'ok',
text: `Einladungscode ${inv.code} erfolgreich erstellt.`,
});
} catch (err: any) {
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Erstellen der Einladung' });
}
};
const handleDeleteInvite = async (id: string) => {
try {
await api.adminDeleteInvite(id);
setInvites((prev) => prev.filter((i) => i.id !== id));
} catch (err: any) {
setStatusMsg({ type: 'err', text: err.message });
}
};
const handleCopyInviteCode = (inv: UserInvite) => {
navigator.clipboard.writeText(inv.code);
setCopiedInviteId(inv.id);
setTimeout(() => setCopiedInviteId(null), 2000);
};
const handleChangeUserRole = async (u: User, role: UserRole) => {
setStatusMsg(null);
try {
const updated = await api.adminUpdateUser(u.id, { role });
setUsers((prev) => prev.map((item) => (item.id === u.id ? updated : item)));
setStatusMsg({
type: 'ok',
text: `Rolle für ${updated.displayName} auf "${role}" aktualisiert.`,
});
} catch (err: any) {
setStatusMsg({ type: 'err', text: err.message });
}
};
const handleToggleUserActive = async (u: User) => {
setStatusMsg(null);
try {
const updated = await api.adminUpdateUser(u.id, { isActive: !u.isActive });
setUsers((prev) => prev.map((item) => (item.id === u.id ? updated : item)));
} catch (err: any) {
setStatusMsg({ type: 'err', text: err.message });
}
};
const handleConfirmResetPassword = async (userId: string) => {
if (!resetPwdVal.trim()) return;
setStatusMsg(null);
try {
await api.adminResetPassword(userId, resetPwdVal.trim());
setResetPwdUserId(null);
setResetPwdVal('');
setStatusMsg({ type: 'ok', text: 'Passwort erfolgreich zurückgesetzt.' });
} catch (err: any) {
setStatusMsg({ type: 'err', text: err.message });
}
};
const handleDeleteUser = async (u: User) => {
if (
!window.confirm(
`Benutzer "${u.displayName}" (${u.email}) wirklich unwiderruflich löschen?`
)
)
return;
setStatusMsg(null);
try {
await api.adminDeleteUser(u.id);
setUsers((prev) => prev.filter((item) => item.id !== u.id));
setStatusMsg({ type: 'ok', text: 'Benutzer gelöscht.' });
} catch (err: any) {
setStatusMsg({ type: 'err', text: err.message });
}
};
// ============================================================================
// HANDLERS: SERVER & SMTP SETTINGS
// ============================================================================
const handleSaveSystemSettings = async (patch?: Partial<SystemSettings>) => {
setStatusMsg(null);
const next = { ...settings, ...(patch || {}) };
setSettings(next);
try {
const saved = await api.adminUpdateSettings(next);
setSettings((prev) => ({ ...prev, ...saved }));
setStatusMsg({ type: 'ok', text: 'Server-Einstellungen erfolgreich gespeichert.' });
} catch (err: any) {
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Speichern' });
}
};
const handleTestSmtp = async () => {
if (!testSmtpEmail.trim()) return;
setTestingSmtp(true);
setStatusMsg(null);
try {
await api.adminUpdateSettings(settings);
await api.adminTestSMTP(testSmtpEmail.trim());
setStatusMsg({
type: 'ok',
text: `Test-E-Mail erfolgreich an ${testSmtpEmail.trim()} gesendet!`,
});
} catch (err: any) {
setStatusMsg({
type: 'err',
text: `SMTP-Test fehlgeschlagen: ${err.message}`,
});
} finally {
setTestingSmtp(false);
}
};
return (
<div className="w-full space-y-4">
{/* Top Page Header */}
<div className="flex items-center justify-between border-b border-border pb-3">
<div className="flex items-center gap-3">
<button
onClick={onBack}
className="btn-action h-8 px-2.5 text-xs flex items-center gap-1.5"
title="Zurück zur Übersicht"
>
<ArrowLeft className="w-3.5 h-3.5" />
<span>Zurück</span>
</button>
<div>
<h1 className="text-base font-bold text-main tracking-tight flex items-center gap-2">
<Shield className="w-4 h-4 text-accent" />
<span>Server-Administration</span>
</h1>
<p className="text-xs text-muted">
Benutzerverwaltung, Einladungscodes, Isolationsmodus & Mail-Server
</p>
</div>
</div>
<span className="badge-accent">Server-Administrator</span>
</div>
{statusMsg && (
<div
className={`p-3 text-xs flex items-center justify-between gap-2 rounded-card ${
statusMsg.type === 'ok' ? 'alert-ok' : 'alert-err'
}`}
>
<div className="flex items-center gap-2">
{statusMsg.type === 'ok' ? (
<Check className="w-4 h-4 text-accent flex-shrink-0" />
) : (
<AlertCircle className="w-4 h-4 text-status-err flex-shrink-0" />
)}
<span>{statusMsg.text}</span>
</div>
<button
onClick={() => setStatusMsg(null)}
className="text-muted hover:text-main text-[11px]"
>
Schließen
</button>
</div>
)}
{/* Main 2-Column Layout */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 items-start">
{/* Left Navigation Sidebar */}
<aside className="panel-surface divide-y divide-border overflow-hidden">
<button
onClick={() => setSection('users')}
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
section === 'users'
? 'bg-panel-elevated text-main border-l-2 border-l-accent'
: 'text-muted hover:text-main hover:bg-canvas/50'
}`}
>
<Users className="w-4 h-4 text-accent flex-shrink-0" />
<div>
<p className="font-semibold">Benutzer & Einladungen</p>
<p className="text-[10px] text-faint">Konten, Rollen & Invite-Codes</p>
</div>
</button>
{isServerAdmin && (
<>
<button
onClick={() => setSection('server')}
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
section === 'server'
? 'bg-panel-elevated text-main border-l-2 border-l-accent'
: 'text-muted hover:text-main hover:bg-canvas/50'
}`}
>
<Server className="w-4 h-4 text-accent flex-shrink-0" />
<div>
<p className="font-semibold">Server & Isolation</p>
<p className="text-[10px] text-faint">Sichtbarkeit & Registrierung</p>
</div>
</button>
<button
onClick={() => setSection('smtp')}
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
section === 'smtp'
? 'bg-panel-elevated text-main border-l-2 border-l-accent'
: 'text-muted hover:text-main hover:bg-canvas/50'
}`}
>
<Mail className="w-4 h-4 text-accent flex-shrink-0" />
<div>
<p className="font-semibold">E-Mail / SMTP-Server</p>
<p className="text-[10px] text-faint">Mailversand für Alarme</p>
</div>
</button>
</>
)}
</aside>
{/* Right Content Area */}
<div className="md:col-span-3 panel-surface p-5 text-xs">
{/* ================================================================= */}
{/* SECTION 1: BENUTZER, ROLLEN & EINLADUNGEN */}
{/* ================================================================= */}
{section === 'users' && (
<div className="space-y-6">
<div className="border-b border-border pb-3">
<h2 className="text-sm font-bold text-main">Benutzersteuerung, Rollen & Einladungen</h2>
<p className="text-muted mt-0.5">
Lege Benutzerkonten direkt an oder erzeuge Einladungscodes, mit denen sich eingeladene
Personen selbst registrieren können, auch wenn die öffentliche Registrierung gesperrt ist.
</p>
</div>
{/* Direct User Creation Form */}
<div className="p-4 bg-canvas border border-border rounded-card space-y-3">
<h3 className="font-bold text-main flex items-center gap-1.5">
<UserPlus className="w-3.5 h-3.5 text-accent" />
<span>Neues Benutzerkonto direkt anlegen</span>
</h3>
<form onSubmit={handleCreateUser} className="space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<div className="min-w-0">
<label className="block text-muted mb-1">Anzeigename</label>
<input
type="text"
value={newUserName}
onChange={(e) => setNewUserName(e.target.value)}
placeholder="z.B. Anna Beispiel"
required
className="w-full min-w-0 h-8 px-2.5 input-field"
/>
</div>
<div className="min-w-0">
<label className="block text-muted mb-1">E-Mail-Adresse</label>
<input
type="email"
value={newUserEmail}
onChange={(e) => setNewUserEmail(e.target.value)}
placeholder="anna@beispiel.de"
required
className="w-full min-w-0 h-8 px-2.5 input-field"
/>
</div>
<div className="min-w-0">
<label className="block text-muted mb-1">Initial-Passwort</label>
<input
type="password"
value={newUserPassword}
onChange={(e) => setNewUserPassword(e.target.value)}
placeholder="Mind. 4 Zeichen"
minLength={4}
required
className="w-full min-w-0 h-8 px-2.5 input-field"
/>
</div>
<div className="min-w-0">
<label className="block text-muted mb-1">Benutzerrolle</label>
<select
value={newUserRole}
onChange={(e) => setNewUserRole(e.target.value as UserRole)}
className="w-full min-w-0 h-8 px-2 input-field bg-panel"
>
<option value="member">Mitglied (Standard)</option>
<option value="admin">Server-Admin (Vollzugriff)</option>
</select>
</div>
</div>
<div className="flex justify-end">
<button
type="submit"
className="btn-primary h-8 px-4 text-xs font-semibold flex items-center gap-1.5"
>
<Plus className="w-3.5 h-3.5" />
<span>Benutzer jetzt anlegen</span>
</button>
</div>
</form>
</div>
{/* Invite Code Generator */}
<div className="p-4 bg-canvas border border-border rounded-card space-y-3">
<h3 className="font-bold text-main flex items-center gap-1.5">
<KeyRound className="w-3.5 h-3.5 text-accent" />
<span>Einladungscode erzeugen (gültig für 7 Tage)</span>
</h3>
<form onSubmit={handleCreateInvite} className="space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="min-w-0">
<label className="block text-muted mb-1">
Gebundene E-Mail (optional)
</label>
<input
type="email"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
placeholder="Leer lassen für beliebige E-Mail"
className="w-full min-w-0 h-8 px-2.5 input-field"
/>
</div>
<div className="min-w-0">
<label className="block text-muted mb-1">Zugewiesene Server-Rolle</label>
<select
value={inviteRole}
onChange={(e) => setInviteRole(e.target.value as UserRole)}
className="w-full min-w-0 h-8 px-2 input-field bg-panel"
>
<option value="member">Mitglied</option>
<option value="admin">Server-Admin</option>
</select>
</div>
</div>
<div className="flex justify-end">
<button
type="submit"
className="btn-action h-8 px-4 text-xs font-semibold flex items-center gap-1.5 border-accent text-main"
>
<KeyRound className="w-3.5 h-3.5 text-accent" />
<span>Einladungscode generieren</span>
</button>
</div>
</form>
{/* Active Invites List */}
{invites.length > 0 && (
<div className="pt-2 border-t border-border space-y-1.5">
<p className="text-[11px] font-semibold text-muted">
Aktive & bisherige Einladungscodes:
</p>
<div className="divide-y divide-border border border-border bg-panel rounded-card overflow-hidden">
{invites.map((inv) => (
<div
key={inv.id}
className="p-2 flex flex-wrap items-center justify-between gap-2"
>
<div className="flex items-center gap-2.5">
<code className="px-2 py-0.5 bg-canvas border border-border font-mono font-bold text-accent rounded-card">
{inv.code}
</code>
<span className="text-muted">
Rolle: <strong className="text-main">{inv.role}</strong>
{inv.email ? ` · Für: ${inv.email}` : ''}
</span>
{inv.usedBy ? (
<span className="badge-neutral text-[10px]">
Eingelöst
</span>
) : (
<span className="badge-ok text-[10px]">
Offen
</span>
)}
</div>
<div className="flex items-center gap-1.5">
{!inv.usedBy && (
<button
onClick={() => handleCopyInviteCode(inv)}
className="btn-action h-7 px-2 text-[11px] flex items-center gap-1"
>
<Copy className="w-3 h-3" />
<span>{copiedInviteId === inv.id ? 'Kopiert!' : 'Code kopieren'}</span>
</button>
)}
<button
onClick={() => handleDeleteInvite(inv.id)}
className="btn-icon btn-icon-danger"
title="Einladung löschen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
))}
</div>
</div>
)}
</div>
{/* Users List */}
<div className="space-y-2">
<h3 className="font-bold text-main">
Registrierte Benutzer ({users.length})
</h3>
<div className="border border-border divide-y divide-border bg-canvas rounded-card overflow-hidden">
{users.map((u) => (
<div key={u.id} className="p-3 space-y-2">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div className="flex items-center gap-2.5">
<UserAvatar user={u} size="sm" className="border border-border/80" />
<div>
<div className="flex items-center gap-2">
<span className="font-bold text-main">{u.displayName}</span>
{!u.isActive && (
<span className="badge-err text-[10px]">
Gesperrt
</span>
)}
</div>
<p className="text-muted text-[11px]">{u.email}</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{/* Role selector */}
<select
value={u.role}
disabled={!isServerAdmin && u.role === 'admin'}
onChange={(e) => handleChangeUserRole(u, e.target.value as UserRole)}
className="h-7 px-2 input-field text-[11px] bg-panel"
title="Benutzerrolle ändern"
>
<option value="member">Mitglied</option>
<option value="admin">Server-Admin</option>
</select>
{isServerAdmin && (
<>
<button
onClick={() =>
setResetPwdUserId(resetPwdUserId === u.id ? null : u.id)
}
className="btn-action h-7 px-2 text-[11px]"
>
Passwort
</button>
{u.id !== currentUser.id && (
<>
<button
onClick={() => handleToggleUserActive(u)}
className="btn-action h-7 px-2 text-[11px]"
>
{u.isActive ? 'Sperren' : 'Aktivieren'}
</button>
<button
onClick={() => handleDeleteUser(u)}
className="btn-icon btn-icon-danger"
title="Benutzer löschen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</>
)}
</>
)}
</div>
</div>
{resetPwdUserId === u.id && (
<div className="flex items-center gap-2 pt-2 border-t border-border">
<input
type="password"
value={resetPwdVal}
onChange={(e) => setResetPwdVal(e.target.value)}
placeholder="Neues Passwort eingeben…"
className="flex-1 h-7 px-2 input-field"
/>
<button
onClick={() => handleConfirmResetPassword(u.id)}
className="btn-primary h-7 px-3 text-[11px]"
>
Speichern
</button>
<button
onClick={() => setResetPwdUserId(null)}
className="btn-action h-7 px-2 text-[11px]"
>
Abbrechen
</button>
</div>
)}
</div>
))}
</div>
</div>
</div>
)}
{/* ================================================================= */}
{/* SECTION 3: SERVER & SHARING-ISOLATION (SERVER-ADMIN ONLY) */}
{/* ================================================================= */}
{section === 'server' && isServerAdmin && (
<div className="space-y-6">
<div className="border-b border-border pb-3">
<h2 className="text-sm font-bold text-main">
Server-Grundeinstellungen & Benutzer-Sichtbarkeit
</h2>
<p className="text-muted mt-0.5">
Als Server-Admin bestimmst du zentral für alle Nutzer, ob sie komplett isoliert
arbeiten, sich nur innerhalb ihrer Teams sehen oder ob alle Nutzer für alle sichtbar sind.
</p>
</div>
<div className="space-y-4">
<div>
<label className="block text-muted font-semibold mb-1">
Name der MindSift-Instanz
</label>
<div className="flex gap-2 max-w-md">
<input
type="text"
value={settings.instanceName}
onChange={(e) =>
setSettings((prev) => ({ ...prev, instanceName: e.target.value }))
}
className="flex-1 h-9 px-3 input-field"
/>
<button
type="button"
onClick={() => handleSaveSystemSettings()}
className="btn-primary h-9 px-4 text-xs"
>
Speichern
</button>
</div>
</div>
{/* Sharing Isolation Cards */}
<div className="space-y-2">
<label className="block text-muted font-semibold flex items-center gap-1.5">
<Users className="w-4 h-4 text-accent" />
<span>Globale Sharing- & Sichtbarkeits-Isolation</span>
</label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<button
type="button"
onClick={() => handleSaveSystemSettings({ sharingMode: 'strict_private' })}
className={`p-4 text-left border rounded-card transition-colors ${
settings.sharingMode === 'strict_private'
? '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">1. Strikt Privat (Keiner sieht andere)</p>
<p className="text-[11px] text-muted mt-1 leading-relaxed">
Jeder Benutzer arbeitet komplett für sich allein. Keine geteilten Spaces und keine
Benutzersuche möglich.
</p>
</button>
<button
type="button"
onClick={() => handleSaveSystemSettings({ sharingMode: 'teams_only' })}
className={`p-4 text-left border rounded-card transition-colors ${
settings.sharingMode === 'teams_only'
? '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">2. Teams isoliert (Empfohlen)</p>
<p className="text-[11px] text-muted mt-1 leading-relaxed">
Benutzer sehen ausschließlich Personen, mit denen sie mindestens ein gemeinsames Team
teilen.
</p>
</button>
<button
type="button"
onClick={() => handleSaveSystemSettings({ sharingMode: 'open' })}
className={`p-4 text-left border rounded-card transition-colors ${
settings.sharingMode === 'open'
? '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">3. Offen (Alle sehen alle)</p>
<p className="text-[11px] text-muted mt-1 leading-relaxed">
Alle registrierten Benutzer dieser Instanz können sich gegenseitig sehen und Spaces
miteinander teilen.
</p>
</button>
</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>
<button
type="button"
role="switch"
aria-checked={settings.allowRegistration}
onClick={() =>
handleSaveSystemSettings({ allowRegistration: !settings.allowRegistration })
}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-pill border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
settings.allowRegistration ? 'bg-accent' : 'bg-panel-elevated border border-border'
}`}
title={settings.allowRegistration ? 'Selbstregistrierung deaktivieren' : 'Selbstregistrierung aktivieren'}
>
<span
aria-hidden="true"
className={`pointer-events-none inline-block h-5 w-5 transform rounded-pill bg-white shadow-md ring-0 transition duration-200 ease-in-out ${
settings.allowRegistration ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
</div>
</div>
</div>
)}
{/* ================================================================= */}
{/* SECTION 4: E-MAIL / SMTP-SERVER (SERVER-ADMIN ONLY) */}
{/* ================================================================= */}
{section === 'smtp' && isServerAdmin && (
<div className="space-y-6">
<div className="border-b border-border pb-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div>
<h2 className="text-sm font-bold text-main">
E-Mail / SMTP-Server für Aufgaben-Alarme
</h2>
<p className="text-muted mt-0.5">
Konfiguriere den Postausgangsserver per Docker-Compose Environment-Variablen oder
direkt hier über die Oberfläche (wird persistent in der SQLite-Datenbank gespeichert).
</p>
</div>
{settings.smtpConfiguredViaEnv && (
<span className="badge-ok text-[11px] flex-shrink-0">
Docker Env erkannt
</span>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-muted font-medium mb-1">
SMTP Host (`SMTP_HOST`)
</label>
<input
type="text"
value={settings.smtpHost || ''}
onChange={(e) =>
setSettings((prev) => ({ ...prev, smtpHost: e.target.value }))
}
placeholder="z.B. smtp.example.com oder mail.deinedomain.de"
className="w-full h-9 px-3 input-field font-mono"
/>
</div>
<div>
<label className="block text-muted font-medium mb-1">
SMTP Port (`SMTP_PORT`)
</label>
<input
type="number"
value={settings.smtpPort || 587}
onChange={(e) =>
setSettings((prev) => ({ ...prev, smtpPort: Number(e.target.value) }))
}
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="flex items-center pt-5">
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={settings.smtpTls ?? true}
onChange={(e) =>
setSettings((prev) => ({ ...prev, smtpTls: e.target.checked }))
}
className="w-4 h-4 checkbox-square cursor-pointer"
/>
<span className="text-main font-medium">
STARTTLS / TLS-Verschlüsselung verwenden (`SMTP_TLS=true`)
</span>
</label>
</div>
</div>
<div className="flex justify-end pt-2 border-t border-border">
<button
type="button"
onClick={() => handleSaveSystemSettings()}
className="btn-primary h-9 px-5 text-xs font-semibold"
>
SMTP-Konfiguration in Datenbank speichern
</button>
</div>
{/* Test SMTP Delivery */}
<div className="p-4 bg-canvas border border-border rounded-card space-y-2">
<h3 className="font-bold text-main">SMTP-Verbindung testen</h3>
<p className="text-[11px] text-muted">
Sendet sofort eine Test-E-Mail mit den oben eingetragenen Daten.
</p>
<div className="flex flex-col sm:flex-row gap-2">
<input
type="email"
value={testSmtpEmail}
onChange={(e) => setTestSmtpEmail(e.target.value)}
placeholder="empfaenger@beispiel.de"
className="flex-1 h-8 px-3 input-field"
/>
<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>
</div>
);
};
@@ -92,34 +92,34 @@ export const EcosystemView: React.FC<EcosystemViewProps> = ({ onIngested }) => {
const baseUrl = window.location.origin; const baseUrl = window.location.origin;
return ( return (
<div className="max-w-4xl mx-auto space-y-6"> <div className="w-full panel-surface divide-y divide-border">
{/* Section 1: API Keys for Smarthome & Automation */} {/* Section 1: API Keys for Smarthome & Automation */}
<div className="p-6 sm:p-8 rounded-3xl bg-[#1e1f20] gemini-border shadow-card-depth"> <div className="p-4">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-full bg-[#8ab4f8]/15 flex items-center justify-center"> <div className="w-8 h-8 bg-accent text-white flex items-center justify-center flex-shrink-0">
<Key className="w-5 h-5 text-[#8ab4f8]" /> <Key className="w-4 h-4" />
</div> </div>
<div> <div>
<h3 className="text-lg font-bold text-[#e3e3e3]"> <h3 className="text-base font-bold text-white">
Webhooks & Ingestion API-Schlüssel Webhooks & Ingestion API-Schlüssel
</h3> </h3>
<p className="text-xs sm:text-sm text-[#9aa0a6]"> <p className="text-xs sm:text-sm text-zinc-400">
Verbinde Home Assistant, Zigbee2MQTT, n8n oder eigene Cronjobs direkt mit deiner MindSift Inbox. Verbinde Home Assistant, Zigbee2MQTT, n8n oder eigene Cronjobs direkt mit deiner MindSift Inbox.
</p> </p>
</div> </div>
</div> </div>
<form onSubmit={handleCreateKey} className="mt-5 flex flex-col sm:flex-row gap-2.5"> <form onSubmit={handleCreateKey} className="mt-3 flex flex-col sm:flex-row gap-2">
<input <input
type="text" type="text"
value={newKeyName} value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)} onChange={(e) => setNewKeyName(e.target.value)}
placeholder="Bezeichnung (z. B. Home Assistant, Proxmox Backup, Rauchmelder)…" placeholder="Bezeichnung (z. B. Home Assistant, Proxmox Backup, Rauchmelder)…"
className="flex-1 h-11 px-4 rounded-full bg-[#131314] text-sm text-[#e3e3e3] placeholder-[#9aa0a6] gemini-border focus:outline-none focus:border-[#8ab4f8]" className="flex-1 h-9 px-3 input-field text-sm"
/> />
<button <button
type="submit" type="submit"
className="h-11 px-6 rounded-full bg-[#8ab4f8] text-[#0b0f19] font-semibold text-sm flex items-center justify-center gap-1.5 hover:bg-[#a1c2fa] transition-colors" className="btn-primary h-9 px-4 text-xs sm:text-sm"
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
<span>API-Key erzeugen</span> <span>API-Key erzeugen</span>
@@ -127,18 +127,18 @@ export const EcosystemView: React.FC<EcosystemViewProps> = ({ onIngested }) => {
</form> </form>
{newlyCreatedKey && ( {newlyCreatedKey && (
<div className="mt-4 p-4 rounded-2xl bg-[#81c995]/15 border border-[#81c995]/40 flex flex-wrap items-center justify-between gap-3"> <div className="mt-3 p-3 bg-accent/15 border border-accent/30 flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<p className="text-xs font-semibold text-[#81c995] mb-1"> <p className="text-xs font-semibold text-accent mb-1">
Neuer API-Schlüssel erstellt (bitte jetzt kopieren): Neuer API-Schlüssel erstellt (bitte jetzt kopieren):
</p> </p>
<code className="text-xs sm:text-sm font-mono text-[#e3e3e3] break-all"> <code className="text-xs sm:text-sm font-mono text-white break-all">
{newlyCreatedKey} {newlyCreatedKey}
</code> </code>
</div> </div>
<button <button
onClick={() => handleCopy(newlyCreatedKey)} onClick={() => handleCopy(newlyCreatedKey)}
className="h-9 px-4 rounded-full bg-[#81c995] text-[#0b0f19] font-semibold text-xs flex items-center gap-1.5" className="btn-primary h-8 px-3 text-xs"
> >
{copiedKey ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />} {copiedKey ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
<span>{copiedKey ? 'Kopiert!' : 'Key kopieren'}</span> <span>{copiedKey ? 'Kopiert!' : 'Key kopieren'}</span>
@@ -147,29 +147,29 @@ export const EcosystemView: React.FC<EcosystemViewProps> = ({ onIngested }) => {
)} )}
{/* Active Keys List */} {/* Active Keys List */}
<div className="mt-5 space-y-2"> <div className="mt-3 border border-border divide-y divide-border">
{keys.length === 0 ? ( {keys.length === 0 ? (
<p className="text-xs text-[#5f6368] py-2"> <p className="text-xs text-zinc-500 px-3 py-2">
Noch keine dedizierten API-Schlüssel hinterlegt. Noch keine dedizierten API-Schlüssel hinterlegt.
</p> </p>
) : ( ) : (
keys.map((k) => ( keys.map((k) => (
<div <div
key={k.id} key={k.id}
className="p-3.5 rounded-2xl bg-[#131314] gemini-border flex items-center justify-between gap-3" className="px-3 py-1.5 bg-canvas flex items-center justify-between gap-3"
> >
<div> <div>
<span className="text-sm font-medium text-[#e3e3e3]">{k.name}</span> <span className="text-sm font-medium text-white">{k.name}</span>
<span className="ml-3 text-xs font-mono text-[#8ab4f8]">{k.keyPrefix}</span> <span className="ml-3 text-xs font-mono text-accent">{k.keyPrefix}</span>
{k.lastUsedAt && ( {k.lastUsedAt && (
<span className="ml-3 text-xs text-[#5f6368]"> <span className="ml-3 text-xs text-zinc-500">
Zuletzt genutzt: {new Date(k.lastUsedAt).toLocaleDateString('de-DE')} Zuletzt genutzt: {new Date(k.lastUsedAt).toLocaleDateString('de-DE')}
</span> </span>
)} )}
</div> </div>
<button <button
onClick={() => handleDeleteKey(k.id)} onClick={() => handleDeleteKey(k.id)}
className="w-8 h-8 rounded-full hover:bg-[#f28b82]/20 text-[#9aa0a6] hover:text-[#f28b82] flex items-center justify-center" className="btn-icon btn-icon-danger w-7 h-7"
> >
<Trash2 className="w-3.5 h-3.5" /> <Trash2 className="w-3.5 h-3.5" />
</button> </button>
@@ -180,22 +180,22 @@ export const EcosystemView: React.FC<EcosystemViewProps> = ({ onIngested }) => {
</div> </div>
{/* Section 2: Interactive Smarthome Webhook Simulator */} {/* Section 2: Interactive Smarthome Webhook Simulator */}
<div className="p-6 sm:p-8 rounded-3xl bg-[#1e1f20] gemini-border shadow-card-depth"> <div className="p-4">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 rounded-full bg-[#c58af9]/15 flex items-center justify-center"> <div className="w-7 h-7 bg-accent text-white flex items-center justify-center flex-shrink-0">
<Webhook className="w-5 h-5 text-[#c58af9]" /> <Webhook className="w-4 h-4" />
</div> </div>
<div> <div>
<h3 className="text-lg font-bold text-[#e3e3e3]"> <h3 className="text-base font-bold text-white">
Live Smarthome Webhook-Simulator Live Smarthome Webhook-Simulator
</h3> </h3>
<p className="text-xs sm:text-sm text-[#9aa0a6]"> <p className="text-xs sm:text-sm text-zinc-400">
Teste den Webhook-Endpunkt direkt hier – das Event landet sofort als Karte im Triage-Deck. Teste den Webhook-Endpunkt direkt hier – das Event landet sofort als Karte im Triage-Deck.
</p> </p>
</div> </div>
</div> </div>
<div className="flex flex-wrap gap-2 mb-4"> <div className="flex flex-wrap gap-1.5 mb-3">
{[ {[
{ label: '🧺 Waschmaschine fertig', text: 'Waschmaschine im Keller ist fertig – Wäsche aufhängen!', prio: 'high' as PriorityLevel }, { label: '🧺 Waschmaschine fertig', text: 'Waschmaschine im Keller ist fertig – Wäsche aufhängen!', prio: 'high' as PriorityLevel },
{ label: '🚨 Rauchmelder Batterie', text: 'Rauchmelder Dachgeschoss meldet niedrige Batterie (12%)', prio: 'urgent' as PriorityLevel }, { label: '🚨 Rauchmelder Batterie', text: 'Rauchmelder Dachgeschoss meldet niedrige Batterie (12%)', prio: 'urgent' as PriorityLevel },
@@ -208,24 +208,24 @@ export const EcosystemView: React.FC<EcosystemViewProps> = ({ onIngested }) => {
setSimTitle(preset.text); setSimTitle(preset.text);
setSimPriority(preset.prio); setSimPriority(preset.prio);
}} }}
className="px-3.5 py-1.5 rounded-full bg-[#131314] hover:bg-[#282a2c] gemini-border text-xs text-[#e3e3e3]" className="btn-action text-xs"
> >
{preset.label} {preset.label}
</button> </button>
))} ))}
</div> </div>
<div className="flex flex-col sm:flex-row gap-2.5"> <div className="flex flex-col sm:flex-row gap-2">
<input <input
type="text" type="text"
value={simTitle} value={simTitle}
onChange={(e) => setSimTitle(e.target.value)} onChange={(e) => setSimTitle(e.target.value)}
className="flex-1 h-11 px-4 rounded-full bg-[#131314] text-sm text-[#e3e3e3] gemini-border focus:outline-none" className="flex-1 h-9 px-3 input-field text-sm"
/> />
<button <button
type="button" type="button"
onClick={handleSimulateWebhook} onClick={handleSimulateWebhook}
className="h-11 px-6 rounded-full bg-[#c58af9] text-[#0b0f19] font-semibold text-sm flex items-center justify-center gap-1.5 hover:opacity-90 transition-opacity" className="btn-primary h-9 px-4 text-xs sm:text-sm flex items-center gap-1.5"
> >
<Zap className="w-4 h-4" /> <Zap className="w-4 h-4" />
<span>Webhook jetzt abfeuern</span> <span>Webhook jetzt abfeuern</span>
@@ -233,16 +233,16 @@ export const EcosystemView: React.FC<EcosystemViewProps> = ({ onIngested }) => {
</div> </div>
{simStatus && ( {simStatus && (
<p className="mt-3 text-xs font-medium text-[#81c995]">{simStatus}</p> <p className="mt-3 text-xs font-medium text-accent">{simStatus}</p>
)} )}
{/* Code Example Snippets */} {/* Code Example Snippets */}
<div className="mt-6 space-y-3"> <div className="mt-4 space-y-2">
<div className="flex items-center gap-2 text-xs font-semibold text-[#9aa0a6]"> <div className="flex items-center gap-2 text-xs font-medium text-zinc-400">
<Terminal className="w-3.5 h-3.5 text-[#8ab4f8]" /> <Terminal className="w-3.5 h-3.5 text-accent" />
<span>cURL / Home Assistant REST Command:</span> <span>cURL / Home Assistant REST Command:</span>
</div> </div>
<pre className="p-4 rounded-2xl bg-[#0b0f19] gemini-border font-mono text-xs text-[#8ab4f8] overflow-x-auto"> <pre className="p-3 bg-canvas border border-border font-mono text-xs text-zinc-300 overflow-x-auto">
{`curl -X POST "${baseUrl}/api/v1/webhooks/ingest?token=${exampleToken}" \\ {`curl -X POST "${baseUrl}/api/v1/webhooks/ingest?token=${exampleToken}" \\
-H "Content-Type: application/json" \\ -H "Content-Type: application/json" \\
-d '{ -d '{
@@ -255,16 +255,16 @@ export const EcosystemView: React.FC<EcosystemViewProps> = ({ onIngested }) => {
</div> </div>
{/* Section 3: Data Sovereignty & Export */} {/* Section 3: Data Sovereignty & Export */}
<div className="p-6 sm:p-8 rounded-3xl bg-[#1e1f20] gemini-border flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4"> <div className="p-4 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-[#81c995]/15 flex items-center justify-center flex-shrink-0"> <div className="w-7 h-7 bg-accent text-white flex items-center justify-center flex-shrink-0">
<ShieldCheck className="w-5 h-5 text-[#81c995]" /> <ShieldCheck className="w-4 h-4" />
</div> </div>
<div> <div>
<h3 className="text-base font-bold text-[#e3e3e3]"> <h3 className="text-base font-bold text-white">
100 % Datensouveränität & Komplett-Export 100 % Datensouveränität & Komplett-Export
</h3> </h3>
<p className="text-xs sm:text-sm text-[#9aa0a6]"> <p className="text-xs sm:text-sm text-zinc-400">
Exportiere jederzeit alle Gedanken, Aufgaben, Dokumente und geteilten Listen als portables JSON-Archiv. Exportiere jederzeit alle Gedanken, Aufgaben, Dokumente und geteilten Listen als portables JSON-Archiv.
</p> </p>
</div> </div>
@@ -272,7 +272,7 @@ export const EcosystemView: React.FC<EcosystemViewProps> = ({ onIngested }) => {
<button <button
onClick={handleFullExport} onClick={handleFullExport}
className="h-11 px-6 rounded-full bg-[#81c995] text-[#0b0f19] font-semibold text-sm flex items-center gap-2 hover:opacity-90 flex-shrink-0" className="btn-primary h-9 px-4 text-xs sm:text-sm flex items-center gap-2"
> >
<Download className="w-4 h-4" /> <Download className="w-4 h-4" />
<span>Komplett-Backup (.json)</span> <span>Komplett-Backup (.json)</span>
File diff suppressed because it is too large. Load diff
+653
View File
@@ -0,0 +1,653 @@
import React, { useEffect, useState } from 'react';
import {
Users,
Plus,
Trash2,
ArrowLeft,
Check,
AlertCircle,
UserPlus,
Shield,
Pencil,
X,
LogOut,
} from 'lucide-react';
import { Team, User } from '../../lib/types';
import { api, triggerHaptic } from '../../lib/api';
import { UserAutocomplete } from '../UserAutocomplete';
import { UserAvatar } from '../../components/UserAvatar';
interface GroupsViewProps {
currentUser: User;
onBack: () => void;
}
export const GroupsView: React.FC<GroupsViewProps> = ({ currentUser, onBack }) => {
const [teams, setTeams] = useState<Team[]>([]);
const [directoryUsers, setDirectoryUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [statusMsg, setStatusMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
// Create Group Modal State
const [showCreateModal, setShowCreateModal] = useState(false);
const [newGroupName, setNewGroupName] = useState('');
const [newGroupDesc, setNewGroupDesc] = useState('');
const [creating, setCreating] = useState(false);
// Edit Group State
const [editingTeamId, setEditingTeamId] = useState<string | null>(null);
const [editName, setEditName] = useState('');
const [editDesc, setEditDesc] = useState('');
// Add Member State per Team
const [selectedUserByTeam, setSelectedUserByTeam] = useState<Record<string, User | null>>({});
const [memberRoleByTeam, setMemberRoleByTeam] = useState<Record<string, 'admin' | 'member'>>({});
const loadData = async () => {
try {
const [userTeams, allUsers] = await Promise.all([
api.getUserTeams(),
api.getUsersDirectory().catch(() => []),
]);
setTeams(userTeams);
setDirectoryUsers(allUsers);
} catch (err: any) {
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Laden der Gruppen' });
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const handleCreateGroup = async (e: React.FormEvent) => {
e.preventDefault();
const name = newGroupName.trim();
if (!name) return;
setCreating(true);
setStatusMsg(null);
triggerHaptic(15);
try {
const created = await api.createTeam(name, newGroupDesc.trim());
setNewGroupName('');
setNewGroupDesc('');
setShowCreateModal(false);
await loadData();
setStatusMsg({ type: 'ok', text: `Gruppe "${created.name}" erfolgreich erstellt.` });
} catch (err: any) {
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Erstellen der Gruppe' });
} finally {
setCreating(false);
}
};
const handleStartEdit = (team: Team) => {
setEditingTeamId(team.id);
setEditName(team.name);
setEditDesc(team.description || '');
};
const handleSaveEdit = async (teamId: string) => {
const name = editName.trim();
if (!name) return;
setStatusMsg(null);
triggerHaptic(10);
try {
await api.updateTeam(teamId, name, editDesc.trim());
setEditingTeamId(null);
await loadData();
setStatusMsg({ type: 'ok', text: 'Gruppe erfolgreich aktualisiert.' });
} catch (err: any) {
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Aktualisieren der Gruppe' });
}
};
const handleDeleteTeam = async (team: Team) => {
if (!window.confirm(`Gruppe "${team.name}" wirklich löschen? Alle Freigaben für diese Gruppe werden entfernt.`)) {
return;
}
setStatusMsg(null);
triggerHaptic(15);
try {
await api.deleteTeam(team.id);
await loadData();
setStatusMsg({ type: 'ok', text: `Gruppe "${team.name}" gelöscht.` });
} catch (err: any) {
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Löschen der Gruppe' });
}
};
const handleAddMember = async (teamId: string) => {
const user = selectedUserByTeam[teamId];
if (!user) return;
const role = memberRoleByTeam[teamId] || 'member';
setStatusMsg(null);
triggerHaptic(10);
try {
await api.addTeamMember(teamId, { userId: user.id, role });
setSelectedUserByTeam((prev) => ({ ...prev, [teamId]: null }));
await loadData();
setStatusMsg({ type: 'ok', text: `${user.displayName} wurde zur Gruppe hinzugefügt.` });
} catch (err: any) {
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Hinzufügen des Mitglieds' });
}
};
const handleChangeMemberRole = async (teamId: string, memberUserId: string, newRole: 'admin' | 'member') => {
setStatusMsg(null);
triggerHaptic(10);
try {
await api.updateTeamMemberRole(teamId, memberUserId, newRole);
await loadData();
setStatusMsg({
type: 'ok',
text: `Rolle erfolgreich zu "${newRole === 'admin' ? 'Gruppen-Admin' : 'Mitglied'}" geändert.`,
});
} catch (err: any) {
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Ändern der Rolle' });
}
};
const handleRemoveMember = async (teamId: string, memberUserId: string, memberName: string) => {
const isSelf = memberUserId === currentUser.id;
const msg = isSelf
? 'Möchtest du diese Gruppe wirklich verlassen?'
: `Mitglied "${memberName}" wirklich aus der Gruppe entfernen?`;
if (!window.confirm(msg)) return;
setStatusMsg(null);
triggerHaptic(10);
try {
await api.removeTeamMember(teamId, memberUserId);
await loadData();
setStatusMsg({
type: 'ok',
text: isSelf ? 'Du hast die Gruppe verlassen.' : `Mitglied "${memberName}" wurde entfernt.`,
});
} catch (err: any) {
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Entfernen des Mitglieds' });
}
};
// Group teams into managed (admin) and member-only
const managedTeams = teams.filter((t) =>
(t.members || []).some((m) => m.userId === currentUser.id && m.role === 'admin')
);
const memberTeams = teams.filter(
(t) =>
(t.members || []).some((m) => m.userId === currentUser.id && m.role === 'member') &&
!managedTeams.some((mt) => mt.id === t.id)
);
return (
<div className="w-full space-y-5">
{/* Page Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 border-b border-border pb-3">
<div className="flex items-center gap-3">
<button
onClick={onBack}
className="btn-action h-8 px-2.5 text-xs flex items-center gap-1.5 cursor-pointer"
title="Zurück"
>
<ArrowLeft className="w-3.5 h-3.5" />
<span>Zurück</span>
</button>
<div>
<h1 className="text-base font-bold text-main tracking-tight flex items-center gap-2">
<Users className="w-4 h-4 text-accent" />
<span>Gruppen & Teams</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-panel-elevated text-muted border border-border">
{teams.length}
</span>
</h1>
<p className="text-xs text-muted mt-0.5">
Erstelle und verwalte Gruppen (z. B. Familie, WG oder Projektteams) für gemeinsame Notizen, Aufgaben und Listen.
</p>
</div>
</div>
<button
onClick={() => {
triggerHaptic(10);
setShowCreateModal(true);
}}
className="btn-primary h-8 px-3.5 text-xs flex items-center gap-1.5 self-start sm:self-auto cursor-pointer"
>
<Plus className="w-3.5 h-3.5" />
<span>Neue Gruppe</span>
</button>
</div>
{/* Status Messages */}
{statusMsg && (
<div
className={`p-3 text-xs flex items-center justify-between gap-2 rounded-card ${
statusMsg.type === 'ok' ? 'alert-ok' : 'alert-err'
}`}
>
<div className="flex items-center gap-2">
{statusMsg.type === 'ok' ? (
<Check className="w-4 h-4 text-accent flex-shrink-0" />
) : (
<AlertCircle className="w-4 h-4 text-status-err flex-shrink-0" />
)}
<span>{statusMsg.text}</span>
</div>
<button
onClick={() => setStatusMsg(null)}
className="text-muted hover:text-main text-[11px] cursor-pointer"
>
Schließen
</button>
</div>
)}
{/* Modal: Neue Gruppe erstellen */}
{showCreateModal && (
<div
className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center bg-black/60 backdrop-blur-xs p-4 animate-in fade-in duration-150"
onClick={() => setShowCreateModal(false)}
>
<div
className="w-full max-w-md bg-panel border border-border rounded-xl shadow-panel p-5 space-y-4 animate-in zoom-in-95 duration-150"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between pb-3 border-b border-border">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-btn bg-accent/15 text-accent flex items-center justify-center">
<Users className="w-4 h-4" />
</div>
<h3 className="font-bold text-sm text-main">Neue Gruppe erstellen</h3>
</div>
<button
type="button"
onClick={() => setShowCreateModal(false)}
className="p-1 text-muted hover:text-main rounded-btn transition-colors cursor-pointer"
>
<X className="w-4 h-4" />
</button>
</div>
<form onSubmit={handleCreateGroup} className="space-y-4">
<div>
<label className="block text-muted text-xs mb-1 font-medium">
Name der Gruppe *
</label>
<input
type="text"
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
placeholder="z. B. Familie, WG, Arbeit, Projekt X…"
required
autoFocus
className="w-full h-8 px-2.5 input-field text-xs"
/>
</div>
<div>
<label className="block text-muted text-xs mb-1 font-medium">
Beschreibung (optional)
</label>
<input
type="text"
value={newGroupDesc}
onChange={(e) => setNewGroupDesc(e.target.value)}
placeholder="z. B. Gemeinsame Einkaufslisten & Notizen…"
className="w-full h-8 px-2.5 input-field text-xs"
/>
</div>
<div className="p-3 bg-canvas border border-border rounded-btn text-[11px] text-muted space-y-1">
<p className="font-semibold text-main flex items-center gap-1.5">
<Shield className="w-3.5 h-3.5 text-accent" />
<span>Du wirst Gruppen-Admin</span>
</p>
<p>
Als Ersteller kannst du weitere Mitglieder hinzufügen, Rollen zuweisen und die Gruppe verwalten.
</p>
</div>
<div className="flex justify-end gap-2 pt-2 border-t border-border">
<button
type="button"
onClick={() => setShowCreateModal(false)}
className="btn-action h-8 px-3 text-xs cursor-pointer"
>
Abbrechen
</button>
<button
type="submit"
disabled={creating || !newGroupName.trim()}
className="btn-primary h-8 px-4 text-xs flex items-center gap-1.5 cursor-pointer disabled:opacity-50"
>
<Plus className="w-3.5 h-3.5" />
<span>Gruppe anlegen</span>
</button>
</div>
</form>
</div>
</div>
)}
{/* Main Content Area */}
{loading ? (
<div className="p-8 text-center text-muted text-xs">Lade Gruppen…</div>
) : teams.length === 0 ? (
<div className="p-10 bg-canvas border border-border rounded-card text-center space-y-3">
<div className="w-12 h-12 rounded-full bg-panel-elevated text-muted mx-auto flex items-center justify-center">
<Users className="w-6 h-6 text-accent opacity-75" />
</div>
<div>
<h3 className="font-bold text-main text-sm">Noch keine Gruppen vorhanden</h3>
<p className="text-muted text-xs mt-1 max-w-md mx-auto">
Erstelle deine erste Gruppe, um Aufgaben, Einkaufslisten oder Dokumente gemeinsam mit anderen zu nutzen.
</p>
</div>
<button
onClick={() => {
triggerHaptic(10);
setShowCreateModal(true);
}}
className="btn-primary h-8 px-4 text-xs inline-flex items-center gap-1.5 cursor-pointer"
>
<Plus className="w-3.5 h-3.5" />
<span>Erste Gruppe anlegen</span>
</button>
</div>
) : (
<div className="space-y-6">
{/* SECTION 1: Gruppen, die ich verwalte */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-xs font-bold text-main uppercase tracking-wider text-muted flex items-center gap-2">
<Shield className="w-3.5 h-3.5 text-accent" />
<span>Gruppen, die ich verwalte ({managedTeams.length})</span>
</h2>
</div>
{managedTeams.length === 0 ? (
<div className="p-4 bg-canvas border border-border rounded-card text-xs text-muted">
Du bist derzeit noch in keiner Gruppe als Admin eingetragen. Klicke oben auf &quot;Neue Gruppe&quot;, um eine eigene Gruppe zu erstellen.
</div>
) : (
<div className="space-y-4">
{managedTeams.map((team) => {
const members = team.members || [];
const isEditing = editingTeamId === team.id;
const memberUserIds = members.map((m) => m.userId);
return (
<div
key={team.id}
className="p-4 bg-canvas border border-border rounded-card space-y-4 shadow-sm"
>
{/* Team Header */}
<div className="flex items-start justify-between gap-3 border-b border-border pb-3">
<div className="flex items-start gap-2.5 flex-1 min-w-0">
<div className="w-8 h-8 rounded-btn bg-accent/15 text-accent flex items-center justify-center flex-shrink-0 mt-0.5">
<Users className="w-4 h-4" />
</div>
{isEditing ? (
<div className="flex-1 space-y-2 max-w-lg">
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
placeholder="Gruppenname"
className="w-full h-8 px-2.5 input-field text-xs font-bold"
/>
<input
type="text"
value={editDesc}
onChange={(e) => setEditDesc(e.target.value)}
placeholder="Optionale Beschreibung"
className="w-full h-7 px-2.5 input-field text-xs"
/>
<div className="flex gap-2">
<button
type="button"
onClick={() => handleSaveEdit(team.id)}
className="btn-primary h-7 px-3 text-[11px] cursor-pointer"
>
Speichern
</button>
<button
type="button"
onClick={() => setEditingTeamId(null)}
className="btn-action h-7 px-2.5 text-[11px] cursor-pointer"
>
Abbrechen
</button>
</div>
</div>
) : (
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-bold text-sm text-main truncate">
{team.name}
</span>
<span className="badge-accent text-[10px] py-0.5 px-2">
Gruppen-Admin
</span>
<span className="text-[11px] px-1.5 py-0.5 rounded-full bg-panel-elevated text-muted border border-border">
{members.length} {members.length === 1 ? 'Mitglied' : 'Mitglieder'}
</span>
</div>
{team.description && (
<p className="text-xs text-muted mt-1 break-words">
{team.description}
</p>
)}
</div>
)}
</div>
{!isEditing && (
<div className="flex items-center gap-1.5 flex-shrink-0">
<button
onClick={() => handleStartEdit(team)}
className="btn-action h-7 px-2.5 text-xs flex items-center gap-1 cursor-pointer"
title="Gruppe bearbeiten"
>
<Pencil className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Bearbeiten</span>
</button>
<button
onClick={() => handleDeleteTeam(team)}
className="btn-icon btn-icon-danger"
title="Gruppe löschen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
{/* Members List */}
<div className="space-y-2">
<p className="text-[11px] font-semibold text-muted uppercase tracking-wider">
Mitglieder ({members.length})
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
{members.map((m) => {
const isSelf = m.userId === currentUser.id;
const isAdmin = m.role === 'admin';
return (
<div
key={m.userId}
className="p-2.5 bg-panel border border-border rounded-btn flex items-center justify-between gap-2 text-xs"
>
<div className="flex items-center gap-2 min-w-0">
<UserAvatar user={{ displayName: m.displayName, email: m.email, avatarUrl: m.avatarUrl }} size="xs" />
<div className="min-w-0">
<p className="text-main font-medium truncate text-xs">
{m.displayName} {isSelf && <span className="text-faint">(Du)</span>}
</p>
<p className="text-[10px] text-muted truncate">
{m.email}
</p>
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
<select
value={m.role}
onChange={(e) =>
handleChangeMemberRole(
team.id,
m.userId,
e.target.value as 'admin' | 'member'
)
}
className={`h-6 px-1.5 text-[10px] rounded input-field bg-panel cursor-pointer ${
isAdmin
? 'border-accent text-accent font-semibold'
: 'text-muted'
}`}
title="Rolle ändern"
>
<option value="member" className="bg-panel text-main">Mitglied</option>
<option value="admin" className="bg-panel text-main">Gruppen-Admin</option>
</select>
<button
onClick={() => handleRemoveMember(team.id, m.userId, m.displayName)}
className="p-1 text-faint hover:text-status-err rounded transition-colors cursor-pointer"
title={isSelf ? 'Gruppe verlassen' : 'Mitglied entfernen'}
>
<X className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
})}
</div>
</div>
{/* Add Member Row */}
<div className="pt-3 border-t border-border flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
<div className="flex-1 min-w-[220px]">
<UserAutocomplete
users={directoryUsers}
selectedUser={selectedUserByTeam[team.id] || null}
onSelectUser={(u) =>
setSelectedUserByTeam((prev) => ({ ...prev, [team.id]: u }))
}
excludeUserIds={memberUserIds}
placeholder="Benutzer suchen zum Hinzufügen…"
/>
</div>
<select
value={memberRoleByTeam[team.id] || 'member'}
onChange={(e) =>
setMemberRoleByTeam((prev) => ({
...prev,
[team.id]: e.target.value as 'admin' | 'member',
}))
}
className="h-8 px-2.5 input-field text-xs bg-panel"
>
<option value="member">Mitglied</option>
<option value="admin">Gruppen-Admin</option>
</select>
<button
type="button"
onClick={() => handleAddMember(team.id)}
disabled={!selectedUserByTeam[team.id]}
className="btn-primary h-8 px-3.5 text-xs flex items-center justify-center gap-1.5 cursor-pointer disabled:opacity-40"
>
<UserPlus className="w-3.5 h-3.5" />
<span>Hinzufügen</span>
</button>
</div>
</div>
);
})}
</div>
)}
</div>
{/* SECTION 2: Meine Mitgliedschaften (als Mitglied) */}
{memberTeams.length > 0 && (
<div className="space-y-3 pt-2">
<h2 className="text-xs font-bold text-main uppercase tracking-wider text-muted flex items-center gap-2">
<Users className="w-3.5 h-3.5 text-accent" />
<span>Meine Mitgliedschaften ({memberTeams.length})</span>
</h2>
<div className="space-y-3">
{memberTeams.map((team) => {
const members = team.members || [];
const admins = members.filter((m) => m.role === 'admin');
return (
<div
key={team.id}
className="p-4 bg-canvas border border-border rounded-card space-y-3 shadow-sm"
>
<div className="flex items-start justify-between gap-3 border-b border-border pb-3">
<div className="flex items-start gap-2.5">
<div className="w-8 h-8 rounded-btn bg-panel-elevated text-muted flex items-center justify-center flex-shrink-0 mt-0.5">
<Users className="w-4 h-4" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-bold text-sm text-main">{team.name}</span>
<span className="text-[11px] px-1.5 py-0.5 rounded-full bg-panel-elevated text-muted border border-border">
{members.length} {members.length === 1 ? 'Mitglied' : 'Mitglieder'}
</span>
</div>
{team.description && (
<p className="text-xs text-muted mt-1">{team.description}</p>
)}
</div>
</div>
<button
onClick={() => handleRemoveMember(team.id, currentUser.id, currentUser.displayName)}
className="btn-action text-status-err hover:bg-status-err-bg h-7 px-2.5 text-xs flex items-center gap-1.5 cursor-pointer"
title="Gruppe verlassen"
>
<LogOut className="w-3.5 h-3.5" />
<span>Gruppe verlassen</span>
</button>
</div>
{/* Admins & Members list in read-only mode */}
<div className="space-y-1.5">
<p className="text-[11px] font-semibold text-muted">
Gruppen-Admins:{' '}
<span className="text-main font-normal">
{admins.map((a) => a.displayName).join(', ') || 'Keiner'}
</span>
</p>
<p className="text-[11px] font-semibold text-muted">
Alle Mitglieder:{' '}
<span className="text-faint font-normal">
{members.map((m) => m.displayName).join(', ')}
</span>
</p>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
)}
</div>
);
};
@@ -0,0 +1,295 @@
import React, { useState, useEffect } from 'react';
import {
X,
Plus,
Trash2,
ArrowUp,
ArrowDown,
RotateCcw,
Check,
Columns,
Layers,
} from 'lucide-react';
import { KanbanColumn } from '../../lib/types';
import { triggerHaptic } from '../../lib/api';
interface ManageColumnsModalProps {
isOpen: boolean;
onClose: () => void;
initialColumns: KanbanColumn[];
onSave: (columns: KanbanColumn[]) => Promise<void>;
listTitle?: string;
}
export const DEFAULT_KANBAN_COLUMNS: KanbanColumn[] = [
{ id: 'todo', name: 'Zu erledigen' },
{ id: 'in_progress', name: 'In Bearbeitung', color: 'accent' },
{ id: 'waiting', name: 'Wartet auf andere', color: 'amber' },
{ id: 'done', name: 'Erledigt', isDone: true, color: 'emerald' },
];
export const ManageColumnsModal: React.FC<ManageColumnsModalProps> = ({
isOpen,
onClose,
initialColumns,
onSave,
listTitle,
}) => {
if (!isOpen) return null;
const [columns, setColumns] = useState<KanbanColumn[]>(() => {
return initialColumns && initialColumns.length > 0 ? initialColumns : DEFAULT_KANBAN_COLUMNS;
});
const [newColName, setNewColName] = useState('');
const [newColIsDone, setNewColIsDone] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
setColumns(initialColumns && initialColumns.length > 0 ? initialColumns : DEFAULT_KANBAN_COLUMNS);
}, [initialColumns, isOpen]);
const handleMove = (index: number, direction: 'up' | 'down') => {
triggerHaptic(10);
const targetIndex = direction === 'up' ? index - 1 : index + 1;
if (targetIndex < 0 || targetIndex >= columns.length) return;
const next = [...columns];
const temp = next[index];
next[index] = next[targetIndex];
next[targetIndex] = temp;
setColumns(next);
};
const handleRename = (id: string, newName: string) => {
setColumns((prev) =>
prev.map((c) => (c.id === id ? { ...c, name: newName } : c))
);
};
const handleToggleDone = (id: string) => {
triggerHaptic(10);
setColumns((prev) =>
prev.map((c) => (c.id === id ? { ...c, isDone: !c.isDone } : c))
);
};
const handleDelete = (id: string) => {
if (columns.length <= 1) {
alert('Mindestens eine Spalte muss vorhanden sein.');
return;
}
triggerHaptic(10);
setColumns((prev) => prev.filter((c) => c.id !== id));
};
const handleAddColumn = (e: React.FormEvent) => {
e.preventDefault();
const name = newColName.trim();
if (!name) return;
triggerHaptic(10);
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '_').slice(0, 20) || 'col';
const id = `${slug}_${Date.now().toString(36).slice(-4)}`;
setColumns((prev) => [
...prev,
{
id,
name,
isDone: newColIsDone,
},
]);
setNewColName('');
setNewColIsDone(false);
};
const handleResetToDefault = () => {
if (window.confirm('Spalten auf Standard zurücksetzen? (Zu erledigen, In Bearbeitung, Wartet auf andere, Erledigt)')) {
triggerHaptic(15);
setColumns(DEFAULT_KANBAN_COLUMNS);
}
};
const handleSave = async () => {
if (columns.length === 0) {
alert('Es muss mindestens eine Spalte vorhanden sein.');
return;
}
setSaving(true);
triggerHaptic(15);
try {
await onSave(columns);
onClose();
} catch (err: any) {
alert(err.message || 'Fehler beim Speichern der Spalten');
} finally {
setSaving(false);
}
};
return (
<div className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center p-3 sm:p-4">
<div
className="w-full max-w-md panel-surface shadow-panel border border-border my-auto flex flex-col max-h-[90vh] overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-panel">
<div className="flex items-center gap-2">
<Columns className="w-4 h-4 text-accent" />
<div>
<h3 className="text-sm font-semibold text-main">Kanban-Spalten anpassen</h3>
{listTitle && (
<p className="text-[10px] text-muted truncate">Liste: {listTitle}</p>
)}
</div>
</div>
<button
type="button"
onClick={onClose}
className="text-muted hover:text-main p-1.5 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Content */}
<div className="p-4 space-y-4 overflow-y-auto flex-1">
<p className="text-xs text-muted">
Passe die Spalten für das Kanban-Board an. Aufgaben können in jede beliebige Spalte verschoben werden.
</p>
{/* Columns List */}
<div className="space-y-2">
<label className="block text-xs font-semibold text-main">Aktive Spalten ({columns.length}):</label>
<div className="space-y-1.5 max-h-60 overflow-y-auto pr-1">
{columns.map((col, idx) => (
<div
key={col.id}
className="p-2 bg-canvas border border-border rounded-card flex items-center gap-2"
>
<div className="flex flex-col gap-0.5">
<button
type="button"
disabled={idx === 0}
onClick={() => handleMove(idx, 'up')}
className="text-muted hover:text-main disabled:opacity-20 p-0.5"
title="Nach oben"
>
<ArrowUp className="w-3 h-3" />
</button>
<button
type="button"
disabled={idx === columns.length - 1}
onClick={() => handleMove(idx, 'down')}
className="text-muted hover:text-main disabled:opacity-20 p-0.5"
title="Nach unten"
>
<ArrowDown className="w-3 h-3" />
</button>
</div>
<input
type="text"
value={col.name}
onChange={(e) => handleRename(col.id, e.target.value)}
className="flex-1 h-7 px-2 text-xs input-field"
placeholder="Spaltenname"
/>
{/* IsDone Toggle */}
<button
type="button"
onClick={() => handleToggleDone(col.id)}
className={`h-7 px-2 text-[10px] rounded-btn border transition-colors flex items-center gap-1 ${
col.isDone
? 'bg-status-ok/15 border-status-ok text-status-ok font-semibold'
: 'bg-panel border-border text-faint hover:text-muted'
}`}
title={col.isDone ? 'Gilt als Erledigt' : 'Gilt als Offen'}
>
<Check className={`w-2.5 h-2.5 ${col.isDone ? 'stroke-[3]' : 'opacity-40'}`} />
<span>Erledigt</span>
</button>
<button
type="button"
onClick={() => handleDelete(col.id)}
disabled={columns.length <= 1}
className="text-muted hover:text-status-err disabled:opacity-20 p-1 transition-colors"
title="Spalte löschen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
</div>
{/* Add New Column Form */}
<form onSubmit={handleAddColumn} className="space-y-2 pt-2 border-t border-border">
<label className="block text-xs font-semibold text-main">Neue Spalte hinzufügen:</label>
<div className="flex items-center gap-1.5">
<input
type="text"
value={newColName}
onChange={(e) => setNewColName(e.target.value)}
placeholder="z.B. Wartet auf Feedback, Review…"
className="flex-1 h-8 px-2.5 text-xs input-field"
/>
<button
type="button"
onClick={() => setNewColIsDone((prev) => !prev)}
className={`h-8 px-2.5 text-xs rounded-btn border transition-colors flex items-center gap-1 ${
newColIsDone
? 'bg-status-ok/15 border-status-ok text-status-ok font-semibold'
: 'bg-panel border-border text-muted hover:text-main'
}`}
title="Sollen Aufgaben in dieser Spalte als erledigt gelten?"
>
<Check className="w-3 h-3" />
<span>Erledigt</span>
</button>
<button
type="submit"
disabled={!newColName.trim()}
className="btn-primary h-8 px-3 text-xs flex items-center gap-1 disabled:opacity-40"
>
<Plus className="w-3.5 h-3.5" />
<span>Hinzufügen</span>
</button>
</div>
</form>
{/* Reset button */}
<div className="pt-2 flex justify-start">
<button
type="button"
onClick={handleResetToDefault}
className="text-xs text-muted hover:text-main flex items-center gap-1"
>
<RotateCcw className="w-3 h-3 text-accent" />
<span>Auf Standard-Spalten zurücksetzen</span>
</button>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 px-4 py-2.5 border-t border-border bg-panel">
<button
type="button"
onClick={onClose}
className="btn-action h-7 px-3 text-xs"
>
Abbrechen
</button>
<button
type="button"
onClick={handleSave}
disabled={saving}
className="btn-primary h-7 px-3 text-xs disabled:opacity-50"
>
Spalten speichern
</button>
</div>
</div>
</div>
);
};
@@ -0,0 +1,745 @@
import React, { useState, useEffect } from 'react';
import {
X,
Check,
Calendar,
Trash2,
FileText,
Plus,
CheckSquare,
Clock,
Layers,
Hash,
AlertTriangle,
Flame,
ChevronRight,
Folder,
Webhook,
Lock,
} from 'lucide-react';
import { Thought, SubTask, TaskList, KanbanColumn } from '../../lib/types';
import { triggerHaptic } from '../../lib/api';
import { MarkdownViewer } from '../../lib/markdown';
interface TaskDetailModalProps {
task: Thought | null;
isOpen: boolean;
onClose: () => void;
activeColumns: KanbanColumn[];
taskLists: TaskList[];
onUpdateTitle: (task: Thought, newTitle: string) => Promise<void>;
onUpdateStatus: (task: Thought, newStatus: string) => Promise<void>;
onUpdatePriority: (task: Thought, newPriority: string) => Promise<void>;
onUpdateDueDate: (task: Thought, newDueDate: string) => Promise<void>;
onUpdateTaskList: (task: Thought, newListId: string) => Promise<void>;
onUpdateNotes: (task: Thought, newNotes: string) => Promise<void>;
onUpdateTags?: (task: Thought, newTags: string[]) => Promise<void>;
onToggleSubtask: (task: Thought, subtaskId: string) => Promise<void>;
onAddSubtask: (task: Thought, title: string) => Promise<void>;
onDeleteSubtask: (task: Thought, subtaskId: string) => Promise<void>;
onConvertToDoc: (task: Thought) => void;
onOpenDocument?: (docId: string, blockId?: string) => void;
onDeleteTask: (taskId: string) => void;
readOnly?: boolean; // Task belongs to a list shared with "viewer" permission
}
export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
task,
isOpen,
onClose,
activeColumns,
taskLists,
onUpdateTitle,
onUpdateStatus,
onUpdatePriority,
onUpdateDueDate,
onUpdateTaskList,
onUpdateNotes,
onUpdateTags,
onToggleSubtask,
onAddSubtask,
onDeleteSubtask,
onConvertToDoc,
onOpenDocument,
onDeleteTask,
readOnly = false,
}) => {
if (!isOpen || !task) return null;
const [titleDraft, setTitleDraft] = useState(task.content);
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [isEditingNotes, setIsEditingNotes] = useState(false);
const [notesDraft, setNotesDraft] = useState(task.notes || '');
const [newSubtaskTitle, setNewSubtaskTitle] = useState('');
const [newTagDraft, setNewTagDraft] = useState('');
useEffect(() => {
setTitleDraft(task.content);
setNotesDraft(task.notes || '');
setIsEditingTitle(false);
setIsEditingNotes(false);
}, [task.id]);
// Handle escape key to close
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (isEditingNotes) {
setIsEditingNotes(false);
} else if (isEditingTitle) {
setIsEditingTitle(false);
} else {
onClose();
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose, isEditingNotes, isEditingTitle]);
const handleTitleSubmit = async () => {
const trimmed = titleDraft.trim();
if (!trimmed) {
setTitleDraft(task.content);
setIsEditingTitle(false);
return;
}
if (trimmed !== task.content) {
await onUpdateTitle(task, trimmed);
}
setIsEditingTitle(false);
};
const handleNotesSubmit = async () => {
await onUpdateNotes(task, notesDraft);
setIsEditingNotes(false);
};
const handleAddSubtaskSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const trimmed = newSubtaskTitle.trim();
if (!trimmed) return;
await onAddSubtask(task, trimmed);
setNewSubtaskTitle('');
};
const handleAddTagSubmit = async (e: React.FormEvent) => {
e.preventDefault();
let cleaned = newTagDraft.trim().replace(/^#+/, '').toLowerCase();
if (!cleaned) return;
const currentTags = Array.isArray(task.tags) ? [...task.tags] : [];
if (!currentTags.includes(cleaned)) {
const updated = [...currentTags, cleaned];
if (onUpdateTags) {
await onUpdateTags(task, updated);
} else {
await onUpdateTitle(task, `${task.content} #${cleaned}`);
}
}
setNewTagDraft('');
};
const handleRemoveTag = async (tagToRemove: string) => {
const currentTags = Array.isArray(task.tags) ? [...task.tags] : [];
const updated = currentTags.filter((t) => t !== tagToRemove);
if (onUpdateTags) {
await onUpdateTags(task, updated);
}
};
const handleQuickDueDate = (daysOffset: number | null) => {
if (daysOffset === null) {
onUpdateDueDate(task, '');
return;
}
const d = new Date();
d.setDate(d.getDate() + daysOffset);
const dateStr = d.toISOString().split('T')[0];
onUpdateDueDate(task, dateStr);
};
const currentColumn = activeColumns.find((c) => c.id === task.kanbanStatus) ||
(task.isCompleted
? activeColumns.find((c) => c.isDone) || activeColumns[activeColumns.length - 1]
: activeColumns.find((c) => !c.isDone) || activeColumns[0]);
const subtasks = Array.isArray(task.subtasks) ? task.subtasks : [];
const completedSubtasks = subtasks.filter((s) => s.isCompleted).length;
const subtasksPercent = subtasks.length > 0 ? Math.round((completedSubtasks / subtasks.length) * 100) : 0;
return (
<div className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center p-3 sm:p-4 overflow-y-auto">
<div
className="w-full max-w-2xl panel-surface shadow-panel border border-border my-auto flex flex-col max-h-[92vh] overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Top Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-panel">
<div className="flex items-center gap-2 min-w-0">
<button
disabled={readOnly}
onClick={() => {
triggerHaptic(15);
const nextState = !task.isCompleted;
const doneCol = activeColumns.find((c) => c.isDone) || activeColumns[activeColumns.length - 1];
const todoCol = activeColumns.find((c) => !c.isDone) || activeColumns[0];
const nextKanban = nextState ? (doneCol ? doneCol.id : 'done') : (todoCol ? todoCol.id : 'todo');
onUpdateStatus(task, nextKanban);
}}
className={`w-5 h-5 checkbox-square flex items-center justify-center flex-shrink-0 transition-colors border disabled:cursor-default ${
task.isCompleted
? 'bg-status-ok border-status-ok text-on-accent'
: 'border-strong hover:border-accent text-transparent'
}`}
title={task.isCompleted ? 'Als offen markieren' : 'Als erledigt markieren'}
>
<Check className="w-3.5 h-3.5 stroke-[3]" />
</button>
<span className="text-xs font-semibold text-main truncate">
{readOnly ? 'Aufgabe ansehen' : task.isCompleted ? 'Erledigte Aufgabe' : 'Aufgabe bearbeiten'}
</span>
{readOnly && (
<span className="px-1.5 py-0.5 border border-border bg-canvas text-muted rounded-btn text-[10px] font-medium flex items-center gap-1 flex-shrink-0">
<Lock className="w-2.5 h-2.5 text-faint" />
<span>Nur Lesen</span>
</span>
)}
{task.source?.toLowerCase() === 'webhook' && (
<span className="px-1.5 py-0.5 border border-border bg-canvas text-muted rounded-btn text-[10px] font-medium flex items-center gap-1 flex-shrink-0">
<Webhook className="w-2.5 h-2.5 text-accent" />
<span>Webhook</span>
</span>
)}
</div>
<div className="flex items-center gap-1.5">
{(task.convertedType === 'document_block' || task.docId) ? (
<button
type="button"
onClick={() => {
onClose();
const docId = task.docId || (task.convertedRefId ? task.convertedRefId.split(':')[0] : '');
const blockId = task.blockId || (task.convertedRefId ? task.convertedRefId.split(':')[1] : '');
if (docId && onOpenDocument) onOpenDocument(docId, blockId);
}}
title="Zur verlinkten Notiz springen"
className="btn-primary min-h-[38px] sm:min-h-0 h-9 sm:h-7 px-2.5 sm:px-2 text-xs flex items-center gap-1.5 font-semibold"
>
<FileText className="w-4 h-4 sm:w-3.5 sm:h-3.5" />
<span>In Notiz öffnen</span>
</button>
) : !readOnly && (
<button
type="button"
onClick={() => {
onClose();
onConvertToDoc(task);
}}
title="Als Dokument / Notiz vertiefen"
className="btn-action min-h-[38px] sm:min-h-0 h-9 sm:h-7 px-2.5 sm:px-2 text-xs flex items-center gap-1.5"
>
<FileText className="w-4 h-4 sm:w-3.5 sm:h-3.5 text-accent" />
<span className="hidden sm:inline">Als Notiz vertiefen</span>
</button>
)}
{!readOnly && (
<button
type="button"
onClick={() => {
if (window.confirm('Möchten Sie diese Aufgabe wirklich löschen?')) {
onClose();
onDeleteTask(task.id);
}
}}
title="Aufgabe löschen"
className="btn-action min-h-[38px] sm:min-h-0 h-9 sm:h-7 px-2.5 sm:px-2 text-xs text-status-err border-status-err/30 hover:bg-status-err/10 flex items-center gap-1"
>
<Trash2 className="w-4 h-4 sm:w-3.5 sm:h-3.5" />
<span className="sm:hidden font-medium">Löschen</span>
</button>
)}
<button
type="button"
onClick={onClose}
className="text-muted hover:text-main p-2 sm:p-1.5 min-h-[38px] min-w-[38px] sm:min-h-0 sm:min-w-0 flex items-center justify-center transition-colors"
title="Schließen"
>
<X className="w-5 h-5 sm:w-4 sm:h-4" />
</button>
</div>
</div>
{/* Scrollable Body (all controls disabled for read-only shares) */}
<fieldset disabled={readOnly} className="p-4 overflow-y-auto space-y-4 flex-1 min-w-0 border-0 m-0">
{/* Task Title */}
<div>
{isEditingTitle ? (
<div className="space-y-2">
<textarea
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleTitleSubmit();
} else if (e.key === 'Escape') {
setIsEditingTitle(false);
setTitleDraft(task.content);
}
}}
autoFocus
rows={2}
className="w-full text-base font-semibold input-field p-2.5 resize-none leading-snug"
/>
<div className="flex justify-end gap-1.5">
<button
type="button"
onClick={() => {
setIsEditingTitle(false);
setTitleDraft(task.content);
}}
className="btn-action h-7 px-2.5 text-xs"
>
Abbrechen
</button>
<button
type="button"
onClick={handleTitleSubmit}
className="btn-primary h-7 px-3 text-xs"
>
Speichern
</button>
</div>
</div>
) : (
<div
onClick={() => !readOnly && setIsEditingTitle(true)}
className={`group p-2 rounded-card transition-colors border border-transparent ${
readOnly ? '' : 'cursor-pointer hover:bg-panel hover:border-border'
}`}
title={readOnly ? undefined : 'Klicken zum Bearbeiten des Titels'}
>
<h2
className={`text-base font-semibold break-words leading-snug ${
task.isCompleted ? 'line-through text-faint' : 'text-main'
}`}
>
{task.content}
</h2>
{!readOnly && (
<span className="text-[10px] text-muted opacity-0 group-hover:opacity-100 transition-opacity">
Klicken zum Umbenennen
</span>
)}
</div>
)}
</div>
{/* Kanban Status Buttons */}
<div className="space-y-1.5 pt-1 border-t border-border">
<label className="block text-xs font-semibold text-main">
Zustand / Kanban-Status:
</label>
<div className="flex flex-wrap items-center gap-1.5">
{activeColumns.map((col) => {
const isActive = (task.kanbanStatus === col.id) || (!task.kanbanStatus && col.id === 'todo');
return (
<button
key={col.id}
type="button"
onClick={() => {
triggerHaptic(10);
onUpdateStatus(task, col.id);
}}
className={`h-8 px-3 text-xs rounded-btn font-medium transition-all flex items-center gap-1.5 border ${
isActive
? 'bg-panel border-accent text-accent font-semibold shadow-sm'
: 'bg-canvas border-border text-muted hover:text-main hover:border-strong'
}`}
>
<span
className={`w-2 h-2 rounded-full ${
col.isDone
? 'bg-status-ok'
: col.id === 'in_progress'
? 'bg-accent'
: col.id === 'waiting'
? 'bg-status-warn'
: 'bg-faint'
}`}
/>
<span>{col.name}</span>
{isActive && <Check className="w-3 h-3 text-accent stroke-[3] ml-0.5" />}
</button>
);
})}
</div>
</div>
{/* Priority Buttons (4 Buttons) */}
<div className="space-y-1.5 pt-1 border-t border-border">
<label className="block text-xs font-semibold text-main">
Priorität:
</label>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-1.5">
<button
type="button"
onClick={() => {
triggerHaptic(10);
onUpdatePriority(task, 'low');
}}
className={`h-8 px-2 text-xs rounded-btn font-medium transition-colors border flex items-center justify-center gap-1 cursor-pointer ${
task.priority === 'low'
? 'bg-canvas border-strong text-main font-semibold shadow-xs'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
<span>Niedrig</span>
</button>
<button
type="button"
onClick={() => {
triggerHaptic(10);
onUpdatePriority(task, 'normal');
}}
className={`h-8 px-2 text-xs rounded-btn font-medium transition-colors border flex items-center justify-center gap-1 cursor-pointer ${
!task.priority || task.priority === 'normal'
? 'bg-accent/15 border-accent text-accent font-semibold shadow-xs'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
<span>Normal</span>
</button>
<button
type="button"
onClick={() => {
triggerHaptic(10);
onUpdatePriority(task, 'high');
}}
className={`h-8 px-2 text-xs rounded-btn font-medium transition-colors border flex items-center justify-center gap-1 cursor-pointer ${
task.priority === 'high'
? 'bg-status-warn/15 border-status-warn text-status-warn font-semibold shadow-xs'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
<AlertTriangle className="w-3 h-3 text-status-warn" />
<span>Wichtig</span>
</button>
<button
type="button"
onClick={() => {
triggerHaptic(10);
onUpdatePriority(task, 'urgent');
}}
className={`h-8 px-2 text-xs rounded-btn font-medium transition-colors border flex items-center justify-center gap-1 cursor-pointer ${
task.priority === 'urgent'
? 'bg-status-err/15 border-status-err text-status-err font-semibold shadow-xs'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
<Flame className="w-3 h-3 text-status-err" />
<span>Dringend</span>
</button>
</div>
</div>
{/* Due Date & List Selection Row */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-1 border-t border-border">
{/* Due Date */}
<div className="space-y-1.5">
<label className="block text-xs font-semibold text-main flex items-center gap-1">
<Calendar className="w-3.5 h-3.5 text-accent" />
<span>Fälligkeitsdatum:</span>
</label>
<div className="flex items-center gap-1.5">
<input
type="date"
value={task.dueDate || ''}
onChange={(e) => onUpdateDueDate(task, e.target.value)}
className="flex-1 h-8 input-field px-2 text-xs bg-panel"
/>
{task.dueDate && (
<button
type="button"
onClick={() => onUpdateDueDate(task, '')}
className="btn-action h-8 px-2 text-xs text-muted hover:text-status-err"
title="Datum entfernen"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
<div className="flex flex-wrap gap-1 text-[10px]">
<button
type="button"
onClick={() => handleQuickDueDate(0)}
className="px-1.5 py-0.5 bg-canvas border border-border rounded-btn text-muted hover:text-main"
>
Heute
</button>
<button
type="button"
onClick={() => handleQuickDueDate(1)}
className="px-1.5 py-0.5 bg-canvas border border-border rounded-btn text-muted hover:text-main"
>
Morgen
</button>
<button
type="button"
onClick={() => handleQuickDueDate(7)}
className="px-1.5 py-0.5 bg-canvas border border-border rounded-btn text-muted hover:text-main"
>
+1 Woche
</button>
</div>
</div>
{/* Task List assignment */}
<div className="space-y-1.5">
<label className="block text-xs font-semibold text-main flex items-center gap-1">
<Folder className="w-3.5 h-3.5 text-accent" />
<span>Aufgabenliste:</span>
</label>
<select
value={task.taskListId || ''}
onChange={(e) => onUpdateTaskList(task, e.target.value)}
className="w-full h-8 input-field px-2.5 text-xs bg-panel"
>
<option value="">-- Keine Liste (Allgemein) --</option>
{taskLists.map((tl) => (
<option key={tl.id} value={tl.id}>
{tl.icon} {tl.title}
</option>
))}
</select>
</div>
</div>
{/* Hashtags Section */}
<div className="space-y-1.5 pt-1 border-t border-border">
<label className="block text-xs font-semibold text-main flex items-center gap-1">
<Hash className="w-3.5 h-3.5 text-accent" />
<span>Hashtags:</span>
</label>
<div className="flex flex-wrap items-center gap-1.5">
{Array.isArray(task.tags) &&
task.tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-pill bg-panel border border-border text-xs text-main"
>
<span>#{tag}</span>
{!readOnly && (
<button
type="button"
onClick={() => handleRemoveTag(tag)}
className="text-muted hover:text-status-err"
>
<X className="w-3 h-3" />
</button>
)}
</span>
))}
{!readOnly && (
<form onSubmit={handleAddTagSubmit} className="inline-flex items-center gap-1">
<input
type="text"
value={newTagDraft}
onChange={(e) => setNewTagDraft(e.target.value)}
placeholder="+ #hashtag"
className="h-6 w-24 px-1.5 text-xs input-field"
/>
</form>
)}
</div>
</div>
{/* Subtasks Checklist */}
<div className="space-y-2 pt-1 border-t border-border">
<div className="flex items-center justify-between">
<label className="block text-xs font-semibold text-main flex items-center gap-1.5">
<CheckSquare className="w-3.5 h-3.5 text-accent" />
<span>Unteraufgaben ({completedSubtasks}/{subtasks.length}):</span>
</label>
{subtasks.length > 0 && (
<span className="text-[11px] font-mono text-muted">{subtasksPercent}%</span>
)}
</div>
{subtasks.length > 0 && (
<div className="w-full h-1 bg-border rounded-full overflow-hidden">
<div
className={`h-full transition-all duration-300 ${
subtasksPercent === 100 ? 'bg-status-ok' : 'bg-accent'
}`}
style={{ width: `${subtasksPercent}%` }}
/>
</div>
)}
{/* Subtask items list */}
{subtasks.length > 0 && (
<div className="space-y-1 max-h-48 overflow-y-auto pr-1">
{subtasks.map((st) => (
<div
key={st.id}
className="p-1.5 bg-canvas border border-border rounded-card flex items-center justify-between text-xs group"
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<button
type="button"
onClick={() => onToggleSubtask(task, st.id)}
className={`w-3.5 h-3.5 checkbox-square flex items-center justify-center flex-shrink-0 transition-colors border ${
st.isCompleted
? 'bg-status-ok border-status-ok text-on-accent'
: 'border-strong hover:border-accent text-transparent'
}`}
>
<Check className="w-2.5 h-2.5 stroke-[3]" />
</button>
<span
className={`truncate ${
st.isCompleted ? 'line-through text-faint' : 'text-main'
}`}
>
{st.title}
</span>
</div>
{!readOnly && (
<button
type="button"
onClick={() => onDeleteSubtask(task, st.id)}
className="opacity-0 group-hover:opacity-100 text-faint hover:text-status-err p-0.5 transition-opacity"
>
<Trash2 className="w-3 h-3" />
</button>
)}
</div>
))}
</div>
)}
{/* Inline add subtask form */}
{!readOnly && (
<form onSubmit={handleAddSubtaskSubmit} className="flex items-center gap-1.5">
<input
type="text"
value={newSubtaskTitle}
onChange={(e) => setNewSubtaskTitle(e.target.value)}
placeholder="Neue Unteraufgabe hinzufügen…"
className="flex-1 h-7 px-2 text-xs input-field"
/>
<button
type="submit"
disabled={!newSubtaskTitle.trim()}
className="btn-action h-7 px-2.5 text-xs disabled:opacity-40"
>
<Plus className="w-3 h-3" />
<span>Hinzufügen</span>
</button>
</form>
)}
</div>
{/* Notes Section */}
<div className="space-y-2 pt-1 border-t border-border">
<div className="flex items-center justify-between">
<label className="block text-xs font-semibold text-main flex items-center gap-1.5">
<FileText className="w-3.5 h-3.5 text-accent" />
<span>Detailnotizen:</span>
</label>
{!readOnly && !isEditingNotes && task.notes && (
<button
type="button"
onClick={() => {
setNotesDraft(task.notes || '');
setIsEditingNotes(true);
}}
className="text-[11px] text-accent hover:underline"
>
Bearbeiten
</button>
)}
</div>
{isEditingNotes ? (
<div className="space-y-2">
<textarea
value={notesDraft}
onChange={(e) => setNotesDraft(e.target.value)}
placeholder="Detailnotizen eingeben… (Markdown unterstützt)"
rows={6}
autoFocus
className="w-full input-field p-2.5 text-xs font-mono leading-relaxed resize-y min-h-[100px]"
/>
<div className="flex justify-end gap-1.5">
<button
type="button"
onClick={() => {
setIsEditingNotes(false);
setNotesDraft(task.notes || '');
}}
className="btn-action h-7 px-2.5 text-xs"
>
Abbrechen
</button>
<button
type="button"
onClick={handleNotesSubmit}
className="btn-primary h-7 px-3 text-xs"
>
Notiz speichern
</button>
</div>
</div>
) : task.notes ? (
<div
onClick={() => {
if (readOnly) return;
setNotesDraft(task.notes || '');
setIsEditingNotes(true);
}}
className={`p-3 bg-canvas border border-border rounded-card text-xs text-muted leading-relaxed transition-colors ${
readOnly ? '' : 'cursor-pointer hover:border-strong'
}`}
title={readOnly ? undefined : 'Klicken zum Bearbeiten'}
>
<MarkdownViewer content={task.notes} />
</div>
) : readOnly ? (
<p className="text-xs text-faint">Keine Detailnotizen.</p>
) : (
<button
type="button"
onClick={() => {
setNotesDraft('');
setIsEditingNotes(true);
}}
className="w-full py-2.5 px-3 border border-dashed border-border rounded-card text-xs text-muted hover:text-main hover:bg-panel transition-colors text-left flex items-center gap-1.5"
>
<Plus className="w-3.5 h-3.5 text-accent" />
<span>Detailnotiz hinzufügen…</span>
</button>
)}
</div>
</fieldset>
{/* Modal Footer */}
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border bg-panel">
<div className="text-[11px] text-faint">
Erstellt: {new Date(task.createdAt).toLocaleDateString('de-DE')}
</div>
<button
type="button"
onClick={onClose}
className="btn-action h-7 px-3 text-xs"
>
Fertig
</button>
</div>
</div>
</div>
);
};
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,282 @@
import React, { useState, useMemo, useRef, useEffect } from 'react';
import Prism from 'prismjs';
import 'prismjs/components/prism-python';
import 'prismjs/components/prism-typescript';
import 'prismjs/components/prism-bash';
import 'prismjs/components/prism-json';
import 'prismjs/components/prism-go';
import 'prismjs/components/prism-sql';
import 'prismjs/components/prism-yaml';
import 'prismjs/components/prism-rust';
import 'prismjs/components/prism-c';
import 'prismjs/components/prism-cpp';
import 'prismjs/components/prism-java';
import 'prismjs/components/prism-php';
import 'prismjs/components/prism-docker';
import 'prismjs/components/prism-markdown';
import { Code, Copy, Check, Eye, Edit3 } from 'lucide-react';
import { DocumentBlock } from '../../lib/types';
export interface CodeBlockItemProps {
block: DocumentBlock;
isReadOnly: boolean;
onChangeContent: (content: string) => void;
onChangeLang: (lang: string) => void;
onFocus?: () => void;
onBlur?: () => void;
inputRef?: (el: HTMLTextAreaElement | null) => void;
}
export const CODE_LANGUAGES = [
{ id: 'javascript', label: 'JavaScript' },
{ id: 'typescript', label: 'TypeScript' },
{ id: 'python', label: 'Python' },
{ id: 'go', label: 'Go' },
{ id: 'bash', label: 'Bash / Shell' },
{ id: 'json', label: 'JSON' },
{ id: 'html', label: 'HTML / XML', alias: 'markup' },
{ id: 'css', label: 'CSS' },
{ id: 'sql', label: 'SQL' },
{ id: 'rust', label: 'Rust' },
{ id: 'cpp', label: 'C++' },
{ id: 'c', label: 'C' },
{ id: 'java', label: 'Java' },
{ id: 'php', label: 'PHP' },
{ id: 'yaml', label: 'YAML' },
{ id: 'docker', label: 'Dockerfile' },
{ id: 'markdown', label: 'Markdown' },
{ id: 'text', label: 'Plain Text' },
];
export const CodeBlockItem: React.FC<CodeBlockItemProps> = ({
block,
isReadOnly,
onChangeContent,
onChangeLang,
onFocus,
onBlur,
inputRef,
}) => {
const [isEditing, setIsEditing] = useState(false);
const [copied, setCopied] = useState(false);
const localTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const currentLang = block.lang || 'javascript';
// Highlighted HTML generated by Prism
const highlightedCode = useMemo(() => {
const code = block.content || '';
if (!code) return '';
const prismLang = currentLang === 'html' ? 'markup' : currentLang;
const grammar = Prism.languages[prismLang] || Prism.languages.javascript;
if (!grammar) {
return code
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
try {
return Prism.highlight(code, grammar, prismLang);
} catch {
return code
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
}, [block.content, currentLang]);
// Code lines for line numbers
const lines = useMemo(() => {
return (block.content || '').split('\n');
}, [block.content]);
const handleCopy = async (e: React.MouseEvent) => {
e.stopPropagation();
try {
await navigator.clipboard.writeText(block.content || '');
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Fallback
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Escape') {
setIsEditing(false);
return;
}
// Tab key indent/outdent
if (e.key === 'Tab') {
e.preventDefault();
const textarea = e.currentTarget;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const val = textarea.value;
if (e.shiftKey) {
if (val.substring(start - 2, start) === ' ') {
const newVal = val.substring(0, start - 2) + val.substring(start);
onChangeContent(newVal);
setTimeout(() => {
textarea.selectionStart = textarea.selectionEnd = Math.max(0, start - 2);
}, 0);
}
} else {
const newVal = val.substring(0, start) + ' ' + val.substring(end);
onChangeContent(newVal);
setTimeout(() => {
textarea.selectionStart = textarea.selectionEnd = start + 2;
}, 0);
}
}
};
const switchToEdit = () => {
if (isReadOnly) return;
setIsEditing(true);
setTimeout(() => {
localTextareaRef.current?.focus();
}, 20);
};
return (
<div className="rounded-card border border-border bg-panel overflow-hidden shadow-xs my-1 group/code">
{/* Code Block Header Toolbar */}
<div className="flex items-center justify-between px-3 py-1.5 bg-canvas border-b border-border text-xs">
{/* Left: Language Selection */}
<div className="flex items-center gap-2">
<Code className="w-3.5 h-3.5 text-accent flex-shrink-0" />
<select
value={currentLang}
disabled={isReadOnly}
onChange={(e) => onChangeLang(e.target.value)}
className="bg-transparent text-main font-mono text-[11px] font-medium border border-transparent hover:border-border focus:border-accent rounded px-1.5 py-0.5 outline-none cursor-pointer"
>
{CODE_LANGUAGES.map((lang) => (
<option key={lang.id} value={lang.id} className="bg-panel text-main">
{lang.label}
</option>
))}
</select>
</div>
{/* Right: Mode Toggle & Copy */}
<div className="flex items-center gap-1.5">
{!isReadOnly && (
<button
type="button"
onClick={() => {
if (isEditing) {
setIsEditing(false);
} else {
switchToEdit();
}
}}
className="px-2 py-0.5 rounded text-[11px] font-medium text-muted hover:text-main hover:bg-panel-elevated transition-colors flex items-center gap-1 cursor-pointer"
title={isEditing ? 'Zur farbigen Vorschau wechseln (Esc)' : 'Code bearbeiten'}
>
{isEditing ? (
<>
<Eye className="w-3 h-3 text-accent" />
<span>Vorschau</span>
</>
) : (
<>
<Edit3 className="w-3 h-3 text-muted" />
<span>Bearbeiten</span>
</>
)}
</button>
)}
<button
type="button"
onClick={handleCopy}
className={`px-2 py-0.5 rounded text-[11px] font-medium transition-colors flex items-center gap-1 cursor-pointer ${
copied
? 'text-emerald-500 bg-emerald-500/10'
: 'text-muted hover:text-main hover:bg-panel-elevated'
}`}
title="Code in Zwischenablage kopieren"
>
{copied ? (
<>
<Check className="w-3 h-3 text-emerald-500" />
<span>Kopiert!</span>
</>
) : (
<>
<Copy className="w-3 h-3 opacity-70" />
<span>Kopieren</span>
</>
)}
</button>
</div>
</div>
{/* Code Block Content */}
{isEditing && !isReadOnly ? (
<div className="relative">
<textarea
ref={(el) => {
localTextareaRef.current = el;
if (inputRef) inputRef(el);
}}
rows={Math.max(3, lines.length)}
value={block.content || ''}
onFocus={() => onFocus && onFocus()}
onBlur={() => {
if (onBlur) onBlur();
}}
onChange={(e) => onChangeContent(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="// Code hier eingeben..."
className="w-full p-3 bg-panel-elevated/40 font-mono text-xs text-main leading-relaxed focus:outline-none resize-none placeholder:text-faint"
/>
<div className="px-3 py-1 bg-canvas/40 border-t border-border/50 text-[10px] text-faint flex items-center justify-between">
<span>Tab: Einrücken (2 Leerzeichen) • Shift+Tab: Ausrücken</span>
<button
type="button"
onClick={() => setIsEditing(false)}
className="hover:text-accent cursor-pointer"
>
Vorschau anzeigen (Esc)
</button>
</div>
</div>
) : (
<div
onClick={switchToEdit}
className={`relative p-3 font-mono text-xs overflow-x-auto ${
isReadOnly ? '' : 'cursor-pointer hover:bg-panel-elevated/20 transition-colors'
}`}
title={isReadOnly ? undefined : 'Klicken zum Bearbeiten'}
>
{block.content ? (
<div className="flex gap-3">
{/* Line numbers */}
<div className="select-none text-right text-faint opacity-50 font-mono text-xs leading-relaxed flex-shrink-0">
{lines.map((_, idx) => (
<div key={idx}>{idx + 1}</div>
))}
</div>
{/* Syntax highlighted code */}
<pre className="m-0 p-0 font-mono text-xs leading-relaxed whitespace-pre overflow-x-auto flex-1">
<code
dangerouslySetInnerHTML={{ __html: highlightedCode }}
className={`language-${currentLang}`}
/>
</pre>
</div>
) : (
<div className="text-faint italic py-2">
{isReadOnly ? '(Kein Code hinterlegt)' : '(Klicken, um Code einzugeben...)'}
</div>
)}
</div>
)}
</div>
);
};
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,308 @@
import React, { useState, useEffect, useMemo, useRef } from 'react';
import {
Link as LinkIcon,
FileText,
Globe,
X,
Search,
ExternalLink,
Loader2,
Plus,
} from 'lucide-react';
import { api } from '../../lib/api';
import { Document } from '../../lib/types';
export interface LinkModalProps {
isOpen: boolean;
onClose: () => void;
onInsertLink: (markdown: string) => void;
availableDocs?: { id: string; title: string; icon?: string; folderPath?: string }[];
}
export const LinkModal: React.FC<LinkModalProps> = ({
isOpen,
onClose,
onInsertLink,
availableDocs,
}) => {
const [tab, setTab] = useState<'note' | 'web'>('note');
const [search, setSearch] = useState('');
const [docs, setDocs] = useState<Document[]>([]);
const [loading, setLoading] = useState(false);
const [webTitle, setWebTitle] = useState('');
const [webUrl, setWebUrl] = useState('');
const searchInputRef = useRef<HTMLInputElement | null>(null);
const webTitleInputRef = useRef<HTMLInputElement | null>(null);
// Load documents when modal opens
useEffect(() => {
if (!isOpen) return;
if (availableDocs && availableDocs.length > 0) {
setDocs(availableDocs as Document[]);
return;
}
let isCancelled = false;
setLoading(true);
api
.getDocuments()
.then((fetched) => {
if (!isCancelled) {
setDocs(fetched);
setLoading(false);
}
})
.catch(() => {
if (!isCancelled) {
setLoading(false);
}
});
return () => {
isCancelled = true;
};
}, [isOpen, availableDocs]);
// Focus input on mount or tab change
useEffect(() => {
if (!isOpen) return;
const timer = setTimeout(() => {
if (tab === 'note') {
searchInputRef.current?.focus();
} else {
webTitleInputRef.current?.focus();
}
}, 50);
return () => clearTimeout(timer);
}, [isOpen, tab]);
// Close on Escape
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
const filteredDocs = useMemo(() => {
if (!search.trim()) return docs;
const q = search.toLowerCase();
return docs.filter(
(d) =>
d.title.toLowerCase().includes(q) ||
(d.folderPath && d.folderPath.toLowerCase().includes(q))
);
}, [docs, search]);
const handleSelectNote = (doc: { title: string }) => {
const noteTitle = doc.title.trim() || 'Notiz';
onInsertLink(`[[${noteTitle}]]`);
};
const handleWebLinkSubmit = (e: React.FormEvent) => {
e.preventDefault();
let url = webUrl.trim();
if (!url) return;
if (!/^https?:\/\//i.test(url) && !url.startsWith('#') && !url.startsWith('/')) {
url = `https://${url}`;
}
const label = webTitle.trim() || url;
onInsertLink(`[${label}](${url})`);
};
if (!isOpen) return null;
return (
<div
className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="panel-surface w-full max-w-lg p-5 shadow-panel rounded-card border border-border space-y-4 animate-in fade-in zoom-in-95 duration-150">
{/* Header - identical to UnifiedShareModal */}
<div className="flex items-center justify-between pb-3 border-b border-border">
<div className="flex items-center gap-2.5 min-w-0">
<div className="w-8 h-8 rounded-btn bg-accent/15 text-accent flex items-center justify-center shrink-0">
<LinkIcon className="w-4 h-4" />
</div>
<div className="min-w-0">
<h3 className="text-sm font-bold text-main truncate">Link einfügen</h3>
<p className="text-[11px] text-muted truncate">
Notiz intern verknüpfen oder externen Web-Link einbetten
</p>
</div>
</div>
<button
type="button"
onClick={onClose}
className="p-1.5 text-muted hover:text-main rounded-btn hover:bg-panel transition-colors cursor-pointer"
title="Schließen"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Control Box - matching UnifiedShareModal form header */}
<div className="space-y-3 p-3 bg-panel-elevated border border-border rounded-card">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-semibold text-main">Link-Typ:</span>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => setTab('note')}
className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1.5 border transition-colors cursor-pointer ${
tab === 'note'
? 'bg-panel border-accent text-accent font-semibold shadow-xs'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
<FileText className="w-3.5 h-3.5" />
<span>Notiz</span>
</button>
<button
type="button"
onClick={() => setTab('web')}
className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1.5 border transition-colors cursor-pointer ${
tab === 'web'
? 'bg-panel border-accent text-accent font-semibold shadow-xs'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
<Globe className="w-3.5 h-3.5" />
<span>Web-Link</span>
</button>
</div>
</div>
{tab === 'note' ? (
<div className="relative">
<Search className="w-3.5 h-3.5 absolute left-2.5 top-1/2 -translate-y-1/2 text-muted pointer-events-none" />
<input
ref={searchInputRef}
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Notiz suchen (Titel oder Ordner)…"
className="input-field bg-panel h-8 pl-8 pr-2.5 text-xs w-full text-main placeholder:text-muted"
/>
</div>
) : (
<form onSubmit={handleWebLinkSubmit} className="space-y-2.5">
<div>
<label className="text-[10px] font-bold uppercase tracking-wider text-faint mb-1 block">
Link-Text / Label
</label>
<input
ref={webTitleInputRef}
type="text"
value={webTitle}
onChange={(e) => setWebTitle(e.target.value)}
placeholder="z. B. MindSift Dokumentation"
className="input-field bg-panel h-8 px-2.5 text-xs w-full text-main placeholder:text-muted"
/>
</div>
<div>
<label className="text-[10px] font-bold uppercase tracking-wider text-faint mb-1 block">
Web-Adresse (URL) *
</label>
<input
type="text"
required
value={webUrl}
onChange={(e) => setWebUrl(e.target.value)}
placeholder="https://example.com"
className="input-field bg-panel h-8 px-2.5 text-xs w-full text-main placeholder:text-muted"
/>
</div>
<div className="flex justify-end gap-2 pt-1">
<button
type="button"
onClick={onClose}
className="btn-action text-xs h-8 px-3.5 cursor-pointer"
>
Abbrechen
</button>
<button
type="submit"
disabled={!webUrl.trim()}
className="btn-primary text-xs h-8 px-3.5 flex items-center gap-1.5 cursor-pointer disabled:opacity-50"
>
<Plus className="w-3.5 h-3.5" />
<span>Link einfügen</span>
</button>
</div>
</form>
)}
</div>
{/* Note List Section (Matching Active Shares list in UnifiedShareModal) */}
{tab === 'note' && (
<div className="space-y-2">
<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 className="font-mono text-[11px] font-normal lowercase">
{filteredDocs.length} {filteredDocs.length === 1 ? 'Notiz' : 'Notizen'}
</span>
</div>
<div className="space-y-1.5 max-h-56 overflow-y-auto pr-0.5">
{loading ? (
<div className="p-6 bg-canvas border border-border rounded-card text-xs text-muted flex items-center gap-2 justify-center text-center">
<Loader2 className="w-4 h-4 animate-spin text-accent" />
<span>Notizen werden geladen…</span>
</div>
) : filteredDocs.length === 0 ? (
<div className="p-4 bg-canvas border border-border rounded-card text-xs text-muted flex items-center gap-2.5 justify-center text-center">
<Search className="w-4 h-4 text-faint shrink-0" />
<span>
{search
? `Keine Notiz für "${search}" gefunden.`
: 'Keine Notizen im Workspace vorhanden.'}
</span>
</div>
) : (
filteredDocs.map((d) => (
<div
key={d.id}
onClick={() => handleSelectNote(d)}
className="flex items-center justify-between p-2.5 bg-canvas border border-border rounded-card hover:border-accent/60 transition-colors group cursor-pointer"
>
<div className="flex items-center gap-2.5 min-w-0">
<span className="text-base shrink-0">{d.icon || '📝'}</span>
<div className="min-w-0">
<div className="text-xs font-semibold text-main truncate group-hover:text-accent">
{d.title || 'Unbenannte Notiz'}
</div>
{d.folderPath && (
<div className="text-[10px] text-muted truncate">
📁 {d.folderPath}
</div>
)}
</div>
</div>
<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 shrink-0"
>
<span>Einfügen</span>
<ExternalLink className="w-3 h-3" />
</button>
</div>
))
)}
</div>
</div>
)}
</div>
</div>
);
};
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,776 @@
import React, { useState, useRef, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
import { DocumentBlock, BlockType } from '../../lib/types';
import { OutlinerBlock } from './OutlinerBlock';
export interface OutlinerTreeHandle {
focusFirstBlock: () => void;
focusBlock: (blockId: string, cursor?: 'start' | 'end') => void;
}
export interface OutlinerTreeProps {
docId: string;
blocks: DocumentBlock[];
canEdit: boolean;
levelOffset?: number;
onZoomBlock?: (blockId: string) => void;
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
onUpdateBlocks?: (updated: DocumentBlock[]) => void;
onCreateBlock: (newBlock: DocumentBlock, afterBlockId?: string) => void;
onInsertBlocks?: (afterBlockId: string, updatedCurrent: DocumentBlock, newBlocks: DocumentBlock[]) => void;
onDeleteBlock: (blockId: string) => void;
onMoveBlock: (blockId: string, newIndex: number, level?: number) => void;
onOpenTask?: (taskId: string) => void;
onTagClick?: (tag: string) => void;
onWikiLinkClick?: (pageTitle: string) => void;
initialFocusedBlockId?: string | null;
}
export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
docId,
blocks,
canEdit,
levelOffset = 0,
onZoomBlock,
onUpdateBlock,
onUpdateBlocks,
onCreateBlock,
onInsertBlocks,
onDeleteBlock,
onMoveBlock,
onOpenTask,
onTagClick,
onWikiLinkClick,
initialFocusedBlockId,
}, ref) => {
const [focusedBlockId, setFocusedBlockId] = useState<string | null>(null);
const [highlightedBlockId, setHighlightedBlockId] = useState<string | null>(
initialFocusedBlockId || null
);
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const textareaMapRef = useRef<Map<string, HTMLTextAreaElement>>(new Map());
const pendingFocusRef = useRef<{ blockId: string; cursor: 'start' | 'end' | 'select' | number } | null>(null);
const focusBlockInput = useCallback(
(blockId: string, cursor: 'start' | 'end' | 'select' | number = 'end') => {
setFocusedBlockId(blockId);
const el = textareaMapRef.current.get(blockId);
if (el) {
const apply = () => {
el.focus();
try {
if (cursor === 'select') {
el.select();
} else {
const pos =
typeof cursor === 'number'
? Math.min(cursor, el.value.length)
: cursor === 'end'
? el.value.length
: 0;
el.setSelectionRange(pos, pos);
}
} catch {
// ignore
}
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
};
// A numeric position refers to content that was just changed, so wait one extra frame for it to render
requestAnimationFrame(() => (typeof cursor === 'number' ? requestAnimationFrame(apply) : apply()));
} else {
pendingFocusRef.current = { blockId, cursor };
}
},
[]
);
useImperativeHandle(
ref,
() => ({
focusFirstBlock: () => {
if (blocks.length > 0) {
focusBlockInput(blocks[0].id, 'end');
} else {
const firstBlock: DocumentBlock = {
id: `blk_${Date.now().toString(36)}_0`,
documentId: docId,
type: 'text',
content: '',
level: levelOffset || 0,
position: 1000,
};
pendingFocusRef.current = { blockId: firstBlock.id, cursor: 'end' };
onCreateBlock(firstBlock);
}
},
focusBlock: (blockId: string, cursor: 'start' | 'end' = 'end') => {
focusBlockInput(blockId, cursor);
},
}),
[blocks, docId, levelOffset, onCreateBlock, focusBlockInput]
);
const registerTextareaRef = useCallback((blockId: string, el: HTMLTextAreaElement | null) => {
if (el) {
textareaMapRef.current.set(blockId, el);
if (pendingFocusRef.current && pendingFocusRef.current.blockId === blockId) {
const target = pendingFocusRef.current;
pendingFocusRef.current = null;
requestAnimationFrame(() => {
setFocusedBlockId(blockId);
el.focus();
try {
if (target.cursor === 'select') {
el.select();
} else {
const pos = target.cursor === 'end' ? el.value.length : 0;
el.setSelectionRange(pos, pos);
}
} catch {
// ignore
}
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
}
} else {
textareaMapRef.current.delete(blockId);
}
}, []);
useEffect(() => {
if (initialFocusedBlockId) {
setHighlightedBlockId(initialFocusedBlockId);
focusBlockInput(initialFocusedBlockId, 'select');
const timer = setTimeout(() => {
focusBlockInput(initialFocusedBlockId, 'select');
}, 70);
const timer2 = setTimeout(() => {
focusBlockInput(initialFocusedBlockId, 'select');
}, 200);
const fadeTimer = setTimeout(() => {
setHighlightedBlockId(null);
}, 2800);
return () => {
clearTimeout(timer);
clearTimeout(timer2);
clearTimeout(fadeTimer);
};
}
}, [initialFocusedBlockId, blocks.length, focusBlockInput]);
// Compute visibility based on collapsed ancestor blocks
const visibleEntries = useMemo(() => {
const result: {
block: DocumentBlock;
index: number;
hasChildren: boolean;
maxAllowedLevel: number;
}[] = [];
let collapsedAboveLevel: number | null = null;
for (let i = 0; i < blocks.length; i++) {
const b = blocks[i];
const lvl = Math.max(0, b.level || 0);
if (collapsedAboveLevel !== null) {
if (lvl > collapsedAboveLevel) {
continue;
} else {
collapsedAboveLevel = null;
}
}
const nextBlock = blocks[i + 1];
const nextLvl = nextBlock ? Math.max(0, nextBlock.level || 0) : 0;
const hasChildren = Boolean(nextBlock && nextLvl > lvl);
const prevBlock = i > 0 ? blocks[i - 1] : null;
const maxAllowedLevel = prevBlock ? Math.max(0, prevBlock.level || 0) + 1 : (levelOffset || 0);
result.push({
block: b,
index: i,
hasChildren,
maxAllowedLevel,
});
if (b.collapsed && hasChildren) {
collapsedAboveLevel = lvl;
}
}
return result;
}, [blocks, levelOffset]);
const handleCreateBlockBelow = useCallback(
(afterBlockId: string, newContent: string = '') => {
if (!canEdit) return;
const idx = blocks.findIndex((b) => b.id === afterBlockId);
const currentBlock = idx >= 0 ? blocks[idx] : null;
const level = currentBlock ? Math.max(0, currentBlock.level || 0) : 0;
const inheritedType: BlockType = 'text';
const newBlock: DocumentBlock = {
id: `blk_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
documentId: docId,
type: inheritedType,
content: newContent,
level,
checked: false,
collapsed: false,
position: ((idx >= 0 ? idx + 2 : blocks.length + 1) * 1000),
};
pendingFocusRef.current = { blockId: newBlock.id, cursor: 'start' };
setFocusedBlockId(newBlock.id);
onCreateBlock(newBlock, afterBlockId);
},
[blocks, canEdit, docId, onCreateBlock]
);
const handlePasteMultiLines = useCallback(
(blockId: string, textBefore: string, textAfter: string, rawLines: string[]) => {
if (!canEdit || rawLines.length === 0) return;
const idx = blocks.findIndex((b) => b.id === blockId);
if (idx === -1) return;
const currentBlock = blocks[idx];
const baseLevel = Math.max(0, currentBlock.level || 0);
// Detect indentation style (2-space vs 4-space tabs)
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;
const line0 = parsed[0];
const baseIndent = line0.indent;
const isSingle = parsed.length === 1;
const line0Content = textBefore + line0.content + (isSingle ? textAfter : '');
const isCurrentEmpty = (currentBlock.content || '').trim() === '';
const adoptedType = line0.type !== 'text' ? line0.type : (isCurrentEmpty ? 'text' : currentBlock.type);
const adoptedChecked = line0.type === 'todo' ? (line0.checked ?? false) : currentBlock.checked;
const updatedCurrentBlock: DocumentBlock = {
...currentBlock,
content: line0Content,
type: adoptedType,
checked: adoptedChecked,
};
const newBlocks: DocumentBlock[] = [];
const nowBase = Date.now().toString(36);
for (let j = 1; j < parsed.length; j++) {
const p = parsed[j];
const isLastLine = j === parsed.length - 1;
const lineContent = isLastLine ? (p.content + textAfter) : p.content;
const lineLevel = Math.max(0, baseLevel + (p.indent - baseIndent));
newBlocks.push({
id: `blk_${nowBase}_${j}_${Math.random().toString(36).slice(2, 6)}`,
documentId: docId,
type: p.type,
checked: p.checked || false,
content: lineContent,
level: lineLevel,
collapsed: false,
position: (currentBlock.position || 1000) + j * 10,
});
}
const lastTargetId = newBlocks.length > 0 ? newBlocks[newBlocks.length - 1].id : currentBlock.id;
const existingEl = textareaMapRef.current.get(lastTargetId);
if (existingEl) {
pendingFocusRef.current = null;
focusBlockInput(lastTargetId, 'end');
} else {
pendingFocusRef.current = { blockId: lastTargetId, cursor: 'end' };
}
setFocusedBlockId(lastTargetId);
if (onInsertBlocks) {
onInsertBlocks(currentBlock.id, updatedCurrentBlock, newBlocks);
} else {
onUpdateBlock(updatedCurrentBlock, true);
for (let k = 0; k < newBlocks.length; k++) {
const afterId = k === 0 ? currentBlock.id : newBlocks[k - 1].id;
onCreateBlock(newBlocks[k], afterId);
}
}
},
[blocks, canEdit, docId, focusBlockInput, onCreateBlock, onInsertBlocks, onUpdateBlock]
);
const handleDeleteBlock = useCallback(
(blockId: string, deleteSubtree = false) => {
if (!canEdit) return;
const idx = blocks.findIndex((b) => b.id === blockId);
if (idx === -1) return;
// If this is the only block in the document (and not in an isolated zoom subtree), clear instead of deleting
if (blocks.length <= 1 && !levelOffset) {
const only = blocks[0];
onUpdateBlock(
{
...only,
content: '',
type: 'text',
level: 0,
checked: false,
},
true
);
return;
}
const targetFocusBlock = idx > 0 ? blocks[idx - 1] : blocks[idx + 1];
if (targetFocusBlock) {
focusBlockInput(targetFocusBlock.id, 'end');
}
if (deleteSubtree) {
const rootLevel = blocks[idx].level || 0;
const idsToDelete = [blockId];
for (let j = idx + 1; j < blocks.length; j++) {
if ((blocks[j].level || 0) > rootLevel) {
idsToDelete.push(blocks[j].id);
} else {
break;
}
}
for (const id of idsToDelete) {
onDeleteBlock(id);
}
} else {
onDeleteBlock(blockId);
}
},
[blocks, canEdit, focusBlockInput, levelOffset, onDeleteBlock, onUpdateBlock]
);
// Backspace at the start of a non-empty block: append its text to the previous visible block
const handleMergeWithPrevious = useCallback(
(blockId: string, content: string) => {
if (!canEdit) return false;
const notMergeable = ['code', 'divider', 'table', 'progress'];
const visIdx = visibleEntries.findIndex((e) => e.block.id === blockId);
if (visIdx <= 0) return false;
const prev = visibleEntries[visIdx - 1].block;
const idx = blocks.findIndex((b) => b.id === blockId);
const cur = blocks[idx];
if (!cur || notMergeable.includes(prev.type) || notMergeable.includes(cur.type)) return false;
// Children of the merged block move up so they become children of the previous block
const curLevel = Math.max(0, cur.level || 0);
const delta = Math.max(0, prev.level || 0) - curLevel;
const movedChildren: DocumentBlock[] = [];
for (let j = idx + 1; j < blocks.length; j++) {
const lvl = Math.max(0, blocks[j].level || 0);
if (lvl <= curLevel) break;
movedChildren.push({ ...blocks[j], level: Math.max(levelOffset || 0, lvl + delta) });
}
const joinPos = (prev.content || '').length;
const mergedPrev: DocumentBlock = { ...prev, content: (prev.content || '') + content };
if (movedChildren.length > 0 && onUpdateBlocks) {
onUpdateBlocks([mergedPrev, ...movedChildren]);
} else {
onUpdateBlock(mergedPrev, true);
movedChildren.forEach((c) => onUpdateBlock(c));
}
onDeleteBlock(cur.id);
focusBlockInput(prev.id, joinPos);
return true;
},
[blocks, canEdit, focusBlockInput, levelOffset, onDeleteBlock, onUpdateBlock, onUpdateBlocks, visibleEntries]
);
const handleIndentBlock = useCallback(
(blockId: string, delta: 1 | -1) => {
if (!canEdit) return;
const idx = blocks.findIndex((b) => b.id === blockId);
if (idx === -1) return;
const current = blocks[idx];
const currentLevel = Math.max(0, current.level || 0);
const prevLevel = idx > 0 ? Math.max(0, blocks[idx - 1].level || 0) : 0;
const minLevel = levelOffset || 0;
const maxLevel = idx === 0 ? minLevel : prevLevel + 1;
const nextLevel =
delta === 1
? Math.min(maxLevel, currentLevel + 1)
: Math.max(minLevel, currentLevel - 1);
if (nextLevel === currentLevel) return;
const actualDelta = nextLevel - currentLevel;
// Find all contiguous children / descendants:
// contiguous blocks with level > currentLevel
let count = 1;
for (let i = idx + 1; i < blocks.length; i++) {
if (Math.max(0, blocks[i].level || 0) > currentLevel) {
count++;
} else {
break;
}
}
const updatedBlocks: DocumentBlock[] = [];
for (let i = idx; i < idx + count; i++) {
const b = blocks[i];
const newLvl = Math.max(minLevel, (b.level || 0) + actualDelta);
updatedBlocks.push({
...b,
level: newLvl,
});
}
if (onUpdateBlocks) {
onUpdateBlocks(updatedBlocks);
} else {
for (const b of updatedBlocks) {
onUpdateBlock(b, true);
}
}
},
[blocks, canEdit, levelOffset, onUpdateBlock, onUpdateBlocks]
);
const handleNavigateFocus = useCallback(
(blockId: string, direction: 'up' | 'down') => {
const visIdx = visibleEntries.findIndex((e) => e.block.id === blockId);
if (visIdx === -1) return;
const targetEntry =
direction === 'up' ? visibleEntries[visIdx - 1] : visibleEntries[visIdx + 1];
if (targetEntry) {
focusBlockInput(
targetEntry.block.id,
direction === 'up' ? 'end' : 'start'
);
}
},
[focusBlockInput, visibleEntries]
);
const handleDragStartBlock = useCallback((index: number) => {
setDraggedIndex(index);
}, []);
const handleDragOverBlock = useCallback(
(e: React.DragEvent, index: number) => {
e.preventDefault();
if (draggedIndex !== null) {
const parentLevel = Math.max(0, blocks[draggedIndex]?.level || 0);
let count = 1;
for (let i = draggedIndex + 1; i < blocks.length; i++) {
if (Math.max(0, blocks[i].level || 0) > parentLevel) {
count++;
} else {
break;
}
}
// Do not allow dropping inside its own subtree (self or descendants)
if (index >= draggedIndex && index < draggedIndex + count) {
setDragOverIndex(null);
return;
}
}
setDragOverIndex(index);
},
[blocks, draggedIndex]
);
const handleDropBlock = useCallback(
(targetIndex: number) => {
if (draggedIndex === null || draggedIndex === targetIndex) {
setDraggedIndex(null);
setDragOverIndex(null);
return;
}
const parentLevel = Math.max(0, blocks[draggedIndex]?.level || 0);
let count = 1;
for (let i = draggedIndex + 1; i < blocks.length; i++) {
if (Math.max(0, blocks[i].level || 0) > parentLevel) {
count++;
} else {
break;
}
}
if (targetIndex >= draggedIndex && targetIndex < draggedIndex + count) {
setDraggedIndex(null);
setDragOverIndex(null);
return;
}
const movingBlock = blocks[draggedIndex];
if (movingBlock) {
onMoveBlock(movingBlock.id, targetIndex, movingBlock.level);
}
setDraggedIndex(null);
setDragOverIndex(null);
},
[blocks, draggedIndex, onMoveBlock]
);
useEffect(() => {
const clearDrag = () => {
setDraggedIndex(null);
setDragOverIndex(null);
};
window.addEventListener('dragend', clearDrag);
return () => window.removeEventListener('dragend', clearDrag);
}, []);
return (
<div className="space-y-0.5">
{visibleEntries.map(({ block, index, hasChildren, maxAllowedLevel }, entryIdx) => {
const isLast = entryIdx === visibleEntries.length - 1;
return (
<OutlinerBlock
key={block.id}
docId={docId}
block={block}
index={index}
isLast={isLast}
maxAllowedLevel={maxAllowedLevel}
hasChildren={hasChildren}
isFocused={focusedBlockId === block.id}
canEdit={canEdit}
levelOffset={levelOffset}
onZoomBlock={onZoomBlock}
onUpdateBlock={onUpdateBlock}
onCreateBlockBelow={handleCreateBlockBelow}
onPasteMultiLines={handlePasteMultiLines}
onDeleteBlock={handleDeleteBlock}
onMergeWithPrevious={handleMergeWithPrevious}
onIndentBlock={handleIndentBlock}
onNavigateFocus={handleNavigateFocus}
onFocusBlock={setFocusedBlockId}
onOpenTask={onOpenTask}
onTagClick={onTagClick}
onWikiLinkClick={onWikiLinkClick}
onDragStartBlock={handleDragStartBlock}
onDragOverBlock={handleDragOverBlock}
onDropBlock={handleDropBlock}
isDragTarget={dragOverIndex === index && draggedIndex !== index}
isTargeted={highlightedBlockId === block.id}
registerTextareaRef={registerTextareaRef}
/>
);
})}
{canEdit && (
<div
className={`min-h-[14rem] pt-2 pl-2 transition-colors cursor-text ${
dragOverIndex === blocks.length ? 'border-t-2 border-accent bg-accent/10 rounded' : ''
}`}
onClick={(e) => {
if (e.target !== e.currentTarget && (e.target as HTMLElement).closest('button, a, input, textarea, select')) {
return;
}
if (blocks.length === 0) {
const firstBlock: DocumentBlock = {
id: `blk_${Date.now().toString(36)}_0`,
documentId: docId,
type: 'text',
content: '',
level: levelOffset || 0,
position: 1000,
};
pendingFocusRef.current = { blockId: firstBlock.id, cursor: 'start' };
onCreateBlock(firstBlock);
} else {
const lastBlock = blocks[blocks.length - 1];
if (lastBlock.content && lastBlock.content.trim() !== '') {
handleCreateBlockBelow(lastBlock.id, '');
} else {
focusBlockInput(lastBlock.id, 'end');
}
}
}}
onDragOver={(e) => {
e.preventDefault();
if (draggedIndex !== null) {
setDragOverIndex(blocks.length);
}
}}
onDrop={() => handleDropBlock(blocks.length)}
/>
)}
</div>
);
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,64 @@
import React from 'react';
import { describe, it, expect, afterEach } from 'vitest';
import { render, fireEvent } from '@testing-library/react';
import { OutlinerBlock, mapRenderedOffsetToRaw } from '../OutlinerBlock';
import { DocumentBlock } from '../../../lib/types';
describe('mapRenderedOffsetToRaw', () => {
it('is the identity for plain text', () => {
expect(mapRenderedOffsetToRaw('Hallo Welt', 'Hallo Welt', 4)).toBe(4);
});
it('skips bold markers', () => {
expect(mapRenderedOffsetToRaw('Hallo **Welt** du', 'Hallo Welt du', 8)).toBe(10);
});
it('maps behind closing markers', () => {
expect(mapRenderedOffsetToRaw('a **b** c', 'a b c', 4)).toBe(8);
});
it('maps into a link label', () => {
expect(mapRenderedOffsetToRaw('x [Seite](https://a.de) y', 'x Seite y', 4)).toBe(5);
});
it('handles the empty placeholder', () => {
expect(mapRenderedOffsetToRaw('', '​', 1)).toBe(0);
});
});
describe('clicking into an unfocused bullet', () => {
afterEach(() => {
delete (document as any).caretRangeFromPoint;
});
it('places the caret at the click position instead of the end', () => {
const block = { id: 'b1', type: 'bullet', content: 'Hallo **Welt** du', level: 0, position: 1 } as DocumentBlock;
const { container } = render(
<OutlinerBlock
docId="d"
block={block}
index={0}
isLast
maxAllowedLevel={1}
hasChildren={false}
isFocused={false}
canEdit
onUpdateBlock={() => {}}
onCreateBlockBelow={() => {}}
onDeleteBlock={() => {}}
onIndentBlock={() => {}}
onNavigateFocus={() => {}}
registerTextareaRef={() => {}}
/>
);
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
const strong = container.querySelector('strong') as HTMLElement; // rendered "Welt"
// jsdom has no layout: pretend the click hit between "We" and "lt"
(document as any).caretRangeFromPoint = () => {
const r = document.createRange();
r.setStart(strong.firstChild!, 2);
return r;
};
fireEvent.click(strong);
expect(document.activeElement).toBe(ta);
expect(ta.selectionStart).toBe(10); // "Hallo **We|lt** du"
});
});
@@ -0,0 +1,79 @@
import React, { useState } from 'react';
import { describe, it, expect } from 'vitest';
import { render, fireEvent, screen, act } from '@testing-library/react';
import { OutlinerTree } from '../OutlinerTree';
import { DocumentBlock } from '../../../lib/types';
// Holds the latest block state rendered by the harness
let live: DocumentBlock[] = [];
const Harness: React.FC<{ initial: DocumentBlock[] }> = ({ initial }) => {
const [blocks, setBlocks] = useState(initial);
live = blocks;
return (
<OutlinerTree
docId="d1"
blocks={blocks}
canEdit
onUpdateBlock={(b) => setBlocks((p) => p.map((x) => (x.id === b.id ? b : x)))}
onUpdateBlocks={(u) => setBlocks((p) => p.map((x) => u.find((y) => y.id === x.id) || x))}
onCreateBlock={() => {}}
onDeleteBlock={(id) => setBlocks((p) => p.filter((x) => x.id !== id))}
onMoveBlock={() => {}}
/>
);
};
const mk = (id: string, content: string, level = 0, type: any = 'bullet'): DocumentBlock =>
({ id, type, content, level, position: 1 }) as DocumentBlock;
const area = (text: string) => screen.getAllByDisplayValue(text)[0] as HTMLTextAreaElement;
const backspaceAt = async (ta: HTMLTextAreaElement, pos = 0) => {
ta.focus();
ta.setSelectionRange(pos, pos);
await act(async () => {
fireEvent.keyDown(ta, { key: 'Backspace' });
});
};
describe('Backspace at the start of a bullet', () => {
it('appends its text to the previous bullet', async () => {
render(<Harness initial={[mk('a', 'Hallo '), mk('b', 'Welt')]} />);
await backspaceAt(area('Welt'));
expect(live.map((b) => b.content)).toEqual(['Hallo Welt']);
});
it('works for legacy blocks without a type', async () => {
render(<Harness initial={[mk('a', 'A', 0, undefined), mk('b', 'B', 0, '')]} />);
await backspaceAt(area('B'));
expect(live.map((b) => b.content)).toEqual(['AB']);
});
it('moves children up under the previous bullet', async () => {
render(<Harness initial={[mk('a', 'A'), mk('b', 'B'), mk('c', 'C', 1)]} />);
await backspaceAt(area('B'));
expect(live.map((b) => [b.content, b.level])).toEqual([
['AB', 0],
['C', 1],
]);
});
it('does nothing when the caret is not at the start', async () => {
render(<Harness initial={[mk('a', 'A'), mk('b', 'BB')]} />);
await backspaceAt(area('BB'), 1);
expect(live).toHaveLength(2);
});
it('does not merge the first bullet', async () => {
render(<Harness initial={[mk('a', 'A'), mk('b', 'B')]} />);
await backspaceAt(area('A'));
expect(live).toHaveLength(2);
});
it('does not merge into a code block', async () => {
render(<Harness initial={[mk('a', 'x = 1', 0, 'code'), mk('b', 'B')]} />);
await backspaceAt(area('B'));
expect(live).toHaveLength(2);
});
});
+400 -39
View File
@@ -1,27 +1,23 @@
/* ==========================================================================
MINDSIFT THEME ENGINE — ZENTRALE EINBINDUNG
Jedes Theme ist eine eigenständige CSS-Datei unter src/themes/ mit 6 Gruppen:
1. Akzentfarbe
2. Oberflächen & Hintergründe
3. Text & Kontraste
4. Rahmen, Linien & Schatten
5. Status- & Signalfarben
6. Rundungen (Geometrie)
========================================================================== */
@import './themes/theme-dark.css';
@import './themes/theme-light.css';
@import './themes/theme-high-contrast.css';
@import './themes/theme-soft.css';
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
:root {
/* BRAIN.md Strict Gemini Dark Theme Tokens */
--bg-app: #0b0f19;
--bg-surface: #131314;
--bg-card: #1e1f20;
--bg-card-hover: #282a2c;
--border-subtle: rgba(255, 255, 255, 0.08);
--border-focus: rgba(138, 180, 248, 0.40);
--accent-blue: #8ab4f8;
--accent-purple: #c58af9;
--swipe-keep: #81c995;
--swipe-discard: #f28b82;
--text-primary: #e3e3e3;
--text-muted: #9aa0a6;
--text-subtle: #5f6368;
}
* { * {
box-sizing: border-box; box-sizing: border-box;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
@@ -29,15 +25,16 @@
body { body {
background-color: var(--bg-app); background-color: var(--bg-app);
color: var(--text-primary); color: var(--text-main);
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, sans-serif; font-family: '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;
} }
/* Subtle custom dark scrollbar */ /* Scrollbar über Theme-Variablen */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 6px; width: 5px;
height: 6px; height: 5px;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
@@ -45,25 +42,389 @@ body {
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.12); background: var(--scrollbar-thumb);
border-radius: 9999px; border-radius: var(--radius-btn);
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: rgba(138, 180, 248, 0.35); background: var(--scrollbar-thumb-hover);
} }
.gemini-border { /* ==========================================================================
border: 1px solid var(--border-subtle); SEMANTISCHE KOMPONENTEN-KLASSEN (100% Theme-gesteuert)
} ========================================================================== */
.gemini-input-glow:focus-within { @layer components {
border-color: rgba(138, 180, 248, 0.55); /* Haupt-Karten, Sidebars und Container */
box-shadow: 0 0 32px -4px rgba(138, 180, 248, 0.25), 0 0 12px -2px rgba(197, 138, 249, 0.2); .panel-surface {
} background-color: var(--bg-panel);
border: 1px solid var(--border-color);
border-radius: var(--radius-card);
}
.gemini-gradient-text { .panel-elevated {
background: linear-gradient(135deg, #8ab4f8 0%, #c58af9 55%, #81c995 100%); background-color: var(--bg-panel-elevated);
-webkit-background-clip: text; border: 1px solid var(--border-color);
-webkit-text-fill-color: transparent; border-radius: var(--radius-card);
}
.panel-header {
border-bottom: 1px solid var(--border-color);
}
/* Automatische Übernahme von --radius-card auf gerahmten Panels & Boxen */
.bg-panel.border,
.bg-canvas.border {
border-radius: var(--radius-card);
}
/* Quick-Capture-Blase im Header (immer vollständig abgerundet) */
.capture-pill,
.capture-pill.bg-panel.border {
border-radius: 9999px !important;
}
/* Quick-Capture-Blase bei mehrzeiliger Eingabe (Shift+Enter) */
.capture-bar-multiline,
.capture-bar-multiline.bg-panel.border {
border-radius: 18px !important;
}
/* Aufgaben-Checkboxen */
.checkbox-square {
border-radius: var(--radius-checkbox);
}
/* Standard-Aktionsbutton */
.btn-action {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
height: 1.75rem;
padding-left: 0.625rem;
padding-right: 0.625rem;
background-color: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: var(--radius-btn);
color: var(--text-muted);
font-size: 0.75rem;
font-weight: 500;
transition: all 120ms ease;
cursor: pointer;
}
.btn-action:hover {
background-color: var(--bg-panel-elevated);
color: var(--text-main);
}
/* Primärer Akzent-Button */
.btn-primary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
height: 1.75rem;
padding-left: 0.75rem;
padding-right: 0.75rem;
background-color: var(--accent-primary);
border: 1px solid var(--accent-border);
border-radius: var(--radius-btn);
color: var(--text-on-accent);
font-size: 0.75rem;
font-weight: 500;
transition: all 120ms ease;
cursor: pointer;
}
.btn-primary:hover {
background-color: var(--accent-hover);
}
/* Kleiner Icon-Button */
.btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
background-color: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: var(--radius-btn);
color: var(--text-muted);
transition: all 120ms ease;
cursor: pointer;
}
.btn-icon:hover {
background-color: var(--bg-panel-elevated);
color: var(--text-main);
}
.btn-icon-danger {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
background-color: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: var(--radius-btn);
color: var(--text-muted);
transition: all 120ms ease;
cursor: pointer;
}
.btn-icon-danger:hover {
background-color: var(--bg-danger);
color: var(--color-danger);
border-color: var(--border-danger);
}
/* Sekundärer Button */
.btn-secondary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
height: 1.75rem;
padding-left: 0.75rem;
padding-right: 0.75rem;
background-color: var(--bg-panel-elevated);
border: 1px solid var(--border-color);
border-radius: var(--radius-btn);
color: var(--text-main);
font-size: 0.75rem;
font-weight: 500;
transition: all 120ms ease;
cursor: pointer;
}
.btn-secondary:hover {
background-color: var(--bg-panel-hover);
border-color: var(--border-strong);
}
/* Gefahren-/Löschen-Button */
.btn-danger {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
height: 1.75rem;
padding-left: 0.75rem;
padding-right: 0.75rem;
background-color: var(--bg-danger);
border: 1px solid var(--border-danger);
border-radius: var(--radius-btn);
color: var(--color-danger);
font-size: 0.75rem;
font-weight: 500;
transition: all 120ms ease;
cursor: pointer;
}
.btn-danger:hover {
filter: brightness(1.15);
}
/* Zentrales Texteingabefeld & Aliasse */
.input-field,
.input-base {
background-color: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: var(--radius-input);
color: var(--text-main);
font-size: 0.75rem;
transition: border-color 120ms ease;
}
.input-field:focus,
.input-base:focus {
outline: none;
border-color: var(--accent-primary);
}
.input-field::placeholder,
.input-base::placeholder {
color: var(--text-faint);
}
/* Header Tabs */
.tab-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.875rem;
font-size: 0.75rem;
font-weight: 500;
color: var(--text-muted);
border-bottom: 2px solid transparent;
transition: all 120ms ease;
cursor: pointer;
}
.tab-btn:hover {
color: var(--text-main);
background-color: var(--bg-panel-elevated);
}
.tab-btn-active {
color: var(--accent-primary);
border-bottom-color: var(--accent-primary);
background-color: var(--bg-panel-elevated);
}
/* Status Badges */
.badge-accent {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.125rem 0.5rem;
background-color: var(--accent-dim);
border: 1px solid var(--accent-border);
border-radius: var(--radius-btn);
color: var(--accent-primary);
font-size: 0.75rem;
font-weight: 500;
}
.badge-neutral {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.125rem 0.375rem;
background-color: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: var(--radius-btn);
color: var(--text-muted);
font-size: 0.65rem;
font-weight: 500;
}
.badge-ok {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.125rem 0.375rem;
background-color: var(--bg-success);
border: 1px solid var(--border-success);
border-radius: var(--radius-btn);
color: var(--color-success);
font-size: 0.65rem;
font-weight: 500;
}
.badge-err {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.125rem 0.375rem;
background-color: var(--bg-danger);
border: 1px solid var(--border-danger);
border-radius: var(--radius-btn);
color: var(--color-danger);
font-size: 0.65rem;
font-weight: 500;
}
/* Status Alert Boxen */
.alert-ok {
padding: 0.75rem;
background-color: var(--bg-success);
border: 1px solid var(--border-success);
border-radius: var(--radius-card);
color: var(--text-main);
font-size: 0.75rem;
}
.alert-err {
padding: 0.75rem;
background-color: var(--bg-danger);
border: 1px solid var(--border-danger);
border-radius: var(--radius-card);
color: var(--color-danger);
font-size: 0.75rem;
}
/* Code- & Terminal-Blöcke */
.code-block {
background-color: var(--bg-code);
border: 1px solid var(--border-color);
border-radius: var(--radius-card);
color: var(--text-main);
}
/* Modal-Hintergrund-Overlay */
.modal-overlay {
margin: 0 !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
background-color: var(--bg-overlay);
backdrop-filter: blur(4px);
}
/* Verhindert, dass Elterncontainer mit space-y-* oder space-x-* feste Overlays nach unten verschieben */
.fixed.inset-0,
[class*="fixed inset-0"] {
margin: 0 !important;
}
/* File Tree Item */
.tree-node-active {
background-color: var(--bg-panel-elevated);
border-left: 2px solid var(--accent-primary);
color: var(--text-main);
}
/* Prism Syntax Highlighting Tokens */
.token.comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: #71717a;
font-style: italic;
}
.token.punctuation {
color: #94a3b8;
}
.token.property,
.token.tag,
.token.boolean,
.token.number,
.token.constant,
.token.symbol,
.token.deleted {
color: #f43f5e;
}
.token.selector,
.token.attr-name,
.token.string,
.token.char,
.token.builtin,
.token.inserted {
color: #10b981;
}
.token.operator,
.token.entity,
.token.url,
.language-css .token.string,
.style .token.string {
color: #38bdf8;
}
.token.atrule,
.token.attr-value,
.token.keyword {
color: #818cf8;
font-weight: 600;
}
.token.function,
.token.class-name {
color: #fbbf24;
}
.token.regex,
.token.important,
.token.variable {
color: #f97316;
}
} }
+1163 -34
View File
File diff suppressed because it is too large. Load diff
+260
View File
@@ -0,0 +1,260 @@
import React from 'react';
/**
* Parses inline markdown:
* - **bold** or __bold__
* - *italic* or _italic_
* - ~~strikethrough~~
* - `code`
* - [label](url)
* - #hashtag (only when `options.onTagClick` is given), rendered inline as a clickable chip
*/
export interface InlineMarkdownOptions {
onTagClick?: (tag: string) => void;
}
function renderInlineTokens(text: string, options?: InlineMarkdownOptions): React.ReactNode[] {
if (!text) return [];
const tagAlt = options?.onTagClick ? '|#[a-zA-Z0-9äöüÄÖÜß_-]+' : '';
const regex = new RegExp(
'(`[^`]+`|\\*\\*[^*]+\\*\\*|__[^_]+__|~~[^~]+~~|\\*[^*]+\\*|_[^_]+_|\\[([^\\]]+)\\]\\(([^)]+)\\)' + tagAlt + ')',
'g'
);
const elements: React.ReactNode[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
if (match.index > lastIndex) {
elements.push(text.slice(lastIndex, match.index));
}
const token = match[0];
const key = `${match.index}-${token}`;
if (token.startsWith('#')) {
const prevChar = match.index > 0 ? text[match.index - 1] : '';
if (prevChar && !/[\s(]/.test(prevChar)) {
// e.g. an URL fragment like https://x/#anchor: keep as plain text
elements.push(token);
} else {
const tag = token.slice(1);
elements.push(
<span
key={key}
role="button"
tabIndex={-1}
onClick={(e) => {
e.stopPropagation();
options?.onTagClick?.(tag);
}}
className="text-accent bg-accent-dim hover:bg-accent/20 rounded-btn px-0.5 cursor-pointer transition-colors"
title={`Nach #${tag} filtern`}
>
{token}
</span>
);
}
} else if (token.startsWith('`') && token.endsWith('`')) {
elements.push(
<code key={key} className="px-1.5 py-0.5 rounded bg-panel-elevated text-accent font-mono text-[0.9em] border border-border/50">
{token.slice(1, -1)}
</code>
);
} else if (
(token.startsWith('**') && token.endsWith('**')) ||
(token.startsWith('__') && token.endsWith('__'))
) {
elements.push(
<strong key={key} className="font-semibold text-main">
{token.slice(2, -2)}
</strong>
);
} else if (
(token.startsWith('*') && token.endsWith('*')) ||
(token.startsWith('_') && token.endsWith('_'))
) {
elements.push(
<em key={key} className="italic text-main/90">
{token.slice(1, -1)}
</em>
);
} else if (token.startsWith('~~') && token.endsWith('~~')) {
elements.push(
<del key={key} className="line-through text-muted/80">
{token.slice(2, -2)}
</del>
);
} else if (token.startsWith('[') && match[2] && match[3]) {
const label = match[2];
const url = match[3];
elements.push(
<a
key={key}
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-accent underline hover:opacity-80 transition-opacity"
onClick={(e) => e.stopPropagation()}
>
{label}
</a>
);
} else {
elements.push(token);
}
lastIndex = regex.lastIndex;
}
if (lastIndex < text.length) {
elements.push(text.slice(lastIndex));
}
return elements;
}
/**
* Parses inline markdown and fenced code blocks:
* - ```lang\ncode\n```
* - **bold** or __bold__
* - *italic* or _italic_
* - ~~strikethrough~~
* - `code`
* - [label](url)
*/
export function renderInlineMarkdown(text: string, options?: InlineMarkdownOptions): React.ReactNode[] {
if (!text) return [];
if (text.includes('```')) {
const codeBlockRegex = /(```[a-zA-Z0-9_-]*\r?\n[\s\S]*?```|```[\s\S]*?```)/g;
const parts = text.split(codeBlockRegex);
const nodes: React.ReactNode[] = [];
parts.forEach((part, partIdx) => {
if (part.startsWith('```') && part.endsWith('```')) {
const lines = part.split(/\r?\n/);
const firstLine = lines[0].slice(3).trim();
const codeLines = lines.slice(1, lines.length - 1);
const code = codeLines.join('\n');
nodes.push(
<div
key={`fenced-${partIdx}`}
className="p-2.5 my-2 rounded bg-code text-accent font-mono text-xs overflow-x-auto border border-border/60 select-text"
>
{firstLine && (
<div className="text-[10px] text-faint uppercase font-sans pb-1 select-none">
{firstLine}
</div>
)}
<pre className="whitespace-pre overflow-x-auto font-mono text-status-ok leading-relaxed">
<code>{code}</code>
</pre>
</div>
);
} else if (part) {
nodes.push(...renderInlineTokens(part, options));
}
});
return nodes;
}
return renderInlineTokens(text, options);
}
export function MarkdownViewer({ content, className = '' }: { content: string; className?: string }) {
if (!content) return null;
const lines = content.split('\n');
const renderedNodes: React.ReactNode[] = [];
let inCodeBlock = false;
let codeBlockLines: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.trim().startsWith('```')) {
if (inCodeBlock) {
renderedNodes.push(
<pre key={`code-${i}`} className="p-2.5 my-2 rounded bg-panel-elevated text-accent font-mono text-xs overflow-x-auto border border-border/60">
<code>{codeBlockLines.join('\n')}</code>
</pre>
);
codeBlockLines = [];
inCodeBlock = false;
} else {
inCodeBlock = true;
codeBlockLines = [];
}
continue;
}
if (inCodeBlock) {
codeBlockLines.push(line);
continue;
}
const trimmed = line.trim();
if (trimmed.startsWith('### ')) {
renderedNodes.push(
<h3 key={i} className="text-sm font-bold text-main mt-2 mb-1">
{renderInlineMarkdown(trimmed.slice(4))}
</h3>
);
} else if (trimmed.startsWith('## ')) {
renderedNodes.push(
<h2 key={i} className="text-base font-bold text-main mt-3 mb-1">
{renderInlineMarkdown(trimmed.slice(3))}
</h2>
);
} else if (trimmed.startsWith('# ')) {
renderedNodes.push(
<h1 key={i} className="text-lg font-bold text-main mt-3 mb-1.5">
{renderInlineMarkdown(trimmed.slice(2))}
</h1>
);
} else if (trimmed.startsWith('> ')) {
renderedNodes.push(
<blockquote key={i} className="border-l-2 border-accent pl-2.5 my-1 text-muted italic">
{renderInlineMarkdown(trimmed.slice(2))}
</blockquote>
);
} else if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) {
renderedNodes.push(
<div key={i} className="flex items-start gap-1.5 my-0.5">
<span className="text-accent mt-0.5">•</span>
<span className="flex-1 text-main">{renderInlineMarkdown(trimmed.slice(2))}</span>
</div>
);
} else if (/^\d+\.\s/.test(trimmed)) {
const matchNum = trimmed.match(/^(\d+)\.\s(.*)$/);
renderedNodes.push(
<div key={i} className="flex items-start gap-1.5 my-0.5">
<span className="text-muted text-[11px] font-mono mt-0.5">{matchNum ? matchNum[1] + '.' : '•'}</span>
<span className="flex-1 text-main">{renderInlineMarkdown(matchNum ? matchNum[2] : trimmed)}</span>
</div>
);
} else if (trimmed === '') {
renderedNodes.push(<div key={i} className="h-2" />);
} else {
renderedNodes.push(
<div key={i} className="my-0.5 text-main leading-relaxed">
{renderInlineMarkdown(line)}
</div>
);
}
}
if (inCodeBlock && codeBlockLines.length > 0) {
renderedNodes.push(
<pre key="code-end" className="p-2.5 my-2 rounded bg-panel-elevated text-accent font-mono text-xs overflow-x-auto border border-border/60">
<code>{codeBlockLines.join('\n')}</code>
</pre>
);
}
return <div className={`markdown-body text-xs ${className}`}>{renderedNodes}</div>;
}
+103
View File
@@ -0,0 +1,103 @@
export type ThemeId = 'dark' | 'light' | 'high-contrast' | 'soft';
export interface ThemeOption {
id: ThemeId;
name: string;
subtitle: string;
file: string;
previewBg: string;
previewPanel: string;
previewAccent: string;
previewText: string;
isRounded: boolean;
}
export const THEME_STORAGE_KEY = 'mindsift_active_theme';
export const AVAILABLE_THEMES: ThemeOption[] = [
{
id: 'dark',
name: 'MindSift Dark (Standard)',
subtitle: 'Monochrom, dunkel & eckig (0px)',
file: 'src/themes/theme-dark.css',
previewBg: '#121316',
previewPanel: '#1a1c20',
previewAccent: '#2563eb',
previewText: '#f4f4f5',
isRounded: false,
},
{
id: 'light',
name: 'MindSift Light (Tagmodus)',
subtitle: 'Helles Papier-Design & klare Kontraste',
file: 'src/themes/theme-light.css',
previewBg: '#f3f4f6',
previewPanel: '#ffffff',
previewAccent: '#2563eb',
previewText: '#111827',
isRounded: false,
},
{
id: 'high-contrast',
name: 'High Contrast (Scharf)',
subtitle: 'Tiefschwarz (#000) & weiße Konturen',
file: 'src/themes/theme-high-contrast.css',
previewBg: '#000000',
previewPanel: '#09090b',
previewAccent: '#38bdf8',
previewText: '#ffffff',
isRounded: false,
},
{
id: 'soft',
name: 'Soft Slate (Abgerundet)',
subtitle: 'Schiefer-Blau mit weichen Radien (10px)',
file: 'src/themes/theme-soft.css',
previewBg: '#0f172a',
previewPanel: '#1e293b',
previewAccent: '#3b82f6',
previewText: '#f8fafc',
isRounded: true,
},
];
export function getActiveTheme(): ThemeId {
try {
const saved = localStorage.getItem(THEME_STORAGE_KEY) as ThemeId | null;
if (saved && AVAILABLE_THEMES.some((t) => t.id === saved)) {
return saved;
}
} catch {
// ignore
}
return 'dark';
}
export function setActiveTheme(themeId: ThemeId): void {
try {
localStorage.setItem(THEME_STORAGE_KEY, themeId);
} catch {
// ignore
}
if (typeof document !== 'undefined') {
document.documentElement.setAttribute('data-theme', themeId);
if (themeId !== 'light') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}
}
export function initThemeOnBoot(): ThemeId {
const active = getActiveTheme();
if (typeof document !== 'undefined') {
document.documentElement.setAttribute('data-theme', active);
if (active !== 'light') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}
return active;
}
+291 -5
View File
@@ -1,13 +1,49 @@
export type ThoughtStatus = 'inbox' | 'kept' | 'discarded' | 'converted'; export type ThoughtStatus = 'inbox' | 'kept' | 'discarded' | 'converted';
export type PriorityLevel = 'low' | 'normal' | 'high' | 'urgent'; export type PriorityLevel = 'low' | 'normal' | 'high' | 'urgent';
export type UserRole = 'admin' | 'member';
export interface AuthStatus {
needsSetup: boolean;
allowRegistration: boolean;
instanceName: string;
demoMode?: boolean;
}
export interface User { export interface User {
id: string; id: string;
email: string; email: string;
username?: string;
displayName: string; displayName: string;
avatarUrl?: string;
role: UserRole;
isActive: boolean;
alarmChannels?: string[];
alarmEmail?: string;
discordWebhookUrl?: string;
customWebhookUrl?: string;
notifyBeforeMinutes?: number;
createdAt: string; createdAt: string;
} }
export interface UserInvite {
id: string;
code: string;
email?: string;
role: UserRole;
teamId?: string;
createdBy: string;
usedBy?: string;
createdAt: string;
expiresAt: string;
}
export interface SubTask {
id: string;
title: string;
isCompleted: boolean;
createdAt?: string;
}
export interface Thought { export interface Thought {
id: string; id: string;
userId: string; userId: string;
@@ -17,21 +53,156 @@ export interface Thought {
source: 'manual' | 'voice' | 'webhook'; source: 'manual' | 'voice' | 'webhook';
status: ThoughtStatus; status: ThoughtStatus;
isCompleted: boolean; isCompleted: boolean;
convertedType?: 'task' | 'document' | 'list_item'; convertedType?: 'task' | 'document' | 'list_item' | 'document_block';
convertedRefId?: string; convertedRefId?: string;
docId?: string;
blockId?: string;
dueDate?: string; dueDate?: string;
taskListId?: string;
kanbanStatus?: string;
notes?: string;
subtasks?: SubTask[];
alarmSent?: boolean;
sortOrder?: number;
createdAt: string; createdAt: string;
triagedAt?: string; triagedAt?: string;
} }
export type BlockType = 'h1' | 'h2' | 'h3' | 'paragraph' | 'todo' | 'bullet' | 'code' | 'callout' | 'divider'; export interface KanbanColumn {
id: string;
name: string;
color?: string;
isDone?: boolean;
}
export interface TaskListShare {
id: string;
taskListId: string;
targetType: 'team' | 'user';
targetId: string;
targetName?: string;
email?: string;
avatarUrl?: string;
permission?: 'editor' | 'viewer';
createdAt: string;
}
export interface TaskListMember {
userId: string;
displayName: string;
email: string;
avatarUrl?: string;
role: string;
}
export interface TaskList {
id: string;
userId: string;
spaceId?: string;
spaceName?: string;
teamId?: string;
teamName?: string;
ownerId?: string;
ownerName?: string;
isOwner?: boolean;
permission?: 'owner' | 'editor' | 'viewer';
members?: TaskListMember[];
shares?: TaskListShare[];
columns?: KanbanColumn[];
title: string;
icon: string;
color: string;
tasksCount?: number;
createdAt: string;
updatedAt: string;
}
export interface Organization {
id: string;
name: string;
ownerId: string;
teams?: Team[];
createdAt: string;
}
export interface Team {
id: string;
orgId?: string;
name: string;
description: string;
members?: TeamMember[];
createdAt: string;
}
export interface TeamMember {
teamId: string;
userId: string;
displayName: string;
email: string;
avatarUrl?: string;
role: 'admin' | 'member';
joinedAt: string;
}
export interface SystemSettings {
sharingMode: 'open' | 'teams_only' | 'strict_private';
allowRegistration: boolean;
instanceName: string;
smtpHost?: string;
smtpPort?: number;
smtpUser?: string;
smtpPass?: string;
smtpFrom?: string;
smtpTls?: boolean;
smtpConfiguredViaEnv?: boolean;
}
export type BlockType = 'h1' | 'h2' | 'h3' | 'paragraph' | 'text' | 'todo' | 'bullet' | 'code' | 'callout' | 'divider' | 'table' | 'progress';
export interface DocumentBlock { export interface DocumentBlock {
id: string; id: string;
docId?: string;
documentId?: string;
taskId?: string;
type: BlockType; type: BlockType;
content: string; content: string;
checked?: boolean; checked?: boolean;
lang?: string; lang?: string;
level?: number;
position?: number;
collapsed?: boolean;
sortOrder?: number;
updatedAt?: string;
}
export interface DocBlocksPatchRequest {
upserted?: DocumentBlock[];
deletedIds?: string[];
blockOrder?: string[];
}
export interface DocBlocksPatchEvent {
docId: string;
updatedBy: string;
updatedAt: string;
upserted?: DocumentBlock[];
deletedIds?: string[];
blockOrder?: string[];
}
export interface DocShare {
id: string;
docId: string;
targetType?: 'user' | 'team';
targetId?: string;
targetName?: string;
teamId?: string;
userId?: string;
userEmail?: string;
displayName?: string;
permission: 'read' | 'write';
createdBy: string;
createdAt: string;
} }
export interface Document { export interface Document {
@@ -40,13 +211,82 @@ export interface Document {
spaceId?: string; spaceId?: string;
title: string; title: string;
icon: string; icon: string;
folderId?: string;
markdownCache?: string;
content: string; content: string;
blocks: DocumentBlock[]; blocks: DocumentBlock[];
tags: string[]; tags: string[];
folderPath?: string;
isPinned: boolean; isPinned: boolean;
isArchived: boolean; isArchived: boolean;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
// Collaboration & Permissions
permission?: 'owner' | 'write' | 'read';
isOwner?: boolean;
ownerName?: string;
sharedWith?: DocShare[];
sharedFolderId?: string;
subFolderPath?: string;
isSharedFolder?: boolean;
folderPermission?: string;
}
export interface SharedFolder {
id: string;
ownerId: string;
ownerName?: string;
name: string;
mountPath: string;
isOwner: boolean;
permission: 'owner' | 'write' | 'read';
shares?: FolderShare[];
createdAt: string;
updatedAt: string;
}
export interface FolderShare {
id: string;
folderId: string;
targetType: 'user' | 'team';
targetId: string;
targetName?: string;
email?: string;
avatarUrl?: string;
permission: 'read' | 'write';
createdBy: string;
createdAt: string;
}
export interface TagSummary {
tag: string;
count: number;
}
export interface MatchedTagBlock {
rootBlock: DocumentBlock;
children: DocumentBlock[];
}
export interface TagDocumentGroup {
docId: string;
title: string;
icon: string;
folderPath: string;
updatedAt: string;
isOwner: boolean;
permission: 'owner' | 'write' | 'read';
ownerName?: string;
matchedBlocks: MatchedTagBlock[];
}
export interface TagDetails {
tag: string;
noteContent?: string;
noteUpdatedAt?: string;
documents: TagDocumentGroup[];
totalMatches: number;
} }
export interface SpaceMember { export interface SpaceMember {
@@ -54,10 +294,35 @@ export interface SpaceMember {
userId: string; userId: string;
displayName: string; displayName: string;
email: string; email: string;
avatarUrl?: string;
role: 'owner' | 'editor' | 'viewer'; role: 'owner' | 'editor' | 'viewer';
joinedAt: string; joinedAt: string;
} }
export interface UnifiedShare {
id: string;
targetType: 'team' | 'user';
targetId: string;
targetName?: string;
targetEmail?: string;
avatarUrl?: string;
permission?: 'editor' | 'viewer';
role?: string;
createdBy?: string;
createdAt: string;
}
export interface SpaceShare {
id: string;
spaceId: string;
targetType: 'team' | 'user';
targetId: string;
targetName?: string;
role: 'editor' | 'viewer';
createdBy?: string;
createdAt: string;
}
export interface Space { export interface Space {
id: string; id: string;
name: string; name: string;
@@ -66,7 +331,9 @@ export interface Space {
inviteCode: string; inviteCode: string;
isPersonal: boolean; isPersonal: boolean;
ownerId: string; ownerId: string;
ownerName?: string;
members?: SpaceMember[]; members?: SpaceMember[];
shares?: SpaceShare[];
listsCount: number; listsCount: number;
createdAt: string; createdAt: string;
} }
@@ -85,13 +352,30 @@ export interface ListItem {
completedAt?: string; completedAt?: string;
} }
export interface ListShare {
id: string;
listId: string;
targetType: 'team' | 'user';
targetId: string;
targetName?: string;
targetEmail?: string;
role: 'editor' | 'viewer';
createdBy?: string;
createdAt: string;
}
export interface SharedList { export interface SharedList {
id: string; id: string;
spaceId: string; userId?: string;
ownerName?: string;
isOwner?: boolean;
role?: string;
spaceId?: string;
title: string; title: string;
icon: string; icon: string;
listType: 'shopping' | 'checklist' | 'vacation'; listType: 'shopping' | 'checklist' | 'vacation';
items: ListItem[]; items: ListItem[];
shares?: ListShare[];
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
} }
@@ -112,14 +396,16 @@ export interface DashboardStats {
tasksOpenCount: number; tasksOpenCount: number;
tasksDoneCount: number; tasksDoneCount: number;
docsCount: number; docsCount: number;
docsTodosOpenCount?: number;
listsCount: number; listsCount: number;
discardedCount: number; discardedCount: number;
} }
export interface WSMessage<T = any> { export interface WSMessage<T = any> {
event: 'ITEM_CREATED' | 'ITEM_UPDATED' | 'ITEM_TOGGLED' | 'ITEM_DELETED' | 'LIST_CLEARED' | 'LIST_CREATED' | 'LIST_DELETED' | 'USER_PRESENCE'; event: string;
space_id: string; space_id?: string;
list_id?: string; list_id?: string;
listId?: string;
payload: T; payload: T;
timestamp: string; timestamp: string;
} }
+4
View File
@@ -1,10 +1,14 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import App from './App'; import App from './App';
import { MobileActionProvider } from './context/MobileActionContext';
import './index.css'; import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<MobileActionProvider>
<App /> <App />
</MobileActionProvider>
</React.StrictMode> </React.StrictMode>
); );
+9
View File
@@ -0,0 +1,9 @@
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
// jsdom has no layout engine, so scrolling is a no-op here
Element.prototype.scrollIntoView = () => {};
afterEach(() => {
cleanup();
});
+56
View File
@@ -0,0 +1,56 @@
/* ==========================================================================
MINDSIFT THEME: DARK MONOCHROME (Standard)
Alle Farben, Kontraste und Rundungen zentral in einer einzigen Datei.
========================================================================== */
:root,
:root[data-theme="dark"] {
/* --- 1. AKZENTFARBE --- */
--accent-primary: #2563eb;
--accent-hover: #3b82f6;
--accent-dim: rgba(37, 99, 235, 0.14);
--accent-border: rgba(59, 130, 246, 0.45);
--text-on-accent: #ffffff;
/* --- 2. OBERFLÄCHEN & HINTERGRÜNDE --- */
--bg-app: #121316;
--bg-panel: #1a1c20;
--bg-panel-elevated: #22252a;
--bg-panel-hover: #2a2e35;
--bg-input: #121316;
--bg-overlay: rgba(0, 0, 0, 0.72);
--bg-code: #0d0e11;
/* --- 3. TEXT & KONTRASTE --- */
--text-main: #f4f4f5;
--text-muted: #a1a1aa;
--text-faint: #71717a;
/* --- 4. RAHMEN, LINIEN & SCHATTEN --- */
--border-color: rgba(255, 255, 255, 0.09);
--border-subtle: rgba(255, 255, 255, 0.05);
--border-strong: rgba(255, 255, 255, 0.22);
--shadow-panel: 0 10px 30px rgba(0, 0, 0, 0.45);
--scrollbar-thumb: rgba(255, 255, 255, 0.14);
--scrollbar-thumb-hover: rgba(255, 255, 255, 0.26);
/* --- 5. STATUS- & SIGNALFARBEN --- */
--color-success: #22c55e;
--bg-success: rgba(34, 197, 94, 0.12);
--border-success: rgba(34, 197, 94, 0.38);
--color-danger: #ef4444;
--bg-danger: rgba(239, 68, 68, 0.12);
--border-danger: rgba(239, 68, 68, 0.38);
--color-warning: #f59e0b;
--bg-warning: rgba(245, 158, 11, 0.12);
--border-warning: rgba(245, 158, 11, 0.38);
/* --- 6. RUNDUNGEN (Geometrie) --- */
--radius-card: 0px;
--radius-btn: 2px;
--radius-input: 2px;
--radius-checkbox: 0px;
--radius-pill: 9999px;
}
@@ -0,0 +1,55 @@
/* ==========================================================================
MINDSIFT THEME: HIGH CONTRAST (Maximaler Kontrast & Schärfe)
Alle Farben, Kontraste und Rundungen zentral in einer einzigen Datei.
========================================================================== */
:root[data-theme="high-contrast"] {
/* --- 1. AKZENTFARBE --- */
--accent-primary: #38bdf8;
--accent-hover: #7dd3fc;
--accent-dim: rgba(56, 189, 248, 0.22);
--accent-border: #38bdf8;
--text-on-accent: #000000;
/* --- 2. OBERFLÄCHEN & HINTERGRÜNDE --- */
--bg-app: #000000;
--bg-panel: #09090b;
--bg-panel-elevated: #18181b;
--bg-panel-hover: #27272a;
--bg-input: #000000;
--bg-overlay: rgba(0, 0, 0, 0.88);
--bg-code: #000000;
/* --- 3. TEXT & KONTRASTE --- */
--text-main: #ffffff;
--text-muted: #e4e4e7;
--text-faint: #a1a1aa;
/* --- 4. RAHMEN, LINIEN & SCHATTEN --- */
--border-color: rgba(255, 255, 255, 0.38);
--border-subtle: rgba(255, 255, 255, 0.20);
--border-strong: #ffffff;
--shadow-panel: 0 0 0 1px #ffffff;
--scrollbar-thumb: rgba(255, 255, 255, 0.45);
--scrollbar-thumb-hover: #ffffff;
/* --- 5. STATUS- & SIGNALFARBEN --- */
--color-success: #4ade80;
--bg-success: rgba(74, 222, 128, 0.20);
--border-success: #4ade80;
--color-danger: #f87171;
--bg-danger: rgba(248, 113, 113, 0.20);
--border-danger: #f87171;
--color-warning: #fbbf24;
--bg-warning: rgba(251, 191, 36, 0.20);
--border-warning: #fbbf24;
/* --- 6. RUNDUNGEN (Geometrie: Komplett scharfkantig) --- */
--radius-card: 0px;
--radius-btn: 0px;
--radius-input: 0px;
--radius-checkbox: 0px;
--radius-pill: 0px;
}
+55
View File
@@ -0,0 +1,55 @@
/* ==========================================================================
MINDSIFT THEME: LIGHT (Tagmodus / Hell & Klar)
Alle Farben, Kontraste und Rundungen zentral in einer einzigen Datei.
========================================================================== */
:root[data-theme="light"] {
/* --- 1. AKZENTFARBE --- */
--accent-primary: #2563eb;
--accent-hover: #1d4ed8;
--accent-dim: rgba(37, 99, 235, 0.10);
--accent-border: rgba(37, 99, 235, 0.38);
--text-on-accent: #ffffff;
/* --- 2. OBERFLÄCHEN & HINTERGRÜNDE --- */
--bg-app: #f3f4f6;
--bg-panel: #ffffff;
--bg-panel-elevated: #ebedf0;
--bg-panel-hover: #e2e5ea;
--bg-input: #ffffff;
--bg-overlay: rgba(15, 23, 42, 0.45);
--bg-code: #f1f5f9;
/* --- 3. TEXT & KONTRASTE --- */
--text-main: #111827;
--text-muted: #4b5563;
--text-faint: #9ca3af;
/* --- 4. RAHMEN, LINIEN & SCHATTEN --- */
--border-color: rgba(17, 24, 39, 0.13);
--border-subtle: rgba(17, 24, 39, 0.06);
--border-strong: rgba(17, 24, 39, 0.28);
--shadow-panel: 0 8px 24px rgba(15, 23, 42, 0.08);
--scrollbar-thumb: rgba(17, 24, 39, 0.18);
--scrollbar-thumb-hover: rgba(17, 24, 39, 0.32);
/* --- 5. STATUS- & SIGNALFARBEN --- */
--color-success: #15803d;
--bg-success: rgba(22, 163, 74, 0.10);
--border-success: rgba(22, 163, 74, 0.35);
--color-danger: #b91c1c;
--bg-danger: rgba(220, 38, 38, 0.09);
--border-danger: rgba(220, 38, 38, 0.35);
--color-warning: #b45309;
--bg-warning: rgba(217, 119, 6, 0.10);
--border-warning: rgba(217, 119, 6, 0.35);
/* --- 6. RUNDUNGEN (Geometrie) --- */
--radius-card: 0px;
--radius-btn: 2px;
--radius-input: 2px;
--radius-checkbox: 0px;
--radius-pill: 9999px;
}
+56
View File
@@ -0,0 +1,56 @@
/* ==========================================================================
MINDSIFT THEME: SOFT SLATE (Abgerundete Geometrie & Sanftes Schiefer-Blau)
Demonstriert, wie über die Rundungs-Variablen in einer einzigen Datei
das gesamte App-Layout auf abgerundete Ecken umschaltet.
========================================================================== */
:root[data-theme="soft"] {
/* --- 1. AKZENTFARBE --- */
--accent-primary: #3b82f6;
--accent-hover: #60a5fa;
--accent-dim: rgba(59, 130, 246, 0.15);
--accent-border: rgba(96, 165, 250, 0.40);
--text-on-accent: #ffffff;
/* --- 2. OBERFLÄCHEN & HINTERGRÜNDE --- */
--bg-app: #0f172a;
--bg-panel: #1e293b;
--bg-panel-elevated: #293548;
--bg-panel-hover: #334155;
--bg-input: #0f172a;
--bg-overlay: rgba(2, 6, 23, 0.72);
--bg-code: #090d16;
/* --- 3. TEXT & KONTRASTE --- */
--text-main: #f8fafc;
--text-muted: #94a3b8;
--text-faint: #64748b;
/* --- 4. RAHMEN, LINIEN & SCHATTEN --- */
--border-color: rgba(148, 163, 184, 0.16);
--border-subtle: rgba(148, 163, 184, 0.08);
--border-strong: rgba(148, 163, 184, 0.32);
--shadow-panel: 0 12px 32px rgba(2, 6, 23, 0.40);
--scrollbar-thumb: rgba(148, 163, 184, 0.22);
--scrollbar-thumb-hover: rgba(148, 163, 184, 0.38);
/* --- 5. STATUS- & SIGNALFARBEN --- */
--color-success: #10b981;
--bg-success: rgba(16, 185, 129, 0.14);
--border-success: rgba(16, 185, 129, 0.38);
--color-danger: #f43f5e;
--bg-danger: rgba(244, 63, 94, 0.14);
--border-danger: rgba(244, 63, 94, 0.38);
--color-warning: #f59e0b;
--bg-warning: rgba(245, 158, 11, 0.14);
--border-warning: rgba(245, 158, 11, 0.38);
/* --- 6. RUNDUNGEN (Weich abgerundete Ecken überall) --- */
--radius-card: 10px;
--radius-btn: 6px;
--radius-input: 6px;
--radius-checkbox: 4px;
--radius-pill: 9999px;
}
+56
View File
@@ -0,0 +1,56 @@
/* ==========================================================================
MINDSIFT THEME-VORLAGE (TEMPLATE)
Kopiere diese Datei, benenne [data-theme="mein-theme"] um und passe
die 6 Gruppen nach deinen Wünschen an.
========================================================================== */
:root[data-theme="custom-template"] {
/* --- 1. AKZENTFARBE (Buttons, aktive Tabs, Fokus-Ränder, Badges) --- */
--accent-primary: #2563eb;
--accent-hover: #3b82f6;
--accent-dim: rgba(37, 99, 235, 0.14);
--accent-border: rgba(59, 130, 246, 0.45);
--text-on-accent: #ffffff;
/* --- 2. OBERFLÄCHEN & HINTERGRÜNDE --- */
--bg-app: #121316; /* Seiten-Hintergrund (Canvas) */
--bg-panel: #1a1c20; /* Karten, Header, Sidebar, Dropdowns */
--bg-panel-elevated: #22252a; /* Hervorgehobene Elemente & aktive Zeilen */
--bg-panel-hover: #2a2e35; /* Hover-Effekt auf Oberflächen */
--bg-input: #121316; /* Textfelder, Selects, Textareas */
--bg-overlay: rgba(0, 0, 0, 0.72); /* Hintergrund bei Modals/Dialogen */
--bg-code: #0d0e11; /* Code-Blöcke & Terminal-Boxen */
/* --- 3. TEXT & KONTRASTE --- */
--text-main: #f4f4f5; /* Haupttext & Überschriften */
--text-muted: #a1a1aa; /* Sekundärtext & Beschreibungen */
--text-faint: #71717a; /* Platzhalter & dezente Hinweise */
/* --- 4. RAHMEN, LINIEN & SCHATTEN --- */
--border-color: rgba(255, 255, 255, 0.09);
--border-subtle: rgba(255, 255, 255, 0.05);
--border-strong: rgba(255, 255, 255, 0.22);
--shadow-panel: 0 10px 30px rgba(0, 0, 0, 0.45);
--scrollbar-thumb: rgba(255, 255, 255, 0.14);
--scrollbar-thumb-hover: rgba(255, 255, 255, 0.26);
/* --- 5. STATUS- & SIGNALFARBEN --- */
--color-success: #22c55e;
--bg-success: rgba(34, 197, 94, 0.12);
--border-success: rgba(34, 197, 94, 0.38);
--color-danger: #ef4444;
--bg-danger: rgba(239, 68, 68, 0.12);
--border-danger: rgba(239, 68, 68, 0.38);
--color-warning: #f59e0b;
--bg-warning: rgba(245, 158, 11, 0.12);
--border-warning: rgba(245, 158, 11, 0.38);
/* --- 6. RUNDUNGEN (Geometrie) --- */
--radius-card: 0px; /* Karten, Panels, Container (0px = eckig) */
--radius-btn: 2px; /* Buttons & Badges */
--radius-input: 2px; /* Eingabefelder & Dropdowns */
--radius-checkbox: 0px; /* Aufgaben-Checkboxen */
--radius-pill: 9999px; /* Quick-Capture-Blase & Profil-Pill */
}
+66 -18
View File
@@ -5,28 +5,76 @@ export default {
theme: { theme: {
extend: { extend: {
colors: { colors: {
gemini: { canvas: 'var(--bg-app)',
bg: '#0b0f19', panel: {
surface: '#131314', DEFAULT: 'var(--bg-panel)',
card: '#1e1f20', elevated: 'var(--bg-panel-elevated)',
hover: '#282a2c', hover: 'var(--bg-panel-hover)',
blue: '#8ab4f8',
purple: '#c58af9',
keep: '#81c995',
discard: '#f28b82',
text: '#e3e3e3',
muted: '#9aa0a6',
subtle: '#5f6368',
}, },
surface: {
DEFAULT: 'var(--bg-panel)',
elevated: 'var(--bg-panel-elevated)',
hover: 'var(--bg-panel-hover)',
border: 'var(--border-color)',
}, },
fontFamily: { input: 'var(--bg-input)',
sans: ['Inter', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'], overlay: 'var(--bg-overlay)',
mono: ['JetBrains Mono', 'Fira Code', 'monospace'], code: 'var(--bg-code)',
main: 'var(--text-main)',
muted: 'var(--text-muted)',
faint: 'var(--text-faint)',
'on-accent': 'var(--text-on-accent)',
accent: {
DEFAULT: 'var(--accent-primary)',
hover: 'var(--accent-hover)',
dim: 'var(--accent-dim)',
border: 'var(--accent-border)',
},
brand: {
DEFAULT: 'var(--accent-primary)',
hover: 'var(--accent-hover)',
dark: 'var(--accent-primary)',
},
status: {
ok: 'var(--color-success)',
'ok-bg': 'var(--bg-success)',
'ok-border': 'var(--border-success)',
err: 'var(--color-danger)',
'err-bg': 'var(--bg-danger)',
'err-border': 'var(--border-danger)',
warn: 'var(--color-warning)',
'warn-bg': 'var(--bg-warning)',
'warn-border': 'var(--border-warning)',
},
border: 'var(--border-color)',
},
borderColor: {
DEFAULT: 'var(--border-color)',
border: 'var(--border-color)',
subtle: 'var(--border-subtle)',
strong: 'var(--border-strong)',
},
borderRadius: {
card: 'var(--radius-card)',
btn: 'var(--radius-btn)',
input: 'var(--radius-input)',
checkbox: 'var(--radius-checkbox)',
pill: 'var(--radius-pill)',
}, },
boxShadow: { boxShadow: {
'gemini-glow': '0 0 28px -4px rgba(138, 180, 248, 0.22)', panel: 'var(--shadow-panel)',
'purple-glow': '0 0 32px -4px rgba(197, 138, 249, 0.35)', },
'card-depth': '0 20px 50px -12px rgba(0, 0, 0, 0.7)', fontFamily: {
sans: [
'Inter',
'system-ui',
'-apple-system',
'BlinkMacSystemFont',
'Segoe UI',
'Roboto',
'sans-serif',
],
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
}, },
}, },
}, },
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
},
});
+16
View File
@@ -10,3 +10,19 @@ require (
golang.org/x/crypto v0.31.0 golang.org/x/crypto v0.31.0
modernc.org/sqlite v1.34.4 modernc.org/sqlite v1.34.4
) )
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.28.0 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
modernc.org/libc v1.55.3 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
)
+59
View File
@@ -0,0 +1,59 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0=
github.com/go-chi/chi/v5 v5.2.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.34.4 h1:sjdARozcL5KJBvYQvLlZEmctRgW9xqIZc2ncN7PU0P8=
modernc.org/sqlite v1.34.4/go.mod h1:3QQFCG2SEMtc2nv+Wq4cQCH7Hjcg+p/RMlS1XK+zwbk=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
Executable
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
set -euo pipefail
echo "============================================="
echo "🧠 MindSift — Schnell-Installation"
echo "============================================="
# 1. Prüfen ob Docker / Compose verfügbar ist
COMPOSE_CMD=""
if command -v docker &>/dev/null && docker compose version &>/dev/null; then
COMPOSE_CMD="docker compose"
elif command -v docker-compose &>/dev/null; then
COMPOSE_CMD="docker-compose"
elif command -v podman &>/dev/null && podman compose version &>/dev/null; then
COMPOSE_CMD="podman compose"
else
echo "❌ Fehler: Docker Compose (oder Podman Compose) wurde nicht gefunden."
echo "Bitte installiere Docker (https://docs.docker.com/get-docker/) und versuche es erneut."
exit 1
fi
INSTALL_DIR="${MINDSIFT_DIR:-mindsift}"
mkdir -p "${INSTALL_DIR}"
cd "${INSTALL_DIR}"
echo "📥 Lade Konfigurationsdateien herunter..."
REPO_RAW_URL="https://git.dsit.me/sandrodach/MindSift/raw/branch/dev"
curl -fsSL "${REPO_RAW_URL}/docker-compose.yml" -o docker-compose.yml
curl -fsSL "${REPO_RAW_URL}/.env.example" -o .env
# Zufälliges JWT-Secret generieren falls Standardwert vorhanden
if grep -q "change_this_to_a_secure_random_key_min_32_chars" .env; then
RANDOM_SECRET=$(head -c 32 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 32 || openssl rand -hex 16 2>/dev/null || date +%s%N)
sed -i "s/change_this_to_a_secure_random_key_min_32_chars/${RANDOM_SECRET}/g" .env
fi
echo "🚀 Starte MindSift Container..."
${COMPOSE_CMD} up -d
echo ""
echo "============================================="
echo "✅ MindSift wurde erfolgreich gestartet!"
echo ""
echo "🌐 Öffne deinen Browser unter:"
echo " http://localhost:8080"
echo ""
echo "💡 Beim ersten Aufruf wirst du automatisch durch"
echo " die Ersteinrichtung deines Admin-Kontos geführt."
echo "============================================="
+2198 -109
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+14 -3
View File
@@ -21,6 +21,7 @@ type Claims struct {
UserID string `json:"userId"` UserID string `json:"userId"`
Email string `json:"email"` Email string `json:"email"`
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
TokenVersion int `json:"tokenVersion"`
jwt.RegisteredClaims jwt.RegisteredClaims
} }
@@ -34,14 +35,24 @@ func CheckPassword(password, hash string) bool {
return err == nil return err == nil
} }
func GenerateToken(secret, userID, email, displayName string) (string, error) { func GenerateToken(secret, userID, email, displayName string, tokenVersion int) (string, error) {
if tokenVersion <= 0 {
tokenVersion = 1
}
b := make([]byte, 16)
_, _ = rand.Read(b)
jti := hex.EncodeToString(b)
now := time.Now()
claims := &Claims{ claims := &Claims{
UserID: userID, UserID: userID,
Email: email, Email: email,
DisplayName: displayName, DisplayName: displayName,
TokenVersion: tokenVersion,
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * 24 * time.Hour)), ID: jti,
IssuedAt: jwt.NewNumericDate(time.Now()), ExpiresAt: jwt.NewNumericDate(now.Add(30 * 24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(now),
Issuer: "mindsift", Issuer: "mindsift",
}, },
} }
+52 -2
View File
@@ -1,6 +1,7 @@
package config package config
import ( import (
"log/slog"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -14,6 +15,16 @@ type Config struct {
BaseURL string BaseURL string
AllowRegistration bool AllowRegistration bool
LogLevel string LogLevel string
Debug bool
DemoMode bool
// Optional SMTP environment variables (can also be overridden/configured in DB system_settings)
SMTPHost string
SMTPPort string
SMTPUser string
SMTPPass string
SMTPFrom string
SMTPTLS bool
} }
func Load() *Config { func Load() *Config {
@@ -30,8 +41,25 @@ func Load() *Config {
jwtSecret := getEnv("JWT_SECRET", "mindsift-default-selfhosted-jwt-secret-key-2026") jwtSecret := getEnv("JWT_SECRET", "mindsift-default-selfhosted-jwt-secret-key-2026")
baseURL := getEnv("BASE_URL", "http://localhost:"+port) baseURL := getEnv("BASE_URL", "http://localhost:"+port)
allowReg := strings.ToLower(getEnv("ALLOW_REGISTRATION", "true")) != "false" allowReg := strings.ToLower(getEnv("ALLOW_REGISTRATION", "false")) == "true"
logLevel := getEnv("LOG_LEVEL", "info")
debugVal := strings.ToLower(getEnv("DEBUG", "false"))
debug := debugVal == "true" || debugVal == "1" || debugVal == "yes" || debugVal == "on"
logLevel := strings.ToLower(getEnv("LOG_LEVEL", "info"))
if debug {
logLevel = "debug"
}
demoVal := strings.ToLower(getEnv("MINDA_DEMO", getEnv("MINDSIFT_DEMO", getEnv("DEMO_MODE", "false"))))
demoMode := demoVal == "true" || demoVal == "1" || demoVal == "yes" || demoVal == "on"
smtpHost := getEnv("SMTP_HOST", "")
smtpPort := getEnv("SMTP_PORT", "587")
smtpUser := getEnv("SMTP_USER", "")
smtpPass := getEnv("SMTP_PASS", "")
smtpFrom := getEnv("SMTP_FROM", "")
smtpTLS := strings.ToLower(getEnv("SMTP_TLS", "true")) != "false"
return &Config{ return &Config{
Port: port, Port: port,
@@ -41,6 +69,28 @@ func Load() *Config {
BaseURL: baseURL, BaseURL: baseURL,
AllowRegistration: allowReg, AllowRegistration: allowReg,
LogLevel: logLevel, LogLevel: logLevel,
Debug: debug,
DemoMode: demoMode,
SMTPHost: smtpHost,
SMTPPort: smtpPort,
SMTPUser: smtpUser,
SMTPPass: smtpPass,
SMTPFrom: smtpFrom,
SMTPTLS: smtpTLS,
}
}
// SlogLevel parses Config.LogLevel into a standard slog.Level.
func (c *Config) SlogLevel() slog.Level {
switch strings.ToLower(strings.TrimSpace(c.LogLevel)) {
case "debug":
return slog.LevelDebug
case "warn", "warning":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
} }
} }
+58
View File
@@ -0,0 +1,58 @@
package config
import (
"log/slog"
"os"
"testing"
)
func TestConfigLogLevelAndDebug(t *testing.T) {
os.Setenv("PORT", "9090")
defer os.Unsetenv("PORT")
// 1. Default info
os.Unsetenv("LOG_LEVEL")
os.Unsetenv("DEBUG")
cfg := Load()
if cfg.SlogLevel() != slog.LevelInfo {
t.Fatalf("expected LevelInfo, got %v", cfg.SlogLevel())
}
// 2. LOG_LEVEL=debug
os.Setenv("LOG_LEVEL", "debug")
cfg = Load()
if cfg.SlogLevel() != slog.LevelDebug {
t.Fatalf("expected LevelDebug, got %v", cfg.SlogLevel())
}
// 3. LOG_LEVEL=warn
os.Setenv("LOG_LEVEL", "warn")
cfg = Load()
if cfg.SlogLevel() != slog.LevelWarn {
t.Fatalf("expected LevelWarn, got %v", cfg.SlogLevel())
}
// 4. LOG_LEVEL=error
os.Setenv("LOG_LEVEL", "error")
cfg = Load()
if cfg.SlogLevel() != slog.LevelError {
t.Fatalf("expected LevelError, got %v", cfg.SlogLevel())
}
// 5. DEBUG=true overrides LOG_LEVEL
os.Setenv("LOG_LEVEL", "error")
os.Setenv("DEBUG", "true")
cfg = Load()
if cfg.SlogLevel() != slog.LevelDebug {
t.Fatalf("expected LevelDebug when DEBUG=true, got %v", cfg.SlogLevel())
}
// 6. DEBUG=1 overrides LOG_LEVEL
os.Setenv("DEBUG", "1")
cfg = Load()
if cfg.SlogLevel() != slog.LevelDebug {
t.Fatalf("expected LevelDebug when DEBUG=1, got %v", cfg.SlogLevel())
}
os.Unsetenv("DEBUG")
os.Unsetenv("LOG_LEVEL")
}
+346 -22
View File
@@ -40,11 +40,86 @@ func ExtractTags(content string) []string {
return tags return tags
} }
// FormatGermanDateTime formats an ISO-8601 string into "25. Okt. 2026 - 15:40 Uhr"
func FormatGermanDateTime(isoStr string) string {
if isoStr == "" {
return ""
}
var t time.Time
var err error
formats := []string{
time.RFC3339Nano,
time.RFC3339,
"2006-01-02T15:04:05Z07:00",
"2006-01-02T15:04:05",
"2006-01-02 15:04:05",
"2006-01-02",
}
for _, f := range formats {
if parsed, parseErr := time.Parse(f, isoStr); parseErr == nil {
t = parsed
err = nil
break
} else {
err = parseErr
}
}
if err != nil {
return isoStr
}
t = t.Local()
germanMonths := map[time.Month]string{
time.January: "Jan.",
time.February: "Feb.",
time.March: "März",
time.April: "Apr.",
time.May: "Mai",
time.June: "Juni",
time.July: "Juli",
time.August: "Aug.",
time.September: "Sept.",
time.October: "Okt.",
time.November: "Nov.",
time.December: "Dez.",
}
mStr := germanMonths[t.Month()]
if mStr == "" {
mStr = t.Format("Jan")
}
return fmt.Sprintf("%d. %s %d - %02d:%02d Uhr", t.Day(), mStr, t.Year(), t.Hour(), t.Minute())
}
type User struct { type User struct {
ID string `json:"id"` ID string `json:"id"`
Email string `json:"email"` Email string `json:"email"`
Username string `json:"username,omitempty"`
PasswordHash string `json:"-"` PasswordHash string `json:"-"`
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
AvatarURL string `json:"avatarUrl,omitempty"`
Role string `json:"role"` // admin | member
IsActive bool `json:"isActive"`
TokenVersion int `json:"tokenVersion"`
AlarmChannels []string `json:"alarmChannels"` // email | discord | webhook
AlarmEmail string `json:"alarmEmail"`
DiscordWebhookURL string `json:"discordWebhookUrl"`
CustomWebhookURL string `json:"customWebhookUrl"`
NotifyBeforeMinutes int `json:"notifyBeforeMinutes"`
CreatedAt string `json:"createdAt"`
}
type UserInvite struct {
ID string `json:"id"`
Code string `json:"code"`
Email string `json:"email,omitempty"`
Role string `json:"role"` // member | admin
TeamID string `json:"teamId,omitempty"`
TeamName string `json:"teamName,omitempty"`
CreatedBy string `json:"createdBy"`
ExpiresAt string `json:"expiresAt"`
UsedBy string `json:"usedBy,omitempty"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
} }
@@ -57,6 +132,13 @@ const (
StatusConverted ThoughtStatus = "converted" StatusConverted ThoughtStatus = "converted"
) )
type SubTask struct {
ID string `json:"id"`
Title string `json:"title"`
IsCompleted bool `json:"isCompleted"`
CreatedAt string `json:"createdAt,omitempty"`
}
type Thought struct { type Thought struct {
ID string `json:"id"` ID string `json:"id"`
UserID string `json:"userId"` UserID string `json:"userId"`
@@ -66,19 +148,198 @@ type Thought struct {
Source string `json:"source"` // manual | voice | webhook Source string `json:"source"` // manual | voice | webhook
Status ThoughtStatus `json:"status"` Status ThoughtStatus `json:"status"`
IsCompleted bool `json:"isCompleted"` IsCompleted bool `json:"isCompleted"`
ConvertedType string `json:"convertedType,omitempty"` // task | document | list_item ConvertedType string `json:"convertedType,omitempty"` // task | document | list_item | document_block
ConvertedRefID string `json:"convertedRefId,omitempty"` // referenced entity ID ConvertedRefID string `json:"convertedRefId,omitempty"` // referenced entity ID
DocID string `json:"docId,omitempty"`
BlockID string `json:"blockId,omitempty"`
DueDate string `json:"dueDate,omitempty"` DueDate string `json:"dueDate,omitempty"`
TaskListID string `json:"taskListId,omitempty"`
KanbanStatus string `json:"kanbanStatus,omitempty"` // todo | in_progress | done
Notes string `json:"notes,omitempty"`
Subtasks []SubTask `json:"subtasks"`
AlarmSent bool `json:"alarmSent,omitempty"`
SortOrder int `json:"sortOrder"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
TriagedAt string `json:"triagedAt,omitempty"` TriagedAt string `json:"triagedAt,omitempty"`
} }
type ThoughtReorderItem struct {
ID string `json:"id"`
SortOrder int `json:"sortOrder"`
KanbanStatus *string `json:"kanbanStatus,omitempty"`
}
type ListItemReorderItem struct {
ID string `json:"id"`
SortOrder int `json:"sortOrder"`
Category *string `json:"category,omitempty"`
Content *string `json:"content,omitempty"`
}
type TaskListShare struct {
ID string `json:"id"`
TaskListID string `json:"taskListId"`
TargetType string `json:"targetType"` // team | user
TargetID string `json:"targetId"`
TargetName string `json:"targetName,omitempty"`
Email string `json:"email,omitempty"`
AvatarURL string `json:"avatarUrl,omitempty"`
Permission string `json:"permission"` // editor | viewer
CreatedAt string `json:"createdAt"`
}
type TaskListMember struct {
UserID string `json:"userId"`
DisplayName string `json:"displayName"`
Email string `json:"email"`
AvatarURL string `json:"avatarUrl,omitempty"`
Role string `json:"role"` // owner | member
}
type KanbanColumn struct {
ID string `json:"id"`
Name string `json:"name"`
Color string `json:"color,omitempty"`
IsDone bool `json:"isDone,omitempty"`
}
type TaskList struct {
ID string `json:"id"`
UserID string `json:"userId"`
SpaceID string `json:"spaceId,omitempty"`
SpaceName string `json:"spaceName,omitempty"`
TeamID string `json:"teamId,omitempty"`
TeamName string `json:"teamName,omitempty"`
OwnerName string `json:"ownerName,omitempty"`
IsOwner bool `json:"isOwner"`
Permission string `json:"permission,omitempty"` // owner | editor | viewer
Members []TaskListMember `json:"members,omitempty"`
Shares []TaskListShare `json:"shares,omitempty"`
Columns []KanbanColumn `json:"columns,omitempty"`
Title string `json:"title"`
Icon string `json:"icon"`
Color string `json:"color"`
TasksCount int `json:"tasksCount,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
type Organization struct {
ID string `json:"id"`
Name string `json:"name"`
OwnerID string `json:"ownerId"`
Teams []Team `json:"teams,omitempty"`
CreatedAt string `json:"createdAt"`
}
type Team struct {
ID string `json:"id"`
OrgID string `json:"orgId,omitempty"`
Name string `json:"name"`
Description string `json:"description"`
Members []TeamMember `json:"members"`
CreatedAt string `json:"createdAt"`
}
type TeamMember struct {
TeamID string `json:"teamId"`
UserID string `json:"userId"`
DisplayName string `json:"displayName"`
Email string `json:"email"`
AvatarURL string `json:"avatarUrl,omitempty"`
Role string `json:"role"` // admin | member
JoinedAt string `json:"joinedAt"`
}
type SystemSettings struct {
SharingMode string `json:"sharingMode"` // open | teams_only | strict_private
AllowRegistration bool `json:"allowRegistration"`
InstanceName string `json:"instanceName"`
SMTPHost string `json:"smtpHost"`
SMTPPort string `json:"smtpPort"`
SMTPUser string `json:"smtpUser"`
SMTPPass string `json:"smtpPass"`
SMTPFrom string `json:"smtpFrom"`
SMTPTLS bool `json:"smtpTls"`
SMTPConfiguredViaEnv bool `json:"smtpConfiguredViaEnv,omitempty"`
}
type DocumentBlock struct { type DocumentBlock struct {
ID string `json:"id"` ID string `json:"id"`
Type string `json:"type"` // h1 | h2 | h3 | paragraph | todo | bullet | code | callout | divider DocumentID string `json:"documentId,omitempty"`
Type string `json:"type"` // h1 | h2 | h3 | paragraph | todo | bullet | code | callout | divider | table | progress
Content string `json:"content"` Content string `json:"content"`
Checked bool `json:"checked,omitempty"` Checked bool `json:"checked,omitempty"`
Lang string `json:"lang,omitempty"` Lang string `json:"lang,omitempty"`
Level int `json:"level"`
Position float64 `json:"position"`
Collapsed bool `json:"collapsed,omitempty"`
TaskID string `json:"taskId,omitempty"`
}
func CompileBlocksToMarkdown(blocks []DocumentBlock) string {
var sb strings.Builder
for _, b := range blocks {
lvl := b.Level
if lvl < 0 {
lvl = 0
}
indent := strings.Repeat(" ", lvl)
switch b.Type {
case "h1":
sb.WriteString(indent + "- # " + b.Content + "\n")
case "h2":
sb.WriteString(indent + "- ## " + b.Content + "\n")
case "h3":
sb.WriteString(indent + "- ### " + b.Content + "\n")
case "todo":
mark := "[ ]"
if b.Checked {
mark = "[x]"
}
sb.WriteString(indent + "- " + mark + " " + b.Content + "\n")
case "code":
sb.WriteString(indent + "- ```" + b.Lang + "\n" + b.Content + "\n" + indent + " ```\n")
case "divider":
sb.WriteString(indent + "- ---\n")
default:
sb.WriteString(indent + "- " + b.Content + "\n")
}
}
return sb.String()
}
type DocBlocksPatchRequest struct {
Upserted []DocumentBlock `json:"upserted,omitempty"`
DeletedIDs []string `json:"deletedIds,omitempty"`
BlockOrder []string `json:"blockOrder,omitempty"`
Title *string `json:"title,omitempty"`
Icon *string `json:"icon,omitempty"`
}
type DocBlocksPatchEvent struct {
DocID string `json:"docId"`
Upserted []DocumentBlock `json:"upserted,omitempty"`
DeletedIDs []string `json:"deletedIds,omitempty"`
BlockOrder []string `json:"blockOrder,omitempty"`
Title *string `json:"title,omitempty"`
Icon *string `json:"icon,omitempty"`
UpdatedBy string `json:"updatedBy"`
UpdatedAt string `json:"updatedAt"`
}
type DocShare struct {
ID string `json:"id"`
DocID string `json:"docId"`
TargetType string `json:"targetType,omitempty"` // user | team
TargetID string `json:"targetId,omitempty"`
TargetName string `json:"targetName,omitempty"`
UserID string `json:"userId,omitempty"`
UserEmail string `json:"userEmail,omitempty"`
DisplayName string `json:"displayName,omitempty"`
Permission string `json:"permission"` // read | write
CreatedBy string `json:"createdBy"`
CreatedAt string `json:"createdAt"`
} }
type Document struct { type Document struct {
@@ -88,33 +349,66 @@ type Document struct {
Title string `json:"title"` Title string `json:"title"`
Icon string `json:"icon"` Icon string `json:"icon"`
Content string `json:"content"` Content string `json:"content"`
MarkdownCache string `json:"markdownCache,omitempty"`
Blocks []DocumentBlock `json:"blocks"` Blocks []DocumentBlock `json:"blocks"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
FolderID string `json:"folderId,omitempty"`
FolderPath string `json:"folderPath,omitempty"`
IsPinned bool `json:"isPinned"` IsPinned bool `json:"isPinned"`
IsArchived bool `json:"isArchived"` IsArchived bool `json:"isArchived"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"` UpdatedAt string `json:"updatedAt"`
// Collaboration & Permissions
Permission string `json:"permission"` // owner | write | read
IsOwner bool `json:"isOwner"`
OwnerName string `json:"ownerName,omitempty"`
SharedWith []DocShare `json:"sharedWith,omitempty"`
SharedFolderID string `json:"sharedFolderId,omitempty"`
SubFolderPath string `json:"subFolderPath,omitempty"`
IsSharedFolder bool `json:"isSharedFolder,omitempty"`
FolderPermission string `json:"folderPermission,omitempty"`
} }
type SpaceMember struct { type TagSummary struct {
SpaceID string `json:"spaceId"` Tag string `json:"tag"`
UserID string `json:"userId"` Count int `json:"count"`
DisplayName string `json:"displayName"`
Email string `json:"email"`
Role string `json:"role"` // owner | editor | viewer
JoinedAt string `json:"joinedAt"`
} }
type Space struct { type MatchedTagBlock struct {
ID string `json:"id"` RootBlock DocumentBlock `json:"rootBlock"`
Name string `json:"name"` Children []DocumentBlock `json:"children"`
}
type TagDocumentGroup struct {
DocID string `json:"docId"`
Title string `json:"title"`
Icon string `json:"icon"` Icon string `json:"icon"`
Description string `json:"description"` FolderPath string `json:"folderPath"`
InviteCode string `json:"inviteCode"` UpdatedAt string `json:"updatedAt"`
IsPersonal bool `json:"isPersonal"` IsOwner bool `json:"isOwner"`
OwnerID string `json:"ownerId"` Permission string `json:"permission"`
Members []SpaceMember `json:"members,omitempty"` OwnerName string `json:"ownerName,omitempty"`
ListsCount int `json:"listsCount"` MatchedBlocks []MatchedTagBlock `json:"matchedBlocks"`
}
type TagDetails struct {
Tag string `json:"tag"`
NoteContent string `json:"noteContent"`
NoteUpdatedAt string `json:"noteUpdatedAt,omitempty"`
Documents []TagDocumentGroup `json:"documents"`
TotalMatches int `json:"totalMatches"`
}
type ListShare struct {
ID string `json:"id"`
ListID string `json:"listId"`
TargetType string `json:"targetType"` // team | user
TargetID string `json:"targetId"`
TargetName string `json:"targetName,omitempty"`
Email string `json:"email,omitempty"`
Role string `json:"role"` // editor | viewer
CreatedBy string `json:"createdBy"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
} }
@@ -134,11 +428,15 @@ type ListItem struct {
type SharedList struct { type SharedList struct {
ID string `json:"id"` ID string `json:"id"`
SpaceID string `json:"spaceId"` UserID string `json:"userId"`
OwnerName string `json:"ownerName,omitempty"`
IsOwner bool `json:"isOwner"`
Role string `json:"role,omitempty"` // editor | viewer
Title string `json:"title"` Title string `json:"title"`
Icon string `json:"icon"` Icon string `json:"icon"`
ListType string `json:"listType"` // shopping | checklist | vacation ListType string `json:"listType"` // shopping | checklist | vacation
Items []ListItem `json:"items"` Items []ListItem `json:"items"`
Shares []ListShare `json:"shares,omitempty"`
UpdatedAt string `json:"updatedAt"` UpdatedAt string `json:"updatedAt"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
} }
@@ -157,8 +455,7 @@ type APIKey struct {
type WSMessage struct { type WSMessage struct {
Event string `json:"event"` // ITEM_CREATED | ITEM_UPDATED | ITEM_TOGGLED | ITEM_DELETED | LIST_CLEARED | USER_PRESENCE Event string `json:"event"` // ITEM_CREATED | ITEM_UPDATED | ITEM_TOGGLED | ITEM_DELETED | LIST_CLEARED | USER_PRESENCE
SpaceID string `json:"space_id"` ListID string `json:"list_id"`
ListID string `json:"list_id,omitempty"`
Payload interface{} `json:"payload"` Payload interface{} `json:"payload"`
Timestamp string `json:"timestamp"` Timestamp string `json:"timestamp"`
} }
@@ -168,6 +465,7 @@ type DashboardStats struct {
TasksOpenCount int `json:"tasksOpenCount"` TasksOpenCount int `json:"tasksOpenCount"`
TasksDoneCount int `json:"tasksDoneCount"` TasksDoneCount int `json:"tasksDoneCount"`
DocsCount int `json:"docsCount"` DocsCount int `json:"docsCount"`
DocsTodosOpenCount int `json:"docsTodosOpenCount"`
ListsCount int `json:"listsCount"` ListsCount int `json:"listsCount"`
DiscardedCount int `json:"discardedCount"` DiscardedCount int `json:"discardedCount"`
} }
@@ -178,6 +476,32 @@ type FullExport struct {
User User `json:"user"` User User `json:"user"`
Thoughts []Thought `json:"thoughts"` Thoughts []Thought `json:"thoughts"`
Documents []Document `json:"documents"` Documents []Document `json:"documents"`
Spaces []Space `json:"spaces"`
Lists []SharedList `json:"lists"` Lists []SharedList `json:"lists"`
TaskLists []TaskList `json:"taskLists,omitempty"`
}
type SharedFolder struct {
ID string `json:"id"`
OwnerID string `json:"ownerId"`
OwnerName string `json:"ownerName,omitempty"`
Name string `json:"name"`
MountPath string `json:"mountPath"`
IsOwner bool `json:"isOwner"`
Permission string `json:"permission"` // owner | write | read
Shares []FolderShare `json:"shares,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
type FolderShare struct {
ID string `json:"id"`
FolderID string `json:"folderId"`
TargetType string `json:"targetType"` // team | user
TargetID string `json:"targetId"`
TargetName string `json:"targetName,omitempty"`
Email string `json:"email,omitempty"`
AvatarURL string `json:"avatarUrl,omitempty"`
Permission string `json:"permission"` // read | write
CreatedBy string `json:"createdBy"`
CreatedAt string `json:"createdAt"`
} }
+4637 -318
View File
File diff suppressed because it is too large. Load diff
+710 -7
View File
@@ -1,9 +1,11 @@
package storage package storage
import ( import (
"errors"
"path/filepath" "path/filepath"
"testing" "testing"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/domain" "github.com/sandrodach/mindsift/internal/domain"
) )
@@ -38,7 +40,7 @@ func TestStoreLifecycle(t *testing.T) {
} }
// 2. Test Tinder-Style Triage -> Convert to Document // 2. Test Tinder-Style Triage -> Convert to Document
triaged, err := store.UpdateThoughtTriage(user.ID, thought.ID, "keep", "document", "", "") triaged, err := store.UpdateThoughtTriage(user.ID, thought.ID, "keep", "document", "", "", "")
if err != nil { if err != nil {
t.Fatalf("UpdateThoughtTriage failed: %v", err) t.Fatalf("UpdateThoughtTriage failed: %v", err)
} }
@@ -46,12 +48,8 @@ func TestStoreLifecycle(t *testing.T) {
t.Fatalf("expected converted document, got status=%s type=%s ref=%s", triaged.Status, triaged.ConvertedType, triaged.ConvertedRefID) t.Fatalf("expected converted document, got status=%s type=%s ref=%s", triaged.Status, triaged.ConvertedType, triaged.ConvertedRefID)
} }
// 3. Test Shared Space & Shopping List // 3. Test Standalone Shopping List & Sharing
sp, err := store.CreateSpace(user.ID, "Test WG", "🍕", "Test Space", false) lst, err := store.CreateList(user.ID, "REWE Einkauf", "🛒", "shopping")
if err != nil {
t.Fatalf("CreateSpace failed: %v", err)
}
lst, err := store.CreateList(sp.ID, "REWE Einkauf", "🛒", "shopping")
if err != nil { if err != nil {
t.Fatalf("CreateList failed: %v", err) t.Fatalf("CreateList failed: %v", err)
} }
@@ -64,6 +62,28 @@ func TestStoreLifecycle(t *testing.T) {
t.Fatalf("ToggleListItem failed: %v", err) t.Fatalf("ToggleListItem failed: %v", err)
} }
// Test AddListShare & RemoveListShare
u2, err := store.CreateUser("collab_space@example.com", "hash", "Collab User", "member")
if err != nil {
t.Fatalf("CreateUser u2 failed: %v", err)
}
share, err := store.AddListShare(user.ID, lst.ID, "user", u2.ID, "editor")
if err != nil || share.TargetID != u2.ID {
t.Fatalf("AddListShare failed: %v", err)
}
shares, err := store.GetListShares(lst.ID)
if err != nil || len(shares) != 1 {
t.Fatalf("expected 1 share, got %d", len(shares))
}
err = store.RemoveListShare(user.ID, lst.ID, share.ID)
if err != nil {
t.Fatalf("RemoveListShare failed: %v", err)
}
sharesAfter, _ := store.GetListShares(lst.ID)
if len(sharesAfter) != 0 {
t.Fatalf("expected 0 shares after remove, got %d", len(sharesAfter))
}
// 4. Test Webhook API Key generation & validation // 4. Test Webhook API Key generation & validation
apiKey, err := store.CreateAPIKey(user.ID, "Home Assistant") apiKey, err := store.CreateAPIKey(user.ID, "Home Assistant")
if err != nil { if err != nil {
@@ -73,4 +93,687 @@ func TestStoreLifecycle(t *testing.T) {
if err != nil || validatedUser.ID != user.ID { if err != nil || validatedUser.ID != user.ID {
t.Fatalf("ValidateAPIKeyAndGetUser failed: %v", err) t.Fatalf("ValidateAPIKeyAndGetUser failed: %v", err)
} }
// 5. Test TaskLists CRUD
taskList, err := store.CreateTaskList(user.ID, "", "", "Haus & Garten", "🏡", "#10b981")
if err != nil || taskList.ID == "" {
t.Fatalf("CreateTaskList failed: %v", err)
}
taskLists, err := store.GetTaskLists(user.ID)
if err != nil || len(taskLists) == 0 {
t.Fatalf("GetTaskLists failed: %v", err)
}
// 6. Test Organizations & Teams
org, err := store.CreateOrganization(user.ID, "Mindsift Inc")
if err != nil || org.ID == "" {
t.Fatalf("CreateOrganization failed: %v", err)
}
team, err := store.CreateTeam(org.ID, "DevOps", "Infrastructure team")
if err != nil || team.ID == "" {
t.Fatalf("CreateTeam failed: %v", err)
}
err = store.AddTeamMember(team.ID, user.ID, "owner")
if err != nil {
t.Fatalf("AddTeamMember failed: %v", err)
}
// Test TaskList Sharing with Team
shareTeam, err := store.AddTaskListShare(taskList.ID, "team", team.ID, "editor", user.ID)
if err != nil || shareTeam.ID == "" {
t.Fatalf("AddTaskListShare team failed: %v", err)
}
tlShares, err := store.GetTaskListShares(taskList.ID)
if err != nil || len(tlShares) == 0 {
t.Fatalf("GetTaskListShares failed: %v", err)
}
fetchedTL, err := store.GetTaskListByID(user.ID, taskList.ID)
if err != nil || len(fetchedTL.Shares) == 0 {
t.Fatalf("GetTaskListByID with shares failed: %v", err)
}
// 7. Test System Settings
sysSettings, err := store.GetSystemSettings()
if err != nil || sysSettings.InstanceName == "" {
t.Fatalf("GetSystemSettings failed: %v", err)
}
sysSettings.SharingMode = "teams_only"
err = store.UpdateSystemSettings(sysSettings)
if err != nil {
t.Fatalf("UpdateSystemSettings failed: %v", err)
}
// 8. Test Document Sharing & Permissions
user2, err := store.CreateUser("collab@example.com", "hash", "Collab User", "member")
if err != nil {
t.Fatalf("CreateUser collab failed: %v", err)
}
// Test read-only TaskList share
ownerTask, err := store.CreateThoughtWithDetails(user.ID, "Rasen mähen", "normal", "manual", "", taskList.ID, "todo", "")
if err != nil {
t.Fatalf("CreateThoughtWithDetails in own list failed: %v", err)
}
if _, err := store.UpdateThoughtTriage(user.ID, ownerTask.ID, "keep", "task", "", "", taskList.ID); err != nil {
t.Fatalf("UpdateThoughtTriage in own list failed: %v", err)
}
if _, err := store.AddTaskListShare(taskList.ID, "user", user2.ID, "viewer", user.ID); err != nil {
t.Fatalf("AddTaskListShare viewer failed: %v", err)
}
if perm := store.GetTaskListPermission(user2.ID, taskList.ID); perm != "viewer" {
t.Fatalf("expected viewer permission, got %q", perm)
}
viewerTasks, err := store.GetTasks(user2.ID, taskList.ID)
if err != nil || len(viewerTasks) == 0 {
t.Fatalf("viewer should see tasks of shared list: %v", err)
}
done := true
if _, err := store.UpdateThoughtDetails(user2.ID, ownerTask.ID, nil, &done, nil, nil, nil, nil, nil, nil, nil); !errors.Is(err, ErrTaskListReadOnly) {
t.Fatalf("viewer must not update tasks, got %v", err)
}
if _, err := store.CreateThoughtWithDetails(user2.ID, "Hack", "normal", "manual", "", taskList.ID, "todo", ""); !errors.Is(err, ErrTaskListReadOnly) {
t.Fatalf("viewer must not create tasks, got %v", err)
}
if err := store.DeleteThought(user2.ID, ownerTask.ID); !errors.Is(err, ErrTaskListReadOnly) {
t.Fatalf("viewer must not delete tasks, got %v", err)
}
newTitle := "Umbenannt"
if _, err := store.UpdateTaskList(user2.ID, taskList.ID, &newTitle, nil, nil, nil, nil, nil); !errors.Is(err, ErrTaskListReadOnly) {
t.Fatalf("viewer must not update task list, got %v", err)
}
if _, err := store.AddTaskListShare(taskList.ID, "user", user2.ID, "editor", user.ID); err != nil {
t.Fatalf("upgrading share to editor failed: %v", err)
}
if !store.CanEditTaskList(user2.ID, taskList.ID) {
t.Fatalf("editor share should allow editing")
}
newDoc, err := store.CreateDocument(user.ID, "", "Test Note", "📄", "", []domain.DocumentBlock{
{ID: "blk_1", Type: "bullet", Content: "Geheim", Level: 0},
}, []string{"test"}, false)
if err != nil {
t.Fatalf("CreateDocument failed: %v", err)
}
// Share with user2 as "read"
docShare, err := store.CreateDocShare(user.ID, newDoc.ID, user2.ID, "read")
if err != nil || docShare.Permission != "read" {
t.Fatalf("CreateDocShare failed: %v", err)
}
// user2 can get document
docForUser2, err := store.GetDocumentByID(user2.ID, newDoc.ID)
if err != nil || docForUser2.Permission != "read" || docForUser2.IsOwner {
t.Fatalf("GetDocumentByID for user2 failed or wrong permission: %v", err)
}
// user2 tries to edit read-only doc -> must fail
updatedTitle := "Hacked Title"
_, err = store.UpdateDocument(user2.ID, newDoc.ID, &updatedTitle, nil, nil, nil, nil, nil)
if err == nil {
t.Fatalf("expected UpdateDocument to fail for read-only user, but succeeded")
}
// user2 is a read-only collaborator -> editing must fail
_, err = store.UpdateDocument(user2.ID, newDoc.ID, &updatedTitle, nil, nil, nil, nil, nil)
if err == nil {
t.Fatalf("expected UpdateDocument to fail for read-only collaborator, but succeeded")
}
// Remove share
err = store.DeleteDocShare(user.ID, newDoc.ID, user2.ID)
if err != nil {
t.Fatalf("DeleteDocShare failed: %v", err)
}
_, err = store.GetDocumentByID(user2.ID, newDoc.ID)
if err == nil {
t.Fatalf("expected GetDocumentByID to fail after share removed, but succeeded")
}
} }
func TestPerUserDocPinning(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "pin_test.db")
store, err := NewStore(dbPath)
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
defer store.Close()
user1, _ := store.CreateUser("user1@example.com", "hash", "User 1", "admin")
user2, _ := store.CreateUser("user2@example.com", "hash", "User 2", "member")
// user1 creates document and pins it
doc, err := store.CreateDocument(user1.ID, "", "Doc 1", "📝", "", []domain.DocumentBlock{
{ID: "b1", Type: "text", Content: "Hello"},
}, []string{"test"}, true)
if err != nil {
t.Fatalf("CreateDocument failed: %v", err)
}
if !doc.IsPinned {
t.Fatalf("expected doc to be pinned for user1")
}
// Share with user2 as read-only
_, err = store.CreateDocShare(user1.ID, doc.ID, user2.ID, "read")
if err != nil {
t.Fatalf("CreateDocShare failed: %v", err)
}
// user2 reads document: should NOT be pinned for user2
docForUser2, err := store.GetDocumentByID(user2.ID, doc.ID)
if err != nil {
t.Fatalf("GetDocumentByID user2 failed: %v", err)
}
if docForUser2.IsPinned {
t.Fatalf("expected doc to NOT be pinned for user2 initially")
}
// user2 pins the shared document (even with read-only access)
pinTrue := true
updatedDoc2, err := store.UpdateDocument(user2.ID, doc.ID, nil, nil, nil, nil, nil, &pinTrue)
if err != nil {
t.Fatalf("user2 pinning read-only doc failed: %v", err)
}
if !updatedDoc2.IsPinned {
t.Fatalf("expected doc to be pinned for user2")
}
// Verify user1's doc is still pinned
docForUser1, err := store.GetDocumentByID(user1.ID, doc.ID)
if err != nil {
t.Fatalf("GetDocumentByID user1 failed: %v", err)
}
if !docForUser1.IsPinned {
t.Fatalf("expected doc to still be pinned for user1")
}
// user1 unpins the doc
pinFalse := false
_, err = store.UpdateDocument(user1.ID, doc.ID, nil, nil, nil, nil, nil, &pinFalse)
if err != nil {
t.Fatalf("user1 unpinning doc failed: %v", err)
}
// user1 doc is now unpinned, but user2 doc must STILL be pinned!
docForUser1, _ = store.GetDocumentByID(user1.ID, doc.ID)
if docForUser1.IsPinned {
t.Fatalf("expected doc to be unpinned for user1")
}
docForUser2, _ = store.GetDocumentByID(user2.ID, doc.ID)
if !docForUser2.IsPinned {
t.Fatalf("expected doc to remain pinned for user2")
}
}
func TestEnsureDemoAccountsAndLoginLookup(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "demo_test.db")
store, err := NewStore(dbPath)
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
defer store.Close()
if err := store.EnsureDemoAccounts(); err != nil {
t.Fatalf("EnsureDemoAccounts failed: %v", err)
}
// 1. Lookup by username "admin"
adminUser, err := store.GetUserByLogin("admin")
if err != nil || adminUser == nil {
t.Fatalf("expected to find admin user by username 'admin', got %v", err)
}
if adminUser.Role != "admin" {
t.Fatalf("expected role 'admin', got %s", adminUser.Role)
}
if !auth.CheckPassword("admin", adminUser.PasswordHash) {
t.Fatalf("admin password check failed")
}
// 2. Lookup by email "admin@mindsift.local"
adminByEmail, err := store.GetUserByLogin("admin@mindsift.local")
if err != nil || adminByEmail.ID != adminUser.ID {
t.Fatalf("lookup by email failed: got %v", err)
}
// 3. Lookup with case insensitivity "ADMIN"
adminCaps, err := store.GetUserByLogin("ADMIN")
if err != nil || adminCaps.ID != adminUser.ID {
t.Fatalf("lookup by uppercase failed: got %v", err)
}
// 4. Lookup by username "user"
normalUser, err := store.GetUserByLogin("user")
if err != nil || normalUser == nil {
t.Fatalf("expected to find user by username 'user', got %v", err)
}
if normalUser.Role != "member" {
t.Fatalf("expected role 'member', got %s", normalUser.Role)
}
if !auth.CheckPassword("user", normalUser.PasswordHash) {
t.Fatalf("user password check failed")
}
// 5. Ensure re-running is idempotent and preserves/refreshes credentials
if err := store.EnsureDemoAccounts(); err != nil {
t.Fatalf("second EnsureDemoAccounts call failed: %v", err)
}
}
func TestFolderManagementAndDeletion(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "folder_test.db")
store, err := NewStore(dbPath)
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
defer store.Close()
user, err := store.CreateUser("folderuser@example.com", "hash", "Folder User", "member")
if err != nil {
t.Fatalf("CreateUser failed: %v", err)
}
// 1. Create folders
f1, err := store.CreateFolder(user.ID, "Projekte/MindSift")
if err != nil || f1 != "Projekte/MindSift" {
t.Fatalf("CreateFolder failed: %v, f1=%s", err, f1)
}
f2, err := store.CreateFolder(user.ID, "Archiv")
if err != nil || f2 != "Archiv" {
t.Fatalf("CreateFolder Archiv failed: %v", err)
}
// 2. Create document in subfolder
doc1, err := store.CreateDocument(user.ID, "", "Doc in MindSift", "📄", "Projekte/MindSift", nil, nil, false)
if err != nil {
t.Fatalf("CreateDocument failed: %v", err)
}
doc2, err := store.CreateDocument(user.ID, "", "SubDoc", "📝", "Projekte/MindSift/Sub", nil, nil, false)
if err != nil {
t.Fatalf("CreateDocument 2 failed: %v", err)
}
// 3. GetFolders
folders, err := store.GetFolders(user.ID)
if err != nil {
t.Fatalf("GetFolders failed: %v", err)
}
expected := map[string]bool{
"Archiv": true,
"Projekte": true,
"Projekte/MindSift": true,
"Projekte/MindSift/Sub": true,
}
for _, f := range folders {
delete(expected, f)
}
if len(expected) > 0 {
t.Fatalf("GetFolders missing expected folders: %v (got %v)", expected, folders)
}
// 4. DeleteFolder "Projekte/MindSift" -> should remove doc1 & doc2
deletedDocs, err := store.DeleteFolder(user.ID, "Projekte/MindSift")
if err != nil {
t.Fatalf("DeleteFolder failed: %v", err)
}
if len(deletedDocs) != 2 {
t.Fatalf("expected 2 deleted docs, got %d (%v)", len(deletedDocs), deletedDocs)
}
// Verify docs are gone from DB
_, err = store.GetDocumentByID(user.ID, doc1.ID)
if err == nil {
t.Fatalf("expected doc1 to be deleted")
}
_, err = store.GetDocumentByID(user.ID, doc2.ID)
if err == nil {
t.Fatalf("expected doc2 to be deleted")
}
}
func TestWorkspaceBlockHashtagsAndDetails(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "tags_test.db")
store, err := NewStore(dbPath)
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
defer store.Close()
user, err := store.CreateUser("taguser@example.com", "hash", "Tag User", "member")
if err != nil {
t.Fatalf("CreateUser failed: %v", err)
}
// Create document with nested bullet points and hashtags
blocks := []domain.DocumentBlock{
{ID: "b0", Type: "bullet", Content: "Einleitung ohne Tag", Level: 0},
{ID: "b1", Type: "bullet", Content: "Einkauf planen #Kühlregal #Wocheneinkauf", Level: 0},
{ID: "b2", Type: "bullet", Content: "Milch 3.8%", Level: 1},
{ID: "b3", Type: "bullet", Content: "Gouda alt", Level: 1},
{ID: "b4", Type: "bullet", Content: "Zusatzinfo: Aus Weidemilch", Level: 2},
{ID: "b5", Type: "bullet", Content: "Anderes Thema ohne Bezug", Level: 0},
}
doc, err := store.CreateDocument(user.ID, "", "Einkaufszettel", "🛒", "Haushalt", blocks, nil, false)
if err != nil {
t.Fatalf("CreateDocument failed: %v", err)
}
// 1. Check GetWorkspaceTags
tags, err := store.GetWorkspaceTags(user.ID)
if err != nil {
t.Fatalf("GetWorkspaceTags failed: %v", err)
}
if len(tags) != 2 {
t.Fatalf("expected 2 tags, got %d (%v)", len(tags), tags)
}
tagMap := make(map[string]int)
for _, tg := range tags {
tagMap[tg.Tag] = tg.Count
}
if tagMap["kühlregal"] != 1 || tagMap["wocheneinkauf"] != 1 {
t.Fatalf("unexpected tag counts: %v", tagMap)
}
// 2. Check GetTagDetails for #Kühlregal
details, err := store.GetTagDetails(user.ID, "#Kühlregal")
if err != nil {
t.Fatalf("GetTagDetails failed: %v", err)
}
if details.Tag != "kühlregal" {
t.Fatalf("expected tag 'kühlregal', got %s", details.Tag)
}
if details.TotalMatches != 1 {
t.Fatalf("expected 1 match, got %d", details.TotalMatches)
}
if len(details.Documents) != 1 {
t.Fatalf("expected 1 document, got %d", len(details.Documents))
}
docGroup := details.Documents[0]
if docGroup.DocID != doc.ID || docGroup.Title != "Einkaufszettel" {
t.Fatalf("unexpected doc group: %+v", docGroup)
}
if len(docGroup.MatchedBlocks) != 1 {
t.Fatalf("expected 1 matched block, got %d", len(docGroup.MatchedBlocks))
}
matched := docGroup.MatchedBlocks[0]
if matched.RootBlock.ID != "b1" {
t.Fatalf("expected root block b1, got %s", matched.RootBlock.ID)
}
// Children should be b2, b3, b4 (all subsequent blocks with Level > 0 until b5 where Level == 0)
if len(matched.Children) != 3 {
t.Fatalf("expected 3 children (b2, b3, b4), got %d (%v)", len(matched.Children), matched.Children)
}
if matched.Children[0].ID != "b2" || matched.Children[1].ID != "b3" || matched.Children[2].ID != "b4" {
t.Fatalf("unexpected children IDs: %v", matched.Children)
}
// 3. Update document: remove #Wocheneinkauf, add #Vorrat
updatedBlocks := []domain.DocumentBlock{
{ID: "b1", Type: "bullet", Content: "Einkauf planen #Kühlregal #Vorrat", Level: 0},
}
_, err = store.UpdateDocument(user.ID, doc.ID, nil, nil, nil, &updatedBlocks, nil, nil)
if err != nil {
t.Fatalf("UpdateDocument failed: %v", err)
}
tagsAfter, err := store.GetWorkspaceTags(user.ID)
if err != nil {
t.Fatalf("GetWorkspaceTags after update failed: %v", err)
}
tagMapAfter := make(map[string]int)
for _, tg := range tagsAfter {
tagMapAfter[tg.Tag] = tg.Count
}
if tagMapAfter["wocheneinkauf"] != 0 {
t.Fatalf("expected wocheneinkauf to be removed, got %v", tagMapAfter)
}
if tagMapAfter["vorrat"] != 1 || tagMapAfter["kühlregal"] != 1 {
t.Fatalf("expected vorrat and kühlregal, got %v", tagMapAfter)
}
}
func TestDatabaseFirstWorkspaceArchitecture(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test_db_first.db")
store, err := NewStore(dbPath)
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
defer store.Close()
userA, err := store.EnsureDefaultUser()
if err != nil {
t.Fatalf("EnsureDefaultUser failed: %v", err)
}
userB, err := store.CreateUser("userb@example.com", "hash", "User B", "member")
if err != nil {
t.Fatalf("CreateUser userB failed: %v", err)
}
// 1. Create a document in User A's folder "Privat" and share with User B (write)
initialBlocks := []domain.DocumentBlock{
{ID: "b1", Type: "bullet", Content: "Zeile 1 Original", Level: 0},
{ID: "b2", Type: "bullet", Content: "Zeile 2 Original", Level: 0},
}
doc, err := store.CreateDocument(userA.ID, "", "Gemeinsame Notiz", "📝", "Privat", initialBlocks, []string{"folder:Privat", "notiz"}, false)
if err != nil {
t.Fatalf("CreateDocument failed: %v", err)
}
// Verify folder:* tag was stripped
for _, tg := range doc.Tags {
if tg == "folder:Privat" {
t.Fatalf("expected folder:* tag to be stripped, got tags: %v", doc.Tags)
}
}
_, err = store.CreateDocShare(userA.ID, doc.ID, userB.ID, "write")
if err != nil {
t.Fatalf("CreateDocShare failed: %v", err)
}
// User B moves the individually shared note into "Arbeit/Eingang"
_, err = store.MoveDocument(userB.ID, doc.ID, "Arbeit/Eingang")
if err != nil {
t.Fatalf("MoveDocument by userB failed: %v", err)
}
// User B is a read-only collaborator -> PatchDocumentBlocks must fail
_, _, err = store.PatchDocumentBlocks(userB.ID, doc.ID, domain.DocBlocksPatchRequest{
Upserted: []domain.DocumentBlock{
{ID: "b2", Type: "bullet", Content: "Zeile 2 von B bearbeitet", Level: 0},
{ID: "b3", Type: "bullet", Content: "Zeile 3 neu von B", Level: 0},
},
BlockOrder: []string{"b1", "b2", "b3"},
})
if err == nil {
t.Fatalf("expected PatchDocumentBlocks by read-only userB to fail, but succeeded")
}
// User A's client only knew about b1, b2 when sending its patch (adds b4)
patchedDocA, _, err := store.PatchDocumentBlocks(userA.ID, doc.ID, domain.DocBlocksPatchRequest{
Upserted: []domain.DocumentBlock{
{ID: "b1", Type: "bullet", Content: "Zeile 1 von A bearbeitet", Level: 0},
{ID: "b4", Type: "bullet", Content: "Zeile 4 neu von A", Level: 0},
},
BlockOrder: []string{"b1", "b2", "b4"}, // stale order without b3!
})
if err != nil {
t.Fatalf("PatchDocumentBlocks by userA failed: %v", err)
}
// Verify User A's blocks are preserved: b1, b2, b4 (3 blocks)
if len(patchedDocA.Blocks) != 3 {
t.Fatalf("expected 3 blocks after patch, got %d: %+v", len(patchedDocA.Blocks), patchedDocA.Blocks)
}
blockContent := make(map[string]string)
for _, b := range patchedDocA.Blocks {
blockContent[b.ID] = b.Content
}
if blockContent["b1"] != "Zeile 1 von A bearbeitet" {
t.Fatalf("expected b1 updated by A, got %q", blockContent["b1"])
}
if blockContent["b4"] != "Zeile 4 neu von A" {
t.Fatalf("expected b4 added by A, got %q", blockContent["b4"])
}
// Verify folder independence: User A still sees "Privat", User B still sees "Arbeit/Eingang"
docForA, err := store.GetDocumentByID(userA.ID, doc.ID)
if err != nil || docForA.FolderPath != "Privat" {
t.Fatalf("expected User A FolderPath 'Privat', got %q (err=%v)", docForA.FolderPath, err)
}
docForB, err := store.GetDocumentByID(userB.ID, doc.ID)
if err != nil || docForB.FolderPath != "Arbeit/Eingang" {
t.Fatalf("expected User B FolderPath 'Arbeit/Eingang', got %q (err=%v)", docForB.FolderPath, err)
}
foldersA, err := store.GetFolders(userA.ID)
if err != nil {
t.Fatalf("GetFolders for userA failed: %v", err)
}
for _, f := range foldersA {
if f == "Arbeit" || f == "Arbeit/Eingang" {
t.Fatalf("User B's private folder leaked into User A's folder list: %v", foldersA)
}
}
// 2. Shared Folder & Empty Subfolder Sync
_, err = store.CreateFolder(userA.ID, "TeamProjekt")
if err != nil {
t.Fatalf("CreateFolder TeamProjekt failed: %v", err)
}
sf, err := store.CreateSharedFolder(userA.ID, "TeamProjekt", "TeamProjekt")
if err != nil {
t.Fatalf("CreateSharedFolder failed: %v", err)
}
_, err = store.CreateFolderShare(userA.ID, sf.ID, "user", userB.ID, "write")
if err != nil {
t.Fatalf("CreateFolderShare failed: %v", err)
}
// User B moves their mount root of the shared folder to "ExterneProjekte/TeamProjekt"
err = store.UpdateFolderMount(userB.ID, sf.ID, "ExterneProjekte/TeamProjekt")
if err != nil {
t.Fatalf("UpdateFolderMount failed: %v", err)
}
// User A creates an EMPTY subfolder inside "TeamProjekt/Konzepte"
_, err = store.CreateFolder(userA.ID, "TeamProjekt/Konzepte")
if err != nil {
t.Fatalf("CreateFolder TeamProjekt/Konzepte failed: %v", err)
}
// User B must immediately see "ExterneProjekte/TeamProjekt/Konzepte" in GetFolders without any document inside!
foldersB, err := store.GetFolders(userB.ID)
if err != nil {
t.Fatalf("GetFolders for userB failed: %v", err)
}
foundSubfolderForB := false
for _, f := range foldersB {
if f == "ExterneProjekte/TeamProjekt/Konzepte" {
foundSubfolderForB = true
}
}
if !foundSubfolderForB {
t.Fatalf("expected User B to see empty shared subfolder 'ExterneProjekte/TeamProjekt/Konzepte', got folders: %v", foldersB)
}
// 3. MoveFolder inside a shared folder
newPath, _, err := store.MoveFolder(userA.ID, "TeamProjekt/Konzepte", "TeamProjekt/Archiv")
if err != nil {
t.Fatalf("MoveFolder failed: %v", err)
}
if newPath != "TeamProjekt/Archiv/Konzepte" {
t.Fatalf("expected newPath 'TeamProjekt/Archiv/Konzepte', got %q", newPath)
}
// User B must now see "ExterneProjekte/TeamProjekt/Archiv/Konzepte"
foldersBAfterMove, err := store.GetFolders(userB.ID)
if err != nil {
t.Fatalf("GetFolders after MoveFolder failed: %v", err)
}
foundMovedForB := false
for _, f := range foldersBAfterMove {
if f == "ExterneProjekte/TeamProjekt/Archiv/Konzepte" {
foundMovedForB = true
}
}
if !foundMovedForB {
t.Fatalf("expected User B to see moved shared subfolder 'ExterneProjekte/TeamProjekt/Archiv/Konzepte', got %v", foldersBAfterMove)
}
}
func TestFlushDocumentStateAndSchema(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "tabula_rasa_test.db")
store, err := NewStore(dbPath)
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
defer store.Close()
user, err := store.CreateUser("outliner@example.com", "hash", "Outliner User", "member")
if err != nil {
t.Fatalf("CreateUser failed: %v", err)
}
doc, err := store.CreateDocument(user.ID, "", "Initial Note", "📝", "Projekte", nil, nil, false)
if err != nil {
t.Fatalf("CreateDocument failed: %v", err)
}
blocks := []domain.DocumentBlock{
{
ID: "blk_root_1",
Type: "text",
Content: "Architektur Übersicht #backend #go",
Level: 0,
},
{
ID: "blk_child_1",
Type: "todo",
Content: "SQLite WAL Modus aktivieren #sqlite",
Level: 1,
Checked: true,
},
}
if err := store.FlushDocumentState(doc.ID, "Architektur Notiz", blocks); err != nil {
t.Fatalf("FlushDocumentState failed: %v", err)
}
updatedDoc, err := store.GetDocumentByID(user.ID, doc.ID)
if err != nil {
t.Fatalf("GetDocumentByID failed: %v", err)
}
if updatedDoc.Title != "Architektur Notiz" {
t.Fatalf("expected title 'Architektur Notiz', got '%s'", updatedDoc.Title)
}
if len(updatedDoc.Blocks) != 2 {
t.Fatalf("expected 2 blocks, got %d", len(updatedDoc.Blocks))
}
if updatedDoc.Blocks[1].Level != 1 || updatedDoc.Blocks[1].Position != 2000 {
t.Fatalf("expected block[1] level=1 position=2000, got level=%d position=%v", updatedDoc.Blocks[1].Level, updatedDoc.Blocks[1].Position)
}
if updatedDoc.MarkdownCache == "" {
t.Fatalf("expected markdown_cache to be populated")
}
tags, err := store.GetWorkspaceTags(user.ID)
if err != nil {
t.Fatalf("GetWorkspaceTags failed: %v", err)
}
if len(tags) != 3 {
t.Fatalf("expected 3 extracted tags in block_tags (#backend, #go, #sqlite), got %d: %+v", len(tags), tags)
}
}
+521 -35
View File
@@ -2,8 +2,10 @@ package ws
import ( import (
"encoding/json" "encoding/json"
"log" "fmt"
"log/slog"
"net/http" "net/http"
"strings"
"sync" "sync"
"time" "time"
@@ -12,8 +14,8 @@ import (
) )
var upgrader = websocket.Upgrader{ var upgrader = websocket.Upgrader{
ReadBufferSize: 1024, ReadBufferSize: 4096,
WriteBufferSize: 1024, WriteBufferSize: 4096,
CheckOrigin: func(r *http.Request) bool { CheckOrigin: func(r *http.Request) bool {
return true // Self-hosted reverse proxy compatible return true // Self-hosted reverse proxy compatible
}, },
@@ -22,62 +24,358 @@ var upgrader = websocket.Upgrader{
type Client struct { type Client struct {
hub *Hub hub *Hub
conn *websocket.Conn conn *websocket.Conn
spaceID string roomID string
userID string userID string
send chan []byte send chan []byte
subscribedRooms map[string]bool
sendMu sync.RWMutex
closed bool
}
func (c *Client) closeSend() {
c.sendMu.Lock()
defer c.sendMu.Unlock()
if !c.closed {
c.closed = true
close(c.send)
}
}
func (c *Client) trySend(data []byte) {
c.sendMu.RLock()
defer c.sendMu.RUnlock()
if c.closed {
return
}
select {
case c.send <- data:
default:
}
}
type SSEClient struct {
id string
userID string
roomID string
send chan []byte
subscribedRooms map[string]bool
sendMu sync.RWMutex
closed bool
}
func (c *SSEClient) closeSend() {
c.sendMu.Lock()
defer c.sendMu.Unlock()
if !c.closed {
c.closed = true
close(c.send)
}
}
func (c *SSEClient) trySend(data []byte) {
c.sendMu.RLock()
defer c.sendMu.RUnlock()
if c.closed {
return
}
select {
case c.send <- data:
default:
}
} }
type Hub struct { type Hub struct {
mu sync.RWMutex mu sync.RWMutex
rooms map[string]map[*Client]bool rooms map[string]map[*Client]bool
userRooms map[string]map[*Client]bool
allClients map[*Client]bool
sseClients map[*SSEClient]bool
sseUserRooms map[string]map[*SSEClient]bool
sseRooms map[string]map[*SSEClient]bool
closed bool
} }
func NewHub() *Hub { func NewHub() *Hub {
return &Hub{ return &Hub{
rooms: make(map[string]map[*Client]bool), rooms: make(map[string]map[*Client]bool),
userRooms: make(map[string]map[*Client]bool),
allClients: make(map[*Client]bool),
sseClients: make(map[*SSEClient]bool),
sseUserRooms: make(map[string]map[*SSEClient]bool),
sseRooms: make(map[string]map[*SSEClient]bool),
} }
} }
func (h *Hub) Register(c *Client) { func (h *Hub) Register(c *Client) {
h.mu.Lock() h.mu.Lock()
if h.rooms[c.spaceID] == nil { h.allClients[c] = true
h.rooms[c.spaceID] = make(map[*Client]bool) if c.roomID != "" {
if h.rooms[c.roomID] == nil {
h.rooms[c.roomID] = make(map[*Client]bool)
} }
h.rooms[c.spaceID][c] = true h.rooms[c.roomID][c] = true
onlineCount := len(h.rooms[c.spaceID]) }
if c.userID != "" && c.userID != "anonymous" {
if h.userRooms[c.userID] == nil {
h.userRooms[c.userID] = make(map[*Client]bool)
}
h.userRooms[c.userID][c] = true
}
onlineCount := len(h.allClients) + len(h.sseClients)
h.mu.Unlock() h.mu.Unlock()
h.Broadcast(c.spaceID, "", "USER_PRESENCE", map[string]interface{}{ if c.roomID != "" {
h.Broadcast(c.roomID, "", "USER_PRESENCE", map[string]interface{}{
"onlineCount": onlineCount, "onlineCount": onlineCount,
}) })
}
} }
func (h *Hub) Unregister(c *Client) { func (h *Hub) Unregister(c *Client) {
h.mu.Lock() h.mu.Lock()
var onlineCount int delete(h.allClients, c)
if room, ok := h.rooms[c.spaceID]; ok { if c.roomID != "" {
if _, exists := room[c]; exists { if room, ok := h.rooms[c.roomID]; ok {
delete(room, c) delete(room, c)
close(c.send) if len(room) == 0 {
delete(h.rooms, c.roomID)
} }
onlineCount = len(room)
if onlineCount == 0 {
delete(h.rooms, c.spaceID)
} }
} }
if c.subscribedRooms != nil {
for rID := range c.subscribedRooms {
if room, ok := h.rooms[rID]; ok {
delete(room, c)
if len(room) == 0 {
delete(h.rooms, rID)
}
}
}
}
if c.userID != "" && c.userID != "anonymous" {
if uRoom, ok := h.userRooms[c.userID]; ok {
delete(uRoom, c)
if len(uRoom) == 0 {
delete(h.userRooms, c.userID)
}
}
}
c.closeSend()
h.mu.Unlock()
}
func (h *Hub) RegisterSSE(c *SSEClient) {
h.mu.Lock()
h.sseClients[c] = true
if c.roomID != "" {
if h.sseRooms[c.roomID] == nil {
h.sseRooms[c.roomID] = make(map[*SSEClient]bool)
}
h.sseRooms[c.roomID][c] = true
}
if c.userID != "" && c.userID != "anonymous" {
if h.sseUserRooms[c.userID] == nil {
h.sseUserRooms[c.userID] = make(map[*SSEClient]bool)
}
h.sseUserRooms[c.userID][c] = true
}
if c.subscribedRooms != nil {
for rID := range c.subscribedRooms {
if h.sseRooms[rID] == nil {
h.sseRooms[rID] = make(map[*SSEClient]bool)
}
h.sseRooms[rID][c] = true
}
}
onlineCount := len(h.allClients) + len(h.sseClients)
h.mu.Unlock()
if c.roomID != "" {
h.Broadcast(c.roomID, "", "USER_PRESENCE", map[string]interface{}{
"onlineCount": onlineCount,
})
}
}
func (h *Hub) UnregisterSSE(c *SSEClient) {
h.mu.Lock()
delete(h.sseClients, c)
if c.roomID != "" {
if room, ok := h.sseRooms[c.roomID]; ok {
delete(room, c)
if len(room) == 0 {
delete(h.sseRooms, c.roomID)
}
}
}
if c.subscribedRooms != nil {
for rID := range c.subscribedRooms {
if room, ok := h.sseRooms[rID]; ok {
delete(room, c)
if len(room) == 0 {
delete(h.sseRooms, rID)
}
}
}
}
if c.userID != "" && c.userID != "anonymous" {
if uRoom, ok := h.sseUserRooms[c.userID]; ok {
delete(uRoom, c)
if len(uRoom) == 0 {
delete(h.sseUserRooms, c.userID)
}
}
}
c.closeSend()
h.mu.Unlock()
}
func (h *Hub) JoinSSERoomByID(sseID, roomID string) bool {
h.mu.Lock()
defer h.mu.Unlock()
for client := range h.sseClients {
if client.id == sseID {
if h.sseRooms[roomID] == nil {
h.sseRooms[roomID] = make(map[*SSEClient]bool)
}
h.sseRooms[roomID][client] = true
if client.subscribedRooms == nil {
client.subscribedRooms = make(map[string]bool)
}
client.subscribedRooms[roomID] = true
return true
}
}
return false
}
func (h *Hub) LeaveSSERoomByID(sseID, roomID string) bool {
h.mu.Lock()
defer h.mu.Unlock()
for client := range h.sseClients {
if client.id == sseID {
if room, ok := h.sseRooms[roomID]; ok {
delete(room, client)
if len(room) == 0 {
delete(h.sseRooms, roomID)
}
}
if client.subscribedRooms != nil {
delete(client.subscribedRooms, roomID)
}
return true
}
}
return false
}
// Close gracefully disconnects all WebSocket and SSE connections so HTTP shutdown completes immediately.
func (h *Hub) Close() {
h.mu.Lock()
if h.closed {
h.mu.Unlock()
return
}
h.closed = true
allWsClients := make([]*Client, 0, len(h.allClients))
for c := range h.allClients {
allWsClients = append(allWsClients, c)
}
allSSEClients := make([]*SSEClient, 0, len(h.sseClients))
for c := range h.sseClients {
allSSEClients = append(allSSEClients, c)
}
h.mu.Unlock() h.mu.Unlock()
if onlineCount > 0 { for _, c := range allWsClients {
h.Broadcast(c.spaceID, "", "USER_PRESENCE", map[string]interface{}{ _ = c.conn.Close()
"onlineCount": onlineCount, c.closeSend()
}) }
for _, c := range allSSEClients {
c.closeSend()
}
}
// Broadcasting Methods
func (h *Hub) BroadcastToAll(event string, payload interface{}) {
msg := domain.WSMessage{
Event: event,
Payload: payload,
Timestamp: domain.NowISO(),
}
data, err := json.Marshal(msg)
if err != nil {
return
}
h.mu.RLock()
if h.closed {
h.mu.RUnlock()
return
}
defer h.mu.RUnlock()
for client := range h.allClients {
client.trySend(data)
}
for sseClient := range h.sseClients {
sseClient.trySend(data)
}
}
func (h *Hub) BroadcastToUser(userID, event string, payload interface{}) {
msg := domain.WSMessage{
Event: event,
Payload: payload,
Timestamp: domain.NowISO(),
}
data, err := json.Marshal(msg)
if err != nil {
return
}
h.mu.RLock()
if h.closed {
h.mu.RUnlock()
return
}
defer h.mu.RUnlock()
if room, ok := h.userRooms[userID]; ok {
for client := range room {
client.trySend(data)
}
}
if sseRoom, ok := h.sseUserRooms[userID]; ok {
for sseClient := range sseRoom {
sseClient.trySend(data)
}
}
}
func (h *Hub) BroadcastTaskEvent(userID, event string, payload interface{}) {
h.BroadcastToUser(userID, event, payload)
}
func (h *Hub) BroadcastDocEvent(docID, event string, payload interface{}) {
if docID != "" {
h.BroadcastRoom("doc:"+docID, event, payload, nil)
} else {
h.BroadcastToAll(event, payload)
} }
} }
func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) { func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) {
msg := domain.WSMessage{ msg := domain.WSMessage{
Event: event, Event: event,
SpaceID: spaceID,
ListID: listID, ListID: listID,
Payload: payload, Payload: payload,
Timestamp: domain.NowISO(), Timestamp: domain.NowISO(),
@@ -88,34 +386,118 @@ func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) {
} }
h.mu.RLock() h.mu.RLock()
defer h.mu.RUnlock() if h.closed {
h.mu.RUnlock()
room, ok := h.rooms[spaceID]
if !ok {
return return
} }
defer h.mu.RUnlock()
targetRoom := listID
if targetRoom == "" {
targetRoom = spaceID
}
if room, ok := h.rooms[targetRoom]; ok {
for client := range room { for client := range room {
select { client.trySend(data)
case client.send <- data: }
default: }
// Slow client buffer full if sseRoom, ok := h.sseRooms[targetRoom]; ok {
for sseClient := range sseRoom {
sseClient.trySend(data)
} }
} }
} }
func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request, spaceID, userID string) { func (h *Hub) BroadcastListEvent(listID, event string, payload interface{}) {
h.Broadcast("", listID, event, payload)
}
func (h *Hub) JoinRoom(c *Client, roomID string) {
h.mu.Lock()
defer h.mu.Unlock()
if h.rooms[roomID] == nil {
h.rooms[roomID] = make(map[*Client]bool)
}
h.rooms[roomID][c] = true
if c.subscribedRooms == nil {
c.subscribedRooms = make(map[string]bool)
}
c.subscribedRooms[roomID] = true
}
func (h *Hub) LeaveRoom(c *Client, roomID string) {
h.mu.Lock()
defer h.mu.Unlock()
if room, ok := h.rooms[roomID]; ok {
delete(room, c)
if len(room) == 0 {
delete(h.rooms, roomID)
}
}
if c.subscribedRooms != nil {
delete(c.subscribedRooms, roomID)
}
}
func (h *Hub) BroadcastRoom(roomID, event string, payload interface{}, excludeClient *Client) {
msg := domain.WSMessage{
Event: event,
Payload: payload,
Timestamp: domain.NowISO(),
}
data, err := json.Marshal(msg)
if err != nil {
return
}
h.mu.RLock()
if h.closed {
h.mu.RUnlock()
return
}
defer h.mu.RUnlock()
if room, ok := h.rooms[roomID]; ok {
for client := range room {
if excludeClient != nil && client == excludeClient {
continue
}
client.trySend(data)
}
}
if sseRoom, ok := h.sseRooms[roomID]; ok {
for sseClient := range sseRoom {
sseClient.trySend(data)
}
}
}
func (c *Client) SendEvent(event string, payload interface{}) {
msg := domain.WSMessage{
Event: event,
Payload: payload,
Timestamp: domain.NowISO(),
}
data, err := json.Marshal(msg)
if err == nil {
c.trySend(data)
}
}
func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request, roomID, userID string) {
conn, err := upgrader.Upgrade(w, r, nil) conn, err := upgrader.Upgrade(w, r, nil)
if err != nil { if err != nil {
log.Printf("[ws] upgrade error: %v", err) slog.Error("WebSocket upgrade error", "room_id", roomID, "user_id", userID, "error", err)
return return
} }
client := &Client{ client := &Client{
hub: h, hub: h,
conn: conn, conn: conn,
spaceID: spaceID, roomID: roomID,
userID: userID, userID: userID,
send: make(chan []byte, 64), send: make(chan []byte, 128),
} }
h.Register(client) h.Register(client)
@@ -123,13 +505,80 @@ func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request, spaceID, userID st
go client.readPump() go client.readPump()
} }
func (h *Hub) ServeSSE(w http.ResponseWriter, r *http.Request, roomID, userID string, initialRooms []string) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming unsupported!", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache, no-transform")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
w.Header().Set("Access-Control-Allow-Origin", "*")
client := &SSEClient{
id: domain.NewID("sse"),
userID: userID,
roomID: roomID,
send: make(chan []byte, 128),
subscribedRooms: make(map[string]bool),
}
for _, room := range initialRooms {
if strings.TrimSpace(room) != "" {
client.subscribedRooms[strings.TrimSpace(room)] = true
}
}
h.RegisterSSE(client)
defer h.UnregisterSSE(client)
// Send initial connected comment and event
fmt.Fprintf(w, ": connected (transport: sse, id: %s)\n\n", client.id)
connectedMsg, _ := json.Marshal(domain.WSMessage{
Event: "SSE_CONNECTED",
Payload: map[string]interface{}{
"sseId": client.id,
"transport": "sse",
"userId": userID,
},
Timestamp: domain.NowISO(),
})
fmt.Fprintf(w, "data: %s\n\n", connectedMsg)
flusher.Flush()
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
notify := r.Context().Done()
for {
select {
case <-notify:
return
case msg, ok := <-client.send:
if !ok {
return
}
fmt.Fprintf(w, "data: %s\n\n", msg)
flusher.Flush()
case <-ticker.C:
// Periodic ping comment to keep HTTP connection alive and prevent proxy timeouts
fmt.Fprintf(w, ": ping\n\n")
flusher.Flush()
}
}
}
func (c *Client) readPump() { func (c *Client) readPump() {
defer func() { defer func() {
c.hub.Unregister(c) c.hub.Unregister(c)
_ = c.conn.Close() _ = c.conn.Close()
}() }()
c.conn.SetReadLimit(8192) c.conn.SetReadLimit(4 * 1024 * 1024) // 4MB for large document CRDT updates
_ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second)) _ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
c.conn.SetPongHandler(func(string) error { c.conn.SetPongHandler(func(string) error {
_ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second)) _ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
@@ -137,10 +586,47 @@ func (c *Client) readPump() {
}) })
for { for {
_, _, err := c.conn.ReadMessage() _, message, err := c.conn.ReadMessage()
if err != nil { if err != nil {
break break
} }
// Handle client-initiated lock, CRDT, room subscription, and presence messages
var incoming struct {
Event string `json:"event"`
DocID string `json:"docId"`
Room string `json:"room"`
Payload json.RawMessage `json:"payload"`
}
if json.Unmarshal(message, &incoming) == nil && c.userID != "" && c.userID != "anonymous" {
docID := incoming.DocID
if docID == "" && len(incoming.Payload) > 0 {
var p struct {
DocID string `json:"docId"`
}
_ = json.Unmarshal(incoming.Payload, &p)
docID = p.DocID
}
// Room subscription without requiring docID
if incoming.Event == "JOIN_ROOM" {
room := incoming.Room
if room == "" {
room = docID
}
if room != "" {
c.hub.JoinRoom(c, room)
}
} else if incoming.Event == "LEAVE_ROOM" {
room := incoming.Room
if room == "" {
room = docID
}
if room != "" {
c.hub.LeaveRoom(c, room)
}
}
}
} }
} }
+58
View File
@@ -0,0 +1,58 @@
package ws
import (
"sync"
"testing"
"time"
)
func TestHubCloseAndGracefulShutdown(t *testing.T) {
hub := NewHub()
// Register multiple dummy SSE clients
var sseClients []*SSEClient
for i := 0; i < 5; i++ {
client := &SSEClient{
id: "sse-test",
userID: "user-1",
send: make(chan []byte, 10),
}
hub.RegisterSSE(client)
sseClients = append(sseClients, client)
}
// Verify broadcast works
hub.BroadcastToAll("TEST_EVENT", map[string]string{"msg": "hello"})
// Concurrently invoke Close() and Broadcast() to verify no panics or deadlocks
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
for i := 0; i < 50; i++ {
hub.BroadcastToAll("SPAM", map[string]int{"i": i})
time.Sleep(1 * time.Millisecond)
}
}()
go func() {
defer wg.Done()
time.Sleep(5 * time.Millisecond)
hub.Close()
}()
wg.Wait()
// Verify that all SSE client channels are closed
for _, c := range sseClients {
select {
case _, ok := <-c.send:
if ok {
// Drained a message, check next
_, ok = <-c.send
}
default:
}
}
}
+2
View File
@@ -24,6 +24,7 @@ CREATE TABLE IF NOT EXISTS thoughts (
converted_type TEXT DEFAULT '', converted_type TEXT DEFAULT '',
converted_ref_id TEXT DEFAULT '', converted_ref_id TEXT DEFAULT '',
due_date TEXT DEFAULT '', due_date TEXT DEFAULT '',
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
triaged_at TEXT DEFAULT '', triaged_at TEXT DEFAULT '',
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
@@ -31,6 +32,7 @@ CREATE TABLE IF NOT EXISTS thoughts (
CREATE INDEX IF NOT EXISTS idx_thoughts_user_status ON thoughts(user_id, status, created_at DESC); CREATE INDEX IF NOT EXISTS idx_thoughts_user_status ON thoughts(user_id, status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_thoughts_user_due ON thoughts(user_id, due_date); CREATE INDEX IF NOT EXISTS idx_thoughts_user_due ON thoughts(user_id, due_date);
CREATE INDEX IF NOT EXISTS idx_thoughts_list_sort ON thoughts(task_list_id, sort_order ASC, created_at DESC);
CREATE TABLE IF NOT EXISTS spaces ( CREATE TABLE IF NOT EXISTS spaces (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB