diff --git a/README.md b/README.md index 7af3108..9ce2f6d 100644 --- a/README.md +++ b/README.md @@ -8,86 +8,149 @@ [![Markdown](https://img.shields.io/badge/Datenhoheit-Pure%20Markdown%20%26%20Outliner-18181b?style=flat-square)](#) [![License](https://img.shields.io/badge/Lizenz-MIT-22c55e?style=flat-square)](LICENSE) -**MindSift** ist deine zentrale, kompromisslos schnelle Schaltstelle für spontane Gedanken, strukturierte Aufgaben, hierarchische Outliner-Notizen und gemeinsame Echtzeit-Listen — ablenkungsfrei gestaltet, lokal als Standard-Markdown gespeichert und zu 100 % unter deiner eigenen Kontrolle. +**MindSift** ist deine zentrale, kompromisslos schnelle Schaltstelle für spontane Gedanken, strukturierte Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen mit Zoom-Fokus und gemeinsame Echtzeit-Listen — ablenkungsfrei gestaltet, lokal als Standard-Markdown gespeichert und zu 100 % unter deiner eigenen Kontrolle. + +
+ +

+ MindSift Triage Desktop +

[Philosophie](#-die-philosophie) • [Funktionen](#-was-mindsift-auszeichnet) • [Schnellstart](#-schnellstart) • [Für wen?](#-für-wen-ist-mindsift-gemacht) ---- - +--- + ## 💡 Die Philosophie: *Erst raus aus dem Kopf – später sortieren* Klassische Produktivitäts- und Notiz-Apps scheitern im Alltag oft an derselben Hürde: **Sie verlangen Struktur im falschen Moment.** -Wenn dir mitten im Gespräch, unterwegs oder kurz vor Feierabend ein wichtiger Gedanke kommt, willst du keinen Ordner auswählen, keine Datenbank-Eigenschaften pflegen und kein Formular ausfüllen. Du willst den Gedanken in drei Sekunden loswerden – und sicher sein, dass er nicht verloren geht. +Wenn dir mitten im Gespräch, unterwegs auf dem Smartphone oder kurz vor Feierabend ein wichtiger Gedanke kommt, willst du keinen Ordner suchen, keine Datenbankfelder ausfüllen und keine starren Formulare bedienen. Du willst den Gedanken in drei Sekunden festhalten – und sicher sein, dass er verlässlich da ist, wenn du Zeit hast. **MindSift trennt das Erfassen radikal vom Organisieren:** -1. **Sofort abladen (Quick Dump):** Oben in der Eingabeleiste tippen, `Enter` drücken, fertig. Dein Kopf ist wieder frei. Alternativ kannst du einen Eintrag schon beim Schreiben direkt einer Aufgabenliste, Einkaufsliste oder Notiz zuweisen. -2. **Spielerisch sichten (Triage):** Wenn du später Ruhe hast, sichtest du deinen Gedanken-Stapel Karte für Karte per Tastatur oder Swipe. Nach links verwerfen, auf „Später“ schieben oder mit einem Tastendruck in eine Aufgabe, ein Dokument oder einen Listeneintrag verwandeln. -3. **Strukturiert vertiefen:** Aus spontanen Notizen werden hierarchische Outliner-Dokumente, vernetzte Notizbäume, Kanban-Boards oder geteilte Listen für Familie und Team. +1. **Sofort abladen (Quick Dump):** In die globale Eingabeleiste tippen, `Enter` drücken, Kopf wieder frei. Du kannst Gedanken aber auch direkt beim Eintippen gezielt einer Aufgabenliste, Einkaufsliste oder Notiz zuweisen. +2. **Spielerisch sichten (Triage):** In ruhigen Momenten sichtest du deinen Stapel Karte für Karte – am Desktop per Tastaturkürzel oder auf dem Smartphone per Wischgeste. Mit einer Taste nach links verwerfen, auf „Später“ vertagen oder direkt in eine Aufgabe, Notiz oder Liste umwandeln. +3. **Strukturiert vertiefen:** Aus losen Ideen werden vernetzte Outliner-Dokumente, hierarchische Unteraufgaben im Kanban-Board oder geteilte Listen für Familie, WG und Team. --- ## ✨ Was MindSift auszeichnet ### ⚡ Quick-Dump & Triage-Deck (Das 3-Sekunden-Prinzip) -* **Blitz-Erfassung:** Die Schnelleingabeleiste sitzt prominent ganz oben im Header – jederzeit erreichbar per Klick oder Tastendruck (`C` oder `/`). -* **Entscheiden mit 1 Taste:** Im Triage-Deck steht immer genau ein Gedanke im Fokus: + +* **Blitz-Erfassung:** Die Schnelleingabeleiste sitzt prominent im Header – jederzeit blitzschnell per Mausklick oder Tastendruck (`C` oder `/`) fokussierbar. +* **Tastatur-Triage am Desktop:** * `H` oder `←`: Wegwerfen / Erledigt * `L` oder `→`: Sofort zur Aufgabe machen * `T`: Termin-Aufgabe mit Fälligkeitsdatum * `D`: In Workspace-Notiz umwandeln * `Leertaste`: Später (ans Ende des Stapels schieben) +* **Mobile-First & Touch-optimiert:** Unterwegs kannst du Gedanken per Wischgeste (Swipe nach links zum Verwerfen, Swipe nach rechts zum Behalten) sortieren. -### 📝 Workspace: Der hierarchische Logseq-Outliner -* **Jeder Gedanke ein Bullet-Point:** Schreibe Notizen als flexible, hierarchische Gliederung: +

+ MindSift Mobile Triage +

+ +--- + +### 📝 Workspace: Der hierarchische Outliner mit Zoom-Fokus + +Der MindSift-Workspace vereint die Übersicht eines klassischen Dokumenten-Explorers mit der Leichtigkeit eines hierarchischen Outliners (inspiriert von Logseq und Roam). + +

+ MindSift Workspace Outliner +

+ +* **Jeder Gedanke ein Bullet-Point:** * `Enter`: Neuer Bullet-Point auf derselben Ebene * `Tab` / `Shift + Tab`: Unterordnen (einrücken) und ausrücken * `Shift + Enter`: Weicher Zeilenumbruch innerhalb desselben Blocks -* **Zoom-in Hoisting:** Klicke auf den Bullet-Punkt (`•`), um tief in einen Ast hineinzuzoomen (Fokus-Modus). Über die dezenten Breadcrumbs oben springst du jederzeit wieder zurück. -* **Slash-Befehle (`/`):** Tippe einfach `/` für `/todo`, `/h1`, `/h2`, `/h3`, `/code`, `/table`, `/callout` oder `/progress`. -* **Datei-Explorer mit Drag & Drop:** Verschiebe Dokumente mit der Maus direkt per Drag & Drop in Ordner oder zurück ins Hauptverzeichnis. -* **Angepinnte Favoriten:** Hefte wichtige Notizen mit einem Klick an – sie erscheinen prominent ganz oben im Explorer. -* **100 % Standard-Markdown auf der Festplatte:** Alle Notizen werden unter `/data/workspace//...` als echte `.md`-Dateien gespeichert (mit 2-Leerzeichen-Einrückung). Vollständig kompatibel mit Obsidian, Logseq oder einfachen Text-Editoren – kein Vendor-Lock-in! +* **🔍 Fokus-Modus & Zoom-in (Hoisting):** + * Klicke auf einen Bullet-Punkt (`•`), um **isoliert in diesen Ast hineinzuzoomen**. + * Der gewählte Punkt wird zur prominenten Überschrift, sodass du dich ohne jede Ablenkung auf genau dieses Thema konzentrieren kannst. + * Eine klickbare Breadcrumb-Leiste oben zeigt deinen aktuellen Pfad und führt dich mit einem Klick zurück in übergeordnete Ebenen oder ins Hauptdokument. +* **Intuitives Drag & Drop für Bullet-Points:** + * Verschiebe Absätze und ganze Gedankenbäume mühelos mit der Maus. + * **Unterpunkte per Drop erstellen:** Ziehst du einen Bullet-Point direkt auf einen anderen Aufzählungspunkt, wird er automatisch als Unter-Bullet eingerückt. Eingeklappte Eltern-Punkte klappen beim Hineinlegen automatisch auf. + * Untergeordnete Unterpunkte werden beim Ziehen automatisch als zusammenhängender Ast mitbewegt. +* **Echtzeit-Schreib-Lock (Kollisionsschutz):** + * Sobald du einen Bullet-Point per Drag & Drop greifst oder anfängst zu tippen, wird sofort ein Schreib-Lock für das Dokument aktiviert. + * Andere Nutzer sehen live, wer das Dokument gerade bearbeitet, und Kollisionen bei gleichzeitigen Änderungen werden zuverlässig verhindert. +* **Slash-Befehle (`/`):** Tippe einfach `/` für `/todo`, `/h1`, `/h2`, `/h3`, `/code`, `/callout` oder `/divider`. +* **Vernetzung & Hashtags:** Verlinke Notizen untereinander mit Wiki-Links (`[[Notiztitel]]`) und tagge Gedanken mit `#Hashtags`. +* **Explorer mit Ordnern & Favoriten:** Organisiere Dokumente hierarchisch in Ordnern, verschiebe Notizen per Drag & Drop und hefte deine wichtigsten Dokumente als Favoriten ganz oben an. +* **100 % Standard-Markdown:** Jedes Dokument wird lokal unter `./data/workspace/...` als saubere `.md`-Datei mit 2-Leerzeichen-Einrückung abgelegt – jederzeit lesbar und kompatibel mit Obsidian, Logseq oder VS Code. -### 📋 Globale To-Dos: Das automatische Aufgaben-Rollup -* **Alles auf einen Blick:** MindSift durchsucht all deine Notizen und sammelt sämtliche Checkboxen (`- [ ]`) in einem zentralen Rollup-Dashboard. -* **Ordner-Drilldown:** Isoliere einzelne Projekt-Ordner mit einem Klick. -* **Echtzeit-2-Wege-Sync:** Hakst du eine Aufgabe im Rollup ab, wird sie im selben Augenblick direkt in der physischen `.md`-Datei auf deiner Festplatte als `- [x]` aktualisiert! +--- ### ✅ Aufgabenlisten & Kanban-Board -* **Mehrere Listen:** Trenne Berufliches, Privates, Homelab oder Team-Projekte sauber in eigenen Listen. -* **Liste oder Kanban:** Wechsle mit einem Klick zwischen kompakter Listenansicht und dem **3-Spalten-Kanban-Board** (*Zu erledigen*, *In Arbeit*, *Erledigt*). -* **Teilen & Avatar-Stack:** Teile Aufgabenlisten mit Spaces und Teams. Der integrierte Avatar-Stack zeigt mit kleinen Initialen-Bubbles und Tooltips sofort, wer Zugriff hat. -### 🛒 Geteilte Echtzeit-Listen (Einkauf & Checklisten) -* **Gemeinsam abhaken:** Ob Wocheneinkauf oder Urlaubs-Packliste – teile Listen in Echtzeit über WebSocket. -* **Live-Synchronisation:** Wird ein Artikel im Laden abgehakt, verschwindet er sofort bei allen anderen Teilnehmern. +Behalte offene Aufgaben, Projekte und Termine im Blick – wahlweise als strukturierte Liste oder als visuelles Kanban-Board. -### 🏢 Organisationen, Teams & Administration -* **Geführte Ersteinrichtung:** Beim allerersten Start führt dich MindSift automatisch durch die Einrichtung deines Administrator-Kontos. -* **3-Stufen-Rollensystem:** Server-Admin, Team-Admin und reguläres Mitglied. -* **Einladungscodes & Registrierungs-Schalter:** Schalte die öffentliche Registrierung bequem per Schiebeschalter ab und lade neue Mitglieder gezielt per Einladungscode ein. -* **Server-Isolation:** Bestimme zentral, ob Nutzer strikt privat für sich arbeiten, sich nur innerhalb ihrer Teams sehen oder serverweit kooperieren. +

+ MindSift Aufgaben und Kanban-Board +

-### 🔔 Multi-Channel-Alarme -* **Pünktlich erinnert:** Automatische Benachrichtigung vor Fälligkeit wahlweise per **E-Mail (SMTP)**, **Discord-Webhook** oder **eigenem Webhook-Endpunkt**. -* **Eigene API-Schlüssel:** Sende System-Alarme, Server-Logs oder Skripte per einfachem `curl`-Befehl direkt in deine Inbox. +* **Flexible Ansichten (Liste & Kanban):** Wechsle mit einem Klick zwischen kompakter Listenansicht und dem interaktiven Kanban-Board (*Zu erledigen*, *In Arbeit*, *Wartend*, *Erledigt*). +* **Unteraufgaben (Subtasks):** + * Gliedere komplexe Aufgaben in beliebig viele Teilaufgaben. + * Ein visueller Fortschrittsbalken und eine Prozentanzeige zeigen dir auf einen Blick, wie weit die Aufgabe gediehen ist. +* **Freies Drag & Drop:** + * Sortiere Aufgaben vertikal in deiner bevorzugten Reihenfolge. + * Im Kanban-Board verschiebst du Karten per Drag & Drop direkt zwischen Status-Spalten oder ordnest sie innerhalb einer Spalte um. + * Kein mühsames Suchen nach Greifern: Klicke und ziehe eine Aufgabe einfach direkt mit der Maus – ein kurzer Klick ohne Ziehen öffnet das Detail-Modal zum Bearbeiten. +* **Prioritäten & Termine:** Hebe dringende oder wichtige Aufgaben mit Farbcodes und Icons hervor (🔥 Dringend, ⚠️ Wichtig). Setze Fälligkeitsdaten und filtere nach Hashtags. +* **Geteilte Aufgabenlisten:** Erstelle separate Listen für Beruf, Homelab oder Familie und teile sie gezielt mit Gruppen, Teams oder einzelnen Nutzern. -### 🎨 Single-File CSS Theming & Hilfe -* **1 Datei = 1 Theme:** Vollständiges Design-System gesteuert über genau eine CSS-Datei (`dark`, `light`, `high-contrast`, `soft`). -* **Integrierte Hilfeseite:** Ein Klick auf das Profil-Menü oder den Hilfe-Button öffnet eine strukturierte Dokumentation inklusive Tastatur-Cheat-Sheet. +--- + +### 🛒 Geteilte Listen (Einkauf & Checklisten) + +Ob Wochenmarkt, Baumarkt oder Urlaubs-Packliste – erstelle geteilte Listen, die für gemeinsames Arbeiten in Echtzeit optimiert sind. + +

+ MindSift Geteilte Listen +

+ +* **Automatische Hashtag-Kategorien:** + * Tagge Artikel einfach mit einem Hashtag (z. B. `Hafermilch #Kühlregal` oder `Bananen #Obst`). + * MindSift sortiert die Kategorien automatisch alphabetisch und zeigt sie in übersichtlichen Sektionen mit Kacheln an. + * **Hashtag-Autovervollständigung:** Bereits beim Tippen eines `#` schlägt dir die Eingabe existierende Hashtags vor (case-insensitive) – mit Tastaturnavigation und Sofort-Übernahme per `Enter`. +* **Mengenangaben & Schnell-Eingabe:** Erfasse Mengen (`2x`, `500g`) direkt im Eingabefeld. +* **Drag & Drop zwischen Kategorien:** Ziehe Einträge mit der Maus nach oben oder unten oder schiebe sie direkt in einen anderen Hashtag-Bereich – der Hashtag im Text wird automatisch aktualisiert. +* **Gemeinsam teilen mit Gruppen & Teams:** + * Über das einheitliche Teilen-Modal kannst du jede Liste mit deinen Teams, Arbeitsgruppen oder Einzelpersonen freigeben. + * Avatare und Freigabe-Badges im Header zeigen dir transparent, wer Zugriff hat. +* **Echtzeit-WebSocket-Synchronisation:** Wird ein Artikel im Supermarkt abgehakt, hakt er sich bei allen Beteiligten im selben Augenblick live ab. +* **Eckiges Abhaken & Aufräumen:** Erledigte Punkte wandern in einen separaten Bereich und können mit einem Klick aufgeräumt werden. + +--- + +### 📋 Globale To-Dos: Das automatische Notizen-Rollup + +* **Automatische Erfassung:** MindSift durchsucht alle Workspace-Notizen und sammelt sämtliche Checkboxen (`- [ ]`) in einem zentralen Rollup-Dashboard. +* **Ordner-Filter:** Isoliere Aufgaben bestimmter Projekte mit einem Klick. +* **2-Wege-Echtzeit-Sync:** Hakst du ein To-Do im Rollup ab, wird die physische Markdown-Datei auf der Festplatte augenblicklich als `- [x]` aktualisiert. + +--- + +### 🏢 Benutzer, Gruppen & Administration + +* **Geführte Ersteinrichtung:** Beim ersten Start führt dich ein Assistent durch die Einrichtung deines Administrator-Kontos. +* **Eigenständiger Gruppen-Bereich:** Verwalte eigene Gruppen und Teams direkt über das Benutzer-Menü oben rechts. +* **Gruppen-Administration:** Wer eine Gruppe anlegt oder zum Gruppen-Admin ernannt wird, hat die volle Kontrolle über die Mitglieder dieser Gruppe. +* **Server-Sicherheit:** Schalte die öffentliche Registrierung ab und lade neue Mitglieder kontrolliert per Einladungscode ein. --- ## 🚀 Schnellstart -MindSift läuft als leichtgewichtiger, einzelner Container und bringt Webserver, API, Datenbank und Frontend direkt mit. +MindSift läuft als leichtgewichtiger, einzelner Container und bringt Webserver, API, SQLite-Datenbank und Frontend direkt mit. ### Option 1: Der 1-Zeiler (Empfohlen) -Führe diesen Befehl im Terminal deines Servers aus. Er erstellt das Verzeichnis, lädt die Konfiguration, erzeugt ein sicheres JWT-Geheimnis und startet den Container: +Führe diesen Befehl im Terminal deines Servers aus. Er erstellt das Verzeichnis, lädt die Konfiguration, generiert ein sicheres JWT-Geheimnis und startet den Container: ```bash curl -fsSL https://git.dsit.me/sandrodach/MindSift/raw/branch/dev/install.sh | bash @@ -163,7 +226,7 @@ MindSift erkennt den Erststart automatisch und führt dich durch den Einrichtung ## 🔒 Deine Daten gehören dir * **100 % Self-Hosted:** Keine fremden Cloud-Dienste, keine Telemetrie, kein Tracking. -* **Kein Vendor-Lock-in:** Alle Notizen liegen als lesbare `.md`-Dateien im Ordner `./data/workspace`. Backups können jederzeit als JSON exportiert werden. +* **Kein Vendor-Lock-in:** Alle Notizen liegen als lesbare `.md`-Dateien im Ordner `./data/workspace`. Backups können jederzeit exportiert werden. --- diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9c5deac..b250bac 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -44,6 +44,8 @@ import { UserSettingsView } from './features/settings/UserSettingsView'; import { AdminSettingsView } from './features/settings/AdminSettingsView'; import { HelpView } from './features/help/HelpView'; import { GroupsView } from './features/teams/GroupsView'; +import { useMobileActions } from './context/MobileActionContext'; +import { MobileHeaderMenu } from './components/MobileHeaderMenu'; type ActiveTab = | 'triage' @@ -129,6 +131,107 @@ export const App: React.FC = () => { // Mobile Navigation & Quick-Action State const [showMobileCapture, setShowMobileCapture] = useState(false); const [showMobileProfile, setShowMobileProfile] = useState(false); + const { isMenuOpen, setIsMenuOpen, closeMenu, setGroups } = useMobileActions(); + + useEffect(() => { + closeMenu(); + if (activeTab === 'triage') { + setGroups([ + { + id: 'triage-actions', + title: 'Schnellaktionen', + items: [ + { + id: 'new-thought', + label: 'Neuen Gedanken erfassen', + icon: , + variant: 'accent', + onClick: () => setShowMobileCapture(true), + }, + { + id: 'refresh-deck', + label: 'Inbox aktualisieren', + icon: , + badge: stats.inboxCount, + onClick: () => refreshAll(), + }, + ], + }, + { + id: 'triage-nav', + title: 'Bereiche wechseln', + items: [ + { + id: 'goto-tasks', + label: 'Aufgaben & Kanban', + icon: , + badge: stats.tasksOpenCount, + onClick: () => setActiveTab('tasks'), + }, + { + id: 'goto-workspace', + label: 'Notizen & Workspace', + icon: , + badge: stats.docsCount, + onClick: () => setActiveTab('workspace'), + }, + { + id: 'goto-lists', + label: 'Einkaufslisten', + icon: , + badge: stats.listsCount, + onClick: () => setActiveTab('lists'), + }, + { + id: 'goto-groups', + label: 'Teams & Gruppen', + icon: , + onClick: () => setActiveTab('groups'), + }, + ], + }, + ]); + } else if ( + activeTab === 'groups' || + activeTab === 'user_settings' || + activeTab === 'admin_settings' || + activeTab === 'help' + ) { + setGroups([ + { + id: 'subview-nav', + title: 'Navigation', + items: [ + { + id: 'back-triage', + label: 'Zurück zur Triage (Startseite)', + icon: , + variant: 'accent', + onClick: () => setActiveTab('triage'), + }, + { + id: 'goto-tasks', + label: 'Zu den Aufgaben', + icon: , + onClick: () => setActiveTab('tasks'), + }, + { + id: 'goto-workspace', + label: 'Zum Workspace', + icon: , + onClick: () => setActiveTab('workspace'), + }, + { + id: 'goto-lists', + label: 'Zu den Listen', + icon: , + onClick: () => setActiveTab('lists'), + }, + ], + }, + ]); + } + }, [activeTab, stats.inboxCount, stats.tasksOpenCount, stats.docsCount, stats.listsCount]); // Mobile Web-App (PWA) Installation State const [isStandaloneApp, setIsStandaloneApp] = useState(() => { @@ -409,27 +512,50 @@ export const App: React.FC = () => { const isServerAdmin = user.role === 'admin'; const roleLabel = isServerAdmin ? 'Server-Admin' : 'Mitglied'; + const activeTabLabelMap: Record = { + triage: 'Triage', + tasks: 'Aufgaben', + workspace: 'Workspace', + lists: 'Listen', + groups: 'Teams & Gruppen', + user_settings: 'Einstellungen', + admin_settings: 'Administration', + help: 'Hilfe', + }; + return (
{/* Top Header Bar: Floating on mobile, solid panel on desktop */}
- {/* Mobile Header: Floating Bar with Larger Logo Left (Triage Home) & Profile Avatar Right */} + {/* Mobile Header: Floating Bar with Logo Action Dropdown Left & Profile Avatar Right */}
@@ -715,6 +841,8 @@ export const App: React.FC = () => {
+ + {/* Main Container */}
setActiveTab('triage')}> diff --git a/frontend/src/components/MobileHeaderMenu.tsx b/frontend/src/components/MobileHeaderMenu.tsx new file mode 100644 index 0000000..c33bf98 --- /dev/null +++ b/frontend/src/components/MobileHeaderMenu.tsx @@ -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 = ({ activeTabLabel }) => { + const { groups, isMenuOpen, closeMenu } = useMobileActions(); + + if (!isMenuOpen) return null; + + return ( +
+ {/* Backdrop */} +
{ + triggerHaptic(8); + closeMenu(); + }} + /> + + {/* Menu Sheet below Header */} +
+ {/* Menu Header */} +
+
+ + + {activeTabLabel} — Optionen & Aktionen + +
+ +
+ + {groups.length === 0 ? ( +
+ Keine spezifischen Optionen für diese Ansicht verfügbar. +
+ ) : ( +
+ {groups.map((group) => ( +
+ {group.title && ( +
+ {group.title} +
+ )} +
+ {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 ( + + ); + })} +
+
+ ))} +
+ )} +
+
+ ); +}; diff --git a/frontend/src/components/UnifiedShareModal.tsx b/frontend/src/components/UnifiedShareModal.tsx index fc487ee..3671ae3 100644 --- a/frontend/src/components/UnifiedShareModal.tsx +++ b/frontend/src/components/UnifiedShareModal.tsx @@ -6,7 +6,6 @@ import { Plus, Trash2, Lock, - Share2, Check, } from 'lucide-react'; import { User, Team, UnifiedShare } from '../lib/types'; @@ -114,7 +113,7 @@ export const UnifiedShareModal: React.FC = ({ resourceIcon ) ) : ( - + )}
diff --git a/frontend/src/context/MobileActionContext.tsx b/frontend/src/context/MobileActionContext.tsx new file mode 100644 index 0000000..ef661a4 --- /dev/null +++ b/frontend/src/context/MobileActionContext.tsx @@ -0,0 +1,62 @@ +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>; + closeMenu: () => void; +} + +const MobileActionContext = createContext({ + groups: [], + setGroups: () => {}, + isMenuOpen: false, + setIsMenuOpen: () => {}, + closeMenu: () => {}, +}); + +export const MobileActionProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [groups, setGroupsState] = useState([]); + const [isMenuOpen, setIsMenuOpen] = useState(false); + + const setGroups = useCallback((newGroups: MobileMenuGroup[]) => { + setGroupsState(newGroups); + }, []); + + const closeMenu = useCallback(() => { + setIsMenuOpen(false); + }, []); + + return ( + + {children} + + ); +}; + +export const useMobileActions = () => useContext(MobileActionContext); diff --git a/frontend/src/features/auth/SetupAndAuthView.tsx b/frontend/src/features/auth/SetupAndAuthView.tsx index 384b7bb..d382c6b 100644 --- a/frontend/src/features/auth/SetupAndAuthView.tsx +++ b/frontend/src/features/auth/SetupAndAuthView.tsx @@ -7,7 +7,7 @@ import { UserPlus, LogIn, Server, - Share2, + Users, Check, AlertCircle, } from 'lucide-react'; @@ -235,7 +235,7 @@ export const SetupAndAuthView: React.FC = ({
diff --git a/frontend/src/features/help/HelpView.tsx b/frontend/src/features/help/HelpView.tsx index 19b91dc..2cd3c70 100644 --- a/frontend/src/features/help/HelpView.tsx +++ b/frontend/src/features/help/HelpView.tsx @@ -234,7 +234,7 @@ export const HelpView: React.FC = () => {

- + Aufgabenlisten teilen & Avatar-Stack

diff --git a/frontend/src/features/lists/SharedListsView.tsx b/frontend/src/features/lists/SharedListsView.tsx index e992ece..4f3853c 100644 --- a/frontend/src/features/lists/SharedListsView.tsx +++ b/frontend/src/features/lists/SharedListsView.tsx @@ -1,22 +1,18 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Plus, - CheckCircle2, - Circle, Trash2, Users, Check, Radio, Sparkles, - Share, - Share2, - User as UserIcon, X, + Hash, } from 'lucide-react'; -import { ListItem, SharedList, Space, Team, User, WSMessage } from '../../lib/types'; +import { ListItem, SharedList, ListShare, Team, User, WSMessage } from '../../lib/types'; import { api, buildWebSocketURL, triggerHaptic } from '../../lib/api'; -import { UserAutocomplete } from '../UserAutocomplete'; import { UnifiedShareModal } from '../../components/UnifiedShareModal'; +import { useMobileActions } from '../../context/MobileActionContext'; interface SharedListsViewProps { onRefreshGlobal: () => void; @@ -68,14 +64,26 @@ export function getItemDisplayContent(item: ListItem): string { return item.content; } +export function deduplicateItems(items: ListItem[]): ListItem[] { + const seen = new Set(); + const result: ListItem[] = []; + for (const item of items) { + if (item && item.id && !seen.has(item.id)) { + seen.add(item.id); + result.push(item); + } + } + return result; +} + export const SharedListsView: React.FC = ({ onRefreshGlobal, currentUser }) => { - const [spaces, setSpaces] = useState([]); - const [activeSpace, setActiveSpace] = useState(null); + const { setGroups } = useMobileActions(); const [lists, setLists] = useState([]); const [activeListId, setActiveListId] = useState(''); const [newItemText, setNewItemText] = useState(''); const [newItemQty, setNewItemQty] = useState(''); + const [isSubmittingItem, setIsSubmittingItem] = useState(false); const [wsConnected, setWsConnected] = useState(false); const [showNewListModal, setShowNewListModal] = useState(false); @@ -85,61 +93,67 @@ export const SharedListsView: React.FC = ({ onRefreshGloba const [showShareModal, setShowShareModal] = useState(false); const [collaborators, setCollaborators] = useState([]); const [teams, setTeams] = useState([]); - const [addShareType, setAddShareType] = useState<'team' | 'user'>('team'); - const [selectedTeamId, setSelectedTeamId] = useState(''); - const [selectedShareUser, setSelectedShareUser] = useState(null); - const [inviteTarget, setInviteTarget] = useState(''); - const [inviteRole, setInviteRole] = useState<'editor' | 'viewer'>('editor'); - const [invitingLoading, setInvitingLoading] = useState(false); - const [shareError, setShareError] = useState(null); + + const [cursorPosition, setCursorPosition] = useState(null); + const [suggestionIndex, setSuggestionIndex] = useState(0); + const [showSuggestions, setShowSuggestions] = useState(false); + const [workspaceTags, setWorkspaceTags] = useState([]); + const inputRef = useRef(null); + + const [draggingItemId, setDraggingItemId] = useState(null); + const [dragOverItemTarget, setDragOverItemTarget] = useState<{ itemId: string; position: 'before' | 'after' } | null>(null); + const [dragOverCategoryTarget, setDragOverCategoryTarget] = useState(null); const wsRef = useRef(null); - const loadSpaces = async () => { - let sp = await api.getSpaces(); - if (sp.length === 0) { - try { - const created = await api.createSpace('Meine Listen', '🛒', 'Persönliche und geteilte Einkaufslisten'); - sp = [created]; - } catch { - // fallback + const loadLists = async () => { + try { + let all = await api.getAllLists(); + if (all.length === 0) { + try { + const created = await api.createList('Wocheneinkauf', '🛒', 'shopping'); + all = [created]; + } catch { + // fallback + } } - } - setSpaces(sp); - if (sp.length > 0) { - if (!activeSpace) { - setActiveSpace(sp[0]); - } else { - const found = sp.find((s) => s.id === activeSpace.id); - if (found) setActiveSpace(found); + const deduplicated = all.map((l) => ({ + ...l, + items: deduplicateItems(l.items || []), + })); + setLists(deduplicated); + if (deduplicated.length > 0 && (!activeListId || !deduplicated.some((l) => l.id === activeListId))) { + setActiveListId(deduplicated[0].id); } - } - }; - - const loadListsForSpace = async (spaceId: string) => { - const lst = await api.getSpaceLists(spaceId); - setLists(lst); - if (lst.length > 0 && (!activeListId || !lst.some((l) => l.id === activeListId))) { - setActiveListId(lst[0].id); + } catch { + // ignore } }; useEffect(() => { - loadSpaces(); + loadLists(); + api + .getWorkspaceTags() + .then((tags) => { + setWorkspaceTags(tags.map((t) => t.tag.replace(/^#/, ''))); + }) + .catch(() => {}); }, []); + const activeList = useMemo(() => { + return lists.find((l) => l.id === activeListId) || lists[0] || null; + }, [lists, activeListId]); + useEffect(() => { - if (!activeSpace) return; - loadListsForSpace(activeSpace.id); + if (!activeList?.id) return; - // Connect Realtime WebSocket for this Space if (wsRef.current) { wsRef.current.close(); } let ws: WebSocket | null = null; try { - ws = new WebSocket(buildWebSocketURL(activeSpace.id)); + ws = new WebSocket(buildWebSocketURL(activeList.id)); wsRef.current = ws; ws.onopen = () => { @@ -153,41 +167,52 @@ export const SharedListsView: React.FC = ({ onRefreshGloba ws.onmessage = (evt) => { try { const msg: WSMessage = JSON.parse(evt.data); - if (msg.event === 'ITEM_CREATED' && msg.list_id) { + const msgListId = msg.listId || (msg as any).list_id; + if (msg.event === 'ITEM_CREATED' && msgListId) { const newItem: ListItem = msg.payload; + if (!newItem || !newItem.id) return; setLists((prev) => prev.map((l) => { - if (l.id !== msg.list_id) return l; + if (l.id !== msgListId) return l; if (l.items.some((i) => i.id === newItem.id)) return l; - return { ...l, items: [newItem, ...l.items] }; + return { ...l, items: deduplicateItems([newItem, ...l.items]) }; }) ); - } else if (msg.event === 'ITEM_TOGGLED' && msg.list_id) { + } else if (msg.event === 'ITEM_TOGGLED' && msgListId) { const updatedItem: ListItem = msg.payload; setLists((prev) => prev.map((l) => { - if (l.id !== msg.list_id) return l; + if (l.id !== msgListId) return l; return { ...l, items: l.items.map((i) => (i.id === updatedItem.id ? updatedItem : i)), }; }) ); - } else if (msg.event === 'ITEM_DELETED' && msg.list_id) { + } else if (msg.event === 'ITEM_DELETED' && msgListId) { const deletedId = msg.payload?.itemId; setLists((prev) => prev.map((l) => { - if (l.id !== msg.list_id) return l; + if (l.id !== msgListId) return l; return { ...l, items: l.items.filter((i) => i.id !== deletedId) }; }) ); - } else if (msg.event === 'LIST_CLEARED' && msg.list_id) { - const updatedList: SharedList = msg.payload; - setLists((prev) => prev.map((l) => (l.id === msg.list_id ? updatedList : l))); - } else if (msg.event === 'LIST_CREATED' || msg.event === 'LIST_DELETED') { - loadListsForSpace(activeSpace.id); - } else if (msg.event === 'MEMBER_ADDED' || msg.event === 'MEMBER_REMOVED') { - loadSpaces(); + } else if (msg.event === 'LIST_CLEARED' && msgListId) { + setLists((prev) => + prev.map((l) => { + if (l.id !== msgListId) return l; + return { ...l, items: l.items.filter((i) => !i.isCompleted) }; + }) + ); + } else if ( + msg.event === 'LIST_CREATED' || + msg.event === 'LIST_DELETED' || + msg.event === 'LIST_SHARED' || + msg.event === 'LIST_UNSHARED' || + msg.event === 'ITEM_REORDERED' || + msg.event === 'LIST_UPDATED' + ) { + loadLists(); } } catch { // ignore malformed frames @@ -200,44 +225,53 @@ export const SharedListsView: React.FC = ({ onRefreshGloba return () => { if (ws) ws.close(); }; - }, [activeSpace?.id]); + }, [activeList?.id]); useEffect(() => { const handleGlobalWs = (e: any) => { const msg = e.detail; if (!msg) return; if ( - msg.event === 'SPACE_JOINED' || - msg.event === 'SPACE_LEFT' || - msg.event === 'MEMBER_ADDED' || - msg.event === 'MEMBER_REMOVED' + msg.event === 'LIST_SHARED' || + msg.event === 'LIST_UNSHARED' || + msg.event === 'LIST_CREATED' || + msg.event === 'LIST_DELETED' || + msg.event === 'ITEM_REORDERED' || + msg.event === 'LIST_UPDATED' ) { - loadSpaces(); + loadLists(); } }; window.addEventListener('mindsift:ws', handleGlobalWs); return () => window.removeEventListener('mindsift:ws', handleGlobalWs); }, []); - const activeList = lists.find((l) => l.id === activeListId) || lists[0] || null; - const handleAddItem = async (e: React.FormEvent) => { e.preventDefault(); - if (!activeList || !newItemText.trim()) return; + if (!activeList || !newItemText.trim() || isSubmittingItem) return; + setIsSubmittingItem(true); triggerHaptic(15); + setShowSuggestions(false); const parsed = parseListItemInput(newItemText, newItemQty); setNewItemText(''); setNewItemQty(''); + setCursorPosition(null); try { const item = await api.createListItem(activeList.id, parsed.content, parsed.category, parsed.quantity); setLists((prev) => - prev.map((l) => (l.id === activeList.id ? { ...l, items: [item, ...l.items] } : l)) + prev.map((l) => { + if (l.id !== activeList.id) return l; + if (l.items.some((i) => i.id === item.id)) return l; + return { ...l, items: deduplicateItems([item, ...l.items]) }; + }) ); onRefreshGlobal(); } catch (err: any) { alert(err.message || 'Fehler beim Erstellen des Listeneintrags'); + } finally { + setIsSubmittingItem(false); } }; @@ -275,16 +309,193 @@ export const SharedListsView: React.FC = ({ onRefreshGloba if (!activeList) return; triggerHaptic(20); await api.clearCompletedItems(activeList.id); - await loadListsForSpace(activeSpace!.id); + setLists((prev) => + prev.map((l) => + l.id === activeList.id ? { ...l, items: l.items.filter((i) => !i.isCompleted) } : l + ) + ); + }; + + const handleItemDragStart = (e: React.DragEvent, item: ListItem) => { + e.stopPropagation(); + e.dataTransfer.setData('application/mindsift-list-item-id', item.id); + e.dataTransfer.setData('text/plain', item.id); + e.dataTransfer.effectAllowed = 'move'; + setDraggingItemId(item.id); + }; + + const handleItemDragOver = (e: React.DragEvent, targetItemId: string) => { + e.preventDefault(); + e.stopPropagation(); + if (!draggingItemId || draggingItemId === targetItemId) return; + + const rect = e.currentTarget.getBoundingClientRect(); + const position = e.clientY - rect.top < rect.height / 2 ? 'before' : 'after'; + + if ( + !dragOverItemTarget || + dragOverItemTarget.itemId !== targetItemId || + dragOverItemTarget.position !== position + ) { + setDragOverItemTarget({ itemId: targetItemId, position }); + } + }; + + const handleItemDragLeave = (e: React.DragEvent, targetItemId: string) => { + e.stopPropagation(); + if (dragOverItemTarget?.itemId === targetItemId) { + setDragOverItemTarget(null); + } + }; + + const handleDropOnItem = async (e: React.DragEvent, targetItem: ListItem, targetCategory: string) => { + e.preventDefault(); + e.stopPropagation(); + + const sourceId = + e.dataTransfer.getData('application/mindsift-list-item-id') || + e.dataTransfer.getData('text/plain') || + draggingItemId; + + const position = dragOverItemTarget?.position || 'after'; + setDraggingItemId(null); + setDragOverItemTarget(null); + setDragOverCategoryTarget(null); + + if (!activeList || !sourceId || sourceId === targetItem.id) return; + + triggerHaptic(15); + + const sourceItem = activeList.items.find((i) => i.id === sourceId); + if (!sourceItem) return; + + const targetCatNormalized = targetCategory.trim(); + const isCategoryChanged = (sourceItem.category || '').toLowerCase() !== targetCatNormalized.toLowerCase(); + + let newCategory = sourceItem.category; + let newContent = sourceItem.content; + + if (isCategoryChanged) { + newCategory = targetCatNormalized; + if (!targetCatNormalized) { + newContent = newContent.replace(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g, '').replace(/\s+/g, ' ').trim(); + } else if (newContent.includes('#')) { + newContent = newContent.replace(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g, `#${targetCatNormalized}`).trim(); + } + } + + const remainingItems = activeList.items.filter((i) => i.id !== sourceId); + const targetIndex = remainingItems.findIndex((i) => i.id === targetItem.id); + if (targetIndex === -1) return; + + const insertIndex = position === 'before' ? targetIndex : targetIndex + 1; + const updatedSourceItem: ListItem = { + ...sourceItem, + category: newCategory, + content: newContent, + }; + + const newItems = [...remainingItems]; + newItems.splice(insertIndex, 0, updatedSourceItem); + + const reorderPayload: { id: string; sortOrder: number; category?: string; content?: string }[] = []; + newItems.forEach((it, idx) => { + it.sortOrder = idx; + if (it.id === sourceId) { + reorderPayload.push({ + id: it.id, + sortOrder: idx, + category: newCategory, + content: newContent, + }); + } else { + reorderPayload.push({ + id: it.id, + sortOrder: idx, + }); + } + }); + + setLists((prev) => + prev.map((l) => (l.id === activeList.id ? { ...l, items: newItems } : l)) + ); + + try { + await api.reorderListItems(activeList.id, reorderPayload); + } catch { + loadLists(); + } + }; + + const handleDropOnCategoryHeader = async (e: React.DragEvent, targetCategory: string) => { + e.preventDefault(); + e.stopPropagation(); + + const sourceId = + e.dataTransfer.getData('application/mindsift-list-item-id') || + e.dataTransfer.getData('text/plain') || + draggingItemId; + + setDraggingItemId(null); + setDragOverItemTarget(null); + setDragOverCategoryTarget(null); + + if (!activeList || !sourceId) return; + + triggerHaptic(15); + const sourceItem = activeList.items.find((i) => i.id === sourceId); + if (!sourceItem) return; + + const targetCatNormalized = targetCategory.trim(); + let newCategory = targetCatNormalized; + let newContent = sourceItem.content; + + if (!targetCatNormalized) { + newContent = newContent.replace(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g, '').replace(/\s+/g, ' ').trim(); + } else if (newContent.includes('#')) { + newContent = newContent.replace(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g, `#${targetCatNormalized}`).trim(); + } + + const updatedSourceItem: ListItem = { + ...sourceItem, + category: newCategory, + content: newContent, + }; + + const remainingItems = activeList.items.filter((i) => i.id !== sourceId); + const newItems = [updatedSourceItem, ...remainingItems]; + + const reorderPayload = newItems.map((it, idx) => { + it.sortOrder = idx; + if (it.id === sourceId) { + return { + id: it.id, + sortOrder: idx, + category: newCategory, + content: newContent, + }; + } + return { id: it.id, sortOrder: idx }; + }); + + setLists((prev) => + prev.map((l) => (l.id === activeList.id ? { ...l, items: newItems } : l)) + ); + + try { + await api.reorderListItems(activeList.id, reorderPayload); + } catch { + loadLists(); + } }; const handleCreateList = async (e: React.FormEvent) => { e.preventDefault(); - if (!activeSpace || !newListTitle.trim()) return; - const created = await api.createList(activeSpace.id, newListTitle.trim(), newListIcon, 'shopping'); + if (!newListTitle.trim()) return; + const created = await api.createList(newListTitle.trim(), newListIcon, 'shopping'); setNewListTitle(''); setShowNewListModal(false); - await loadListsForSpace(activeSpace.id); + await loadLists(); setActiveListId(created.id); onRefreshGlobal(); }; @@ -293,9 +504,7 @@ export const SharedListsView: React.FC = ({ onRefreshGloba if (!window.confirm('Diese Liste wirklich löschen?')) return; try { await api.deleteList(listId); - if (activeSpace) { - await loadListsForSpace(activeSpace.id); - } + await loadLists(); onRefreshGlobal(); } catch (err: any) { alert(err.message || 'Liste konnte nicht gelöscht werden'); @@ -303,101 +512,156 @@ export const SharedListsView: React.FC = ({ onRefreshGloba }; const openShareModal = async () => { - if (!activeSpace) return; + if (!activeList) return; setShowShareModal(true); - setShareError(null); - setSelectedTeamId(''); - setSelectedShareUser(null); try { const [usersList, teamsList, sharesList] = await Promise.all([ api.getUsersDirectory ? api.getUsersDirectory().catch(() => api.getCollaborators().catch(() => [])) : api.getCollaborators().catch(() => []), api.getUserTeams().catch(() => []), - api.getSpaceShares ? api.getSpaceShares(activeSpace.id).catch(() => []) : Promise.resolve([]), + api.getListShares(activeList.id).catch(() => []), ]); setCollaborators(usersList); setTeams(teamsList); - setActiveSpace((prev) => (prev ? { ...prev, shares: sharesList } : prev)); + setLists((prev) => prev.map((l) => (l.id === activeList.id ? { ...l, shares: sharesList } : l))); } catch { // ignore } }; - const handleAddShare = async (e: React.FormEvent) => { - e.preventDefault(); - if (!activeSpace) return; - setInvitingLoading(true); - setShareError(null); - try { - if (addShareType === 'team') { - if (!selectedTeamId) { - setShareError('Bitte ein Team oder eine Gruppe auswählen'); - setInvitingLoading(false); - return; - } - await api.addSpaceMember(activeSpace.id, { - teamId: selectedTeamId, - role: inviteRole, - }); - setSelectedTeamId(''); - } else { - if (!selectedShareUser) { - setShareError('Bitte einen bestehenden Benutzer auswählen'); - setInvitingLoading(false); - return; - } - await api.addSpaceMember(activeSpace.id, { - userId: selectedShareUser.id, - role: inviteRole, - }); - setSelectedShareUser(null); - } + const allAvailableHashtags = useMemo(() => { + const tagMap = new Map(); - const [sp, freshShares] = await Promise.all([ - api.getSpaces(), - api.getSpaceShares ? api.getSpaceShares(activeSpace.id).catch(() => []) : Promise.resolve([]), - ]); - setSpaces(sp); - const updated = sp.find((s) => s.id === activeSpace.id); - if (updated) { - setActiveSpace({ ...updated, shares: freshShares }); + // 1. Collect from all lists and items (open and completed) + for (const l of lists) { + for (const item of l.items) { + if (item.category && item.category.trim()) { + const raw = item.category.trim().replace(/^#/, ''); + if (raw) { + const lower = raw.toLowerCase(); + const existing = tagMap.get(lower); + const formatted = raw.charAt(0).toUpperCase() + raw.slice(1); + if (!existing || (raw[0] === raw[0].toUpperCase() && existing[0] === existing[0].toLowerCase())) { + tagMap.set(lower, formatted); + } + } + } + if (item.content && item.content.includes('#')) { + const matches = item.content.matchAll(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g); + for (const m of matches) { + const raw = m[1]; + const lower = raw.toLowerCase(); + const existing = tagMap.get(lower); + const formatted = raw.charAt(0).toUpperCase() + raw.slice(1); + if (!existing || (raw[0] === raw[0].toUpperCase() && existing[0] === existing[0].toLowerCase())) { + tagMap.set(lower, formatted); + } + } + } + } + } + + // 2. Also incorporate workspace tags + for (const t of workspaceTags) { + const raw = t.trim().replace(/^#/, ''); + if (raw) { + const lower = raw.toLowerCase(); + if (!tagMap.has(lower)) { + tagMap.set(lower, raw.charAt(0).toUpperCase() + raw.slice(1)); + } + } + } + + return Array.from(tagMap.values()).sort((a, b) => + a.localeCompare(b, 'de', { sensitivity: 'base' }) + ); + }, [lists, workspaceTags]); + + const activeHashtagQuery = useMemo(() => { + if (!showSuggestions) return null; + const pos = cursorPosition !== null ? cursorPosition : newItemText.length; + const textBefore = newItemText.slice(0, pos); + const match = textBefore.match(/(?:^|\s)#([a-zA-Z0-9äöüÄÖÜß_-]*)$/); + if (!match) return null; + return { + query: match[1], + matchIndex: match.index! + (match[0].startsWith(' ') ? 1 : 0), + cursorPos: pos, + }; + }, [newItemText, cursorPosition, showSuggestions]); + + const matchingSuggestions = useMemo(() => { + if (!activeHashtagQuery) return []; + const q = activeHashtagQuery.query.toLowerCase(); + if (!q) { + return allAvailableHashtags; + } + const startsWithMatches: string[] = []; + const containsMatches: string[] = []; + for (const tag of allAvailableHashtags) { + const lower = tag.toLowerCase(); + if (lower.startsWith(q)) { + startsWithMatches.push(tag); + } else if (lower.includes(q)) { + containsMatches.push(tag); + } + } + return [...startsWithMatches, ...containsMatches]; + }, [activeHashtagQuery, allAvailableHashtags]); + + const applySuggestion = (tag: string) => { + if (!activeHashtagQuery) return; + const before = newItemText.slice(0, activeHashtagQuery.matchIndex); + const after = newItemText.slice(activeHashtagQuery.cursorPos); + const newText = `${before}#${tag} ${after}`; + setNewItemText(newText); + setShowSuggestions(false); + setSuggestionIndex(0); + + const newCursor = activeHashtagQuery.matchIndex + tag.length + 2; // '#' + tag + ' ' + setTimeout(() => { + if (inputRef.current) { + inputRef.current.focus(); + inputRef.current.setSelectionRange(newCursor, newCursor); + setCursorPosition(newCursor); + } + }, 10); + }; + + const handleInputKeyDown = (e: React.KeyboardEvent) => { + if (showSuggestions && activeHashtagQuery && matchingSuggestions.length > 0) { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSuggestionIndex((prev) => (prev + 1) % matchingSuggestions.length); + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + setSuggestionIndex((prev) => (prev - 1 + matchingSuggestions.length) % matchingSuggestions.length); + return; + } + if (e.key === 'Enter' || e.key === 'Tab') { + e.preventDefault(); + const selected = matchingSuggestions[suggestionIndex] || matchingSuggestions[0]; + if (selected) { + applySuggestion(selected); + } + return; + } + if (e.key === 'Escape') { + e.preventDefault(); + setShowSuggestions(false); + return; } - onRefreshGlobal(); - } catch (err: any) { - setShareError(err.message || 'Freigabe konnte nicht hinzugefügt werden'); - } finally { - setInvitingLoading(false); } }; - const handleRemoveMember = async (targetId: string) => { - if (!activeSpace) return; - if (!window.confirm('Freigabe wirklich aufheben?')) return; - setInvitingLoading(true); - setShareError(null); - try { - await api.removeSpaceMember(activeSpace.id, targetId); - const [sp, freshShares] = await Promise.all([ - api.getSpaces(), - api.getSpaceShares ? api.getSpaceShares(activeSpace.id).catch(() => []) : Promise.resolve([]), - ]); - setSpaces(sp); - const updated = sp.find((s) => s.id === activeSpace.id); - if (updated) { - setActiveSpace({ ...updated, shares: freshShares }); - } - onRefreshGlobal(); - } catch (err: any) { - setShareError(err.message || 'Freigabe konnte nicht entfernt werden'); - } finally { - setInvitingLoading(false); - } - }; - - const openItems = activeList?.items.filter((i) => !i.isCompleted) || []; - const completedItems = activeList?.items.filter((i) => i.isCompleted) || []; + const safeActiveItems = useMemo(() => deduplicateItems(activeList?.items || []), [activeList?.items]); + const openItems = useMemo(() => safeActiveItems.filter((i) => !i.isCompleted), [safeActiveItems]); + const completedItems = useMemo(() => safeActiveItems.filter((i) => i.isCompleted), [safeActiveItems]); const groupedOpenItems = useMemo(() => { const groupsMap = new Map(); + const canonicalMap = new Map(); const uncategorized: ListItem[] = []; for (const item of openItems) { @@ -405,23 +669,35 @@ export const SharedListsView: React.FC = ({ onRefreshGloba if (!cat) { uncategorized.push(item); } else { - const list = groupsMap.get(cat) || []; + const key = cat.toLowerCase(); + const list = groupsMap.get(key) || []; list.push(item); - groupsMap.set(cat, list); + groupsMap.set(key, list); + + const currentCanonical = canonicalMap.get(key); + if (!currentCanonical) { + const formatted = cat.charAt(0).toUpperCase() + cat.slice(1); + canonicalMap.set(key, formatted); + } else if (cat[0] === cat[0].toUpperCase() && currentCanonical[0] === currentCanonical[0].toLowerCase()) { + canonicalMap.set(key, cat); + } } } - const sortedCategories = Array.from(groupsMap.keys()).sort((a, b) => - a.localeCompare(b, 'de', { sensitivity: 'base' }) - ); + const sortedKeys = Array.from(groupsMap.keys()).sort((a, b) => { + const labelA = canonicalMap.get(a) || a; + const labelB = canonicalMap.get(b) || b; + return labelA.localeCompare(labelB, 'de', { sensitivity: 'base' }); + }); const result: { category: string; label: string; items: ListItem[] }[] = []; - for (const cat of sortedCategories) { + for (const key of sortedKeys) { + const displayLabel = canonicalMap.get(key) || key; result.push({ - category: cat, - label: `#${cat}`, - items: groupsMap.get(cat)!, + category: key, + label: `#${displayLabel}`, + items: groupsMap.get(key)!, }); } @@ -436,14 +712,126 @@ export const SharedListsView: React.FC = ({ onRefreshGloba return result; }, [openItems]); + // Register context-sensitive mobile menu groups behind the MindSift logo + useEffect(() => { + setGroups([ + { + id: 'lists-actions', + title: 'Aktionen', + items: [ + { + id: 'list-new-item', + label: 'Neuen Eintrag hinzufügen', + icon: , + variant: 'accent', + onClick: () => { + setTimeout(() => { + inputRef.current?.focus(); + inputRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, 60); + }, + }, + { + id: 'list-new-list', + label: 'Neue Einkaufsliste erstellen', + icon: , + onClick: () => setShowNewListModal(true), + }, + ...(activeList + ? [ + { + id: 'list-share', + label: `Liste "${activeList.title}" teilen`, + icon: , + onClick: () => openShareModal(), + }, + ] + : []), + ...(completedItems.length > 0 + ? [ + { + id: 'list-clear-completed', + label: `Erledigte aufräumen (${completedItems.length})`, + icon: , + onClick: () => handleClearCompleted(), + }, + ] + : []), + ...(activeList && lists.length > 1 + ? [ + { + id: 'list-delete', + label: `Liste "${activeList.title}" löschen`, + icon: , + variant: 'danger' as const, + onClick: () => handleDeleteList(activeList.id), + }, + ] + : []), + ], + }, + { + id: 'lists-switcher', + title: 'Einkaufslisten wechseln', + items: lists.map((lst) => { + const openCount = deduplicateItems(lst.items || []).filter((i) => !i.isCompleted).length; + return { + id: `select-list-${lst.id}`, + label: lst.title, + icon: {lst.icon}, + badge: openCount > 0 ? openCount : undefined, + active: activeList?.id === lst.id, + onClick: () => setActiveListId(lst.id), + }; + }), + }, + ]); + }, [lists, activeList, completedItems.length, setGroups]); + return (

- {/* Top Header: List Tabs & Actions */} -
+ {/* Mobile Compact Header Bar (List switching & actions live in MindSift Logo Menu) */} +
+
+ + {activeList ? `${activeList.icon} ${activeList.title}` : '🛒 Einkaufsliste'} + + {openItems.length > 0 && ( + + {openItems.length} offen + + )} +
+
+ {activeList && ( + + )} +
+ +
+
+
+ + {/* Top Header: List Tabs & Actions (Desktop) */} +
{/* List Tabs */}
{lists.map((lst) => { - const openCount = lst.items.filter((i) => !i.isCompleted).length; + const openCount = deduplicateItems(lst.items || []).filter((i) => !i.isCompleted).length; const isSelected = activeList?.id === lst.id; return (
@@ -486,16 +874,11 @@ export const SharedListsView: React.FC = ({ onRefreshGloba {/* Right Header Section: Geteilt Badge, Icon-Only Share Button & Live Sync */}
- {activeSpace && (() => { - const isOwner = !activeSpace.ownerId || activeSpace.ownerId === currentUser?.id; - const sharesCount = - (activeSpace.shares?.length || 0) + - (activeSpace.members?.filter((m) => m.userId !== activeSpace.ownerId).length || 0); - const isShared = - (activeSpace.shares && activeSpace.shares.length > 0) || - (activeSpace.members && activeSpace.members.length > 1); - const ownerMember = activeSpace.members?.find((m) => m.role === 'owner' || m.userId === activeSpace.ownerId); - const ownerDisplayName = activeSpace.ownerName || ownerMember?.displayName || ownerMember?.email || 'Eigentümer'; + {activeList && (() => { + const isOwner = activeList.isOwner !== false && (activeList.userId === currentUser?.id || !activeList.userId || currentUser?.role === 'admin'); + const sharesCount = activeList.shares?.length || 0; + const isShared = sharesCount > 0; + const ownerDisplayName = activeList.ownerName || 'Eigentümer'; return (
@@ -508,7 +891,7 @@ export const SharedListsView: React.FC = ({ onRefreshGloba title="Teilen-Menü öffnen: Geteilt mit Nutzern/Gruppen anzeigen & verwalten" > - Geteilt ({sharesCount > 0 ? sharesCount : activeSpace.members?.length || 1}) + Geteilt ({sharesCount}) ) : (
); @@ -587,149 +970,290 @@ export const SharedListsView: React.FC = ({ onRefreshGloba )} - {/* Active Shared List Card */} + {/* Active Shared List View */} {activeList ? ( -
+
{/* Quick-Add Form */} -
-
+ +
+ setNewItemText(e.target.value)} - placeholder={`Eintrag zu "${activeList.title}" hinzufügen (z. B. Hafermilch #Kühlregal oder #Kaffee)…`} - className="flex-1 h-9 px-3 input-field text-sm" + onChange={(e) => { + setNewItemText(e.target.value); + setCursorPosition(e.target.selectionStart); + setShowSuggestions(true); + setSuggestionIndex(0); + }} + onKeyDown={handleInputKeyDown} + onSelect={(e) => { + setCursorPosition(e.currentTarget.selectionStart); + }} + onFocus={(e) => { + setCursorPosition(e.target.selectionStart); + setShowSuggestions(true); + }} + onBlur={() => { + setTimeout(() => setShowSuggestions(false), 200); + }} + placeholder={`Eintrag zu "${activeList.title}" hinzufügen (z. B. Hafermilch #Kühlregal)…`} + className="flex-1 bg-transparent py-1.5 sm:py-1 text-sm text-main placeholder-faint focus:outline-none" /> -
- setNewItemQty(e.target.value)} - placeholder="Menge (z.B. 2x)" - className="w-32 h-9 px-3 input-field text-sm" - /> - -
+ + {/* Hashtag Autocomplete Suggestions Dropdown */} + {showSuggestions && activeHashtagQuery && matchingSuggestions.length > 0 && ( +
+
+ Hashtags +
+ {matchingSuggestions.map((tag, idx) => { + const isSelected = idx === suggestionIndex; + return ( + + ); + })} +
+ )} +
+
+ setNewItemQty(e.target.value)} + placeholder="Menge (z.B. 2x)" + className="flex-1 sm:flex-none sm:w-28 h-9 sm:h-7 px-2.5 sm:px-2 input-field text-xs" + /> +
{/* Open Items Grouped by Hashtags / Categories */} -
- {openItems.length === 0 ? ( -
+ {openItems.length === 0 ? ( +
+

- Alles erledigt. Keine offenen Punkte auf dieser Liste. -

- ) : ( - groupedOpenItems.map((group) => ( -
- {/* Category Group Header (only when categories are in use) */} + Alles erledigt +

+

Keine offenen Punkte auf dieser Liste.

+
+ ) : ( +
+ {groupedOpenItems.map((group) => ( +
+ {/* Category Group Header: white text directly on canvas background */} {group.label && ( -
- - {group.label} +
+ + {group.label} - - {group.items.length} {group.items.length === 1 ? 'Eintrag' : 'Einträge'} + + {group.items.length}
)} - {group.items.map((item) => { - const cat = getItemCategory(item); - const displayContent = getItemDisplayContent(item); + {/* Lighter panel-surface card container with items */} +
{ + e.preventDefault(); + if (draggingItemId) setDragOverCategoryTarget(group.category); + }} + onDragLeave={() => { + if (dragOverCategoryTarget === group.category) setDragOverCategoryTarget(null); + }} + onDrop={(e) => handleDropOnCategoryHeader(e, group.category)} + className={`panel-surface divide-y divide-border overflow-hidden transition-all ${ + dragOverCategoryTarget === group.category && !dragOverItemTarget + ? 'ring-2 ring-accent/60 bg-accent/5' + : '' + }`} + > + {group.items.map((item) => { + const cat = getItemCategory(item); + const displayContent = getItemDisplayContent(item); + const isDragging = draggingItemId === item.id; + const isTarget = dragOverItemTarget?.itemId === item.id; - return ( -
handleToggleItem(item)} - className="px-3 py-2 bg-canvas hover:bg-panel-elevated flex items-center justify-between gap-3 cursor-pointer transition-colors group" - > -
- - - {displayContent} - - {item.quantity && ( - - {item.quantity} - - )} - {cat && ( - - #{cat} - - )} -
- - -
- ); - })} + {/* Drop insertion line indicator */} + {isTarget && dragOverItemTarget.position === 'before' && ( +
+ )} + {isTarget && dragOverItemTarget.position === 'after' && ( +
+ )} + +
+ {/* Eckiges Abhaken */} + + + + {displayContent} + + + {item.quantity && ( + + {item.quantity} + + )} + + {cat && !group.label && ( + + #{cat} + + )} +
+ + +
+ ); + })} +
- )) - )} -
+ ))} +
+ )} {/* Completed Items Section */} {completedItems.length > 0 && ( -
-
- +
+
+ Erledigt ({completedItems.length})
-
+
{completedItems.map((item) => { - const cat = getItemCategory(item); const displayContent = getItemDisplayContent(item); return (
handleToggleItem(item)} - className="px-3 py-1.5 bg-canvas/50 hover:bg-canvas/80 flex items-center justify-between gap-3 cursor-pointer transition-colors" + className="px-3.5 py-3 sm:px-3 sm:py-2 flex items-center justify-between gap-3 cursor-pointer hover:bg-panel-hover transition-colors group" > -
- - +
+ {/* Eckiges Abhaken */} + + + {displayContent} + {item.quantity && ( ({item.quantity}) )} - {cat && ( - - #{cat} - - )}
- {item.completedByName && ( - ✓ {item.completedByName} - )} + +
+ {item.completedByName && ( + ✓ {item.completedByName} + )} + +
); })} @@ -739,78 +1263,47 @@ export const SharedListsView: React.FC = ({ onRefreshGloba
) : null} - {/* Unified Space & Shopping List Sharing Modal */} - {showShareModal && activeSpace && ( + {/* Unified Shopping List Sharing Modal */} + {showShareModal && activeList && ( setShowShareModal(false)} - title={`Einkaufsliste teilen: ${activeSpace.name}`} - resourceName={activeSpace.name} - resourceIcon={activeSpace.icon || '🛒'} - isOwner={activeSpace.ownerId === currentUser?.id || currentUser?.role === 'admin'} + title={`Liste teilen: ${activeList.title}`} + resourceName={activeList.title} + resourceIcon={activeList.icon || '🛒'} + isOwner={activeList.isOwner !== false && (activeList.userId === currentUser?.id || !activeList.userId || currentUser?.role === 'admin')} currentUserId={currentUser?.id} - shares={[ - ...(activeSpace.shares || []) - .filter((s) => s.targetType === 'team') - .map((s) => ({ - id: s.id, - targetType: 'team' as const, - targetId: s.targetId, - targetName: s.targetName || 'Gruppe', - permission: s.role === 'viewer' ? ('viewer' as const) : ('editor' as const), - role: s.role, - createdAt: s.createdAt, - })), - ...(activeSpace.members || []) - .filter((m) => m.userId !== activeSpace.ownerId) - .map((m) => ({ - id: m.userId, - targetType: 'user' as const, - targetId: m.userId, - targetName: m.displayName || m.email, - targetEmail: m.email, - permission: m.role === 'viewer' ? ('viewer' as const) : ('editor' as const), - role: m.role, - createdAt: m.joinedAt, - })), - ]} + shares={(activeList.shares || []).map((s) => ({ + id: s.id, + targetType: s.targetType, + targetId: s.targetId, + targetName: s.targetName || (s.targetType === 'team' ? 'Gruppe' : 'Benutzer'), + targetEmail: s.targetEmail, + permission: s.role === 'viewer' ? ('viewer' as const) : ('editor' as const), + role: s.role, + createdAt: s.createdAt, + }))} teams={teams} collaborators={collaborators} allowRoleSelection={true} onAddShare={async (targetType, targetId, permission) => { - if (targetType === 'team') { - await api.addSpaceMember(activeSpace.id, { - teamId: targetId, - role: permission, - }); - } else { - await api.addSpaceMember(activeSpace.id, { - userId: targetId, - role: permission, - }); - } - const [sp, freshShares] = await Promise.all([ - api.getSpaces(), - api.getSpaceShares ? api.getSpaceShares(activeSpace.id).catch(() => []) : Promise.resolve([]), - ]); - setSpaces(sp); - const updated = sp.find((s) => s.id === activeSpace.id); - if (updated) { - setActiveSpace({ ...updated, shares: freshShares }); - } + await api.addListShare(activeList.id, { + targetType, + targetId, + role: permission, + }); + const freshShares = await api.getListShares(activeList.id); + setLists((prev) => + prev.map((l) => (l.id === activeList.id ? { ...l, shares: freshShares } : l)) + ); onRefreshGlobal(); }} onRemoveShare={async (shareId) => { - await api.removeSpaceMember(activeSpace.id, shareId); - const [sp, freshShares] = await Promise.all([ - api.getSpaces(), - api.getSpaceShares ? api.getSpaceShares(activeSpace.id).catch(() => []) : Promise.resolve([]), - ]); - setSpaces(sp); - const updated = sp.find((s) => s.id === activeSpace.id); - if (updated) { - setActiveSpace({ ...updated, shares: freshShares }); - } + await api.removeListShare(activeList.id, shareId); + const freshShares = await api.getListShares(activeList.id); + setLists((prev) => + prev.map((l) => (l.id === activeList.id ? { ...l, shares: freshShares } : l)) + ); onRefreshGlobal(); }} /> diff --git a/frontend/src/features/settings/AdminSettingsView.tsx b/frontend/src/features/settings/AdminSettingsView.tsx index 9f26048..d76aee3 100644 --- a/frontend/src/features/settings/AdminSettingsView.tsx +++ b/frontend/src/features/settings/AdminSettingsView.tsx @@ -13,7 +13,6 @@ import { Send, AlertCircle, UserPlus, - Share2, } from 'lucide-react'; import { SystemSettings, @@ -690,7 +689,7 @@ export const AdminSettingsView: React.FC = ({ {/* Sharing Isolation Cards */}
diff --git a/frontend/src/features/triage/TaskDetailModal.tsx b/frontend/src/features/triage/TaskDetailModal.tsx index 4f138c6..58709b8 100644 --- a/frontend/src/features/triage/TaskDetailModal.tsx +++ b/frontend/src/features/triage/TaskDetailModal.tsx @@ -194,7 +194,7 @@ export const TaskDetailModal: React.FC = ({
-
+
diff --git a/frontend/src/features/triage/TasksView.tsx b/frontend/src/features/triage/TasksView.tsx index f1d05b3..df53be1 100644 --- a/frontend/src/features/triage/TasksView.tsx +++ b/frontend/src/features/triage/TasksView.tsx @@ -13,8 +13,6 @@ import { Edit3, X, Layers, - Share, - Share2, Users, CheckSquare, User as UserIcon, @@ -32,6 +30,7 @@ import { UnifiedShareModal } from '../../components/UnifiedShareModal'; import { TaskDetailModal } from './TaskDetailModal'; import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS } from './ManageColumnsModal'; import { UserAutocomplete } from '../UserAutocomplete'; +import { useMobileActions } from '../../context/MobileActionContext'; const getInitials = (name?: string): string => { if (!name) return '?'; @@ -65,15 +64,21 @@ export const TasksView: React.FC = ({ const [discarded, setDiscarded] = useState([]); const [loading, setLoading] = useState(true); + // Drag and Drop state + const [draggingTaskId, setDraggingTaskId] = useState(null); + const [dragOverTaskTarget, setDragOverTaskTarget] = useState<{ taskId: string; position: 'before' | 'after' } | null>(null); + const [dragOverKanbanCol, setDragOverKanbanCol] = useState(null); + // Quick Task Add state const [quickTitle, setQuickTitle] = useState(''); const [quickDueDate, setQuickDueDate] = useState(''); + const quickInputRef = useRef(null); + const { setGroups } = useMobileActions(); // New TaskList modal state const [showNewListModal, setShowNewListModal] = useState(false); const [newListTitle, setNewListTitle] = useState(''); const [newListIcon, setNewListIcon] = useState('📋'); - const [newListShareTarget, setNewListShareTarget] = useState(''); // Share TaskList modal state const [showShareListModal, setShowShareListModal] = useState(false); @@ -103,7 +108,7 @@ export const TasksView: React.FC = ({ // Hashtag filtering, grouping and sorting state const [selectedTag, setSelectedTag] = useState(null); const [groupBy, setGroupBy] = useState<'none' | 'tag' | 'status' | 'priority' | 'due'>('none'); - const [sortBy, setSortBy] = useState<'created' | 'due' | 'priority' | 'alpha'>('created'); + const [sortBy, setSortBy] = useState<'custom' | 'created' | 'due' | 'priority' | 'alpha'>('custom'); const loadData = async () => { setLoading(true); @@ -189,6 +194,8 @@ export const TasksView: React.FC = ({ setTasks((prev) => prev.filter((t) => t.id !== deletedId)); onRefreshStatsRef.current(); } + } else if (msg.event === 'TASK_REORDERED') { + loadData(); } }; @@ -260,6 +267,14 @@ export const TasksView: React.FC = ({ if (selectedTag) { result = result.filter((t) => Array.isArray(t.tags) && t.tags.includes(selectedTag)); } + if (sortBy === 'custom') { + return [...result].sort((a, b) => { + const orderA = a.sortOrder ?? 0; + const orderB = b.sortOrder ?? 0; + if (orderA !== orderB) return orderA - orderB; + return b.createdAt.localeCompare(a.createdAt); + }); + } return [...result].sort((a, b) => { if (sortBy === 'due') { if (!a.dueDate && !b.dueDate) return 0; @@ -536,32 +551,164 @@ export const TasksView: React.FC = ({ } }; + const handleTaskDragStart = (e: React.DragEvent, task: Thought) => { + e.stopPropagation(); + e.dataTransfer.setData('application/mindsift-task-id', task.id); + e.dataTransfer.setData('text/plain', task.id); + e.dataTransfer.effectAllowed = 'move'; + setDraggingTaskId(task.id); + }; + + const handleTaskDragOver = (e: React.DragEvent, targetTaskId: string) => { + e.preventDefault(); + e.stopPropagation(); + if (!draggingTaskId || draggingTaskId === targetTaskId) return; + + const rect = e.currentTarget.getBoundingClientRect(); + const position = e.clientY - rect.top < rect.height / 2 ? 'before' : 'after'; + + if ( + !dragOverTaskTarget || + dragOverTaskTarget.taskId !== targetTaskId || + dragOverTaskTarget.position !== position + ) { + setDragOverTaskTarget({ taskId: targetTaskId, position }); + } + }; + + const handleTaskDragLeave = (e: React.DragEvent, targetTaskId: string) => { + e.stopPropagation(); + if (dragOverTaskTarget?.taskId === targetTaskId) { + setDragOverTaskTarget(null); + } + }; + + const handleDropOnTask = async (e: React.DragEvent, targetTask: Thought, newKanbanCol?: string) => { + e.preventDefault(); + e.stopPropagation(); + + const sourceId = + e.dataTransfer.getData('application/mindsift-task-id') || + e.dataTransfer.getData('text/plain') || + draggingTaskId; + + const position = dragOverTaskTarget?.position || 'after'; + setDraggingTaskId(null); + setDragOverTaskTarget(null); + setDragOverKanbanCol(null); + + if (!sourceId || sourceId === targetTask.id) return; + triggerHaptic(15); + + const sourceTask = tasks.find((t) => t.id === sourceId); + if (!sourceTask) return; + + const remainingTasks = tasks.filter((t) => t.id !== sourceId); + const targetIdx = remainingTasks.findIndex((t) => t.id === targetTask.id); + if (targetIdx === -1) return; + + const insertIdx = position === 'before' ? targetIdx : targetIdx + 1; + const updatedSourceTask: Thought = { + ...sourceTask, + kanbanStatus: newKanbanCol || targetTask.kanbanStatus || sourceTask.kanbanStatus, + isCompleted: newKanbanCol ? newKanbanCol === 'done' : targetTask.isCompleted !== undefined ? targetTask.isCompleted : sourceTask.isCompleted, + }; + + const newTasks = [...remainingTasks]; + newTasks.splice(insertIdx, 0, updatedSourceTask); + + const updatedWithSort = newTasks.map((t, idx) => ({ + ...t, + sortOrder: idx, + })); + + setTasks(updatedWithSort); + setSortBy('custom'); + + const reorderPayload = updatedWithSort.map((t) => ({ + id: t.id, + sortOrder: t.sortOrder ?? 0, + kanbanStatus: t.kanbanStatus, + })); + + try { + await api.reorderTasks( + reorderPayload, + selectedListId !== 'all' ? selectedListId : undefined + ); + } catch { + loadData(); + } + }; + + const handleDropOnKanbanCol = async (e: React.DragEvent, colId: string) => { + e.preventDefault(); + e.stopPropagation(); + + const sourceId = + e.dataTransfer.getData('application/mindsift-task-id') || + e.dataTransfer.getData('text/plain') || + draggingTaskId; + + setDraggingTaskId(null); + setDragOverTaskTarget(null); + setDragOverKanbanCol(null); + + if (!sourceId) return; + triggerHaptic(15); + + const sourceTask = tasks.find((t) => t.id === sourceId); + if (!sourceTask) return; + + const isDone = colId === 'done'; + const updatedSourceTask: Thought = { + ...sourceTask, + kanbanStatus: colId, + isCompleted: isDone, + }; + + const remainingTasks = tasks.filter((t) => t.id !== sourceId); + const newTasks = [updatedSourceTask, ...remainingTasks]; + + const updatedWithSort = newTasks.map((t, idx) => ({ + ...t, + sortOrder: idx, + })); + + setTasks(updatedWithSort); + setSortBy('custom'); + + const reorderPayload = updatedWithSort.map((t) => ({ + id: t.id, + sortOrder: t.sortOrder ?? 0, + kanbanStatus: t.kanbanStatus, + })); + + try { + await api.reorderTasks( + reorderPayload, + selectedListId !== 'all' ? selectedListId : undefined + ); + } catch { + loadData(); + } + }; + const handleCreateTaskList = async (e: React.FormEvent) => { e.preventDefault(); const title = newListTitle.trim(); if (!title) return; try { - let teamId: string | undefined = undefined; - let userId: string | undefined = undefined; - if (newListShareTarget.startsWith('team:')) { - teamId = newListShareTarget.replace('team:', ''); - } else if (newListShareTarget.startsWith('user:')) { - userId = newListShareTarget.replace('user:', ''); - } const created = await api.createTaskList({ title, icon: newListIcon, - teamId: teamId, }); - if (userId && created?.id) { - await api.addTaskListShare(created.id, { targetType: 'user', targetId: userId }); - } const freshLists = await api.getTaskLists(); setTaskLists(freshLists); setSelectedListId(created.id); setShowNewListModal(false); setNewListTitle(''); - setNewListShareTarget(''); + setNewListIcon('📋'); } catch (err: any) { alert(err.message || 'Fehler beim Erstellen der Aufgabenliste'); } @@ -667,6 +814,141 @@ export const TasksView: React.FC = ({ } }; + useEffect(() => { + const actionItems: any[] = [ + { + id: 'new-task', + label: 'Neue Aufgabe erstellen', + icon: , + variant: 'accent', + onClick: () => { + window.scrollTo({ top: 0, behavior: 'smooth' }); + setTimeout(() => quickInputRef.current?.focus(), 100); + }, + }, + { + id: 'new-task-list', + label: 'Neue Aufgabenliste anlegen', + icon: , + onClick: () => setShowNewListModal(true), + }, + ]; + + if (currentSelectedList) { + actionItems.push({ + id: 'share-task-list', + label: `Liste „${currentSelectedList.title}“ teilen`, + icon: , + onClick: () => handleOpenShareModal(currentSelectedList), + }); + actionItems.push({ + id: 'delete-task-list', + label: `Liste „${currentSelectedList.title}“ löschen`, + icon: , + variant: 'danger', + onClick: () => handleDeleteTaskList(currentSelectedList.id), + }); + } + + const viewItems: any[] = [ + { + id: 'filter-open', + label: 'Offene Aufgaben (Liste)', + icon: , + badge: openTasks.length, + active: viewMode === 'list' && filter === 'open', + onClick: () => { + setViewMode('list'); + setFilter('open'); + }, + }, + { + id: 'filter-done', + label: 'Erledigte Aufgaben', + icon: , + badge: doneTasks.length, + active: viewMode === 'list' && filter === 'done', + onClick: () => { + setViewMode('list'); + setFilter('done'); + }, + }, + { + id: 'filter-discarded', + label: 'Papierkorb', + icon: , + badge: safeDiscarded.length, + active: viewMode === 'list' && filter === 'discarded', + onClick: () => { + setViewMode('list'); + setFilter('discarded'); + }, + }, + { + id: 'view-kanban', + label: 'Kanban-Board Ansicht', + icon: , + badge: kanbanTasks.length, + active: viewMode === 'kanban', + onClick: () => setViewMode('kanban'), + }, + ]; + + if (viewMode === 'kanban') { + viewItems.push({ + id: 'manage-columns', + label: 'Kanban-Spalten verwalten', + icon: , + onClick: () => setShowManageColumnsModal(true), + }); + } + + const listItems: any[] = [ + { + id: 'list-all', + label: 'Alle Aufgaben', + icon: , + active: selectedListId === 'all', + onClick: () => setSelectedListId('all'), + }, + ...safeTaskLists.map((tl) => ({ + id: `list-${tl.id}`, + label: `${tl.icon || '📋'} ${tl.title}`, + badge: tl.tasksCount || undefined, + active: selectedListId === tl.id, + onClick: () => setSelectedListId(tl.id), + })), + ]; + + setGroups([ + { + id: 'tasks-actions', + title: 'Aktionen', + items: actionItems, + }, + { + id: 'tasks-views', + title: 'Ansicht & Status', + items: viewItems, + }, + { + id: 'tasks-lists', + title: 'Aufgabenlisten', + items: listItems, + }, + ]); + }, [ + viewMode, + filter, + selectedListId, + openTasks.length, + doneTasks.length, + safeDiscarded.length, + kanbanTasks.length, + safeTaskLists, + currentSelectedList, + ]); + const renderTaskProgressBar = (task: Thought) => { if (!task.subtasks || task.subtasks.length === 0) return null; const total = task.subtasks.length; @@ -704,30 +986,56 @@ export const TasksView: React.FC = ({ // Clean Task Row in List View const renderTaskRow = (task: Thought) => { const isCompleted = task.isCompleted; + const isDragging = draggingTaskId === task.id; + const isTarget = dragOverTaskTarget?.taskId === task.id; return (
handleTaskDragStart(e, task)} + onDragEnd={() => { + setDraggingTaskId(null); + setDragOverTaskTarget(null); + setDragOverKanbanCol(null); + }} + onDragOver={(e) => handleTaskDragOver(e, task.id)} + onDragLeave={(e) => handleTaskDragLeave(e, task.id)} + onDrop={(e) => handleDropOnTask(e, task)} + onClick={() => { + setDetailTaskId(task.id); + setShowDetailModal(true); + }} + className={`px-3.5 py-3.5 sm:px-3 sm:py-2.5 flex items-center justify-between gap-3 transition-colors hover:bg-panel-hover group border-b border-border last:border-b-0 relative cursor-pointer cursor-grab active:cursor-grabbing select-none ${ isCompleted ? 'opacity-65' : '' - }`} + } ${isDragging ? 'opacity-30 border border-dashed border-accent/40' : ''}`} > -
- {/* Checkbox */} + {/* Drop insertion line indicator */} + {isTarget && dragOverTaskTarget.position === 'before' && ( +
+ )} + {isTarget && dragOverTaskTarget.position === 'after' && ( +
+ )} + +
+ {/* Checkbox (larger touch target on mobile) */} {/* Clickable Title & Metadata */} @@ -836,10 +1144,11 @@ export const TasksView: React.FC = ({
- {/* Action Controls */} -
+ {/* Action Controls (Desktop only - on mobile, tapping the task opens Detail Modal) */} +
e.stopPropagation()} onClick={(e) => e.stopPropagation()}> + )} +
+ + {/* Top Header: Task Lists Tabs & View Mode Switcher (Desktop) */} +
{/* Task Lists selector tabs */}
); @@ -1027,32 +1366,33 @@ export const TasksView: React.FC = ({ {/* Quick Add Task Input */}
setQuickTitle(e.target.value)} placeholder={ currentSelectedList - ? `Aufgabe zu "${currentSelectedList.title}" hinzufügen… (Enter)` - : 'Neue Aufgabe anlegen… (Enter)' + ? `Aufgabe zu "${currentSelectedList.title}" hinzufügen…` + : 'Neue Aufgabe anlegen…' } - className="flex-1 bg-transparent px-2 py-1 text-sm text-main placeholder-faint focus:outline-none" + className="flex-1 bg-transparent px-2 py-1.5 sm:py-1 text-sm text-main placeholder-faint focus:outline-none min-w-0" /> -
+
setQuickDueDate(e.target.value)} - className="h-7 px-2 input-field text-xs" + className="hidden sm:block h-7 px-2 input-field text-xs" title="Fälligkeitsdatum" /> @@ -1134,6 +1474,7 @@ export const TasksView: React.FC = ({ onChange={(e) => setSortBy(e.target.value as any)} className="h-7 px-2 input-field text-xs bg-panel" > + @@ -1186,37 +1527,6 @@ export const TasksView: React.FC = ({
-
- - -
-
- ))} + ); + })} {colTasks.length === 0 && (
@@ -1509,9 +1856,9 @@ export const TasksView: React.FC = ({ ) : ( /* STANDARD LIST VIEW (CLEAN & UNCLUTTERED) */
- {/* Subfilter tabs for List View */} + {/* Subfilter tabs for List View (Desktop; on mobile controlled via MindSift Logo Menu) */}
-
+
{filter === 'discarded' && safeDiscarded.length > 0 && ( - diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index 236ee51..171ab02 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -45,8 +45,6 @@ import { Unlock, Eye, Edit3, - Share, - Share2, Hash, User as UserIcon, } from 'lucide-react'; @@ -55,6 +53,7 @@ import { api, getToken, triggerHaptic } from '../../lib/api'; import { CodeBlockItem } from './CodeBlockItem'; import { UserAutocomplete } from '../UserAutocomplete'; import { UnifiedShareModal } from '../../components/UnifiedShareModal'; +import { useMobileActions } from '../../context/MobileActionContext'; interface WorkspaceViewProps { currentUser?: User | null; @@ -101,6 +100,9 @@ export const WorkspaceView: React.FC = ({ initialDocId, onStatsChange, }) => { + const { setGroups } = useMobileActions(); + const [showMobileFileDrawer, setShowMobileFileDrawer] = useState(false); + // Top Workspace Tab Mode: 'editor' vs 'todos' vs 'tags' const [workspaceMode, setWorkspaceMode] = useState<'editor' | 'todos' | 'tags'>('editor'); const [selectedTag, setSelectedTag] = useState(null); @@ -171,6 +173,7 @@ export const WorkspaceView: React.FC = ({ // Drag and Drop reordering state for blocks const [draggingBlockId, setDraggingBlockId] = useState(null); + const [dragOverBlockTarget, setDragOverBlockTarget] = useState<{ blockId: string; position: 'before' | 'after' | 'inside' } | null>(null); // --- GLOBAL TO-DO ROLLUP STATE --- const [todoFilter, setTodoFilter] = useState<'all' | 'open' | 'done'>('open'); @@ -197,6 +200,7 @@ export const WorkspaceView: React.FC = ({ const idleUnlockTimerRef = useRef(null); const currentDocIdRef = useRef(null); const isEditorFocusedRef = useRef(false); + const isDraggingBlockRef = useRef(false); const blockInputRefs = useRef>({}); @@ -244,9 +248,9 @@ export const WorkspaceView: React.FC = ({ } }; - const ensureLock = async (): Promise => { + const ensureLock = async (force = false): Promise => { if (!selectedDoc || selectedDoc.permission === 'read') return false; - if (holdsLockRef.current) { + if (holdsLockRef.current && !force) { return true; } try { @@ -285,10 +289,14 @@ export const WorkspaceView: React.FC = ({ if (idleUnlockTimerRef.current) { clearTimeout(idleUnlockTimerRef.current); } + // If a block is currently being dragged, do not schedule an unlock + if (isDraggingBlockRef.current) { + return; + } // Short grace period: if user clicked into another block in the doc, focus will cancel this. // If user clicked outside or stopped editing, save and release the lock immediately! idleUnlockTimerRef.current = setTimeout(async () => { - if (!isEditorFocusedRef.current && holdsLockRef.current) { + if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) { await flushPendingSave(); await releaseMyLock(); } @@ -338,8 +346,15 @@ export const WorkspaceView: React.FC = ({ pendingSaveDocRef.current = null; setSaveState('saved'); } - if (!isEditorFocusedRef.current && holdsLockRef.current) { - releaseMyLock(); + if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) { + if (idleUnlockTimerRef.current) { + clearTimeout(idleUnlockTimerRef.current); + } + idleUnlockTimerRef.current = setTimeout(async () => { + if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) { + await releaseMyLock(); + } + }, 2000); } } }; @@ -514,13 +529,13 @@ export const WorkspaceView: React.FC = ({ // Window blur / visibility change listener: release lock when user leaves tab/window useEffect(() => { const handleWindowBlur = () => { - if (!isEditorFocusedRef.current && holdsLockRef.current) { + if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) { flushPendingSave(); releaseMyLock(); } }; const handleVisibilityChange = () => { - if (document.visibilityState === 'hidden') { + if (document.visibilityState === 'hidden' && !isDraggingBlockRef.current) { isEditorFocusedRef.current = false; flushPendingSave(); releaseMyLock(); @@ -1911,35 +1926,187 @@ export const WorkspaceView: React.FC = ({ return childTodos; }; - // Drag & Drop Handlers on the Bullet Dot - const handleDragStart = (e: React.DragEvent, blockId: string) => { + // Helper: Get subtree count (root + all indented children) + const getBlockSubtreeCount = (blocks: DocumentBlock[], startIndex: number): number => { + if (startIndex < 0 || startIndex >= blocks.length) return 0; + const rootLevel = blocks[startIndex].level || 0; + let count = 1; + for (let i = startIndex + 1; i < blocks.length; i++) { + if ((blocks[i].level || 0) > rootLevel) { + count++; + } else { + break; + } + } + return count; + }; + + // Helper: Check if target block is a descendant of source block + const isDescendantBlock = (blocks: DocumentBlock[], sourceIdx: number, targetBlockId: string): boolean => { + if (sourceIdx < 0 || sourceIdx >= blocks.length) return false; + const subtreeCount = getBlockSubtreeCount(blocks, sourceIdx); + for (let i = sourceIdx; i < sourceIdx + subtreeCount; i++) { + if (blocks[i]?.id === targetBlockId) return true; + } + return false; + }; + + // Drag & Drop Handlers on the Bullet row / Grip / Dot + const handleBlockDragStart = (e: React.DragEvent, blockId: string) => { + e.stopPropagation(); + if (isReadOnly) { + e.preventDefault(); + return; + } + isDraggingBlockRef.current = true; + if (idleUnlockTimerRef.current) { + clearTimeout(idleUnlockTimerRef.current); + idleUnlockTimerRef.current = null; + } + // Activate write lock immediately upon dragging (optimistic UI + backend broadcast) + setHoldsLock(true); + ensureLock(true); + + e.dataTransfer.setData('application/mindsift-block-id', blockId); e.dataTransfer.setData('text/plain', blockId); + e.dataTransfer.effectAllowed = 'move'; setDraggingBlockId(blockId); }; - const handleDropOnBlock = (e: React.DragEvent, targetBlockId: string) => { + const handleBlockDragEnd = () => { + isDraggingBlockRef.current = false; + setDraggingBlockId(null); + setDragOverBlockTarget(null); + if (idleUnlockTimerRef.current) { + clearTimeout(idleUnlockTimerRef.current); + } + idleUnlockTimerRef.current = setTimeout(async () => { + if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) { + await flushPendingSave(); + await releaseMyLock(); + } + }, 2000); + }; + + const handleBlockDragOver = (e: React.DragEvent, targetBlockId: string) => { e.preventDefault(); - const sourceId = e.dataTransfer.getData('text/plain') || draggingBlockId; + e.stopPropagation(); + if (!draggingBlockId || draggingBlockId === targetBlockId) return; + + const rect = e.currentTarget.getBoundingClientRect(); + const relY = e.clientY - rect.top; + const height = rect.height; + + let position: 'before' | 'after' | 'inside'; + if (relY < height * 0.25) { + position = 'before'; + } else if (relY > height * 0.75) { + position = 'after'; + } else { + position = 'inside'; + } + + if ( + !dragOverBlockTarget || + dragOverBlockTarget.blockId !== targetBlockId || + dragOverBlockTarget.position !== position + ) { + setDragOverBlockTarget({ blockId: targetBlockId, position }); + } + }; + + const handleBlockDragLeave = (e: React.DragEvent, targetBlockId: string) => { + e.stopPropagation(); + if (dragOverBlockTarget?.blockId === targetBlockId) { + setDragOverBlockTarget(null); + } + }; + + const handleBlockDrop = (e: React.DragEvent, targetBlockId: string, explicitPosition?: 'before' | 'after' | 'inside') => { + e.preventDefault(); + e.stopPropagation(); + + const sourceId = + e.dataTransfer.getData('application/mindsift-block-id') || + e.dataTransfer.getData('text/plain') || + draggingBlockId; + + const position = explicitPosition || dragOverBlockTarget?.position || 'after'; + isDraggingBlockRef.current = false; + setDraggingBlockId(null); + setDragOverBlockTarget(null); + if (!selectedDoc || !sourceId || sourceId === targetBlockId) { - setDraggingBlockId(null); return; } const blocks = [...selectedDoc.blocks]; const sourceIdx = blocks.findIndex((b) => b.id === sourceId); - const targetIdx = blocks.findIndex((b) => b.id === targetBlockId); - if (sourceIdx === -1 || targetIdx === -1) { - setDraggingBlockId(null); + if (sourceIdx === -1) return; + + // Prevent circular drag into one's own children + if (isDescendantBlock(blocks, sourceIdx, targetBlockId)) { return; } - const [moved] = blocks.splice(sourceIdx, 1); - // Align moved block's level to target - moved.level = blocks[targetIdx]?.level || 0; - blocks.splice(targetIdx, 0, moved); + // Extract the entire subtree (parent + all indented children) + const subtreeCount = getBlockSubtreeCount(blocks, sourceIdx); + const subtree = blocks.splice(sourceIdx, subtreeCount); + + // Find new index of target block after splice + const newTargetIdx = blocks.findIndex((b) => b.id === targetBlockId); + if (newTargetIdx === -1) return; + + const targetBlock = blocks[newTargetIdx]; + + if (position === 'inside') { + // Nest directly under targetBlock as its sub-bullet + const targetLevel = (targetBlock.level || 0) + 1; + const oldRootLevel = subtree[0].level || 0; + const levelDiff = targetLevel - oldRootLevel; + + for (const b of subtree) { + b.level = Math.max(0, (b.level || 0) + levelDiff); + } + + // Automatically uncollapse targetBlock so the newly added sub-bullet is visible + if (targetBlock.collapsed) { + blocks[newTargetIdx] = { ...targetBlock, collapsed: false }; + } + + // Insert immediately below targetBlock + blocks.splice(newTargetIdx + 1, 0, ...subtree); + } else { + const targetLevel = targetBlock.level || 0; + const oldRootLevel = subtree[0].level || 0; + const levelDiff = targetLevel - oldRootLevel; + + // Adjust levels of all blocks in subtree to match target hierarchy + for (const b of subtree) { + b.level = Math.max(0, (b.level || 0) + levelDiff); + } + + // Calculate insertion index + let insertIdx = newTargetIdx; + if (position === 'after') { + const targetSubtreeCount = getBlockSubtreeCount(blocks, newTargetIdx); + insertIdx = newTargetIdx + targetSubtreeCount; + } + + blocks.splice(insertIdx, 0, ...subtree); + } persistDocument({ ...selectedDoc, blocks }, true); - setDraggingBlockId(null); + + if (idleUnlockTimerRef.current) { + clearTimeout(idleUnlockTimerRef.current); + } + idleUnlockTimerRef.current = setTimeout(async () => { + if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) { + await flushPendingSave(); + await releaseMyLock(); + } + }, 2500); }; // Export full document as portable Logseq-indented Markdown @@ -2551,6 +2718,132 @@ export const WorkspaceView: React.FC = ({ return workspaceTags.filter((t) => t.tag.toLowerCase().includes(q)); }, [workspaceTags, tagSearchQuery]); + // Register context-sensitive mobile menu groups behind the MindSift logo + useEffect(() => { + const openTodosCount = allWorkspaceTodos.filter((t) => !t.block.checked).length; + setGroups([ + { + id: 'ws-view-files', + title: 'Workspace-Ansicht & Dateien', + items: [ + { + id: 'ws-open-tree', + label: 'Dateibaum & Ordner öffnen', + icon: , + badge: docs.length, + variant: 'accent', + active: showMobileFileDrawer, + onClick: () => { + setWorkspaceMode('editor'); + setShowMobileFileDrawer(true); + }, + }, + { + id: 'ws-mode-editor', + label: 'Notizen & Editor', + icon: , + active: workspaceMode === 'editor' && !showMobileFileDrawer, + onClick: () => { + setWorkspaceMode('editor'); + setShowMobileFileDrawer(false); + }, + }, + { + id: 'ws-mode-tags', + label: 'Themen & Hashtags', + icon: , + badge: workspaceTags.length, + active: workspaceMode === 'tags', + onClick: () => { + setWorkspaceMode('tags'); + setShowMobileFileDrawer(false); + }, + }, + { + id: 'ws-mode-todos', + label: 'Globale To-Dos', + icon: , + badge: openTodosCount, + active: workspaceMode === 'todos', + onClick: () => { + setWorkspaceMode('todos'); + setShowMobileFileDrawer(false); + }, + }, + ], + }, + { + id: 'ws-doc-actions', + title: 'Erstellen & Dokument-Aktionen', + items: [ + { + id: 'ws-new-doc', + label: 'Neue Datei erstellen', + icon: , + onClick: () => { + setShowMobileFileDrawer(false); + handleCreateDoc(null); + }, + }, + { + id: 'ws-new-folder', + label: 'Neuer Ordner', + icon: , + onClick: () => { + setWorkspaceMode('editor'); + setShowMobileFileDrawer(true); + handleStartCreateFolder(null); + }, + }, + ...(selectedDoc + ? [ + { + id: 'ws-share-doc', + label: `Notiz "${selectedDoc.title || 'Unbenannt'}" teilen`, + icon: , + onClick: () => openShareModal(), + }, + { + id: 'ws-pin-doc', + label: selectedDoc.isPinned ? 'Von Favoriten lösen' : 'Oben in Favoriten anpinnen', + icon: , + active: Boolean(selectedDoc.isPinned), + onClick: () => persistDocument({ ...selectedDoc, isPinned: !selectedDoc.isPinned }, true), + }, + { + id: 'ws-export-md', + label: 'Als Markdown exportieren (.md)', + icon: , + onClick: () => handleExportMarkdown(), + }, + { + id: 'ws-delete-doc', + label: selectedDoc.isOwner !== false ? 'Notiz löschen' : 'Geteilte Notiz verlassen', + icon: , + variant: 'danger' as const, + onClick: () => { + if (selectedDoc.isOwner !== false) { + handleDeleteDoc(selectedDoc.id); + } else { + handleLeaveShare(); + } + }, + }, + ] + : []), + ], + }, + ]); + }, [ + workspaceMode, + showMobileFileDrawer, + docs.length, + workspaceTags.length, + allWorkspaceTodos, + selectedDoc, + setGroups, + ]); + const renderDocItem = (doc: Document, depth: number) => { const isSelected = selectedDoc?.id === doc.id && workspaceMode === 'editor'; const isDragging = draggingDocId === doc.id; @@ -2569,9 +2862,10 @@ export const WorkspaceView: React.FC = ({ setSelectedDoc(doc); setZoomedBlockId(null); setWorkspaceMode('editor'); + setShowMobileFileDrawer(false); }} style={{ paddingLeft: `${depth * 14 + 16}px` }} - className={`group flex items-center justify-between py-1.5 pr-2 border-l-2 transition-all cursor-pointer text-xs ${ + className={`group flex items-center justify-between py-2.5 sm:py-1.5 pr-2 border-l-2 transition-all cursor-pointer text-sm sm:text-xs ${ isDragging ? 'opacity-40 scale-[0.98]' : '' } ${ isSelected @@ -2581,15 +2875,15 @@ export const WorkspaceView: React.FC = ({ title={`${doc.title || 'Notiz'} (Ziehen zum Verschieben in Ordner)`} >
- {doc.icon || '📄'} + {doc.icon || '📄'} {doc.title || 'Unbenannt'} - {doc.isPinned && } + {doc.isPinned && } {doc.isOwner === false && ( - + )}
@@ -2653,7 +2947,7 @@ export const WorkspaceView: React.FC = ({ onDragEnd={() => setDraggingFolderPath(null)} onClick={() => toggleFolder(node.path)} style={{ paddingLeft: `${node.depth * 14 + 8}px` }} - className={`group flex items-center justify-between py-1.5 pr-2 cursor-pointer transition-colors text-xs ${ + className={`group flex items-center justify-between py-2.5 sm:py-1.5 pr-2 cursor-pointer transition-colors text-sm sm:text-xs ${ isDraggingThisFolder ? 'opacity-40 scale-[0.98]' : '' } ${ isDragOver @@ -2665,31 +2959,32 @@ export const WorkspaceView: React.FC = ({
{isExpanded ? ( - + ) : ( - + )} {isExpanded ? ( - + ) : ( - + )} - {node.name} + {node.name} ({totalItems})
-
+
@@ -2719,9 +3014,9 @@ export const WorkspaceView: React.FC = ({ {isCreatingSubfolder && (
- + = ({ if (e.key === 'Escape') setIsCreatingFolder(false); }} placeholder="Unterordner…" - className="flex-1 h-6 px-1.5 input-field text-xs" + className="flex-1 h-8 sm:h-6 px-2 sm:px-1.5 input-field text-xs" />
)} @@ -2775,8 +3070,52 @@ export const WorkspaceView: React.FC = ({ return (
- {/* Top Navigation Mode Switcher: Notizen & Outliner vs. Globales To-Do Dashboard */} -
+ {/* Mobile Compact Workspace Bar (Options & mode switching live in MindSift Logo Menu) */} +
+ + +
+ {workspaceMode === 'editor' && selectedDoc && ( + + + {saveState === 'saving' ? 'Speichert…' : 'Gespeichert'} + + )} + +
+
+ + {/* Top Navigation Mode Switcher: Notizen & Outliner vs. Globales To-Do Dashboard (Desktop) */} +
+
+ +
- + setSearchQuery(e.target.value)} placeholder="Dateien durchsuchen…" - className="w-full h-7 pl-7 pr-2 input-field text-xs" + className="w-full h-9 sm:h-7 pl-8 sm:pl-7 pr-2 input-field text-sm sm:text-xs" />
@@ -2891,7 +3263,7 @@ export const WorkspaceView: React.FC = ({ {isCreatingFolder && newFolderParent === null && (
- + = ({ if (e.key === 'Escape') setIsCreatingFolder(false); }} placeholder="Neuer Ordnername…" - className="flex-1 h-6 px-1.5 input-field text-xs" + className="flex-1 h-8 sm:h-6 px-2 sm:px-1.5 input-field text-xs" />
)} @@ -2933,8 +3305,11 @@ export const WorkspaceView: React.FC = ({
) : ( -
+
{/* Drag target for Root when dragging a note or a folder */} {(draggingDocId || draggingFolderPath) && (
= ({
); @@ -3066,7 +3442,7 @@ export const WorkspaceView: React.FC = ({ {!isTagsCollapsed && ( -
+
{workspaceTags.map((wt) => ( ) : ( -
+
)} - {/* Interactive Bullet Dot (Click: Zoom-in! Drag: Reorder!) */} + {/* Interactive Bullet Dot (Click: Zoom-in! Drag: Reorder! Drop on it: Nest as child!) */}
!isReadOnly && handleDragStart(e, block.id)} - onDragOver={(e) => e.preventDefault()} - onDrop={(e) => !isReadOnly && handleDropOnBlock(e, block.id)} + onMouseDown={() => { + if (!isReadOnly) { + if (idleUnlockTimerRef.current) { + clearTimeout(idleUnlockTimerRef.current); + idleUnlockTimerRef.current = null; + } + ensureLock(true); + } + }} + onDragStart={(e) => !isReadOnly && handleBlockDragStart(e, block.id)} + onDragEnd={handleBlockDragEnd} + onDragOver={(e) => { + if (!isReadOnly && draggingBlockId && draggingBlockId !== block.id) { + e.preventDefault(); + e.stopPropagation(); + setDragOverBlockTarget({ blockId: block.id, position: 'inside' }); + } + }} + onDrop={(e) => { + if (!isReadOnly) { + e.preventDefault(); + e.stopPropagation(); + handleBlockDrop(e, block.id, 'inside'); + } + }} onClick={() => handleZoomTo(block.id)} className={`w-4 h-4 mt-1 rounded-pill flex items-center justify-center transition-colors flex-shrink-0 ${ isReadOnly ? 'cursor-pointer text-muted' - : 'hover:bg-accent/20 cursor-pointer text-muted hover:text-accent' + : 'hover:bg-accent/20 cursor-grab active:cursor-grabbing text-muted hover:text-accent' + } ${ + isTargetThis && dragOverBlockTarget.position === 'inside' + ? 'scale-125 bg-accent/30 ring-2 ring-accent' + : '' }`} - title="Klick: In diesen Ast zoomen · Ziehen: Verschieben" + title="Klick: Zoomen · Ziehen: Verschieben · Hierher ziehen: Als Unterpunkt einordnen" > { + const url = taskListId ? `/api/v1/task-lists/${taskListId}/tasks/reorder` : '/api/v1/tasks/reorder'; + return request<{ success: boolean }>(url, { + method: 'POST', + body: JSON.stringify({ items }), + }); + }, + async updateThought( thoughtId: string, patch: { @@ -639,17 +648,36 @@ export const api = { return ensureArray(res).map((l) => ({ ...l, items: ensureArray(l.items), + shares: ensureArray(l.shares), })); }, - async createList(spaceId: string, title: string, icon: string, listType: 'shopping' | 'checklist' | 'vacation'): Promise { - const l = await request(`/api/v1/spaces/${spaceId}/lists`, { + async getListById(listId: string): Promise { + const l = await request(`/api/v1/lists/${listId}`); + return { + ...l, + items: ensureArray(l.items), + shares: ensureArray(l.shares), + }; + }, + + async createList(titleOrSpaceId: string, titleOrIcon?: string, iconOrType?: string, listType?: 'shopping' | 'checklist' | 'vacation'): Promise { + let title = titleOrSpaceId; + let icon = titleOrIcon || '🛒'; + let type = (iconOrType as any) || 'shopping'; + if (listType !== undefined) { + title = titleOrIcon || ''; + icon = iconOrType || '🛒'; + type = listType; + } + const l = await request('/api/v1/lists', { method: 'POST', - body: JSON.stringify({ title, icon, listType }), + body: JSON.stringify({ title, icon, listType: type }), }); return { ...l, items: ensureArray(l.items), + shares: ensureArray(l.shares), }; }, @@ -657,6 +685,22 @@ export const api = { await request(`/api/v1/lists/${listId}`, { method: 'DELETE' }); }, + async getListShares(listId: string): Promise { + const res = await request(`/api/v1/lists/${listId}/shares`); + return ensureArray(res); + }, + + async addListShare(listId: string, target: { targetType: 'team' | 'user'; targetId: string; role?: string }): Promise { + return request(`/api/v1/lists/${listId}/shares`, { + method: 'POST', + body: JSON.stringify(target), + }); + }, + + async removeListShare(listId: string, shareId: string): Promise { + await request(`/api/v1/lists/${listId}/shares/${shareId}`, { method: 'DELETE' }); + }, + async createListItem(listId: string, content: string, category = '', quantity = ''): Promise { return request(`/api/v1/lists/${listId}/items`, { method: 'POST', @@ -681,6 +725,16 @@ export const api = { }); }, + async reorderListItems( + listId: string, + items: { id: string; sortOrder: number; category?: string; content?: string }[] + ): Promise<{ success: boolean }> { + return request<{ success: boolean }>(`/api/v1/lists/${listId}/items/reorder`, { + method: 'POST', + body: JSON.stringify({ items }), + }); + }, + // --- ORGANIZATIONS & TEAMS --- async getOrganizations(): Promise { const res = await request('/api/v1/organizations'); @@ -905,11 +959,11 @@ let wsReconnectTimer: any = null; let wsListeners: ((msg: WSMessage) => void)[] = []; let manualDisconnect = false; -export function buildWebSocketURL(spaceId?: string): string { +export function buildWebSocketURL(listId?: string): string { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const token = getToken() || ''; - if (spaceId) { - return `${protocol}//${window.location.host}/api/v1/ws/spaces/${encodeURIComponent(spaceId)}?token=${encodeURIComponent(token)}`; + if (listId) { + return `${protocol}//${window.location.host}/api/v1/ws/lists/${encodeURIComponent(listId)}?token=${encodeURIComponent(token)}`; } return `${protocol}//${window.location.host}/api/v1/ws?token=${encodeURIComponent(token)}`; } diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 07c49f7..ef62c94 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -60,6 +60,7 @@ export interface Thought { notes?: string; subtasks?: SubTask[]; alarmSent?: boolean; + sortOrder?: number; createdAt: string; triagedAt?: string; } @@ -295,13 +296,30 @@ export interface ListItem { 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 { id: string; - spaceId: string; + userId?: string; + ownerName?: string; + isOwner?: boolean; + role?: string; + spaceId?: string; title: string; icon: string; listType: 'shopping' | 'checklist' | 'vacation'; items: ListItem[]; + shares?: ListShare[]; updatedAt: string; createdAt: string; } @@ -331,6 +349,7 @@ export interface WSMessage { event: string; space_id?: string; list_id?: string; + listId?: string; payload: T; timestamp: string; } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 2339d59..6b195bd 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,10 +1,14 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; +import { MobileActionProvider } from './context/MobileActionContext'; import './index.css'; ReactDOM.createRoot(document.getElementById('root')!).render( - + + + ); + diff --git a/internal/api/server.go b/internal/api/server.go index b3c6046..ecebb35 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -63,6 +63,7 @@ func (s *Server) Router() http.Handler { r.Post("/api/v1/webhooks/ingest", s.handleWebhookIngest) r.Get("/api/v1/ws", s.handleWebSocket) r.Get("/api/v1/ws/spaces/{spaceId}", s.handleWebSocket) + r.Get("/api/v1/ws/lists/{listId}", s.handleWebSocket) // Authenticated API Routes r.Group(func(r chi.Router) { @@ -88,6 +89,8 @@ func (s *Server) Router() http.Handler { r.Get("/api/v1/triage/deck", s.handleGetTriageDeck) r.Patch("/api/v1/triage/{id}", s.handleTriageAction) r.Get("/api/v1/tasks", s.handleGetTasks) + r.Post("/api/v1/tasks/reorder", s.handleReorderTasks) + r.Post("/api/v1/task-lists/{id}/tasks/reorder", s.handleReorderTasks) // Task Lists & Shares r.Get("/api/v1/task-lists", s.handleGetTaskLists) @@ -125,21 +128,19 @@ func (s *Server) Router() http.Handler { r.Post("/api/v1/documents/{id}/lock/heartbeat", s.handleHeartbeatDocLock) r.Delete("/api/v1/documents/{id}/lock", s.handleUnlockDoc) - // Shared Spaces & Realtime Lists - r.Get("/api/v1/spaces", s.handleGetSpaces) - r.Post("/api/v1/spaces", s.handleCreateSpace) - r.Post("/api/v1/spaces/join", s.handleJoinSpace) - r.Get("/api/v1/spaces/{id}/shares", s.handleGetSpaceShares) - r.Post("/api/v1/spaces/{id}/members", s.handleAddSpaceMember) - r.Delete("/api/v1/spaces/{id}/members/{userId}", s.handleRemoveSpaceMember) - r.Get("/api/v1/spaces/{id}/lists", s.handleGetSpaceLists) - r.Post("/api/v1/spaces/{id}/lists", s.handleCreateList) - + // Shared Lists & Realtime Collaboration r.Get("/api/v1/lists", s.handleGetAllLists) + r.Post("/api/v1/lists", s.handleCreateList) + r.Get("/api/v1/lists/{id}", s.handleGetListByID) r.Delete("/api/v1/lists/{id}", s.handleDeleteList) + r.Get("/api/v1/lists/{id}/shares", s.handleGetListShares) + r.Post("/api/v1/lists/{id}/shares", s.handleCreateListShare) + r.Delete("/api/v1/lists/{id}/shares/{shareId}", s.handleDeleteListShare) r.Post("/api/v1/lists/{id}/items", s.handleCreateListItem) r.Patch("/api/v1/lists/{id}/items/{itemId}", s.handleToggleListItem) r.Delete("/api/v1/lists/{id}/items/{itemId}", s.handleDeleteListItem) + r.Post("/api/v1/lists/{id}/items/reorder", s.handleReorderListItems) + r.Put("/api/v1/lists/{id}/items/reorder", s.handleReorderListItems) r.Post("/api/v1/lists/{id}/clear-completed", s.handleClearCompletedItems) // Organizations & Teams @@ -584,8 +585,8 @@ func (s *Server) handleCaptureInbox(w http.ResponseWriter, r *http.Request) { if converted, convErr := s.store.UpdateThoughtTriage(userID, thought.ID, "keep", target, targetListID, dueDate, taskListID); convErr == nil && converted != nil { thought = converted if target == "list_item" && targetListID != "" { - if lst, lErr := s.store.GetListByID(targetListID); lErr == nil && lst != nil { - s.wsHub.Broadcast(lst.SpaceID, lst.ID, "ITEM_CREATED", lst) + if lst, lErr := s.store.GetListByID(userID, targetListID); lErr == nil && lst != nil { + s.wsHub.BroadcastListEvent(lst.ID, "LIST_UPDATED", lst) } } } @@ -661,8 +662,8 @@ func (s *Server) handleTriageAction(w http.ResponseWriter, r *http.Request) { // If converted to a list item, broadcast via WebSocket to the space if convertTo == "list_item" && targetListID != "" { - if lst, err := s.store.GetListByID(targetListID); err == nil && lst != nil { - s.wsHub.Broadcast(lst.SpaceID, lst.ID, "ITEM_CREATED", lst) + if lst, err := s.store.GetListByID(userID, targetListID); err == nil && lst != nil { + s.wsHub.BroadcastListEvent(lst.ID, "LIST_UPDATED", lst) } } @@ -681,6 +682,34 @@ func (s *Server) handleGetTasks(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, tasks) } +func (s *Server) handleReorderTasks(w http.ResponseWriter, r *http.Request) { + userID := auth.GetUserID(r.Context()) + taskListID := chi.URLParam(r, "id") + if taskListID == "" { + taskListID = r.URL.Query().Get("taskListId") + } + + var req struct { + Items []domain.ThoughtReorderItem `json:"items"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "Ungültiges JSON") + return + } + + if err := s.store.ReorderTasks(taskListID, req.Items); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + s.wsHub.BroadcastToAll("TASK_REORDERED", map[string]interface{}{ + "taskListId": taskListID, + "items": req.Items, + "userId": userID, + }) + writeJSON(w, http.StatusOK, map[string]bool{"success": true}) +} + func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) thoughtID := chi.URLParam(r, "id") @@ -1073,151 +1102,7 @@ func (s *Server) handleUnlockDoc(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]bool{"unlocked": released}) } -// --- SPACES & REALTIME LISTS --- - -func (s *Server) handleGetSpaces(w http.ResponseWriter, r *http.Request) { - userID := auth.GetUserID(r.Context()) - spaces, err := s.store.GetSpacesForUser(userID) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - writeJSON(w, http.StatusOK, spaces) -} - -func (s *Server) handleCreateSpace(w http.ResponseWriter, r *http.Request) { - userID := auth.GetUserID(r.Context()) - var req struct { - Name string `json:"name"` - Icon string `json:"icon"` - Description string `json:"description"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "Ungültiges JSON") - return - } - - sp, err := s.store.CreateSpace(userID, req.Name, req.Icon, req.Description, false) - if err != nil { - writeError(w, http.StatusBadRequest, err.Error()) - return - } - writeJSON(w, http.StatusCreated, sp) -} - -func (s *Server) handleJoinSpace(w http.ResponseWriter, r *http.Request) { - userID := auth.GetUserID(r.Context()) - var req struct { - InviteCode string `json:"inviteCode"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "Ungültiges JSON") - return - } - - sp, err := s.store.JoinSpaceByInviteCode(userID, req.InviteCode) - if err != nil { - writeError(w, http.StatusBadRequest, err.Error()) - return - } - s.wsHub.BroadcastToUser(userID, "SPACE_JOINED", map[string]interface{}{"spaceId": sp.ID}) - s.wsHub.Broadcast(sp.ID, "", "MEMBER_ADDED", map[string]interface{}{"spaceId": sp.ID, "userId": userID}) - - writeJSON(w, http.StatusOK, sp) -} - -func (s *Server) handleAddSpaceMember(w http.ResponseWriter, r *http.Request) { - currentUserID := auth.GetUserID(r.Context()) - spaceID := chi.URLParam(r, "id") - var req struct { - UserID string `json:"userId"` - Email string `json:"email"` - TeamID string `json:"teamId"` - Role string `json:"role"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "Ungültiges JSON") - return - } - - if req.Role != "owner" && req.Role != "editor" && req.Role != "viewer" { - req.Role = "editor" - } - - if req.TeamID != "" { - share, err := s.store.CreateSpaceTeamShare(currentUserID, spaceID, req.TeamID, req.Role) - if err != nil { - writeError(w, http.StatusBadRequest, err.Error()) - return - } - members, _ := s.store.GetTeamMembers(req.TeamID) - for _, tm := range members { - s.wsHub.BroadcastToUser(tm.UserID, "SPACE_JOINED", map[string]interface{}{"spaceId": spaceID}) - } - s.wsHub.Broadcast(spaceID, "", "SPACE_SHARED", share) - writeJSON(w, http.StatusCreated, share) - return - } - - target := req.UserID - if target == "" { - target = req.Email - } - if target == "" { - writeError(w, http.StatusBadRequest, "Benutzer-ID, Benutzername, E-Mail oder Team erforderlich") - return - } - - member, err := s.store.AddSpaceMember(currentUserID, spaceID, target, req.Role) - if err != nil { - writeError(w, http.StatusBadRequest, err.Error()) - return - } - - s.wsHub.BroadcastToUser(member.UserID, "SPACE_JOINED", map[string]interface{}{"spaceId": spaceID}) - s.wsHub.Broadcast(spaceID, "", "MEMBER_ADDED", member) - - writeJSON(w, http.StatusCreated, member) -} - -func (s *Server) handleGetSpaceShares(w http.ResponseWriter, r *http.Request) { - spaceID := chi.URLParam(r, "id") - shares, err := s.store.GetSpaceShares(spaceID) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - writeJSON(w, http.StatusOK, shares) -} - -func (s *Server) handleRemoveSpaceMember(w http.ResponseWriter, r *http.Request) { - currentUserID := auth.GetUserID(r.Context()) - spaceID := chi.URLParam(r, "id") - targetUserID := chi.URLParam(r, "userId") - - // If prefixed with sm_, strip it for individual space member removal - cleanTargetID := strings.TrimPrefix(targetUserID, "sm_") - - if err := s.store.RemoveSpaceMember(currentUserID, spaceID, cleanTargetID); err != nil { - writeError(w, http.StatusBadRequest, err.Error()) - return - } - - s.wsHub.BroadcastToUser(cleanTargetID, "SPACE_LEFT", map[string]interface{}{"spaceId": spaceID}) - s.wsHub.Broadcast(spaceID, "", "MEMBER_REMOVED", map[string]interface{}{"spaceId": spaceID, "userId": cleanTargetID}) - - writeJSON(w, http.StatusOK, map[string]bool{"success": true}) -} - -func (s *Server) handleGetSpaceLists(w http.ResponseWriter, r *http.Request) { - spaceID := chi.URLParam(r, "id") - lists, err := s.store.GetListsForSpace(spaceID) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - writeJSON(w, http.StatusOK, lists) -} +// --- REALTIME LISTS & SHARING --- func (s *Server) handleGetAllLists(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) @@ -1229,8 +1114,19 @@ func (s *Server) handleGetAllLists(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, lists) } +func (s *Server) handleGetListByID(w http.ResponseWriter, r *http.Request) { + userID := auth.GetUserID(r.Context()) + listID := chi.URLParam(r, "id") + lst, err := s.store.GetListByID(userID, listID) + if err != nil { + writeError(w, http.StatusNotFound, "Liste nicht gefunden") + return + } + writeJSON(w, http.StatusOK, lst) +} + func (s *Server) handleCreateList(w http.ResponseWriter, r *http.Request) { - spaceID := chi.URLParam(r, "id") + userID := auth.GetUserID(r.Context()) var req struct { Title string `json:"title"` Icon string `json:"icon"` @@ -1241,28 +1137,96 @@ func (s *Server) handleCreateList(w http.ResponseWriter, r *http.Request) { return } - lst, err := s.store.CreateList(spaceID, req.Title, req.Icon, req.ListType) + lst, err := s.store.CreateList(userID, req.Title, req.Icon, req.ListType) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } - s.wsHub.Broadcast(spaceID, lst.ID, "LIST_CREATED", lst) + s.wsHub.BroadcastListEvent(lst.ID, "LIST_CREATED", lst) writeJSON(w, http.StatusCreated, lst) } func (s *Server) handleDeleteList(w http.ResponseWriter, r *http.Request) { + userID := auth.GetUserID(r.Context()) listID := chi.URLParam(r, "id") - lst, _ := s.store.GetListByID(listID) - if err := s.store.DeleteList(listID); err != nil { + if err := s.store.DeleteList(userID, listID); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } - if lst != nil { - s.wsHub.Broadcast(lst.SpaceID, listID, "LIST_DELETED", map[string]string{"listId": listID}) - } + s.wsHub.BroadcastListEvent(listID, "LIST_DELETED", map[string]string{"listId": listID}) writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) } +func (s *Server) handleGetListShares(w http.ResponseWriter, r *http.Request) { + listID := chi.URLParam(r, "id") + shares, err := s.store.GetListShares(listID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, shares) +} + +func (s *Server) handleCreateListShare(w http.ResponseWriter, r *http.Request) { + currentUserID := auth.GetUserID(r.Context()) + listID := chi.URLParam(r, "id") + + var req struct { + TargetType string `json:"targetType"` // "user" or "team" + TargetID string `json:"targetId"` // userId/email or teamId + Role string `json:"role"` // "editor" or "viewer" + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "Ungültiges JSON") + return + } + + targetType := strings.ToLower(strings.TrimSpace(req.TargetType)) + if targetType != "team" { + targetType = "user" + } + + role := strings.ToLower(strings.TrimSpace(req.Role)) + if role != "viewer" && role != "editor" { + role = "editor" + } + + share, err := s.store.AddListShare(currentUserID, listID, targetType, req.TargetID, role) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + if targetType == "team" { + members, _ := s.store.GetTeamMembers(share.TargetID) + for _, tm := range members { + s.wsHub.BroadcastToUser(tm.UserID, "LIST_SHARED", share) + } + } else { + s.wsHub.BroadcastToUser(share.TargetID, "LIST_SHARED", share) + } + s.wsHub.BroadcastListEvent(listID, "LIST_SHARED", share) + + writeJSON(w, http.StatusCreated, share) +} + +func (s *Server) handleDeleteListShare(w http.ResponseWriter, r *http.Request) { + currentUserID := auth.GetUserID(r.Context()) + listID := chi.URLParam(r, "id") + shareID := chi.URLParam(r, "shareId") + + if err := s.store.RemoveListShare(currentUserID, listID, shareID); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + s.wsHub.BroadcastListEvent(listID, "LIST_UNSHARED", map[string]string{ + "listId": listID, + "shareId": shareID, + }) + writeJSON(w, http.StatusOK, map[string]bool{"success": true}) +} + func (s *Server) handleCreateListItem(w http.ResponseWriter, r *http.Request) { listID := chi.URLParam(r, "id") var req struct { @@ -1281,9 +1245,7 @@ func (s *Server) handleCreateListItem(w http.ResponseWriter, r *http.Request) { return } - if lst, err := s.store.GetListByID(listID); err == nil && lst != nil { - s.wsHub.Broadcast(lst.SpaceID, listID, "ITEM_CREATED", item) - } + s.wsHub.BroadcastListEvent(listID, "ITEM_CREATED", item) writeJSON(w, http.StatusCreated, item) } @@ -1301,7 +1263,7 @@ func (s *Server) handleToggleListItem(w http.ResponseWriter, r *http.Request) { } user, _ := s.store.GetUserByID(userID) - userName := "Familie" + userName := "Mitglied" if user != nil { userName = user.DisplayName } @@ -1312,9 +1274,7 @@ func (s *Server) handleToggleListItem(w http.ResponseWriter, r *http.Request) { return } - if lst, err := s.store.GetListByID(listID); err == nil && lst != nil { - s.wsHub.Broadcast(lst.SpaceID, listID, "ITEM_TOGGLED", item) - } + s.wsHub.BroadcastListEvent(listID, "ITEM_TOGGLED", item) writeJSON(w, http.StatusOK, item) } @@ -1328,12 +1288,10 @@ func (s *Server) handleDeleteListItem(w http.ResponseWriter, r *http.Request) { return } - if lst, err := s.store.GetListByID(listID); err == nil && lst != nil { - s.wsHub.Broadcast(lst.SpaceID, listID, "ITEM_DELETED", map[string]string{ - "itemId": itemID, - "listId": listID, - }) - } + s.wsHub.BroadcastListEvent(listID, "ITEM_DELETED", map[string]string{ + "itemId": itemID, + "listId": listID, + }) writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) } @@ -1345,14 +1303,47 @@ func (s *Server) handleClearCompletedItems(w http.ResponseWriter, r *http.Reques return } - if lst, err := s.store.GetListByID(listID); err == nil && lst != nil { - s.wsHub.Broadcast(lst.SpaceID, listID, "LIST_CLEARED", lst) - } + s.wsHub.BroadcastListEvent(listID, "LIST_CLEARED", map[string]interface{}{ + "listId": listID, + "removed": removed, + }) writeJSON(w, http.StatusOK, map[string]int64{"removed": removed}) } +func (s *Server) handleReorderListItems(w http.ResponseWriter, r *http.Request) { + listID := chi.URLParam(r, "id") + var req struct { + Items []domain.ListItemReorderItem `json:"items"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "Invalid request body") + return + } + + if err := s.store.ReorderListItems(listID, req.Items); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + s.wsHub.BroadcastListEvent(listID, "ITEM_REORDERED", map[string]interface{}{ + "listId": listID, + "items": req.Items, + }) + writeJSON(w, http.StatusOK, map[string]bool{"success": true}) +} + func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) { - spaceID := chi.URLParam(r, "spaceId") + roomID := chi.URLParam(r, "listId") + if roomID == "" { + roomID = chi.URLParam(r, "spaceId") + } + if roomID == "" { + roomID = r.URL.Query().Get("listId") + } + if roomID == "" { + roomID = r.URL.Query().Get("spaceId") + } + token := r.URL.Query().Get("token") if token == "" { if authHdr := r.Header.Get("Authorization"); strings.HasPrefix(authHdr, "Bearer ") { @@ -1365,7 +1356,7 @@ func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) { userID = claims.UserID } } - s.wsHub.ServeWS(w, r, spaceID, userID) + s.wsHub.ServeWS(w, r, roomID, userID) } // --- API KEYS & WEBHOOK INGESTION --- diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 3568ce4..cf96baf 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -117,7 +117,6 @@ func TestFirstBootSetupAndInviteRegistrationAndNonNullSlices(t *testing.T) { "/api/v1/tasks", "/api/v1/organizations", "/api/v1/teams", - "/api/v1/spaces", "/api/v1/lists", } for _, ep := range arrayEndpoints { @@ -190,7 +189,6 @@ func TestConcurrentInitialLoadAndListsNoDeadlock(t *testing.T) { "/api/v1/auth/me", "/api/v1/triage/deck", "/api/v1/lists", - "/api/v1/spaces", "/api/v1/tasks", "/api/v1/documents", } @@ -557,104 +555,104 @@ func TestSpaceMembersDirectInvitationAPI(t *testing.T) { t.Fatalf("failed to generate token: %v", err) } - // 1. Admin creates a space - createSpaceBody := []byte(`{"name":"Einkauf WG","icon":"🛒","description":"Gemeinsame Einkaufsliste"}`) - req := httptest.NewRequest(http.MethodPost, "/api/v1/spaces", bytes.NewReader(createSpaceBody)) + // 1. Admin creates a list + createListBody := []byte(`{"title":"Einkauf WG","icon":"🛒","listType":"shopping"}`) + req := httptest.NewRequest(http.MethodPost, "/api/v1/lists", bytes.NewReader(createListBody)) req.Header.Set("Authorization", "Bearer "+adminToken) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusCreated { - t.Fatalf("expected 201 Created from /api/v1/spaces, got %d: %s", rec.Code, rec.Body.String()) + t.Fatalf("expected 201 Created from /api/v1/lists, got %d: %s", rec.Code, rec.Body.String()) } - var createdSpace domain.Space - _ = json.Unmarshal(rec.Body.Bytes(), &createdSpace) - if createdSpace.ID == "" { - t.Fatalf("expected created space with ID, got %+v", createdSpace) + var createdList domain.SharedList + _ = json.Unmarshal(rec.Body.Bytes(), &createdList) + if createdList.ID == "" { + t.Fatalf("expected created list with ID, got %+v", createdList) } - // 2. Admin invites normalUser by username "user" - addMemberBody := []byte(`{"userId":"user","role":"editor"}`) - req = httptest.NewRequest(http.MethodPost, "/api/v1/spaces/"+createdSpace.ID+"/members", bytes.NewReader(addMemberBody)) + // 2. Admin shares list with normalUser by username "user" + addShareBody := []byte(`{"targetType":"user","targetId":"user","role":"editor"}`) + req = httptest.NewRequest(http.MethodPost, "/api/v1/lists/"+createdList.ID+"/shares", bytes.NewReader(addShareBody)) req.Header.Set("Authorization", "Bearer "+adminToken) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusCreated { - t.Fatalf("expected 201 Created from add space member, got %d: %s", rec.Code, rec.Body.String()) + t.Fatalf("expected 201 Created from add list share, got %d: %s", rec.Code, rec.Body.String()) } - var newMember domain.SpaceMember - _ = json.Unmarshal(rec.Body.Bytes(), &newMember) - if newMember.UserID != normalUser.ID { - t.Fatalf("expected member userId %s, got %s", normalUser.ID, newMember.UserID) + var newShare domain.ListShare + _ = json.Unmarshal(rec.Body.Bytes(), &newShare) + if newShare.TargetID != normalUser.ID { + t.Fatalf("expected share targetId %s, got %s", normalUser.ID, newShare.TargetID) } - // 3. User checks their spaces; the shared space must be listed - req = httptest.NewRequest(http.MethodGet, "/api/v1/spaces", nil) + // 3. User checks their lists; the shared list must be listed + req = httptest.NewRequest(http.MethodGet, "/api/v1/lists", nil) req.Header.Set("Authorization", "Bearer "+userToken) rec = httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { - t.Fatalf("expected 200 OK for user spaces, got %d", rec.Code) + t.Fatalf("expected 200 OK for user lists, got %d", rec.Code) } - var userSpaces []domain.Space - _ = json.Unmarshal(rec.Body.Bytes(), &userSpaces) + var userLists []domain.SharedList + _ = json.Unmarshal(rec.Body.Bytes(), &userLists) found := false - for _, s := range userSpaces { - if s.ID == createdSpace.ID { + for _, l := range userLists { + if l.ID == createdList.ID { found = true break } } if !found { - t.Fatalf("expected space %s to be visible to user, got %+v", createdSpace.ID, userSpaces) + t.Fatalf("expected list %s to be visible to user, got %+v", createdList.ID, userLists) } - // 4. Admin removes user from space - req = httptest.NewRequest(http.MethodDelete, "/api/v1/spaces/"+createdSpace.ID+"/members/"+normalUser.ID, nil) + // 4. Admin removes user share from list + req = httptest.NewRequest(http.MethodDelete, "/api/v1/lists/"+createdList.ID+"/shares/"+newShare.ID, nil) req.Header.Set("Authorization", "Bearer "+adminToken) rec = httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { - t.Fatalf("expected 200 OK for remove member, got %d: %s", rec.Code, rec.Body.String()) + t.Fatalf("expected 200 OK for remove share, got %d: %s", rec.Code, rec.Body.String()) } - // 5. Test adding a team to a space + // 5. Test sharing list with a team team, err := store.CreateTeam("", "WG Team", "Haushalt") if err != nil { t.Fatalf("failed to create team: %v", err) } _ = store.AddTeamMember(team.ID, normalUser.ID, "member") - addTeamBody := []byte(`{"teamId":"` + team.ID + `","role":"editor"}`) - req = httptest.NewRequest(http.MethodPost, "/api/v1/spaces/"+createdSpace.ID+"/members", bytes.NewReader(addTeamBody)) + addTeamBody := []byte(`{"targetType":"team","targetId":"` + team.ID + `","role":"editor"}`) + req = httptest.NewRequest(http.MethodPost, "/api/v1/lists/"+createdList.ID+"/shares", bytes.NewReader(addTeamBody)) req.Header.Set("Authorization", "Bearer "+adminToken) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusCreated && rec.Code != http.StatusOK { - t.Fatalf("expected 201 or 200 for add space team, got %d: %s", rec.Code, rec.Body.String()) + t.Fatalf("expected 201 or 200 for add list team share, got %d: %s", rec.Code, rec.Body.String()) } - // User should see the space again because their team was added - req = httptest.NewRequest(http.MethodGet, "/api/v1/spaces", nil) + // User should see the list again because their team was added + req = httptest.NewRequest(http.MethodGet, "/api/v1/lists", nil) req.Header.Set("Authorization", "Bearer "+userToken) rec = httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { - t.Fatalf("expected 200 OK for user spaces after team add, got %d", rec.Code) + t.Fatalf("expected 200 OK for user lists after team share add, got %d", rec.Code) } - userSpaces = nil - _ = json.Unmarshal(rec.Body.Bytes(), &userSpaces) + userLists = nil + _ = json.Unmarshal(rec.Body.Bytes(), &userLists) found = false - for _, s := range userSpaces { - if s.ID == createdSpace.ID { + for _, l := range userLists { + if l.ID == createdList.ID { found = true break } } if !found { - t.Fatalf("expected space %s to be visible to user via team, got %+v", createdSpace.ID, userSpaces) + t.Fatalf("expected list %s to be visible to user via team share, got %+v", createdList.ID, userLists) } } diff --git a/internal/domain/models.go b/internal/domain/models.go index 0921c86..4934766 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -154,10 +154,24 @@ type Thought struct { Notes string `json:"notes,omitempty"` Subtasks []SubTask `json:"subtasks"` AlarmSent bool `json:"alarmSent,omitempty"` + SortOrder int `json:"sortOrder"` CreatedAt string `json:"createdAt"` 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"` @@ -324,40 +338,18 @@ type TagDetails struct { TotalMatches int `json:"totalMatches"` } -type SpaceMember struct { - SpaceID string `json:"spaceId"` - UserID string `json:"userId"` - DisplayName string `json:"displayName"` - Email string `json:"email"` - Role string `json:"role"` // owner | editor | viewer - JoinedAt string `json:"joinedAt"` -} - -type SpaceShare struct { +type ListShare struct { ID string `json:"id"` - SpaceID string `json:"spaceId"` + 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"` } -type Space struct { - ID string `json:"id"` - Name string `json:"name"` - Icon string `json:"icon"` - Description string `json:"description"` - InviteCode string `json:"inviteCode"` - IsPersonal bool `json:"isPersonal"` - OwnerID string `json:"ownerId"` - Members []SpaceMember `json:"members"` - Shares []SpaceShare `json:"shares,omitempty"` - ListsCount int `json:"listsCount"` - CreatedAt string `json:"createdAt"` -} - type ListItem struct { ID string `json:"id"` ListID string `json:"listId"` @@ -373,14 +365,18 @@ type ListItem struct { } type SharedList struct { - ID string `json:"id"` - SpaceID string `json:"spaceId"` - Title string `json:"title"` - Icon string `json:"icon"` - ListType string `json:"listType"` // shopping | checklist | vacation - Items []ListItem `json:"items"` - UpdatedAt string `json:"updatedAt"` - CreatedAt string `json:"createdAt"` + ID string `json:"id"` + UserID string `json:"userId"` + OwnerName string `json:"ownerName,omitempty"` + IsOwner bool `json:"isOwner"` + Role string `json:"role,omitempty"` // editor | viewer + Title string `json:"title"` + Icon string `json:"icon"` + ListType string `json:"listType"` // shopping | checklist | vacation + Items []ListItem `json:"items"` + Shares []ListShare `json:"shares,omitempty"` + UpdatedAt string `json:"updatedAt"` + CreatedAt string `json:"createdAt"` } type APIKey struct { @@ -397,8 +393,7 @@ type APIKey struct { type WSMessage struct { 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,omitempty"` + ListID string `json:"list_id"` Payload interface{} `json:"payload"` Timestamp string `json:"timestamp"` } @@ -419,7 +414,6 @@ type FullExport struct { User User `json:"user"` Thoughts []Thought `json:"thoughts"` Documents []Document `json:"documents"` - Spaces []Space `json:"spaces"` Lists []SharedList `json:"lists"` TaskLists []TaskList `json:"taskLists,omitempty"` } diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 674ece8..819cf75 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -137,28 +137,6 @@ func (s *Store) migrate() error { ); CREATE INDEX IF NOT EXISTS idx_task_lists_user ON task_lists(user_id, updated_at DESC); - CREATE TABLE IF NOT EXISTS spaces ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - icon TEXT NOT NULL DEFAULT '🏠', - description TEXT NOT NULL DEFAULT '', - invite_code TEXT UNIQUE NOT NULL, - is_personal INTEGER NOT NULL DEFAULT 0, - owner_id TEXT NOT NULL, - created_at TEXT NOT NULL, - FOREIGN KEY(owner_id) REFERENCES users(id) ON DELETE CASCADE - ); - - CREATE TABLE IF NOT EXISTS space_members ( - space_id TEXT NOT NULL, - user_id TEXT NOT NULL, - role TEXT NOT NULL DEFAULT 'editor', - joined_at TEXT NOT NULL, - PRIMARY KEY (space_id, user_id), - FOREIGN KEY(space_id) REFERENCES spaces(id) ON DELETE CASCADE, - FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE - ); - CREATE TABLE IF NOT EXISTS organizations ( id TEXT PRIMARY KEY, name TEXT NOT NULL, @@ -259,16 +237,32 @@ func (s *Store) migrate() error { CREATE TABLE IF NOT EXISTS lists ( id TEXT PRIMARY KEY, - space_id TEXT NOT NULL, + user_id TEXT NOT NULL, title TEXT NOT NULL, icon TEXT NOT NULL DEFAULT '🛒', list_type TEXT NOT NULL DEFAULT 'shopping', updated_at TEXT NOT NULL, created_at TEXT NOT NULL, - FOREIGN KEY(space_id) REFERENCES spaces(id) ON DELETE CASCADE + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE ); - CREATE INDEX IF NOT EXISTS idx_lists_space ON lists(space_id, updated_at DESC); + CREATE INDEX IF NOT EXISTS idx_lists_user ON lists(user_id, updated_at DESC); + + CREATE TABLE IF NOT EXISTS list_shares ( + id TEXT PRIMARY KEY, + list_id TEXT NOT NULL, + target_type TEXT NOT NULL CHECK(target_type IN ('team', 'user')), + target_id TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'editor' CHECK(role IN ('editor', 'viewer')), + created_by TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE(list_id, target_type, target_id), + FOREIGN KEY(list_id) REFERENCES lists(id) ON DELETE CASCADE, + FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_list_shares_target ON list_shares(target_type, target_id); + CREATE INDEX IF NOT EXISTS idx_list_shares_list ON list_shares(list_id); CREATE TABLE IF NOT EXISTS list_items ( id TEXT PRIMARY KEY, @@ -325,6 +319,8 @@ func (s *Store) migrate() error { "ALTER TABLE thoughts ADD COLUMN notes TEXT NOT NULL DEFAULT '';", "ALTER TABLE thoughts ADD COLUMN alarm_sent INTEGER NOT NULL DEFAULT 0;", "ALTER TABLE thoughts ADD COLUMN subtasks TEXT NOT NULL DEFAULT '[]';", + "ALTER TABLE thoughts ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0;", + "CREATE INDEX IF NOT EXISTS idx_thoughts_list_sort ON thoughts(task_list_id, sort_order ASC, created_at DESC);", "ALTER TABLE documents ADD COLUMN folder_path TEXT NOT NULL DEFAULT '';", @@ -342,14 +338,20 @@ func (s *Store) migrate() error { "CREATE INDEX IF NOT EXISTS idx_doc_team_shares_team ON doc_team_shares(team_id);", "CREATE INDEX IF NOT EXISTS idx_doc_team_shares_doc ON doc_team_shares(doc_id);", - "CREATE TABLE IF NOT EXISTS space_team_shares (id TEXT PRIMARY KEY, space_id TEXT NOT NULL, team_id TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'editor', created_at TEXT NOT NULL, created_by TEXT NOT NULL, UNIQUE(space_id, team_id));", - "CREATE INDEX IF NOT EXISTS idx_space_team_shares_team ON space_team_shares(team_id);", - "CREATE INDEX IF NOT EXISTS idx_space_team_shares_space ON space_team_shares(space_id);", + "ALTER TABLE lists ADD COLUMN user_id TEXT NOT NULL DEFAULT '';", + "CREATE TABLE IF NOT EXISTS list_shares (id TEXT PRIMARY KEY, list_id TEXT NOT NULL, target_type TEXT NOT NULL CHECK(target_type IN ('team', 'user')), target_id TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'editor' CHECK(role IN ('editor', 'viewer')), created_at TEXT NOT NULL, created_by TEXT NOT NULL, UNIQUE(list_id, target_type, target_id));", + "CREATE INDEX IF NOT EXISTS idx_list_shares_target ON list_shares(target_type, target_id);", + "CREATE INDEX IF NOT EXISTS idx_list_shares_list ON list_shares(list_id);", } for _, q := range columnMigrations { _, _ = s.db.Exec(q) } + // Clean up legacy space_id from documents and task_lists if empty or orphaned + _, _ = s.db.Exec(`DROP TABLE IF EXISTS spaces`) + _, _ = s.db.Exec(`DROP TABLE IF EXISTS space_members`) + _, _ = s.db.Exec(`DROP TABLE IF EXISTS space_team_shares`) + // Backfill username for existing accounts where username is empty _, _ = s.db.Exec(`UPDATE users SET username = LOWER(SUBSTR(email, 1, INSTR(email, '@') - 1)) WHERE (username IS NULL OR username = '') AND INSTR(email, '@') > 0`) @@ -442,13 +444,10 @@ func (s *Store) SeedStarterContent(user *domain.User) error { _, _ = s.UpdateThoughtTriage(user.ID, keptTask.ID, "keep", "task", "", now[:10], "") } - familySpace, err := s.CreateSpace(user.ID, "Familie & Zuhause", "🏡", "Geteilter Bereich für Einkäufe, Haushalt & Urlaubsplanung", false) - if err == nil && familySpace != nil { - shopList, err := s.CreateList(familySpace.ID, "Wocheneinkauf Supermarkt", "🛒", "shopping") - if err == nil && shopList != nil { - _, _ = s.CreateListItem(shopList.ID, "Hafermilch (Barista Edition)", "Kühlregal", "3x", 0) - _, _ = s.CreateListItem(shopList.ID, "Espressobohnen (100% Arabica)", "Kaffee & Tee", "1 kg", 1) - } + shopList, err := s.CreateList(user.ID, "Wocheneinkauf Supermarkt", "🛒", "shopping") + if err == nil && shopList != nil { + _, _ = s.CreateListItem(shopList.ID, "Hafermilch (Barista Edition)", "Kühlregal", "3x", 0) + _, _ = s.CreateListItem(shopList.ID, "Espressobohnen (100% Arabica)", "Kaffee & Tee", "1 kg", 1) } starterBlocks := []domain.DocumentBlock{ @@ -692,12 +691,8 @@ func (s *Store) GetDiscoverableUsers(viewerID string) ([]domain.User, error) { SELECT tm2.user_id FROM team_members tm1 JOIN team_members tm2 ON tm1.team_id = tm2.team_id WHERE tm1.user_id = ? - UNION - SELECT sm2.user_id FROM space_members sm1 - JOIN space_members sm2 ON sm1.space_id = sm2.space_id - WHERE sm1.user_id = ? )`, - viewerID, viewerID, viewerID, + viewerID, viewerID, ) if err != nil { return []domain.User{*viewer}, nil @@ -842,16 +837,17 @@ func scanThought(rows interface { }) (*domain.Thought, error) { t := &domain.Thought{} var tagsStr, subtasksStr, statusStr string - var isCompleted, alarmSent int + var isCompleted, alarmSent, sortOrder int err := rows.Scan( &t.ID, &t.UserID, &t.Content, &tagsStr, &t.Priority, &t.Source, &statusStr, &isCompleted, &t.ConvertedType, &t.ConvertedRefID, &t.DueDate, &t.TaskListID, &t.KanbanStatus, &t.Notes, &alarmSent, - &t.CreatedAt, &t.TriagedAt, &subtasksStr, + &t.CreatedAt, &t.TriagedAt, &subtasksStr, &sortOrder, ) if err != nil { return nil, err } + t.SortOrder = sortOrder t.Status = domain.ThoughtStatus(statusStr) t.IsCompleted = isCompleted != 0 t.AlarmSent = alarmSent != 0 @@ -873,21 +869,20 @@ func scanThought(rows interface { func (s *Store) accessibleTaskListIDsQuery() string { return `SELECT id FROM task_lists - WHERE user_id = ? + WHERE user_id = ? OR (team_id != '' AND team_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) OR id IN ( SELECT task_list_id FROM task_list_shares WHERE (target_type = 'user' AND target_id = ?) OR (target_type = 'team' AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) - ) - OR (space_id != '' AND space_id IN (SELECT space_id FROM space_members WHERE user_id = ?))` + )` } func (s *Store) GetThoughtByID(userID, thoughtID string) (*domain.Thought, error) { row := s.db.QueryRow( - `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks + `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order FROM thoughts WHERE id = ? AND (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))`, - thoughtID, userID, userID, userID, userID, userID, userID, + thoughtID, userID, userID, userID, userID, userID, ) return scanThought(row) } @@ -901,16 +896,16 @@ func (s *Store) GetThoughts(userID, statusFilter string, limit int) ([]domain.Th if statusFilter != "" && statusFilter != "all" { rows, err = s.db.Query( - `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks + `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order FROM thoughts WHERE user_id = ? AND status = ? - ORDER BY CASE priority WHEN 'urgent' THEN 1 WHEN 'high' THEN 2 WHEN 'normal' THEN 3 ELSE 4 END, created_at DESC LIMIT ?`, + ORDER BY CASE priority WHEN 'urgent' THEN 1 WHEN 'high' THEN 2 WHEN 'normal' THEN 3 ELSE 4 END, sort_order ASC, created_at DESC LIMIT ?`, userID, statusFilter, limit, ) } else { rows, err = s.db.Query( - `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks + `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order FROM thoughts WHERE user_id = ? - ORDER BY created_at DESC LIMIT ?`, + ORDER BY sort_order ASC, created_at DESC LIMIT ?`, userID, limit, ) } @@ -934,22 +929,22 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er var err error if taskListID != "" { rows, err = s.db.Query( - `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks + `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order FROM thoughts WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`)) AND task_list_id = ? AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task')) - ORDER BY is_completed ASC, CASE WHEN due_date = '' THEN 1 ELSE 0 END, due_date ASC, created_at DESC`, - userID, userID, userID, userID, userID, userID, taskListID, + ORDER BY is_completed ASC, sort_order ASC, created_at DESC`, + userID, userID, userID, userID, userID, taskListID, ) } else { rows, err = s.db.Query( - `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks + `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order FROM thoughts WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`)) AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task')) - ORDER BY is_completed ASC, CASE WHEN due_date = '' THEN 1 ELSE 0 END, due_date ASC, created_at DESC`, - userID, userID, userID, userID, userID, userID, + ORDER BY is_completed ASC, sort_order ASC, created_at DESC`, + userID, userID, userID, userID, userID, ) } if err != nil { @@ -967,6 +962,37 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er return result, nil } +func (s *Store) ReorderTasks(taskListID string, items []domain.ThoughtReorderItem) error { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + for _, it := range items { + if it.KanbanStatus != nil && *it.KanbanStatus != "" { + isCompleted := 0 + if *it.KanbanStatus == "done" { + isCompleted = 1 + } + _, err = tx.Exec( + `UPDATE thoughts SET sort_order = ?, kanban_status = ?, is_completed = ? WHERE id = ?`, + it.SortOrder, *it.KanbanStatus, isCompleted, it.ID, + ) + } else { + _, err = tx.Exec( + `UPDATE thoughts SET sort_order = ? WHERE id = ?`, + it.SortOrder, it.ID, + ) + } + if err != nil { + return err + } + } + + return tx.Commit() +} + // UpdateThoughtTriage handles swipe left (discard), swipe right (keep), restore, and conversion to task/document/list_item func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, targetListID, dueDate, taskListID string) (*domain.Thought, error) { thought, err := s.GetThoughtByID(userID, thoughtID) @@ -2190,339 +2216,9 @@ func (s *Store) GetCollaborators(currentUserID string) ([]domain.User, error) { return users, nil } -// --- SPACES & REALTIME LISTS --- +// --- STANDALONE REALTIME LISTS & SHARING --- -func (s *Store) CreateSpace(ownerID, name, icon, description string, isPersonal bool) (*domain.Space, error) { - if strings.TrimSpace(name) == "" { - return nil, errors.New("space name required") - } - if icon == "" { - icon = "🏠" - } - now := domain.NowISO() - inviteCode := strings.ToUpper(domain.NewID("inv")[4:12]) - personalInt := 0 - if isPersonal { - personalInt = 1 - } - - sp := &domain.Space{ - ID: domain.NewID("spc"), - Name: strings.TrimSpace(name), - Icon: icon, - Description: strings.TrimSpace(description), - InviteCode: inviteCode, - IsPersonal: isPersonal, - OwnerID: ownerID, - CreatedAt: now, - } - - _, err := s.db.Exec( - `INSERT INTO spaces (id, name, icon, description, invite_code, is_personal, owner_id, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - sp.ID, sp.Name, sp.Icon, sp.Description, sp.InviteCode, personalInt, sp.OwnerID, sp.CreatedAt, - ) - if err != nil { - return nil, err - } - - _, _ = s.db.Exec( - `INSERT INTO space_members (space_id, user_id, role, joined_at) VALUES (?, ?, 'owner', ?)`, - sp.ID, ownerID, now, - ) - return s.GetSpaceByID(sp.ID) -} - -func (s *Store) JoinSpaceByInviteCode(userID, inviteCode string) (*domain.Space, error) { - inviteCode = strings.ToUpper(strings.TrimSpace(inviteCode)) - var spaceID string - err := s.db.QueryRow(`SELECT id FROM spaces WHERE invite_code = ?`, inviteCode).Scan(&spaceID) - if err != nil { - return nil, errors.New("Ungültiger Einladungscode") - } - - _, err = s.db.Exec( - `INSERT OR IGNORE INTO space_members (space_id, user_id, role, joined_at) VALUES (?, ?, 'editor', ?)`, - spaceID, userID, domain.NowISO(), - ) - if err != nil { - return nil, err - } - return s.GetSpaceByID(spaceID) -} - -func (s *Store) GetSpaceByID(spaceID string) (*domain.Space, error) { - sp := &domain.Space{} - var personalInt int - err := s.db.QueryRow( - `SELECT id, name, icon, description, invite_code, is_personal, owner_id, created_at FROM spaces WHERE id = ?`, - spaceID, - ).Scan(&sp.ID, &sp.Name, &sp.Icon, &sp.Description, &sp.InviteCode, &personalInt, &sp.OwnerID, &sp.CreatedAt) - if err != nil { - return nil, err - } - sp.IsPersonal = personalInt != 0 - sp.Members, _ = s.GetSpaceMembers(spaceID) - _ = s.db.QueryRow(`SELECT COUNT(*) FROM lists WHERE space_id = ?`, spaceID).Scan(&sp.ListsCount) - return sp, nil -} - -func (s *Store) GetSpaceMembers(spaceID string) ([]domain.SpaceMember, error) { - rows, err := s.db.Query( - `SELECT sm.space_id, sm.user_id, u.display_name, u.email, sm.role, sm.joined_at - FROM space_members sm - JOIN users u ON u.id = sm.user_id - WHERE sm.space_id = ? - ORDER BY sm.joined_at ASC`, - spaceID, - ) - if err != nil { - return nil, err - } - defer rows.Close() - - members := make([]domain.SpaceMember, 0) - for rows.Next() { - var m domain.SpaceMember - if err := rows.Scan(&m.SpaceID, &m.UserID, &m.DisplayName, &m.Email, &m.Role, &m.JoinedAt); err == nil { - members = append(members, m) - } - } - return members, nil -} - -func (s *Store) AddSpaceMember(currentUserID, spaceID, targetUserIdentifier, role string) (*domain.SpaceMember, error) { - // 1. Check if current user is member of space - var myRole string - err := s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, currentUserID).Scan(&myRole) - if err != nil { - return nil, errors.New("keine Berechtigung für diesen Bereich") - } - - // 2. Resolve target user by ID, username or email - cleanTarget := strings.TrimSpace(targetUserIdentifier) - var targetUser *domain.User - if strings.HasPrefix(cleanTarget, "usr_") { - targetUser, _ = s.GetUserByID(cleanTarget) - } - if targetUser == nil { - targetUser, _ = s.GetUserByLogin(cleanTarget) - } - if targetUser == nil { - targetUser, err = s.GetUserByID(cleanTarget) - } - if err != nil || targetUser == nil { - return nil, errors.New("benutzer nicht gefunden") - } - - // Protect existing owner from role downgrade! - var existingRole string - _ = s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, targetUser.ID).Scan(&existingRole) - if existingRole == "owner" { - role = "owner" - } else if role != "owner" && role != "editor" && role != "viewer" { - role = "editor" - } - - now := domain.NowISO() - _, err = s.db.Exec( - `INSERT INTO space_members (space_id, user_id, role, joined_at) VALUES (?, ?, ?, ?) - ON CONFLICT(space_id, user_id) DO UPDATE SET role = excluded.role`, - spaceID, targetUser.ID, role, now, - ) - if err != nil { - return nil, err - } - - return &domain.SpaceMember{ - SpaceID: spaceID, - UserID: targetUser.ID, - DisplayName: targetUser.DisplayName, - Email: targetUser.Email, - Role: role, - JoinedAt: now, - }, nil -} - -func (s *Store) CreateSpaceTeamShare(currentUserID, spaceID, teamID, role string) (*domain.SpaceShare, error) { - var myRole string - err := s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, currentUserID).Scan(&myRole) - if err != nil || (myRole != "owner" && myRole != "editor") { - return nil, errors.New("keine Berechtigung für diesen Bereich") - } - - cleanTeamID := strings.TrimSpace(teamID) - var teamName string - err = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, cleanTeamID).Scan(&teamName) - if err != nil { - return nil, errors.New("team nicht gefunden") - } - - if role != "owner" && role != "editor" && role != "viewer" { - role = "editor" - } - - now := domain.NowISO() - shareID := domain.NewID("spshare") - _, err = s.db.Exec( - `INSERT INTO space_team_shares (id, space_id, team_id, role, created_at, created_by) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(space_id, team_id) DO UPDATE SET role = excluded.role`, - shareID, spaceID, cleanTeamID, role, now, currentUserID, - ) - if err != nil { - return nil, err - } - - return &domain.SpaceShare{ - ID: shareID, - SpaceID: spaceID, - TargetType: "team", - TargetID: cleanTeamID, - TargetName: teamName, - Role: role, - CreatedBy: currentUserID, - CreatedAt: now, - }, nil -} - -func (s *Store) GetSpaceShares(spaceID string) ([]domain.SpaceShare, error) { - shares := make([]domain.SpaceShare, 0) - - // 1. Team shares - tRows, err := s.db.Query( - `SELECT s.id, s.space_id, s.team_id, t.name, s.role, s.created_by, s.created_at - FROM space_team_shares s - JOIN teams t ON s.team_id = t.id - WHERE s.space_id = ? - ORDER BY s.created_at ASC`, - spaceID, - ) - if err == nil { - for tRows.Next() { - var sh domain.SpaceShare - if err := tRows.Scan(&sh.ID, &sh.SpaceID, &sh.TargetID, &sh.TargetName, &sh.Role, &sh.CreatedBy, &sh.CreatedAt); err == nil { - sh.TargetType = "team" - shares = append(shares, sh) - } - } - _ = tRows.Close() - } - - // 2. Individual invited members (non-owner members) - var ownerID string - _ = s.db.QueryRow(`SELECT owner_id FROM spaces WHERE id = ?`, spaceID).Scan(&ownerID) - - mRows, err := s.db.Query( - `SELECT sm.user_id, u.display_name, u.email, sm.role, sm.joined_at - FROM space_members sm - JOIN users u ON u.id = sm.user_id - WHERE sm.space_id = ? AND sm.user_id != ? - ORDER BY sm.joined_at ASC`, - spaceID, ownerID, - ) - if err == nil { - for mRows.Next() { - var uid, dName, email, role, joinedAt string - if err := mRows.Scan(&uid, &dName, &email, &role, &joinedAt); err == nil { - name := dName - if name == "" { - name = email - } - shares = append(shares, domain.SpaceShare{ - ID: "sm_" + uid, - SpaceID: spaceID, - TargetType: "user", - TargetID: uid, - TargetName: name, - Role: role, - CreatedAt: joinedAt, - }) - } - } - _ = mRows.Close() - } - - return shares, nil -} - -func (s *Store) RemoveSpaceTeamShare(currentUserID, spaceID, targetID string) error { - var myRole string - err := s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, currentUserID).Scan(&myRole) - if err != nil || (myRole != "owner" && myRole != "editor") { - return errors.New("keine Berechtigung zum Entfernen der Freigabe") - } - - cleanTarget := strings.TrimSpace(targetID) - _, err = s.db.Exec(`DELETE FROM space_team_shares WHERE space_id = ? AND (id = ? OR team_id = ?)`, spaceID, cleanTarget, cleanTarget) - return err -} - -func (s *Store) RemoveSpaceMember(currentUserID, spaceID, targetUserID string) error { - var myRole string - err := s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, currentUserID).Scan(&myRole) - if err != nil { - return errors.New("keine Berechtigung für diesen Bereich") - } - - cleanTarget := strings.TrimSpace(targetUserID) - if strings.HasPrefix(cleanTarget, "team_") || strings.HasPrefix(cleanTarget, "spshare_") { - return s.RemoveSpaceTeamShare(currentUserID, spaceID, cleanTarget) - } - - if currentUserID != cleanTarget && myRole != "owner" { - return errors.New("nur der Eigentümer kann andere Mitglieder entfernen") - } - - var targetRole string - _ = s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, cleanTarget).Scan(&targetRole) - if targetRole == "owner" { - var ownerCount int - _ = s.db.QueryRow(`SELECT COUNT(*) FROM space_members WHERE space_id = ? AND role = 'owner'`, spaceID).Scan(&ownerCount) - if ownerCount <= 1 { - return errors.New("der letzte Eigentümer kann den Bereich nicht verlassen") - } - } - - _, err = s.db.Exec(`DELETE FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, cleanTarget) - return err -} - -func (s *Store) GetSpacesForUser(userID string) ([]domain.Space, error) { - rows, err := s.db.Query( - `SELECT s.id, s.name, s.icon, s.description, s.invite_code, s.is_personal, s.owner_id, s.created_at - FROM spaces s - LEFT JOIN space_members sm ON sm.space_id = s.id AND sm.user_id = ? - LEFT JOIN space_team_shares sts ON sts.space_id = s.id AND sts.team_id IN (SELECT team_id FROM team_members WHERE user_id = ?) - WHERE sm.user_id = ? OR sts.team_id IS NOT NULL - GROUP BY s.id - ORDER BY s.created_at ASC`, - userID, userID, userID, - ) - if err != nil { - return nil, err - } - - spaces := make([]domain.Space, 0) - for rows.Next() { - var sp domain.Space - var personalInt int - if err := rows.Scan(&sp.ID, &sp.Name, &sp.Icon, &sp.Description, &sp.InviteCode, &personalInt, &sp.OwnerID, &sp.CreatedAt); err == nil { - sp.IsPersonal = personalInt != 0 - spaces = append(spaces, sp) - } - } - _ = rows.Close() - - for i := range spaces { - spaces[i].Members, _ = s.GetSpaceMembers(spaces[i].ID) - spaces[i].Shares, _ = s.GetSpaceShares(spaces[i].ID) - _ = s.db.QueryRow(`SELECT COUNT(*) FROM lists WHERE space_id = ?`, spaces[i].ID).Scan(&spaces[i].ListsCount) - } - return spaces, nil -} - -func (s *Store) CreateList(spaceID, title, icon, listType string) (*domain.SharedList, error) { +func (s *Store) CreateList(userID, title, icon, listType string) (*domain.SharedList, error) { if strings.TrimSpace(title) == "" { return nil, errors.New("list title required") } @@ -2535,59 +2231,167 @@ func (s *Store) CreateList(spaceID, title, icon, listType string) (*domain.Share now := domain.NowISO() lst := &domain.SharedList{ ID: domain.NewID("lst"), - SpaceID: spaceID, + UserID: userID, + IsOwner: true, + Role: "editor", Title: strings.TrimSpace(title), Icon: icon, ListType: listType, Items: []domain.ListItem{}, + Shares: []domain.ListShare{}, UpdatedAt: now, CreatedAt: now, } _, err := s.db.Exec( - `INSERT INTO lists (id, space_id, title, icon, list_type, updated_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, - lst.ID, lst.SpaceID, lst.Title, lst.Icon, lst.ListType, lst.UpdatedAt, lst.CreatedAt, + `INSERT INTO lists (id, user_id, title, icon, list_type, updated_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, + lst.ID, lst.UserID, lst.Title, lst.Icon, lst.ListType, lst.UpdatedAt, lst.CreatedAt, ) if err != nil { return nil, err } + _ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, userID).Scan(&lst.OwnerName) return lst, nil } -func (s *Store) GetListsForSpace(spaceID string) ([]domain.SharedList, error) { - rows, err := s.db.Query( - `SELECT id, space_id, title, icon, list_type, updated_at, created_at - FROM lists WHERE space_id = ? ORDER BY created_at ASC`, - spaceID, +func (s *Store) AddListShare(currentUserID, listID, targetType, targetID, role string) (*domain.ListShare, error) { + if targetType != "team" && targetType != "user" { + return nil, errors.New("ungültiger Freigabetyp") + } + targetID = strings.TrimSpace(targetID) + if targetID == "" { + return nil, errors.New("Ziel-ID erforderlich") + } + if role != "viewer" && role != "editor" { + role = "editor" + } + + if targetType == "user" { + var targetUser *domain.User + if strings.HasPrefix(targetID, "usr_") { + targetUser, _ = s.GetUserByID(targetID) + } + if targetUser == nil { + targetUser, _ = s.GetUserByLogin(targetID) + } + if targetUser == nil { + targetUser, _ = s.GetUserByEmail(targetID) + } + if targetUser == nil { + return nil, fmt.Errorf("benutzer nicht gefunden: %s", targetID) + } + targetID = targetUser.ID + } + + // Verify current user is owner or editor of the list + var ownerID string + err := s.db.QueryRow(`SELECT user_id FROM lists WHERE id = ?`, listID).Scan(&ownerID) + if err != nil { + return nil, errors.New("liste nicht gefunden") + } + if ownerID != currentUserID { + var myRole string + err = s.db.QueryRow(`SELECT role FROM list_shares WHERE list_id = ? AND target_type = 'user' AND target_id = ?`, listID, currentUserID).Scan(&myRole) + if err != nil || myRole != "editor" { + return nil, errors.New("keine Berechtigung zum Teilen dieser Liste") + } + } + + now := domain.NowISO() + share := &domain.ListShare{ + ID: domain.NewID("lsh"), + ListID: listID, + TargetType: targetType, + TargetID: targetID, + Role: role, + CreatedBy: currentUserID, + CreatedAt: now, + } + + _, err = s.db.Exec( + `INSERT INTO list_shares (id, list_id, target_type, target_id, role, created_by, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(list_id, target_type, target_id) DO UPDATE SET role = excluded.role`, + share.ID, share.ListID, share.TargetType, share.TargetID, share.Role, share.CreatedBy, share.CreatedAt, ) if err != nil { return nil, err } - lists := make([]domain.SharedList, 0) + _ = s.db.QueryRow(`SELECT id, created_at FROM list_shares WHERE list_id = ? AND target_type = ? AND target_id = ?`, + listID, targetType, targetID).Scan(&share.ID, &share.CreatedAt) + + if targetType == "team" { + _ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, targetID).Scan(&share.TargetName) + } else { + _ = s.db.QueryRow(`SELECT display_name, email FROM users WHERE id = ?`, targetID).Scan(&share.TargetName, &share.Email) + } + return share, nil +} + +func (s *Store) RemoveListShare(currentUserID, listID, shareIDOrTargetID string) error { + var ownerID string + err := s.db.QueryRow(`SELECT user_id FROM lists WHERE id = ?`, listID).Scan(&ownerID) + if err != nil { + return errors.New("liste nicht gefunden") + } + if ownerID != currentUserID { + return errors.New("nur der Eigentümer kann Freigaben entfernen") + } + + cleanTarget := strings.TrimSpace(shareIDOrTargetID) + _, err = s.db.Exec( + `DELETE FROM list_shares WHERE list_id = ? AND (id = ? OR target_id = ?)`, + listID, cleanTarget, cleanTarget, + ) + return err +} + +func (s *Store) GetListShares(listID string) ([]domain.ListShare, error) { + rows, err := s.db.Query( + `SELECT id, list_id, target_type, target_id, role, created_by, created_at + FROM list_shares WHERE list_id = ? ORDER BY created_at ASC`, + listID, + ) + if err != nil { + return []domain.ListShare{}, err + } + defer rows.Close() + + shares := make([]domain.ListShare, 0) for rows.Next() { - var l domain.SharedList - if err := rows.Scan(&l.ID, &l.SpaceID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt); err == nil { - lists = append(lists, l) + var sh domain.ListShare + if err := rows.Scan(&sh.ID, &sh.ListID, &sh.TargetType, &sh.TargetID, &sh.Role, &sh.CreatedBy, &sh.CreatedAt); err == nil { + if sh.TargetType == "team" { + _ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, sh.TargetID).Scan(&sh.TargetName) + } else { + _ = s.db.QueryRow(`SELECT display_name, email FROM users WHERE id = ?`, sh.TargetID).Scan(&sh.TargetName, &sh.Email) + } + shares = append(shares, sh) } } - _ = rows.Close() - - for i := range lists { - lists[i].Items, _ = s.GetListItems(lists[i].ID) - } - return lists, nil + return shares, nil } func (s *Store) GetAllListsForUser(userID string) ([]domain.SharedList, error) { rows, err := s.db.Query( - `SELECT DISTINCT l.id, l.space_id, l.title, l.icon, l.list_type, l.updated_at, l.created_at + `SELECT DISTINCT l.id, l.user_id, l.title, l.icon, l.list_type, l.updated_at, l.created_at, + u.display_name, + CASE + WHEN l.user_id = ? THEN 'owner' + WHEN ls.role IS NOT NULL THEN ls.role + ELSE 'editor' + END as effective_role FROM lists l - LEFT JOIN space_members sm ON sm.space_id = l.space_id AND sm.user_id = ? - LEFT JOIN space_team_shares sts ON sts.space_id = l.space_id AND sts.team_id IN (SELECT team_id FROM team_members WHERE user_id = ?) - WHERE sm.user_id = ? OR sts.team_id IS NOT NULL + LEFT JOIN users u ON u.id = l.user_id + LEFT JOIN list_shares ls ON ls.list_id = l.id AND ( + (ls.target_type = 'user' AND ls.target_id = ?) + OR (ls.target_type = 'team' AND ls.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) + ) + WHERE l.user_id = ? + OR ls.id IS NOT NULL ORDER BY l.updated_at DESC`, - userID, userID, userID, + userID, userID, userID, userID, ) if err != nil { return nil, err @@ -2596,7 +2400,12 @@ func (s *Store) GetAllListsForUser(userID string) ([]domain.SharedList, error) { lists := make([]domain.SharedList, 0) for rows.Next() { var l domain.SharedList - if err := rows.Scan(&l.ID, &l.SpaceID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt); err == nil { + var ownerName sql.NullString + var effectiveRole string + if err := rows.Scan(&l.ID, &l.UserID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt, &ownerName, &effectiveRole); err == nil { + l.OwnerName = ownerName.String + l.IsOwner = (l.UserID == userID) + l.Role = effectiveRole lists = append(lists, l) } } @@ -2604,25 +2413,68 @@ func (s *Store) GetAllListsForUser(userID string) ([]domain.SharedList, error) { for i := range lists { lists[i].Items, _ = s.GetListItems(lists[i].ID) + lists[i].Shares, _ = s.GetListShares(lists[i].ID) } return lists, nil } -func (s *Store) GetListByID(listID string) (*domain.SharedList, error) { +func (s *Store) GetListByID(userID, listID string) (*domain.SharedList, error) { var l domain.SharedList - err := s.db.QueryRow( - `SELECT id, space_id, title, icon, list_type, updated_at, created_at FROM lists WHERE id = ?`, - listID, - ).Scan(&l.ID, &l.SpaceID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt) + var ownerName sql.NullString + var effectiveRole string + + var query string + var args []interface{} + if userID == "" { + query = `SELECT l.id, l.user_id, l.title, l.icon, l.list_type, l.updated_at, l.created_at, + u.display_name, 'owner' as effective_role + FROM lists l + LEFT JOIN users u ON u.id = l.user_id + WHERE l.id = ?` + args = []interface{}{listID} + } else { + query = `SELECT l.id, l.user_id, l.title, l.icon, l.list_type, l.updated_at, l.created_at, + u.display_name, + CASE + WHEN l.user_id = ? THEN 'owner' + WHEN ls.role IS NOT NULL THEN ls.role + ELSE 'editor' + END as effective_role + FROM lists l + LEFT JOIN users u ON u.id = l.user_id + LEFT JOIN list_shares ls ON ls.list_id = l.id AND ( + (ls.target_type = 'user' AND ls.target_id = ?) + OR (ls.target_type = 'team' AND ls.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) + ) + WHERE l.id = ? AND ( + l.user_id = ? + OR ls.id IS NOT NULL + )` + args = []interface{}{userID, userID, userID, listID, userID} + } + + err := s.db.QueryRow(query, args...).Scan(&l.ID, &l.UserID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt, &ownerName, &effectiveRole) if err != nil { return nil, err } + l.OwnerName = ownerName.String + l.IsOwner = (userID == "" || l.UserID == userID) + l.Role = effectiveRole l.Items, _ = s.GetListItems(l.ID) + l.Shares, _ = s.GetListShares(l.ID) return &l, nil } -func (s *Store) DeleteList(listID string) error { - _, err := s.db.Exec(`DELETE FROM lists WHERE id = ?`, listID) +func (s *Store) DeleteList(userID, listID string) error { + var ownerID string + err := s.db.QueryRow(`SELECT user_id FROM lists WHERE id = ?`, listID).Scan(&ownerID) + if err != nil { + return errors.New("liste nicht gefunden") + } + if ownerID != userID { + return errors.New("nur der Eigentümer kann die Liste löschen") + } + _, err = s.db.Exec(`DELETE FROM lists WHERE id = ?`, listID) return err } @@ -2751,6 +2603,44 @@ func (s *Store) ClearCompletedListItems(listID string) (int64, error) { return res.RowsAffected() } +func (s *Store) ReorderListItems(listID string, items []domain.ListItemReorderItem) error { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + for _, it := range items { + if it.Category != nil && it.Content != nil { + _, err = tx.Exec( + `UPDATE list_items SET sort_order = ?, category = ?, content = ? WHERE id = ? AND list_id = ?`, + it.SortOrder, *it.Category, *it.Content, it.ID, listID, + ) + } else if it.Category != nil { + _, err = tx.Exec( + `UPDATE list_items SET sort_order = ?, category = ? WHERE id = ? AND list_id = ?`, + it.SortOrder, *it.Category, it.ID, listID, + ) + } else if it.Content != nil { + _, err = tx.Exec( + `UPDATE list_items SET sort_order = ?, content = ? WHERE id = ? AND list_id = ?`, + it.SortOrder, *it.Content, it.ID, listID, + ) + } else { + _, err = tx.Exec( + `UPDATE list_items SET sort_order = ? WHERE id = ? AND list_id = ?`, + it.SortOrder, it.ID, listID, + ) + } + if err != nil { + return err + } + } + + _, _ = tx.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, domain.NowISO(), listID) + return tx.Commit() +} + // --- API KEYS & WEBHOOKS --- func (s *Store) CreateAPIKey(userID, name string) (*domain.APIKey, error) { @@ -2829,9 +2719,11 @@ func (s *Store) GetDashboardStats(userID string) (*domain.DashboardStats, error) _ = s.db.QueryRow(` SELECT COUNT(DISTINCT l.id) FROM lists l - LEFT JOIN space_members sm ON sm.space_id = l.space_id AND sm.user_id = ? - LEFT JOIN space_team_shares sts ON sts.space_id = l.space_id AND sts.team_id IN (SELECT team_id FROM team_members WHERE user_id = ?) - WHERE sm.user_id = ? OR sts.team_id IS NOT NULL`, + LEFT JOIN list_shares ls ON ls.list_id = l.id AND ( + (ls.target_type = 'user' AND ls.target_id = ?) + OR (ls.target_type = 'team' AND ls.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) + ) + WHERE l.user_id = ? OR ls.id IS NOT NULL`, userID, userID, userID).Scan(&st.ListsCount) // Count open (uncompleted) To-Dos across accessible workspace documents (owned or shared) @@ -2865,7 +2757,7 @@ func (s *Store) SearchAll(userID, query string) ([]domain.Thought, []domain.Docu q := "%" + strings.ToLower(strings.TrimSpace(query)) + "%" tRows, err := s.db.Query( - `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks + `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order FROM thoughts WHERE user_id = ? AND LOWER(content) LIKE ? ORDER BY created_at DESC LIMIT 30`, userID, q, ) @@ -2908,7 +2800,6 @@ func (s *Store) ExportUserData(userID string) (*domain.FullExport, error) { } thoughts, _ := s.GetThoughts(userID, "all", 5000) docs, _ := s.GetDocuments(userID) - spaces, _ := s.GetSpacesForUser(userID) lists, _ := s.GetAllListsForUser(userID) taskLists, _ := s.GetTaskLists(userID) @@ -2918,7 +2809,6 @@ func (s *Store) ExportUserData(userID string) (*domain.FullExport, error) { User: *user, Thoughts: thoughts, Documents: docs, - Spaces: spaces, Lists: lists, TaskLists: taskLists, }, nil @@ -3127,9 +3017,8 @@ func (s *Store) GetTaskLists(userID string) ([]domain.TaskList, error) { WHERE (target_type = 'user' AND target_id = ?) OR (target_type = 'team' AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) ) - OR (space_id != '' AND space_id IN (SELECT space_id FROM space_members WHERE user_id = ?)) ORDER BY updated_at DESC`, - userID, userID, userID, userID, userID, + userID, userID, userID, userID, ) if err != nil { return []domain.TaskList{}, err @@ -3192,9 +3081,8 @@ func (s *Store) GetTaskListByID(userID, listID string) (*domain.TaskList, error) WHERE (target_type = 'user' AND target_id = ?) OR (target_type = 'team' AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) ) - OR (space_id != '' AND space_id IN (SELECT space_id FROM space_members WHERE user_id = ?)) )`, - listID, userID, userID, userID, userID, userID, + listID, userID, userID, userID, userID, ).Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.TeamID, &tl.Title, &tl.Icon, &tl.Color, &colsJSON, &tl.CreatedAt, &tl.UpdatedAt) if err != nil { return nil, err diff --git a/internal/storage/sqlite_test.go b/internal/storage/sqlite_test.go index c3c25b0..2eb8bcc 100644 --- a/internal/storage/sqlite_test.go +++ b/internal/storage/sqlite_test.go @@ -48,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) } - // 3. Test Shared Space & Shopping List - sp, err := store.CreateSpace(user.ID, "Test WG", "🍕", "Test Space", false) - if err != nil { - t.Fatalf("CreateSpace failed: %v", err) - } - lst, err := store.CreateList(sp.ID, "REWE Einkauf", "🛒", "shopping") + // 3. Test Standalone Shopping List & Sharing + lst, err := store.CreateList(user.ID, "REWE Einkauf", "🛒", "shopping") if err != nil { t.Fatalf("CreateList failed: %v", err) } @@ -66,26 +62,26 @@ func TestStoreLifecycle(t *testing.T) { t.Fatalf("ToggleListItem failed: %v", err) } - // Test AddSpaceMember & RemoveSpaceMember + // 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) } - member, err := store.AddSpaceMember(user.ID, sp.ID, u2.ID, "editor") - if err != nil || member.UserID != u2.ID { - t.Fatalf("AddSpaceMember 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) } - members, err := store.GetSpaceMembers(sp.ID) - if err != nil || len(members) != 2 { - t.Fatalf("expected 2 members, got %d", len(members)) + shares, err := store.GetListShares(lst.ID) + if err != nil || len(shares) != 1 { + t.Fatalf("expected 1 share, got %d", len(shares)) } - err = store.RemoveSpaceMember(user.ID, sp.ID, u2.ID) + err = store.RemoveListShare(user.ID, lst.ID, share.ID) if err != nil { - t.Fatalf("RemoveSpaceMember failed: %v", err) + t.Fatalf("RemoveListShare failed: %v", err) } - membersAfter, _ := store.GetSpaceMembers(sp.ID) - if len(membersAfter) != 1 { - t.Fatalf("expected 1 member after remove, got %d", len(membersAfter)) + 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 @@ -99,7 +95,7 @@ func TestStoreLifecycle(t *testing.T) { } // 5. Test TaskLists CRUD - taskList, err := store.CreateTaskList(user.ID, sp.ID, "", "Haus & Garten", "🏡", "#10b981") + taskList, err := store.CreateTaskList(user.ID, "", "", "Haus & Garten", "🏡", "#10b981") if err != nil || taskList.ID == "" { t.Fatalf("CreateTaskList failed: %v", err) } @@ -160,8 +156,8 @@ func TestStoreLifecycle(t *testing.T) { } // Share with user2 as "read" - share, err := store.CreateDocShare(user.ID, newDoc.ID, user2.ID, "read") - if err != nil || share.Permission != "read" { + docShare, err := store.CreateDocShare(user.ID, newDoc.ID, user2.ID, "read") + if err != nil || docShare.Permission != "read" { t.Fatalf("CreateDocShare failed: %v", err) } diff --git a/internal/ws/hub.go b/internal/ws/hub.go index 11000f9..095c2aa 100644 --- a/internal/ws/hub.go +++ b/internal/ws/hub.go @@ -22,7 +22,7 @@ var upgrader = websocket.Upgrader{ type Client struct { hub *Hub conn *websocket.Conn - spaceID string + roomID string userID string send chan []byte } @@ -37,7 +37,7 @@ type docLockItem struct { type Hub struct { mu sync.RWMutex - spaceRooms map[string]map[*Client]bool + rooms map[string]map[*Client]bool userRooms map[string]map[*Client]bool allClients map[*Client]bool locks map[string]*docLockItem @@ -45,7 +45,7 @@ type Hub struct { func NewHub() *Hub { h := &Hub{ - spaceRooms: 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), locks: make(map[string]*docLockItem), @@ -57,11 +57,11 @@ func NewHub() *Hub { func (h *Hub) Register(c *Client) { h.mu.Lock() h.allClients[c] = true - if c.spaceID != "" { - if h.spaceRooms[c.spaceID] == nil { - h.spaceRooms[c.spaceID] = make(map[*Client]bool) + if c.roomID != "" { + if h.rooms[c.roomID] == nil { + h.rooms[c.roomID] = make(map[*Client]bool) } - h.spaceRooms[c.spaceID][c] = true + h.rooms[c.roomID][c] = true } if c.userID != "" && c.userID != "anonymous" { if h.userRooms[c.userID] == nil { @@ -72,8 +72,8 @@ func (h *Hub) Register(c *Client) { onlineCount := len(h.allClients) h.mu.Unlock() - if c.spaceID != "" { - h.Broadcast(c.spaceID, "", "USER_PRESENCE", map[string]interface{}{ + if c.roomID != "" { + h.Broadcast(c.roomID, "", "USER_PRESENCE", map[string]interface{}{ "onlineCount": onlineCount, }) } @@ -82,11 +82,11 @@ func (h *Hub) Register(c *Client) { func (h *Hub) Unregister(c *Client) { h.mu.Lock() delete(h.allClients, c) - if c.spaceID != "" { - if room, ok := h.spaceRooms[c.spaceID]; ok { + if c.roomID != "" { + if room, ok := h.rooms[c.roomID]; ok { delete(room, c) if len(room) == 0 { - delete(h.spaceRooms, c.spaceID) + delete(h.rooms, c.roomID) } } } @@ -297,7 +297,6 @@ func (h *Hub) BroadcastDocEvent(docID, event string, payload interface{}) { func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) { msg := domain.WSMessage{ Event: event, - SpaceID: spaceID, ListID: listID, Payload: payload, Timestamp: domain.NowISO(), @@ -310,20 +309,27 @@ func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) { h.mu.RLock() defer h.mu.RUnlock() - room, ok := h.spaceRooms[spaceID] - if !ok { - return + targetRoom := listID + if targetRoom == "" { + targetRoom = spaceID } - for client := range room { - select { - case client.send <- data: - default: - // Slow client buffer full + + if room, ok := h.rooms[targetRoom]; ok { + for client := range room { + select { + case client.send <- data: + default: + // Slow client buffer full + } } } } -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) ServeWS(w http.ResponseWriter, r *http.Request, roomID, userID string) { conn, err := upgrader.Upgrade(w, r, nil) if err != nil { log.Printf("[ws] upgrade error: %v", err) @@ -333,7 +339,7 @@ func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request, spaceID, userID st client := &Client{ hub: h, conn: conn, - spaceID: spaceID, + roomID: roomID, userID: userID, send: make(chan []byte, 128), } diff --git a/migrations/001_init.sql b/migrations/001_init.sql index 2d4c77d..9e98593 100644 --- a/migrations/001_init.sql +++ b/migrations/001_init.sql @@ -24,6 +24,7 @@ CREATE TABLE IF NOT EXISTS thoughts ( converted_type TEXT DEFAULT '', converted_ref_id TEXT DEFAULT '', due_date TEXT DEFAULT '', + sort_order INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, triaged_at TEXT DEFAULT '', 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_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 ( id TEXT PRIMARY KEY, diff --git a/screenshots/Listen_desktop.png b/screenshots/Listen_desktop.png new file mode 100644 index 0000000..f0bae8b Binary files /dev/null and b/screenshots/Listen_desktop.png differ diff --git a/screenshots/Mobile_Triage.png b/screenshots/Mobile_Triage.png new file mode 100644 index 0000000..a8ab876 Binary files /dev/null and b/screenshots/Mobile_Triage.png differ diff --git a/screenshots/Triage_desktop.png b/screenshots/Triage_desktop.png new file mode 100644 index 0000000..8d18562 Binary files /dev/null and b/screenshots/Triage_desktop.png differ diff --git a/screenshots/aufgaben_desktop.png b/screenshots/aufgaben_desktop.png new file mode 100644 index 0000000..bf48e27 Binary files /dev/null and b/screenshots/aufgaben_desktop.png differ diff --git a/screenshots/workspace_desktop.png b/screenshots/workspace_desktop.png new file mode 100644 index 0000000..5c8c0ea Binary files /dev/null and b/screenshots/workspace_desktop.png differ