Merge pull request 'Dev' (#4) from dev into main

Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
sandrodach committed 2026-10-25 21:09:44 +01:00
commit 0b9046cb7a
27 files changed
+2844 -1221

No files matched your search

+103 -40
View File
@@ -8,86 +8,149 @@
[![Markdown](https://img.shields.io/badge/Datenhoheit-Pure%20Markdown%20%26%20Outliner-18181b?style=flat-square)](#) [![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) [![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.
<br />
<p align="center">
<img src="screenshots/Triage_desktop.png" alt="MindSift Triage Desktop" width="85%" style="border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.3);" />
</p>
[Philosophie](#-die-philosophie) • [Funktionen](#-was-mindsift-auszeichnet) • [Schnellstart](#-schnellstart) • [Für wen?](#-für-wen-ist-mindsift-gemacht) [Philosophie](#-die-philosophie) • [Funktionen](#-was-mindsift-auszeichnet) • [Schnellstart](#-schnellstart) • [Für wen?](#-für-wen-ist-mindsift-gemacht)
---
</div> </div>
---
## 💡 Die Philosophie: *Erst raus aus dem Kopf – später sortieren* ## 💡 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.** 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:** **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. 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):** 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. 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 spontanen Notizen werden hierarchische Outliner-Dokumente, vernetzte Notizbäume, Kanban-Boards oder geteilte Listen für Familie und Team. 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 ## ✨ Was MindSift auszeichnet
### ⚡ Quick-Dump & Triage-Deck (Das 3-Sekunden-Prinzip) ### ⚡ 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 * `H` oder `←`: Wegwerfen / Erledigt
* `L` oder `→`: Sofort zur Aufgabe machen * `L` oder `→`: Sofort zur Aufgabe machen
* `T`: Termin-Aufgabe mit Fälligkeitsdatum * `T`: Termin-Aufgabe mit Fälligkeitsdatum
* `D`: In Workspace-Notiz umwandeln * `D`: In Workspace-Notiz umwandeln
* `Leertaste`: Später (ans Ende des Stapels schieben) * `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 <p align="center">
* **Jeder Gedanke ein Bullet-Point:** Schreibe Notizen als flexible, hierarchische Gliederung: <img src="screenshots/Mobile_Triage.png" alt="MindSift Mobile Triage" width="280px" style="border-radius: 12px; box-shadow: 0 4px 16px rgba(0,0,0,0.25);" />
</p>
---
### 📝 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).
<p align="center">
<img src="screenshots/workspace_desktop.png" alt="MindSift Workspace Outliner" width="90%" style="border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.3);" />
</p>
* **Jeder Gedanke ein Bullet-Point:**
* `Enter`: Neuer Bullet-Point auf derselben Ebene * `Enter`: Neuer Bullet-Point auf derselben Ebene
* `Tab` / `Shift + Tab`: Unterordnen (einrücken) und ausrücken * `Tab` / `Shift + Tab`: Unterordnen (einrücken) und ausrücken
* `Shift + Enter`: Weicher Zeilenumbruch innerhalb desselben Blocks * `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. * **🔍 Fokus-Modus & Zoom-in (Hoisting):**
* **Slash-Befehle (`/`):** Tippe einfach `/` für `/todo`, `/h1`, `/h2`, `/h3`, `/code`, `/table`, `/callout` oder `/progress`. * Klicke auf einen Bullet-Punkt (`•`), um **isoliert in diesen Ast hineinzuzoomen**.
* **Datei-Explorer mit Drag & Drop:** Verschiebe Dokumente mit der Maus direkt per Drag & Drop in Ordner oder zurück ins Hauptverzeichnis. * Der gewählte Punkt wird zur prominenten Überschrift, sodass du dich ohne jede Ablenkung auf genau dieses Thema konzentrieren kannst.
* **Angepinnte Favoriten:** Hefte wichtige Notizen mit einem Klick an – sie erscheinen prominent ganz oben im Explorer. * Eine klickbare Breadcrumb-Leiste oben zeigt deinen aktuellen Pfad und führt dich mit einem Klick zurück in übergeordnete Ebenen oder ins Hauptdokument.
* **100 % Standard-Markdown auf der Festplatte:** Alle Notizen werden unter `/data/workspace/<user_id>/...` als echte `.md`-Dateien gespeichert (mit 2-Leerzeichen-Einrückung). Vollständig kompatibel mit Obsidian, Logseq oder einfachen Text-Editoren – kein Vendor-Lock-in! * **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 ### ✅ 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) Behalte offene Aufgaben, Projekte und Termine im Blick – wahlweise als strukturierte Liste oder als visuelles Kanban-Board.
* **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.
### 🏢 Organisationen, Teams & Administration <p align="center">
* **Geführte Ersteinrichtung:** Beim allerersten Start führt dich MindSift automatisch durch die Einrichtung deines Administrator-Kontos. <img src="screenshots/aufgaben_desktop.png" alt="MindSift Aufgaben und Kanban-Board" width="90%" style="border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.3);" />
* **3-Stufen-Rollensystem:** Server-Admin, Team-Admin und reguläres Mitglied. </p>
* **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.
### 🔔 Multi-Channel-Alarme * **Flexible Ansichten (Liste & Kanban):** Wechsle mit einem Klick zwischen kompakter Listenansicht und dem interaktiven Kanban-Board (*Zu erledigen*, *In Arbeit*, *Wartend*, *Erledigt*).
* **Pünktlich erinnert:** Automatische Benachrichtigung vor Fälligkeit wahlweise per **E-Mail (SMTP)**, **Discord-Webhook** oder **eigenem Webhook-Endpunkt**. * **Unteraufgaben (Subtasks):**
* **Eigene API-Schlüssel:** Sende System-Alarme, Server-Logs oder Skripte per einfachem `curl`-Befehl direkt in deine Inbox. * 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.
<p align="center">
<img src="screenshots/Listen_desktop.png" alt="MindSift Geteilte Listen" width="90%" style="border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.3);" />
</p>
* **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 ## 🚀 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) ### 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 ```bash
curl -fsSL https://git.dsit.me/sandrodach/MindSift/raw/branch/dev/install.sh | 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 ## 🔒 Deine Daten gehören dir
* **100 % Self-Hosted:** Keine fremden Cloud-Dienste, keine Telemetrie, kein Tracking. * **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.
--- ---
+134 -6
View File
@@ -44,6 +44,8 @@ import { UserSettingsView } from './features/settings/UserSettingsView';
import { AdminSettingsView } from './features/settings/AdminSettingsView'; import { AdminSettingsView } from './features/settings/AdminSettingsView';
import { HelpView } from './features/help/HelpView'; import { HelpView } from './features/help/HelpView';
import { GroupsView } from './features/teams/GroupsView'; import { GroupsView } from './features/teams/GroupsView';
import { useMobileActions } from './context/MobileActionContext';
import { MobileHeaderMenu } from './components/MobileHeaderMenu';
type ActiveTab = type ActiveTab =
| 'triage' | 'triage'
@@ -129,6 +131,107 @@ export const App: React.FC = () => {
// Mobile Navigation & Quick-Action State // Mobile Navigation & Quick-Action State
const [showMobileCapture, setShowMobileCapture] = useState(false); const [showMobileCapture, setShowMobileCapture] = useState(false);
const [showMobileProfile, setShowMobileProfile] = 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: <Plus className="w-4 h-4" />,
variant: 'accent',
onClick: () => setShowMobileCapture(true),
},
{
id: 'refresh-deck',
label: 'Inbox aktualisieren',
icon: <RotateCcw className="w-4 h-4" />,
badge: stats.inboxCount,
onClick: () => refreshAll(),
},
],
},
{
id: 'triage-nav',
title: 'Bereiche wechseln',
items: [
{
id: 'goto-tasks',
label: 'Aufgaben & Kanban',
icon: <CheckSquare className="w-4 h-4" />,
badge: stats.tasksOpenCount,
onClick: () => setActiveTab('tasks'),
},
{
id: 'goto-workspace',
label: 'Notizen & Workspace',
icon: <FileText className="w-4 h-4" />,
badge: stats.docsCount,
onClick: () => setActiveTab('workspace'),
},
{
id: 'goto-lists',
label: 'Einkaufslisten',
icon: <ShoppingCart className="w-4 h-4" />,
badge: stats.listsCount,
onClick: () => setActiveTab('lists'),
},
{
id: 'goto-groups',
label: 'Teams & Gruppen',
icon: <Users className="w-4 h-4" />,
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: <Layers className="w-4 h-4" />,
variant: 'accent',
onClick: () => setActiveTab('triage'),
},
{
id: 'goto-tasks',
label: 'Zu den Aufgaben',
icon: <CheckSquare className="w-4 h-4" />,
onClick: () => setActiveTab('tasks'),
},
{
id: 'goto-workspace',
label: 'Zum Workspace',
icon: <FileText className="w-4 h-4" />,
onClick: () => setActiveTab('workspace'),
},
{
id: 'goto-lists',
label: 'Zu den Listen',
icon: <ShoppingCart className="w-4 h-4" />,
onClick: () => setActiveTab('lists'),
},
],
},
]);
}
}, [activeTab, stats.inboxCount, stats.tasksOpenCount, stats.docsCount, stats.listsCount]);
// Mobile Web-App (PWA) Installation State // Mobile Web-App (PWA) Installation State
const [isStandaloneApp, setIsStandaloneApp] = useState<boolean>(() => { const [isStandaloneApp, setIsStandaloneApp] = useState<boolean>(() => {
@@ -409,27 +512,50 @@ export const App: React.FC = () => {
const isServerAdmin = user.role === 'admin'; const isServerAdmin = user.role === 'admin';
const roleLabel = isServerAdmin ? 'Server-Admin' : 'Mitglied'; const roleLabel = isServerAdmin ? 'Server-Admin' : 'Mitglied';
const activeTabLabelMap: Record<ActiveTab, string> = {
triage: 'Triage',
tasks: 'Aufgaben',
workspace: 'Workspace',
lists: 'Listen',
groups: 'Teams & Gruppen',
user_settings: 'Einstellungen',
admin_settings: 'Administration',
help: 'Hilfe',
};
return ( return (
<div className="min-h-screen bg-canvas text-main flex flex-col font-sans selection:bg-accent/30"> <div className="min-h-screen bg-canvas text-main flex flex-col font-sans selection:bg-accent/30">
{/* Top Header Bar: Floating on mobile, solid panel on desktop */} {/* Top Header Bar: Floating on mobile, solid panel on desktop */}
<header className="sticky top-0 z-30 bg-canvas/85 backdrop-blur-md sm:bg-panel sm:border-b sm:border-border sm:shadow-sm"> <header className="sticky top-0 z-30 bg-canvas/85 backdrop-blur-md sm:bg-panel sm:border-b sm:border-border sm:shadow-sm">
{/* Mobile Header: Floating Bar with Larger Logo Left (Triage Home) & Profile Avatar Right */} {/* Mobile Header: Floating Bar with Logo Action Dropdown Left & Profile Avatar Right */}
<div className="sm:hidden flex items-center justify-between h-14 px-4 pt-1"> <div className="sm:hidden flex items-center justify-between h-14 px-4 pt-1">
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
triggerHaptic(10); triggerHaptic(12);
setActiveTab('triage'); setIsMenuOpen((prev) => !prev);
}} }}
className="h-11 px-2 -ml-2 rounded-xl flex items-center gap-2.5 cursor-pointer select-none active:scale-95 transition-transform" className={`min-h-[44px] px-2.5 -ml-2 rounded-xl flex items-center gap-2.5 cursor-pointer select-none active:scale-95 transition-all ${
title="Zur Startseite (Triage)" isMenuOpen ? 'bg-panel border border-accent/40 shadow-sm' : 'hover:bg-panel/60'
}`}
title="Menü & Ansichts-Optionen öffnen"
> >
<div className="w-8 h-8 bg-accent text-on-accent rounded-xl shadow-sm flex items-center justify-center"> <div className="w-8 h-8 bg-accent text-on-accent rounded-xl shadow-sm flex items-center justify-center flex-shrink-0">
<Brain className="w-4 h-4" /> <Brain className="w-4 h-4" />
</div> </div>
<div className="flex items-center gap-1.5">
<span className="font-bold text-base text-main tracking-tight"> <span className="font-bold text-base text-main tracking-tight">
{authStatus.instanceName || 'MindSift'} {authStatus.instanceName || 'MindSift'}
</span> </span>
<span className="text-[11px] font-semibold text-accent bg-accent/15 px-1.5 py-0.5 rounded-md">
{activeTabLabelMap[activeTab]}
</span>
<ChevronDown
className={`w-4 h-4 text-muted transition-transform duration-150 ${
isMenuOpen ? 'rotate-180 text-accent' : ''
}`}
/>
</div>
</button> </button>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
@@ -715,6 +841,8 @@ export const App: React.FC = () => {
</nav> </nav>
</header> </header>
<MobileHeaderMenu activeTabLabel={activeTabLabelMap[activeTab]} />
{/* Main Container */} {/* Main Container */}
<main className="flex-1 max-w-6xl w-full mx-auto px-3 sm:px-4 pt-3 pb-24 sm:pb-6"> <main className="flex-1 max-w-6xl w-full mx-auto px-3 sm:px-4 pt-3 pb-24 sm:pb-6">
<ViewErrorBoundary onReset={() => setActiveTab('triage')}> <ViewErrorBoundary onReset={() => setActiveTab('triage')}>
@@ -0,0 +1,140 @@
import React from 'react';
import { Check, X } from 'lucide-react';
import { useMobileActions } from '../context/MobileActionContext';
import { triggerHaptic } from '../lib/api';
interface MobileHeaderMenuProps {
activeTabLabel: string;
}
export const MobileHeaderMenu: React.FC<MobileHeaderMenuProps> = ({ activeTabLabel }) => {
const { groups, isMenuOpen, closeMenu } = useMobileActions();
if (!isMenuOpen) return null;
return (
<div className="sm:hidden fixed inset-0 z-50">
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/60 backdrop-blur-xs animate-in fade-in duration-150"
onClick={() => {
triggerHaptic(8);
closeMenu();
}}
/>
{/* Menu Sheet below Header */}
<div className="fixed top-14 left-2.5 right-2.5 max-h-[80vh] overflow-y-auto bg-panel border border-border rounded-2xl shadow-2xl p-3 space-y-3.5 z-50 animate-in fade-in slide-in-from-top-2 duration-150">
{/* Menu Header */}
<div className="flex items-center justify-between px-2 pb-2 border-b border-border">
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-accent"></span>
<span className="text-xs font-bold uppercase tracking-wider text-muted">
{activeTabLabel} — Optionen & Aktionen
</span>
</div>
<button
type="button"
onClick={() => {
triggerHaptic(8);
closeMenu();
}}
className="w-9 h-9 rounded-xl flex items-center justify-center text-muted hover:text-main hover:bg-canvas active:scale-95 transition-all"
title="Menü schließen"
>
<X className="w-4 h-4" />
</button>
</div>
{groups.length === 0 ? (
<div className="py-6 text-center text-xs text-muted">
Keine spezifischen Optionen für diese Ansicht verfügbar.
</div>
) : (
<div className="space-y-4">
{groups.map((group) => (
<div key={group.id} className="space-y-1.5">
{group.title && (
<div className="px-2 text-[11px] font-bold uppercase tracking-wider text-faint">
{group.title}
</div>
)}
<div className="space-y-1">
{group.items.map((item) => {
const isAccent = item.variant === 'accent';
const isDanger = item.variant === 'danger';
const isActive = item.active;
let btnClass =
'bg-canvas/70 hover:bg-canvas text-main border border-border/60';
if (isAccent) {
btnClass =
'bg-accent text-on-accent border border-accent font-semibold shadow-sm';
} else if (isDanger) {
btnClass =
'bg-status-err/10 text-status-err border border-status-err/30 font-medium';
} else if (isActive) {
btnClass =
'bg-accent/15 text-accent border border-accent/40 font-semibold';
}
return (
<button
key={item.id}
type="button"
onClick={() => {
triggerHaptic(12);
closeMenu();
item.onClick();
}}
className={`w-full min-h-[46px] px-3.5 py-2.5 rounded-xl flex items-center justify-between gap-3 text-sm transition-all active:scale-[0.98] cursor-pointer ${btnClass}`}
>
<div className="flex items-center gap-3 min-w-0 flex-1 text-left">
{item.icon && (
<span
className={`flex-shrink-0 flex items-center justify-center w-5 h-5 ${
isAccent
? 'text-on-accent'
: isDanger
? 'text-status-err'
: isActive
? 'text-accent'
: 'text-muted'
}`}
>
{item.icon}
</span>
)}
<span className="truncate">{item.label}</span>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{item.badge !== undefined && item.badge !== '' && (
<span
className={`px-2 py-0.5 rounded-full text-xs font-mono font-semibold ${
isAccent
? 'bg-black/20 text-on-accent'
: isActive
? 'bg-accent/20 text-accent'
: 'bg-panel text-muted border border-border'
}`}
>
{item.badge}
</span>
)}
{isActive && !isAccent && (
<Check className="w-4 h-4 text-accent flex-shrink-0" />
)}
</div>
</button>
);
})}
</div>
</div>
))}
</div>
)}
</div>
</div>
);
};
@@ -6,7 +6,6 @@ import {
Plus, Plus,
Trash2, Trash2,
Lock, Lock,
Share2,
Check, Check,
} from 'lucide-react'; } from 'lucide-react';
import { User, Team, UnifiedShare } from '../lib/types'; import { User, Team, UnifiedShare } from '../lib/types';
@@ -114,7 +113,7 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
resourceIcon resourceIcon
) )
) : ( ) : (
<Share2 className="w-4 h-4" /> <Users className="w-4 h-4" />
)} )}
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
@@ -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<React.SetStateAction<boolean>>;
closeMenu: () => void;
}
const MobileActionContext = createContext<MobileActionContextValue>({
groups: [],
setGroups: () => {},
isMenuOpen: false,
setIsMenuOpen: () => {},
closeMenu: () => {},
});
export const MobileActionProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [groups, setGroupsState] = useState<MobileMenuGroup[]>([]);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const setGroups = useCallback((newGroups: MobileMenuGroup[]) => {
setGroupsState(newGroups);
}, []);
const closeMenu = useCallback(() => {
setIsMenuOpen(false);
}, []);
return (
<MobileActionContext.Provider
value={{
groups,
setGroups,
isMenuOpen,
setIsMenuOpen,
closeMenu,
}}
>
{children}
</MobileActionContext.Provider>
);
};
export const useMobileActions = () => useContext(MobileActionContext);
@@ -7,7 +7,7 @@ import {
UserPlus, UserPlus,
LogIn, LogIn,
Server, Server,
Share2, Users,
Check, Check,
AlertCircle, AlertCircle,
} from 'lucide-react'; } from 'lucide-react';
@@ -235,7 +235,7 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
<div> <div>
<label className="block text-muted mb-1.5 flex items-center gap-1"> <label className="block text-muted mb-1.5 flex items-center gap-1">
<Share2 className="w-3 h-3 text-accent" /> <Users className="w-3 h-3 text-accent" />
<span>Sichtbarkeit & Sharing-Isolation zwischen Benutzern</span> <span>Sichtbarkeit & Sharing-Isolation zwischen Benutzern</span>
</label> </label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
+1 -1
View File
@@ -234,7 +234,7 @@ export const HelpView: React.FC<HelpViewProps> = () => {
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-xs"> <div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-xs">
<div className="p-3 bg-canvas border border-border rounded-card space-y-2"> <div className="p-3 bg-canvas border border-border rounded-card space-y-2">
<h3 className="font-semibold text-main flex items-center gap-1.5"> <h3 className="font-semibold text-main flex items-center gap-1.5">
<Share2 className="w-3.5 h-3.5 text-accent" /> <Users className="w-3.5 h-3.5 text-accent" />
<span>Aufgabenlisten teilen & Avatar-Stack</span> <span>Aufgabenlisten teilen & Avatar-Stack</span>
</h3> </h3>
<p className="text-muted leading-relaxed"> <p className="text-muted leading-relaxed">
File diff suppressed because it is too large. Load diff
@@ -13,7 +13,6 @@ import {
Send, Send,
AlertCircle, AlertCircle,
UserPlus, UserPlus,
Share2,
} from 'lucide-react'; } from 'lucide-react';
import { import {
SystemSettings, SystemSettings,
@@ -690,7 +689,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
{/* Sharing Isolation Cards */} {/* Sharing Isolation Cards */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-muted font-semibold flex items-center gap-1.5"> <label className="block text-muted font-semibold flex items-center gap-1.5">
<Share2 className="w-4 h-4 text-accent" /> <Users className="w-4 h-4 text-accent" />
<span>Globale Sharing- & Sichtbarkeits-Isolation</span> <span>Globale Sharing- & Sichtbarkeits-Isolation</span>
</label> </label>
@@ -194,7 +194,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
</span> </span>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1.5">
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
@@ -202,9 +202,9 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
onConvertToDoc(task); onConvertToDoc(task);
}} }}
title="Als Dokument / Notiz vertiefen" title="Als Dokument / Notiz vertiefen"
className="btn-action h-7 px-2 text-xs flex items-center gap-1" className="btn-action min-h-[38px] sm:min-h-0 h-9 sm:h-7 px-2.5 sm:px-2 text-xs flex items-center gap-1.5"
> >
<FileText className="w-3.5 h-3.5 text-accent" /> <FileText className="w-4 h-4 sm:w-3.5 sm:h-3.5 text-accent" />
<span className="hidden sm:inline">Als Notiz vertiefen</span> <span className="hidden sm:inline">Als Notiz vertiefen</span>
</button> </button>
<button <button
@@ -216,17 +216,18 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
} }
}} }}
title="Aufgabe löschen" title="Aufgabe löschen"
className="text-muted hover:text-status-err p-1.5 transition-colors" className="btn-action min-h-[38px] sm:min-h-0 h-9 sm:h-7 px-2.5 sm:px-2 text-xs text-status-err border-status-err/30 hover:bg-status-err/10 flex items-center gap-1"
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4 sm:w-3.5 sm:h-3.5" />
<span className="sm:hidden font-medium">Löschen</span>
</button> </button>
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="text-muted hover:text-main p-1.5 transition-colors" className="text-muted hover:text-main p-2 sm:p-1.5 min-h-[38px] min-w-[38px] sm:min-h-0 sm:min-w-0 flex items-center justify-center transition-colors"
title="Schließen" title="Schließen"
> >
<X className="w-4 h-4" /> <X className="w-5 h-5 sm:w-4 sm:h-4" />
</button> </button>
</div> </div>
</div> </div>
+420 -73
View File
@@ -13,8 +13,6 @@ import {
Edit3, Edit3,
X, X,
Layers, Layers,
Share,
Share2,
Users, Users,
CheckSquare, CheckSquare,
User as UserIcon, User as UserIcon,
@@ -32,6 +30,7 @@ import { UnifiedShareModal } from '../../components/UnifiedShareModal';
import { TaskDetailModal } from './TaskDetailModal'; import { TaskDetailModal } from './TaskDetailModal';
import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS } from './ManageColumnsModal'; import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS } from './ManageColumnsModal';
import { UserAutocomplete } from '../UserAutocomplete'; import { UserAutocomplete } from '../UserAutocomplete';
import { useMobileActions } from '../../context/MobileActionContext';
const getInitials = (name?: string): string => { const getInitials = (name?: string): string => {
if (!name) return '?'; if (!name) return '?';
@@ -65,15 +64,21 @@ export const TasksView: React.FC<TasksViewProps> = ({
const [discarded, setDiscarded] = useState<Thought[]>([]); const [discarded, setDiscarded] = useState<Thought[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// Drag and Drop state
const [draggingTaskId, setDraggingTaskId] = useState<string | null>(null);
const [dragOverTaskTarget, setDragOverTaskTarget] = useState<{ taskId: string; position: 'before' | 'after' } | null>(null);
const [dragOverKanbanCol, setDragOverKanbanCol] = useState<string | null>(null);
// Quick Task Add state // Quick Task Add state
const [quickTitle, setQuickTitle] = useState(''); const [quickTitle, setQuickTitle] = useState('');
const [quickDueDate, setQuickDueDate] = useState(''); const [quickDueDate, setQuickDueDate] = useState('');
const quickInputRef = useRef<HTMLInputElement | null>(null);
const { setGroups } = useMobileActions();
// New TaskList modal state // New TaskList modal state
const [showNewListModal, setShowNewListModal] = useState(false); const [showNewListModal, setShowNewListModal] = useState(false);
const [newListTitle, setNewListTitle] = useState(''); const [newListTitle, setNewListTitle] = useState('');
const [newListIcon, setNewListIcon] = useState('📋'); const [newListIcon, setNewListIcon] = useState('📋');
const [newListShareTarget, setNewListShareTarget] = useState('');
// Share TaskList modal state // Share TaskList modal state
const [showShareListModal, setShowShareListModal] = useState(false); const [showShareListModal, setShowShareListModal] = useState(false);
@@ -103,7 +108,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
// Hashtag filtering, grouping and sorting state // Hashtag filtering, grouping and sorting state
const [selectedTag, setSelectedTag] = useState<string | null>(null); const [selectedTag, setSelectedTag] = useState<string | null>(null);
const [groupBy, setGroupBy] = useState<'none' | 'tag' | 'status' | 'priority' | 'due'>('none'); 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 () => { const loadData = async () => {
setLoading(true); setLoading(true);
@@ -189,6 +194,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
setTasks((prev) => prev.filter((t) => t.id !== deletedId)); setTasks((prev) => prev.filter((t) => t.id !== deletedId));
onRefreshStatsRef.current(); onRefreshStatsRef.current();
} }
} else if (msg.event === 'TASK_REORDERED') {
loadData();
} }
}; };
@@ -260,6 +267,14 @@ export const TasksView: React.FC<TasksViewProps> = ({
if (selectedTag) { if (selectedTag) {
result = result.filter((t) => Array.isArray(t.tags) && t.tags.includes(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) => { return [...result].sort((a, b) => {
if (sortBy === 'due') { if (sortBy === 'due') {
if (!a.dueDate && !b.dueDate) return 0; if (!a.dueDate && !b.dueDate) return 0;
@@ -536,32 +551,164 @@ export const TasksView: React.FC<TasksViewProps> = ({
} }
}; };
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) => { const handleCreateTaskList = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const title = newListTitle.trim(); const title = newListTitle.trim();
if (!title) return; if (!title) return;
try { 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({ const created = await api.createTaskList({
title, title,
icon: newListIcon, icon: newListIcon,
teamId: teamId,
}); });
if (userId && created?.id) {
await api.addTaskListShare(created.id, { targetType: 'user', targetId: userId });
}
const freshLists = await api.getTaskLists(); const freshLists = await api.getTaskLists();
setTaskLists(freshLists); setTaskLists(freshLists);
setSelectedListId(created.id); setSelectedListId(created.id);
setShowNewListModal(false); setShowNewListModal(false);
setNewListTitle(''); setNewListTitle('');
setNewListShareTarget(''); setNewListIcon('📋');
} catch (err: any) { } catch (err: any) {
alert(err.message || 'Fehler beim Erstellen der Aufgabenliste'); alert(err.message || 'Fehler beim Erstellen der Aufgabenliste');
} }
@@ -667,6 +814,141 @@ export const TasksView: React.FC<TasksViewProps> = ({
} }
}; };
useEffect(() => {
const actionItems: any[] = [
{
id: 'new-task',
label: 'Neue Aufgabe erstellen',
icon: <Plus className="w-4 h-4" />,
variant: 'accent',
onClick: () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
setTimeout(() => quickInputRef.current?.focus(), 100);
},
},
{
id: 'new-task-list',
label: 'Neue Aufgabenliste anlegen',
icon: <Folder className="w-4 h-4" />,
onClick: () => setShowNewListModal(true),
},
];
if (currentSelectedList) {
actionItems.push({
id: 'share-task-list',
label: `Liste „${currentSelectedList.title}“ teilen`,
icon: <Users className="w-4 h-4" />,
onClick: () => handleOpenShareModal(currentSelectedList),
});
actionItems.push({
id: 'delete-task-list',
label: `Liste „${currentSelectedList.title}“ löschen`,
icon: <Trash2 className="w-4 h-4" />,
variant: 'danger',
onClick: () => handleDeleteTaskList(currentSelectedList.id),
});
}
const viewItems: any[] = [
{
id: 'filter-open',
label: 'Offene Aufgaben (Liste)',
icon: <LayoutList className="w-4 h-4" />,
badge: openTasks.length,
active: viewMode === 'list' && filter === 'open',
onClick: () => {
setViewMode('list');
setFilter('open');
},
},
{
id: 'filter-done',
label: 'Erledigte Aufgaben',
icon: <Check className="w-4 h-4" />,
badge: doneTasks.length,
active: viewMode === 'list' && filter === 'done',
onClick: () => {
setViewMode('list');
setFilter('done');
},
},
{
id: 'filter-discarded',
label: 'Papierkorb',
icon: <Trash2 className="w-4 h-4" />,
badge: safeDiscarded.length,
active: viewMode === 'list' && filter === 'discarded',
onClick: () => {
setViewMode('list');
setFilter('discarded');
},
},
{
id: 'view-kanban',
label: 'Kanban-Board Ansicht',
icon: <Columns className="w-4 h-4" />,
badge: kanbanTasks.length,
active: viewMode === 'kanban',
onClick: () => setViewMode('kanban'),
},
];
if (viewMode === 'kanban') {
viewItems.push({
id: 'manage-columns',
label: 'Kanban-Spalten verwalten',
icon: <Settings className="w-4 h-4" />,
onClick: () => setShowManageColumnsModal(true),
});
}
const listItems: any[] = [
{
id: 'list-all',
label: 'Alle Aufgaben',
icon: <Layers className="w-4 h-4" />,
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) => { const renderTaskProgressBar = (task: Thought) => {
if (!task.subtasks || task.subtasks.length === 0) return null; if (!task.subtasks || task.subtasks.length === 0) return null;
const total = task.subtasks.length; const total = task.subtasks.length;
@@ -704,30 +986,56 @@ export const TasksView: React.FC<TasksViewProps> = ({
// Clean Task Row in List View // Clean Task Row in List View
const renderTaskRow = (task: Thought) => { const renderTaskRow = (task: Thought) => {
const isCompleted = task.isCompleted; const isCompleted = task.isCompleted;
const isDragging = draggingTaskId === task.id;
const isTarget = dragOverTaskTarget?.taskId === task.id;
return ( return (
<div <div
key={task.id} key={task.id}
className={`px-3 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 ${ draggable
onDragStart={(e) => 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' : '' isCompleted ? 'opacity-65' : ''
}`} } ${isDragging ? 'opacity-30 border border-dashed border-accent/40' : ''}`}
> >
<div className="flex items-center gap-3 min-w-0 flex-1"> {/* Drop insertion line indicator */}
{/* Checkbox */} {isTarget && dragOverTaskTarget.position === 'before' && (
<div className="absolute top-0 left-0 right-0 h-0.5 bg-accent z-20 pointer-events-none rounded-full shadow-[0_0_8px_rgba(var(--accent-rgb),0.6)]" />
)}
{isTarget && dragOverTaskTarget.position === 'after' && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-accent z-20 pointer-events-none rounded-full shadow-[0_0_8px_rgba(var(--accent-rgb),0.6)]" />
)}
<div className="flex items-center gap-3 sm:gap-2.5 min-w-0 flex-1">
{/* Checkbox (larger touch target on mobile) */}
<button <button
type="button" type="button"
draggable={false}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleToggleDone(task); handleToggleDone(task);
}} }}
className={`w-4 h-4 checkbox-square flex items-center justify-center flex-shrink-0 transition-colors border ${ className={`w-5 h-5 sm:w-4 sm:h-4 checkbox-square flex items-center justify-center flex-shrink-0 transition-colors border ${
isCompleted isCompleted
? 'bg-status-ok border-status-ok text-on-accent' ? 'bg-status-ok border-status-ok text-on-accent'
: 'border-strong hover:border-accent text-transparent' : 'border-strong hover:border-accent text-transparent'
}`} }`}
title={isCompleted ? 'Als offen markieren' : 'Als erledigt abhaken'} title={isCompleted ? 'Als offen markieren' : 'Als erledigt abhaken'}
> >
<Check className="w-3 h-3 stroke-[3]" /> <Check className="w-3.5 h-3.5 sm:w-3 sm:h-3 stroke-[3]" />
</button> </button>
{/* Clickable Title & Metadata */} {/* Clickable Title & Metadata */}
@@ -836,10 +1144,11 @@ export const TasksView: React.FC<TasksViewProps> = ({
</div> </div>
</div> </div>
{/* Action Controls */} {/* Action Controls (Desktop only - on mobile, tapping the task opens Detail Modal) */}
<div className="flex items-center gap-1 flex-shrink-0"> <div className="hidden sm:flex items-center gap-1 flex-shrink-0" onMouseDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}>
<button <button
type="button" type="button"
draggable={false}
onClick={() => { onClick={() => {
setDetailTaskId(task.id); setDetailTaskId(task.id);
setShowDetailModal(true); setShowDetailModal(true);
@@ -851,6 +1160,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
</button> </button>
<button <button
type="button" type="button"
draggable={false}
onClick={() => handleConvertToDoc(task)} onClick={() => handleConvertToDoc(task)}
title="Als Notiz vertiefen" title="Als Notiz vertiefen"
className="text-faint hover:text-accent p-1.5 transition-colors hidden sm:block" className="text-faint hover:text-accent p-1.5 transition-colors hidden sm:block"
@@ -859,6 +1169,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
</button> </button>
<button <button
type="button" type="button"
draggable={false}
onClick={() => handleDeletePermanent(task.id)} onClick={() => handleDeletePermanent(task.id)}
title="Aufgabe löschen" title="Aufgabe löschen"
className="text-faint hover:text-status-err p-1.5 transition-colors" className="text-faint hover:text-status-err p-1.5 transition-colors"
@@ -872,8 +1183,36 @@ export const TasksView: React.FC<TasksViewProps> = ({
return ( return (
<div className="w-full space-y-3"> <div className="w-full space-y-3">
{/* Top Header: Task Lists Tabs & View Mode Switcher */} {/* Mobile Compact Context Bar (replaces cluttered button rows; options live in MindSift Logo Menu) */}
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2"> <div className="flex sm:hidden items-center justify-between gap-2 px-3 py-2 rounded-btn bg-panel border border-border">
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm font-semibold text-main truncate">
{currentSelectedList ? `${currentSelectedList.icon} ${currentSelectedList.title}` : '📋 Alle Aufgaben'}
</span>
<span className="badge-neutral text-[11px] flex-shrink-0">
{viewMode === 'kanban'
? 'Kanban'
: filter === 'open'
? `Offen (${openTasks.length})`
: filter === 'done'
? `Erledigt (${doneTasks.length})`
: `Papierkorb (${safeDiscarded.length})`}
</span>
</div>
{currentSelectedList && (
<button
type="button"
onClick={() => handleOpenShareModal(currentSelectedList)}
className="btn-icon min-h-[36px] min-w-[36px] flex items-center justify-center flex-shrink-0"
title="Aufgabenliste teilen"
>
<Users className="w-4 h-4 text-accent" />
</button>
)}
</div>
{/* Top Header: Task Lists Tabs & View Mode Switcher (Desktop) */}
<div className="hidden sm:flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2">
{/* Task Lists selector tabs */} {/* Task Lists selector tabs */}
<div className="flex flex-wrap items-center gap-1"> <div className="flex flex-wrap items-center gap-1">
<button <button
@@ -990,7 +1329,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
className="btn-icon" className="btn-icon"
title="Aufgabenliste teilen und Berechtigungen" title="Aufgabenliste teilen und Berechtigungen"
> >
<Share className="w-3.5 h-3.5" /> <Users className="w-3.5 h-3.5" />
</button> </button>
</div> </div>
); );
@@ -1027,32 +1366,33 @@ export const TasksView: React.FC<TasksViewProps> = ({
{/* Quick Add Task Input */} {/* Quick Add Task Input */}
<form <form
onSubmit={handleQuickAddTask} onSubmit={handleQuickAddTask}
className="flex items-center gap-1.5 panel-surface p-1.5" className="flex items-center gap-1.5 panel-surface p-2 sm:p-1.5"
> >
<Plus className="w-4 h-4 text-accent ml-1 flex-shrink-0" /> <Plus className="w-4 h-4 text-accent ml-1 flex-shrink-0" />
<input <input
ref={quickInputRef}
type="text" type="text"
value={quickTitle} value={quickTitle}
onChange={(e) => setQuickTitle(e.target.value)} onChange={(e) => setQuickTitle(e.target.value)}
placeholder={ placeholder={
currentSelectedList currentSelectedList
? `Aufgabe zu "${currentSelectedList.title}" hinzufügen… (Enter)` ? `Aufgabe zu "${currentSelectedList.title}" hinzufügen…`
: 'Neue Aufgabe anlegen… (Enter)' : '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"
/> />
<div className="flex items-center gap-1"> <div className="flex items-center gap-1 flex-shrink-0">
<input <input
type="date" type="date"
value={quickDueDate} value={quickDueDate}
onChange={(e) => setQuickDueDate(e.target.value)} onChange={(e) => 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" title="Fälligkeitsdatum"
/> />
<button <button
type="submit" type="submit"
disabled={!quickTitle.trim()} disabled={!quickTitle.trim()}
className="btn-primary h-7 px-3 text-xs disabled:opacity-40" className="btn-primary h-9 sm:h-7 px-3.5 sm:px-3 text-xs disabled:opacity-40"
> >
Hinzufügen Hinzufügen
</button> </button>
@@ -1134,6 +1474,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
onChange={(e) => setSortBy(e.target.value as any)} onChange={(e) => setSortBy(e.target.value as any)}
className="h-7 px-2 input-field text-xs bg-panel" className="h-7 px-2 input-field text-xs bg-panel"
> >
<option value="custom">Manuell (Drag & Drop)</option>
<option value="created">Erstelldatum</option> <option value="created">Erstelldatum</option>
<option value="due">Fälligkeitsdatum</option> <option value="due">Fälligkeitsdatum</option>
<option value="priority">Priorität</option> <option value="priority">Priorität</option>
@@ -1186,37 +1527,6 @@ export const TasksView: React.FC<TasksViewProps> = ({
</div> </div>
</div> </div>
<div>
<label className="block text-xs text-muted mb-1">
Freigabe (optional)
</label>
<select
value={newListShareTarget}
onChange={(e) => setNewListShareTarget(e.target.value)}
className="w-full h-8 input-field px-2 text-xs"
>
<option value="">🔒 Privat (nur für mich)</option>
{safeTeams.length > 0 && (
<optgroup label="Teams & Gruppen">
{safeTeams.map((tm) => (
<option key={tm.id} value={`team:${tm.id}`}>
👥 {tm.name}
</option>
))}
</optgroup>
)}
{safeCollaborators.length > 0 && (
<optgroup label="Einzelne Benutzer">
{safeCollaborators.map((u) => (
<option key={u.id} value={`user:${u.id}`}>
👤 {u.displayName || u.email}
</option>
))}
</optgroup>
)}
</select>
</div>
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border"> <div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
<button <button
type="button" type="button"
@@ -1341,7 +1651,20 @@ export const TasksView: React.FC<TasksViewProps> = ({
}); });
return ( return (
<div key={col.id} className="bg-canvas border border-border flex flex-col min-h-[420px]"> <div
key={col.id}
onDragOver={(e) => {
e.preventDefault();
if (draggingTaskId) setDragOverKanbanCol(col.id);
}}
onDragLeave={() => {
if (dragOverKanbanCol === col.id) setDragOverKanbanCol(null);
}}
onDrop={(e) => handleDropOnKanbanCol(e, col.id)}
className={`bg-canvas border border-border flex flex-col min-h-[420px] transition-all ${
dragOverKanbanCol === col.id ? 'ring-2 ring-accent/60 bg-accent/5' : ''
}`}
>
{/* Column Header */} {/* Column Header */}
<div className="px-3 py-2 bg-panel border-b border-border flex items-center justify-between"> <div className="px-3 py-2 bg-panel border-b border-border flex items-center justify-between">
<span className="text-xs font-semibold text-main flex items-center gap-1.5"> <span className="text-xs font-semibold text-main flex items-center gap-1.5">
@@ -1363,15 +1686,38 @@ export const TasksView: React.FC<TasksViewProps> = ({
{/* Tasks List in Column */} {/* Tasks List in Column */}
<div className="p-2 space-y-2 flex-1 overflow-y-auto max-h-[70vh]"> <div className="p-2 space-y-2 flex-1 overflow-y-auto max-h-[70vh]">
{colTasks.map((task) => ( {colTasks.map((task) => {
const isDragging = draggingTaskId === task.id;
const isTarget = dragOverTaskTarget?.taskId === task.id;
return (
<div <div
key={task.id} key={task.id}
draggable
onDragStart={(e) => 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, col.id)}
onClick={() => { onClick={() => {
setDetailTaskId(task.id); setDetailTaskId(task.id);
setShowDetailModal(true); setShowDetailModal(true);
}} }}
className="panel-surface p-2.5 space-y-2 hover:border-strong transition-all cursor-pointer shadow-sm group" className={`panel-surface p-2.5 space-y-2 hover:border-strong transition-all cursor-grab active:cursor-grabbing shadow-sm group relative ${
isDragging ? 'opacity-30 border border-dashed border-accent/40' : ''
}`}
> >
{/* Drop insertion line indicator */}
{isTarget && dragOverTaskTarget.position === 'before' && (
<div className="absolute top-0 left-0 right-0 h-0.5 bg-accent z-20 pointer-events-none rounded-full shadow-[0_0_8px_rgba(var(--accent-rgb),0.6)]" />
)}
{isTarget && dragOverTaskTarget.position === 'after' && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-accent z-20 pointer-events-none rounded-full shadow-[0_0_8px_rgba(var(--accent-rgb),0.6)]" />
)}
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<p <p
className={`text-xs font-medium break-words flex-1 leading-snug ${ className={`text-xs font-medium break-words flex-1 leading-snug ${
@@ -1380,7 +1726,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
> >
{task.content} {task.content}
</p> </p>
<div className="flex items-center gap-1 flex-shrink-0" onClick={(e) => e.stopPropagation()}> <div className="hidden sm:flex items-center gap-1 flex-shrink-0" onClick={(e) => e.stopPropagation()}>
<button <button
onClick={() => handleConvertToDoc(task)} onClick={() => handleConvertToDoc(task)}
title="Als Notiz vertiefen" title="Als Notiz vertiefen"
@@ -1481,7 +1827,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
</button> </button>
</div> </div>
</div> </div>
))} );
})}
{colTasks.length === 0 && ( {colTasks.length === 0 && (
<div className="p-4 text-center text-faint text-xs italic"> <div className="p-4 text-center text-faint text-xs italic">
@@ -1509,9 +1856,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
) : ( ) : (
/* STANDARD LIST VIEW (CLEAN & UNCLUTTERED) */ /* STANDARD LIST VIEW (CLEAN & UNCLUTTERED) */
<div className="space-y-2"> <div className="space-y-2">
{/* Subfilter tabs for List View */} {/* Subfilter tabs for List View (Desktop; on mobile controlled via MindSift Logo Menu) */}
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="flex items-center border border-border rounded-btn bg-canvas overflow-hidden"> <div className="hidden sm:flex items-center border border-border rounded-btn bg-canvas overflow-hidden">
<button <button
onClick={() => setFilter('open')} onClick={() => setFilter('open')}
className={`h-8 px-3.5 text-xs font-medium border-r border-border transition-colors ${ className={`h-8 px-3.5 text-xs font-medium border-r border-border transition-colors ${
@@ -1541,7 +1888,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
</div> </div>
{filter === 'discarded' && safeDiscarded.length > 0 && ( {filter === 'discarded' && safeDiscarded.length > 0 && (
<button onClick={handlePurgeAllDiscarded} className="btn-action"> <button onClick={handlePurgeAllDiscarded} className="btn-action min-h-[38px] sm:min-h-0">
<Trash2 className="w-3.5 h-3.5" /> <Trash2 className="w-3.5 h-3.5" />
Papierkorb leeren Papierkorb leeren
</button> </button>
+532 -93
View File
@@ -45,8 +45,6 @@ import {
Unlock, Unlock,
Eye, Eye,
Edit3, Edit3,
Share,
Share2,
Hash, Hash,
User as UserIcon, User as UserIcon,
} from 'lucide-react'; } from 'lucide-react';
@@ -55,6 +53,7 @@ import { api, getToken, triggerHaptic } from '../../lib/api';
import { CodeBlockItem } from './CodeBlockItem'; import { CodeBlockItem } from './CodeBlockItem';
import { UserAutocomplete } from '../UserAutocomplete'; import { UserAutocomplete } from '../UserAutocomplete';
import { UnifiedShareModal } from '../../components/UnifiedShareModal'; import { UnifiedShareModal } from '../../components/UnifiedShareModal';
import { useMobileActions } from '../../context/MobileActionContext';
interface WorkspaceViewProps { interface WorkspaceViewProps {
currentUser?: User | null; currentUser?: User | null;
@@ -101,6 +100,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
initialDocId, initialDocId,
onStatsChange, onStatsChange,
}) => { }) => {
const { setGroups } = useMobileActions();
const [showMobileFileDrawer, setShowMobileFileDrawer] = useState(false);
// Top Workspace Tab Mode: 'editor' vs 'todos' vs 'tags' // Top Workspace Tab Mode: 'editor' vs 'todos' vs 'tags'
const [workspaceMode, setWorkspaceMode] = useState<'editor' | 'todos' | 'tags'>('editor'); const [workspaceMode, setWorkspaceMode] = useState<'editor' | 'todos' | 'tags'>('editor');
const [selectedTag, setSelectedTag] = useState<string | null>(null); const [selectedTag, setSelectedTag] = useState<string | null>(null);
@@ -171,6 +173,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
// Drag and Drop reordering state for blocks // Drag and Drop reordering state for blocks
const [draggingBlockId, setDraggingBlockId] = useState<string | null>(null); const [draggingBlockId, setDraggingBlockId] = useState<string | null>(null);
const [dragOverBlockTarget, setDragOverBlockTarget] = useState<{ blockId: string; position: 'before' | 'after' | 'inside' } | null>(null);
// --- GLOBAL TO-DO ROLLUP STATE --- // --- GLOBAL TO-DO ROLLUP STATE ---
const [todoFilter, setTodoFilter] = useState<'all' | 'open' | 'done'>('open'); const [todoFilter, setTodoFilter] = useState<'all' | 'open' | 'done'>('open');
@@ -197,6 +200,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const idleUnlockTimerRef = useRef<any>(null); const idleUnlockTimerRef = useRef<any>(null);
const currentDocIdRef = useRef<string | null>(null); const currentDocIdRef = useRef<string | null>(null);
const isEditorFocusedRef = useRef(false); const isEditorFocusedRef = useRef(false);
const isDraggingBlockRef = useRef(false);
const blockInputRefs = useRef<Record<string, HTMLTextAreaElement | HTMLInputElement | null>>({}); const blockInputRefs = useRef<Record<string, HTMLTextAreaElement | HTMLInputElement | null>>({});
@@ -244,9 +248,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
} }
}; };
const ensureLock = async (): Promise<boolean> => { const ensureLock = async (force = false): Promise<boolean> => {
if (!selectedDoc || selectedDoc.permission === 'read') return false; if (!selectedDoc || selectedDoc.permission === 'read') return false;
if (holdsLockRef.current) { if (holdsLockRef.current && !force) {
return true; return true;
} }
try { try {
@@ -285,10 +289,14 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
if (idleUnlockTimerRef.current) { if (idleUnlockTimerRef.current) {
clearTimeout(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. // 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! // If user clicked outside or stopped editing, save and release the lock immediately!
idleUnlockTimerRef.current = setTimeout(async () => { idleUnlockTimerRef.current = setTimeout(async () => {
if (!isEditorFocusedRef.current && holdsLockRef.current) { if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) {
await flushPendingSave(); await flushPendingSave();
await releaseMyLock(); await releaseMyLock();
} }
@@ -338,8 +346,15 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
pendingSaveDocRef.current = null; pendingSaveDocRef.current = null;
setSaveState('saved'); setSaveState('saved');
} }
if (!isEditorFocusedRef.current && holdsLockRef.current) { if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) {
releaseMyLock(); 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<WorkspaceViewProps> = ({
// Window blur / visibility change listener: release lock when user leaves tab/window // Window blur / visibility change listener: release lock when user leaves tab/window
useEffect(() => { useEffect(() => {
const handleWindowBlur = () => { const handleWindowBlur = () => {
if (!isEditorFocusedRef.current && holdsLockRef.current) { if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) {
flushPendingSave(); flushPendingSave();
releaseMyLock(); releaseMyLock();
} }
}; };
const handleVisibilityChange = () => { const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden') { if (document.visibilityState === 'hidden' && !isDraggingBlockRef.current) {
isEditorFocusedRef.current = false; isEditorFocusedRef.current = false;
flushPendingSave(); flushPendingSave();
releaseMyLock(); releaseMyLock();
@@ -1911,35 +1926,187 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
return childTodos; return childTodos;
}; };
// Drag & Drop Handlers on the Bullet Dot // Helper: Get subtree count (root + all indented children)
const handleDragStart = (e: React.DragEvent, blockId: string) => { 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.setData('text/plain', blockId);
e.dataTransfer.effectAllowed = 'move';
setDraggingBlockId(blockId); setDraggingBlockId(blockId);
}; };
const handleDropOnBlock = (e: React.DragEvent, targetBlockId: string) => { const handleBlockDragEnd = () => {
e.preventDefault(); isDraggingBlockRef.current = false;
const sourceId = e.dataTransfer.getData('text/plain') || draggingBlockId;
if (!selectedDoc || !sourceId || sourceId === targetBlockId) {
setDraggingBlockId(null); 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();
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) {
return; return;
} }
const blocks = [...selectedDoc.blocks]; const blocks = [...selectedDoc.blocks];
const sourceIdx = blocks.findIndex((b) => b.id === sourceId); const sourceIdx = blocks.findIndex((b) => b.id === sourceId);
const targetIdx = blocks.findIndex((b) => b.id === targetBlockId); if (sourceIdx === -1) return;
if (sourceIdx === -1 || targetIdx === -1) {
setDraggingBlockId(null); // Prevent circular drag into one's own children
if (isDescendantBlock(blocks, sourceIdx, targetBlockId)) {
return; return;
} }
const [moved] = blocks.splice(sourceIdx, 1); // Extract the entire subtree (parent + all indented children)
// Align moved block's level to target const subtreeCount = getBlockSubtreeCount(blocks, sourceIdx);
moved.level = blocks[targetIdx]?.level || 0; const subtree = blocks.splice(sourceIdx, subtreeCount);
blocks.splice(targetIdx, 0, moved);
// 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); 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 // Export full document as portable Logseq-indented Markdown
@@ -2551,6 +2718,132 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
return workspaceTags.filter((t) => t.tag.toLowerCase().includes(q)); return workspaceTags.filter((t) => t.tag.toLowerCase().includes(q));
}, [workspaceTags, tagSearchQuery]); }, [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: <FolderTree className="w-4 h-4 text-accent" />,
badge: docs.length,
variant: 'accent',
active: showMobileFileDrawer,
onClick: () => {
setWorkspaceMode('editor');
setShowMobileFileDrawer(true);
},
},
{
id: 'ws-mode-editor',
label: 'Notizen & Editor',
icon: <Layers className="w-4 h-4" />,
active: workspaceMode === 'editor' && !showMobileFileDrawer,
onClick: () => {
setWorkspaceMode('editor');
setShowMobileFileDrawer(false);
},
},
{
id: 'ws-mode-tags',
label: 'Themen & Hashtags',
icon: <Hash className="w-4 h-4" />,
badge: workspaceTags.length,
active: workspaceMode === 'tags',
onClick: () => {
setWorkspaceMode('tags');
setShowMobileFileDrawer(false);
},
},
{
id: 'ws-mode-todos',
label: 'Globale To-Dos',
icon: <CheckSquare className="w-4 h-4" />,
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: <FilePlus className="w-4 h-4 text-accent" />,
onClick: () => {
setShowMobileFileDrawer(false);
handleCreateDoc(null);
},
},
{
id: 'ws-new-folder',
label: 'Neuer Ordner',
icon: <FolderPlus className="w-4 h-4" />,
onClick: () => {
setWorkspaceMode('editor');
setShowMobileFileDrawer(true);
handleStartCreateFolder(null);
},
},
...(selectedDoc
? [
{
id: 'ws-share-doc',
label: `Notiz "${selectedDoc.title || 'Unbenannt'}" teilen`,
icon: <Users className="w-4 h-4" />,
onClick: () => openShareModal(),
},
{
id: 'ws-pin-doc',
label: selectedDoc.isPinned ? 'Von Favoriten lösen' : 'Oben in Favoriten anpinnen',
icon: <Pin className="w-4 h-4" />,
active: Boolean(selectedDoc.isPinned),
onClick: () => persistDocument({ ...selectedDoc, isPinned: !selectedDoc.isPinned }, true),
},
{
id: 'ws-export-md',
label: 'Als Markdown exportieren (.md)',
icon: <Download className="w-4 h-4" />,
onClick: () => handleExportMarkdown(),
},
{
id: 'ws-delete-doc',
label: selectedDoc.isOwner !== false ? 'Notiz löschen' : 'Geteilte Notiz verlassen',
icon: <Trash2 className="w-4 h-4" />,
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 renderDocItem = (doc: Document, depth: number) => {
const isSelected = selectedDoc?.id === doc.id && workspaceMode === 'editor'; const isSelected = selectedDoc?.id === doc.id && workspaceMode === 'editor';
const isDragging = draggingDocId === doc.id; const isDragging = draggingDocId === doc.id;
@@ -2569,9 +2862,10 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
setSelectedDoc(doc); setSelectedDoc(doc);
setZoomedBlockId(null); setZoomedBlockId(null);
setWorkspaceMode('editor'); setWorkspaceMode('editor');
setShowMobileFileDrawer(false);
}} }}
style={{ paddingLeft: `${depth * 14 + 16}px` }} 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]' : '' isDragging ? 'opacity-40 scale-[0.98]' : ''
} ${ } ${
isSelected isSelected
@@ -2581,15 +2875,15 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
title={`${doc.title || 'Notiz'} (Ziehen zum Verschieben in Ordner)`} title={`${doc.title || 'Notiz'} (Ziehen zum Verschieben in Ordner)`}
> >
<div className="flex items-center gap-2 min-w-0 flex-1"> <div className="flex items-center gap-2 min-w-0 flex-1">
<span className="text-sm flex-shrink-0">{doc.icon || '📄'}</span> <span className="text-base sm:text-sm flex-shrink-0">{doc.icon || '📄'}</span>
<span className="truncate">{doc.title || 'Unbenannt'}</span> <span className="truncate">{doc.title || 'Unbenannt'}</span>
{doc.isPinned && <Pin className="w-2.5 h-2.5 text-accent flex-shrink-0" />} {doc.isPinned && <Pin className="w-3 h-3 sm:w-2.5 sm:h-2.5 text-accent flex-shrink-0" />}
{doc.isOwner === false && ( {doc.isOwner === false && (
<span <span
title={`Geteilt von ${doc.ownerName || 'anderem Nutzer'} (${doc.permission === 'read' ? 'Nur Lesen' : 'Schreibrecht'})`} title={`Geteilt von ${doc.ownerName || 'anderem Nutzer'} (${doc.permission === 'read' ? 'Nur Lesen' : 'Schreibrecht'})`}
className="text-accent flex-shrink-0" className="text-accent flex-shrink-0"
> >
<Users className="w-3 h-3" /> <Users className="w-3.5 h-3.5 sm:w-3 sm:h-3" />
</span> </span>
)} )}
</div> </div>
@@ -2653,7 +2947,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
onDragEnd={() => setDraggingFolderPath(null)} onDragEnd={() => setDraggingFolderPath(null)}
onClick={() => toggleFolder(node.path)} onClick={() => toggleFolder(node.path)}
style={{ paddingLeft: `${node.depth * 14 + 8}px` }} 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]' : '' isDraggingThisFolder ? 'opacity-40 scale-[0.98]' : ''
} ${ } ${
isDragOver isDragOver
@@ -2665,31 +2959,32 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
<div className="flex items-center gap-1.5 min-w-0"> <div className="flex items-center gap-1.5 min-w-0">
<span className="text-faint hover:text-muted"> <span className="text-faint hover:text-muted">
{isExpanded ? ( {isExpanded ? (
<ChevronDown className="w-3 h-3 flex-shrink-0" /> <ChevronDown className="w-3.5 h-3.5 sm:w-3 sm:h-3 flex-shrink-0" />
) : ( ) : (
<ChevronRight className="w-3 h-3 flex-shrink-0" /> <ChevronRight className="w-3.5 h-3.5 sm:w-3 sm:h-3 flex-shrink-0" />
)} )}
</span> </span>
{isExpanded ? ( {isExpanded ? (
<FolderOpen className="w-3.5 h-3.5 text-accent flex-shrink-0" /> <FolderOpen className="w-4 h-4 sm:w-3.5 sm:h-3.5 text-accent flex-shrink-0" />
) : ( ) : (
<Folder className="w-3.5 h-3.5 text-muted flex-shrink-0" /> <Folder className="w-4 h-4 sm:w-3.5 sm:h-3.5 text-muted flex-shrink-0" />
)} )}
<span className="font-medium text-xs text-main truncate">{node.name}</span> <span className="font-medium text-sm sm:text-xs text-main truncate">{node.name}</span>
<span className="text-[10px] text-faint font-mono">({totalItems})</span> <span className="text-[10px] text-faint font-mono">({totalItems})</span>
</div> </div>
<div className="opacity-0 group-hover:opacity-100 flex items-center gap-1 transition-opacity"> <div className="opacity-100 sm:opacity-0 sm:group-hover:opacity-100 flex items-center gap-1 transition-opacity">
<button <button
type="button" type="button"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setShowMobileFileDrawer(false);
handleCreateDoc(node.path); handleCreateDoc(node.path);
}} }}
className="p-1 hover:bg-panel-elevated text-muted hover:text-main rounded-btn" className="p-1.5 sm:p-1 hover:bg-panel-elevated text-muted hover:text-main rounded-btn"
title="Neue Datei in diesem Ordner" title="Neue Datei in diesem Ordner"
> >
<FilePlus className="w-3 h-3" /> <FilePlus className="w-3.5 h-3.5 sm:w-3 sm:h-3" />
</button> </button>
<button <button
type="button" type="button"
@@ -2697,10 +2992,10 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
e.stopPropagation(); e.stopPropagation();
handleStartCreateFolder(node.path); handleStartCreateFolder(node.path);
}} }}
className="p-1 hover:bg-panel-elevated text-muted hover:text-main rounded-btn" className="p-1.5 sm:p-1 hover:bg-panel-elevated text-muted hover:text-main rounded-btn"
title="Neuer Unterordner" title="Neuer Unterordner"
> >
<FolderPlus className="w-3 h-3" /> <FolderPlus className="w-3.5 h-3.5 sm:w-3 sm:h-3" />
</button> </button>
<button <button
type="button" type="button"
@@ -2708,10 +3003,10 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
e.stopPropagation(); e.stopPropagation();
handleDeleteFolder(node.path, node.name); handleDeleteFolder(node.path, node.name);
}} }}
className="p-1 hover:bg-red-500/10 text-muted hover:text-red-400 rounded-btn transition-colors" className="p-1.5 sm:p-1 hover:bg-red-500/10 text-muted hover:text-red-400 rounded-btn transition-colors"
title="Ordner löschen" title="Ordner löschen"
> >
<Trash2 className="w-3 h-3" /> <Trash2 className="w-3.5 h-3.5 sm:w-3 sm:h-3" />
</button> </button>
</div> </div>
</div> </div>
@@ -2719,9 +3014,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
{isCreatingSubfolder && ( {isCreatingSubfolder && (
<div <div
style={{ paddingLeft: `${(node.depth + 1) * 14 + 8}px` }} style={{ paddingLeft: `${(node.depth + 1) * 14 + 8}px` }}
className="py-1 pr-2 flex items-center gap-1 bg-canvas" className="py-1.5 sm:py-1 pr-2 flex items-center gap-1 bg-canvas"
> >
<Folder className="w-3 h-3 text-muted flex-shrink-0" /> <Folder className="w-3.5 h-3.5 sm:w-3 sm:h-3 text-muted flex-shrink-0" />
<input <input
type="text" type="text"
autoFocus autoFocus
@@ -2732,23 +3027,23 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
if (e.key === 'Escape') setIsCreatingFolder(false); if (e.key === 'Escape') setIsCreatingFolder(false);
}} }}
placeholder="Unterordner…" 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"
/> />
<button <button
type="button" type="button"
onClick={handleConfirmCreateFolder} onClick={handleConfirmCreateFolder}
className="p-1 text-accent hover:text-main rounded-btn" className="p-1.5 sm:p-1 text-accent hover:text-main rounded-btn"
title="Bestätigen" title="Bestätigen"
> >
<Check className="w-3.5 h-3.5" /> <Check className="w-4 h-4 sm:w-3.5 sm:h-3.5" />
</button> </button>
<button <button
type="button" type="button"
onClick={() => setIsCreatingFolder(false)} onClick={() => setIsCreatingFolder(false)}
className="p-1 text-muted hover:text-main rounded-btn" className="p-1.5 sm:p-1 text-muted hover:text-main rounded-btn"
title="Abbrechen" title="Abbrechen"
> >
<X className="w-3.5 h-3.5" /> <X className="w-4 h-4 sm:w-3.5 sm:h-3.5" />
</button> </button>
</div> </div>
)} )}
@@ -2775,8 +3070,52 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
return ( return (
<div className="w-full space-y-2"> <div className="w-full space-y-2">
{/* Top Navigation Mode Switcher: Notizen & Outliner vs. Globales To-Do Dashboard */} {/* Mobile Compact Workspace Bar (Options & mode switching live in MindSift Logo Menu) */}
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2"> <div className="flex sm:hidden items-center justify-between gap-2 px-3 py-2 rounded-btn bg-panel border border-border">
<button
type="button"
onClick={() => {
setWorkspaceMode('editor');
setShowMobileFileDrawer(true);
}}
className="flex items-center gap-2 min-w-0 text-left py-0.5"
title="Dateibaum & Ordner öffnen"
>
<FolderTree className="w-4 h-4 text-accent flex-shrink-0" />
<span className="text-sm font-semibold text-main truncate">
{workspaceMode === 'tags'
? 'Themen & Hashtags'
: workspaceMode === 'todos'
? 'Globale To-Dos'
: selectedDoc
? `${selectedDoc.icon || '📄'} ${selectedDoc.title || 'Unbenannt'}`
: 'Dateibaum öffnen'}
</span>
</button>
<div className="flex items-center gap-1.5 flex-shrink-0">
{workspaceMode === 'editor' && selectedDoc && (
<span className="badge-accent text-[10px]">
<Check className="w-2.5 h-2.5" />
{saveState === 'saving' ? 'Speichert…' : 'Gespeichert'}
</span>
)}
<button
type="button"
onClick={() => {
setWorkspaceMode('editor');
setShowMobileFileDrawer(true);
}}
className="btn-action min-h-[36px] px-2.5 text-xs flex items-center gap-1"
>
<Folder className="w-3.5 h-3.5 text-accent" />
<span>Dateien</span>
</button>
</div>
</div>
{/* Top Navigation Mode Switcher: Notizen & Outliner vs. Globales To-Do Dashboard (Desktop) */}
<div className="hidden sm:flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button <button
type="button" type="button"
@@ -2852,38 +3191,71 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
{/* ===================================================================== */} {/* ===================================================================== */}
{workspaceMode === 'editor' && ( {workspaceMode === 'editor' && (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-2"> <div className="grid grid-cols-1 lg:grid-cols-12 gap-2">
{/* Left Document & Folder Explorer Tree */} {/* Mobile Backdrop for File Tree Slide-Over Drawer */}
<div className="lg:col-span-4 panel-surface flex flex-col self-start"> {showMobileFileDrawer && (
<div className="p-2 panel-header space-y-2"> <div
className="fixed inset-0 bg-black/50 backdrop-blur-xs z-40 lg:hidden animate-in fade-in duration-150"
onClick={() => setShowMobileFileDrawer(false)}
/>
)}
{/* Left Document & Folder Explorer Tree (Desktop Sidebar + Mobile Slide-Over Drawer) */}
<div
className={`${
showMobileFileDrawer
? 'fixed inset-y-0 left-0 z-50 w-[86vw] max-w-sm bg-panel border-r border-border shadow-2xl flex'
: 'hidden lg:flex'
} lg:col-span-4 panel-surface flex-col self-start max-h-screen lg:max-h-none`}
>
{/* Mobile Drawer Top Header */}
<div className="flex lg:hidden items-center justify-between px-3.5 py-3 border-b border-border bg-panel">
<div className="flex items-center gap-2">
<FolderTree className="w-4 h-4 text-accent" />
<span className="text-sm font-bold text-main">Dateibaum & Ordner</span>
</div>
<button
type="button"
onClick={() => setShowMobileFileDrawer(false)}
className="p-1.5 text-muted hover:text-main rounded-btn"
title="Schließen"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="p-2.5 sm:p-2 panel-header space-y-2">
<div className="relative"> <div className="relative">
<Search className="w-3.5 h-3.5 text-muted absolute left-2.5 top-1/2 -translate-y-1/2" /> <Search className="w-4 h-4 sm:w-3.5 sm:h-3.5 text-muted absolute left-2.5 top-1/2 -translate-y-1/2" />
<input <input
type="text" type="text"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Dateien durchsuchen…" 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"
/> />
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<button <button
type="button" type="button"
onClick={() => handleCreateDoc(null)} onClick={() => {
className="flex-1 btn-action" setShowMobileFileDrawer(false);
handleCreateDoc(null);
}}
className="flex-1 btn-action min-h-[38px] sm:min-h-0 justify-center"
title="Neue Datei erstellen" title="Neue Datei erstellen"
> >
<FilePlus className="w-3.5 h-3.5 text-muted" /> <FilePlus className="w-4 h-4 sm:w-3.5 sm:h-3.5 text-muted" />
<span>Neue Datei</span> <span>Neue Datei</span>
</button> </button>
<button <button
type="button" type="button"
onClick={() => handleStartCreateFolder(null)} onClick={() => handleStartCreateFolder(null)}
className="flex-1 btn-action" className="flex-1 btn-action min-h-[38px] sm:min-h-0 justify-center"
title="Neuen Ordner erstellen" title="Neuen Ordner erstellen"
> >
<FolderPlus className="w-3.5 h-3.5 text-muted" /> <FolderPlus className="w-4 h-4 sm:w-3.5 sm:h-3.5 text-muted" />
<span>Neuer Ordner</span> <span>Neuer Ordner</span>
</button> </button>
</div> </div>
@@ -2891,7 +3263,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
{isCreatingFolder && newFolderParent === null && ( {isCreatingFolder && newFolderParent === null && (
<div className="p-2 panel-header bg-canvas flex items-center gap-1.5"> <div className="p-2 panel-header bg-canvas flex items-center gap-1.5">
<Folder className="w-3.5 h-3.5 text-muted flex-shrink-0" /> <Folder className="w-4 h-4 sm:w-3.5 sm:h-3.5 text-muted flex-shrink-0" />
<input <input
type="text" type="text"
autoFocus autoFocus
@@ -2902,23 +3274,23 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
if (e.key === 'Escape') setIsCreatingFolder(false); if (e.key === 'Escape') setIsCreatingFolder(false);
}} }}
placeholder="Neuer Ordnername…" 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"
/> />
<button <button
type="button" type="button"
onClick={handleConfirmCreateFolder} onClick={handleConfirmCreateFolder}
className="p-1 text-accent hover:text-main rounded-btn" className="p-1.5 sm:p-1 text-accent hover:text-main rounded-btn"
title="Bestätigen" title="Bestätigen"
> >
<Check className="w-3.5 h-3.5" /> <Check className="w-4 h-4 sm:w-3.5 sm:h-3.5" />
</button> </button>
<button <button
type="button" type="button"
onClick={() => setIsCreatingFolder(false)} onClick={() => setIsCreatingFolder(false)}
className="p-1 text-muted hover:text-main rounded-btn" className="p-1.5 sm:p-1 text-muted hover:text-main rounded-btn"
title="Abbrechen" title="Abbrechen"
> >
<X className="w-3.5 h-3.5" /> <X className="w-4 h-4 sm:w-3.5 sm:h-3.5" />
</button> </button>
</div> </div>
)} )}
@@ -2933,8 +3305,11 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
<button <button
type="button" type="button"
onClick={() => handleCreateDoc(null)} onClick={() => {
className="btn-action" setShowMobileFileDrawer(false);
handleCreateDoc(null);
}}
className="btn-action min-h-[38px] sm:min-h-0"
> >
<FilePlus className="w-3.5 h-3.5 text-muted" /> <FilePlus className="w-3.5 h-3.5 text-muted" />
<span>Neue Datei</span> <span>Neue Datei</span>
@@ -2942,7 +3317,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
<button <button
type="button" type="button"
onClick={() => handleStartCreateFolder(null)} onClick={() => handleStartCreateFolder(null)}
className="btn-action" className="btn-action min-h-[38px] sm:min-h-0"
> >
<FolderPlus className="w-3.5 h-3.5 text-muted" /> <FolderPlus className="w-3.5 h-3.5 text-muted" />
<span>Neuer Ordner</span> <span>Neuer Ordner</span>
@@ -2950,7 +3325,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
</div> </div>
</div> </div>
) : ( ) : (
<div className="py-1 max-h-[calc(100vh-240px)] overflow-y-auto"> <div className="py-1 flex-1 lg:max-h-[calc(100vh-240px)] overflow-y-auto">
{/* Drag target for Root when dragging a note or a folder */} {/* Drag target for Root when dragging a note or a folder */}
{(draggingDocId || draggingFolderPath) && ( {(draggingDocId || draggingFolderPath) && (
<div <div
@@ -2997,7 +3372,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
<button <button
type="button" type="button"
onClick={() => setIsPinnedCollapsed((prev) => !prev)} onClick={() => setIsPinnedCollapsed((prev) => !prev)}
className="w-full px-2 py-1 text-[10px] font-bold text-accent uppercase tracking-wider flex items-center justify-between hover:bg-canvas/40 rounded transition-colors" className="w-full px-2 py-1.5 sm:py-1 text-[10px] font-bold text-accent uppercase tracking-wider flex items-center justify-between hover:bg-canvas/40 rounded transition-colors"
title={isPinnedCollapsed ? 'Angepinnte Notizen aufklappen' : 'Angepinnte Notizen einklappen'} title={isPinnedCollapsed ? 'Angepinnte Notizen aufklappen' : 'Angepinnte Notizen einklappen'}
> >
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
@@ -3029,8 +3404,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
setSelectedDoc(doc); setSelectedDoc(doc);
setZoomedBlockId(null); setZoomedBlockId(null);
setWorkspaceMode('editor'); setWorkspaceMode('editor');
setShowMobileFileDrawer(false);
}} }}
className={`group flex items-center justify-between px-2 py-1.5 rounded-btn transition-colors cursor-pointer text-xs ${ className={`group flex items-center justify-between px-2 py-2.5 sm:py-1.5 rounded-btn transition-colors cursor-pointer text-sm sm:text-xs ${
isSelected isSelected
? 'bg-panel-elevated border-l-2 border-accent text-main font-medium' ? 'bg-panel-elevated border-l-2 border-accent text-main font-medium'
: 'text-muted hover:text-main hover:bg-canvas/50' : 'text-muted hover:text-main hover:bg-canvas/50'
@@ -3038,7 +3414,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
title="Angepinnte Notiz öffnen" title="Angepinnte Notiz öffnen"
> >
<div className="flex items-center gap-2 min-w-0 flex-1"> <div className="flex items-center gap-2 min-w-0 flex-1">
<span className="text-sm flex-shrink-0">{doc.icon || '📄'}</span> <span className="text-base sm:text-sm flex-shrink-0">{doc.icon || '📄'}</span>
<span className="truncate">{doc.title || 'Unbenannt'}</span> <span className="truncate">{doc.title || 'Unbenannt'}</span>
</div> </div>
<button <button
@@ -3047,10 +3423,10 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
e.stopPropagation(); e.stopPropagation();
persistDocument({ ...doc, isPinned: false }, true); persistDocument({ ...doc, isPinned: false }, true);
}} }}
className="opacity-0 group-hover:opacity-100 p-0.5 text-muted hover:text-accent rounded transition-opacity" className="opacity-100 sm:opacity-0 sm:group-hover:opacity-100 p-1 sm:p-0.5 text-muted hover:text-accent rounded transition-opacity"
title="Von Angepinnt entfernen" title="Von Angepinnt entfernen"
> >
<Pin className="w-3 h-3 text-accent" /> <Pin className="w-3.5 h-3.5 sm:w-3 sm:h-3 text-accent" />
</button> </button>
</div> </div>
); );
@@ -3066,7 +3442,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
<button <button
type="button" type="button"
onClick={() => setIsTagsCollapsed((prev) => !prev)} onClick={() => setIsTagsCollapsed((prev) => !prev)}
className="w-full px-2 py-1 text-[10px] font-bold text-muted uppercase tracking-wider flex items-center justify-between hover:bg-canvas/40 rounded transition-colors" className="w-full px-2 py-1.5 sm:py-1 text-[10px] font-bold text-muted uppercase tracking-wider flex items-center justify-between hover:bg-canvas/40 rounded transition-colors"
title={isTagsCollapsed ? 'Themen & Hashtags aufklappen' : 'Themen & Hashtags einklappen'} title={isTagsCollapsed ? 'Themen & Hashtags aufklappen' : 'Themen & Hashtags einklappen'}
> >
<div className="flex items-center gap-1.5 text-accent"> <div className="flex items-center gap-1.5 text-accent">
@@ -3080,13 +3456,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
/> />
</button> </button>
{!isTagsCollapsed && ( {!isTagsCollapsed && (
<div className="flex flex-wrap gap-1 px-1.5 py-1 mt-1 max-h-36 overflow-y-auto"> <div className="flex flex-wrap gap-1.5 sm:gap-1 px-1.5 py-1 mt-1 max-h-36 overflow-y-auto">
{workspaceTags.map((wt) => ( {workspaceTags.map((wt) => (
<button <button
key={wt.tag} key={wt.tag}
type="button" type="button"
onClick={() => openTagPage(wt.tag)} onClick={() => {
className={`px-2 py-0.5 rounded-pill text-[11px] font-medium border flex items-center gap-1 transition-colors ${ setShowMobileFileDrawer(false);
openTagPage(wt.tag);
}}
className={`px-2.5 py-1 sm:px-2 sm:py-0.5 rounded-pill text-xs sm:text-[11px] font-medium border flex items-center gap-1 transition-colors ${
selectedTag === wt.tag selectedTag === wt.tag
? 'bg-accent/20 border-accent text-accent font-semibold' ? 'bg-accent/20 border-accent text-accent font-semibold'
: 'bg-canvas border-border text-muted hover:border-accent/40 hover:text-main' : 'bg-canvas border-border text-muted hover:border-accent/40 hover:text-main'
@@ -3193,7 +3572,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
className="btn-icon" className="btn-icon"
title="Notiz teilen und Berechtigungen" title="Notiz teilen und Berechtigungen"
> >
<Share className="w-3.5 h-3.5" /> <Users className="w-3.5 h-3.5" />
</button> </button>
<button <button
@@ -3413,16 +3792,50 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
{visibleBlocks.map(({ block, originalIndex }) => { {visibleBlocks.map(({ block, originalIndex }) => {
const blockLevel = Math.max(0, (block.level || 0) - (zoomedBlock ? (zoomedBlock.level || 0) + 1 : 0)); const blockLevel = Math.max(0, (block.level || 0) - (zoomedBlock ? (zoomedBlock.level || 0) + 1 : 0));
const isChildrenPresent = hasChildren(selectedDoc.blocks, originalIndex); const isChildrenPresent = hasChildren(selectedDoc.blocks, originalIndex);
const isDraggingThis = draggingBlockId === block.id;
const isTargetThis = dragOverBlockTarget?.blockId === block.id;
return ( return (
<div <div
key={block.id} key={block.id}
style={{ paddingLeft: `${blockLevel * 22}px` }} onDragOver={(e) => !isReadOnly && handleBlockDragOver(e, block.id)}
className="group flex items-start gap-1 py-0.5 relative transition-all" onDragLeave={(e) => !isReadOnly && handleBlockDragLeave(e, block.id)}
onDrop={(e) => !isReadOnly && handleBlockDrop(e, block.id)}
style={{ paddingLeft: `${blockLevel * 20}px` }}
className={`group flex items-start gap-0.5 py-0.5 relative transition-all rounded ${
isDraggingThis ? 'opacity-30 border border-dashed border-accent/40' : ''
} ${
isTargetThis && dragOverBlockTarget.position === 'inside'
? 'ring-2 ring-accent/60 bg-accent/10 shadow-sm'
: ''
}`}
> >
{/* Drag Handle on hover */} {/* Drop indicator lines */}
{isTargetThis && dragOverBlockTarget.position === 'before' && (
<div className="absolute top-0 left-0 right-0 h-0.5 bg-accent z-20 pointer-events-none rounded-full shadow-[0_0_8px_rgba(var(--accent-rgb),0.6)]" />
)}
{isTargetThis && dragOverBlockTarget.position === 'after' && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-accent z-20 pointer-events-none rounded-full shadow-[0_0_8px_rgba(var(--accent-rgb),0.6)]" />
)}
{isTargetThis && dragOverBlockTarget.position === 'inside' && (
<div className="absolute left-0 top-0 bottom-0 w-1 bg-accent rounded-l z-20 pointer-events-none" />
)}
{/* Drag Handle on hover - tightened spacing to the bullet */}
<div <div
className="opacity-0 group-hover:opacity-100 flex items-center pt-1 text-faint hover:text-muted cursor-grab flex-shrink-0" draggable={!isReadOnly}
onMouseDown={() => {
if (!isReadOnly) {
if (idleUnlockTimerRef.current) {
clearTimeout(idleUnlockTimerRef.current);
idleUnlockTimerRef.current = null;
}
ensureLock(true);
}
}}
onDragStart={(e) => !isReadOnly && handleBlockDragStart(e, block.id)}
onDragEnd={handleBlockDragEnd}
className="opacity-0 group-hover:opacity-100 flex items-center pt-1 text-faint hover:text-accent cursor-grab active:cursor-grabbing flex-shrink-0 -ml-1 mr-0.5"
title="Ziehen zum Verschieben" title="Ziehen zum Verschieben"
> >
<GripVertical className="w-3 h-3" /> <GripVertical className="w-3 h-3" />
@@ -3433,32 +3846,58 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
<button <button
type="button" type="button"
onClick={() => toggleCollapse(block.id)} onClick={() => toggleCollapse(block.id)}
className="w-4 h-4 mt-0.5 flex items-center justify-center text-muted hover:text-main flex-shrink-0" className="w-3.5 h-3.5 mt-0.5 flex items-center justify-center text-muted hover:text-main flex-shrink-0"
title={block.collapsed ? 'Unterpunkte aufklappen' : 'Unterpunkte einklappen'} title={block.collapsed ? 'Unterpunkte aufklappen' : 'Unterpunkte einklappen'}
> >
{block.collapsed ? ( {block.collapsed ? (
<ChevronRight className="w-3 h-3 text-accent" /> <ChevronRight className="w-2.5 h-2.5 text-accent" />
) : ( ) : (
<ChevronDown className="w-3 h-3" /> <ChevronDown className="w-2.5 h-2.5" />
)} )}
</button> </button>
) : ( ) : (
<div className="w-4 flex-shrink-0" /> <div className="w-1.5 flex-shrink-0" />
)} )}
{/* Interactive Bullet Dot (Click: Zoom-in! Drag: Reorder!) */} {/* Interactive Bullet Dot (Click: Zoom-in! Drag: Reorder! Drop on it: Nest as child!) */}
<div <div
draggable={!isReadOnly} draggable={!isReadOnly}
onDragStart={(e) => !isReadOnly && handleDragStart(e, block.id)} onMouseDown={() => {
onDragOver={(e) => e.preventDefault()} if (!isReadOnly) {
onDrop={(e) => !isReadOnly && handleDropOnBlock(e, block.id)} 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)} onClick={() => handleZoomTo(block.id)}
className={`w-4 h-4 mt-1 rounded-pill flex items-center justify-center transition-colors flex-shrink-0 ${ className={`w-4 h-4 mt-1 rounded-pill flex items-center justify-center transition-colors flex-shrink-0 ${
isReadOnly isReadOnly
? 'cursor-pointer text-muted' ? '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"
> >
<span <span
className={`w-1.5 h-1.5 rounded-pill transition-colors ${ className={`w-1.5 h-1.5 rounded-pill transition-colors ${
+60 -6
View File
@@ -8,6 +8,7 @@ import {
DocLockInfo, DocLockInfo,
KanbanColumn, KanbanColumn,
ListItem, ListItem,
ListShare,
Organization, Organization,
PriorityLevel, PriorityLevel,
SharedList, SharedList,
@@ -309,6 +310,14 @@ export const api = {
return ensureArray(res); return ensureArray(res);
}, },
async reorderTasks(items: { id: string; sortOrder: number; kanbanStatus?: string }[], taskListId?: string): Promise<{ success: boolean }> {
const url = taskListId ? `/api/v1/task-lists/${taskListId}/tasks/reorder` : '/api/v1/tasks/reorder';
return request<{ success: boolean }>(url, {
method: 'POST',
body: JSON.stringify({ items }),
});
},
async updateThought( async updateThought(
thoughtId: string, thoughtId: string,
patch: { patch: {
@@ -639,17 +648,36 @@ export const api = {
return ensureArray(res).map((l) => ({ return ensureArray(res).map((l) => ({
...l, ...l,
items: ensureArray(l.items), items: ensureArray(l.items),
shares: ensureArray(l.shares),
})); }));
}, },
async createList(spaceId: string, title: string, icon: string, listType: 'shopping' | 'checklist' | 'vacation'): Promise<SharedList> { async getListById(listId: string): Promise<SharedList> {
const l = await request<SharedList>(`/api/v1/spaces/${spaceId}/lists`, { const l = await request<SharedList>(`/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<SharedList> {
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<SharedList>('/api/v1/lists', {
method: 'POST', method: 'POST',
body: JSON.stringify({ title, icon, listType }), body: JSON.stringify({ title, icon, listType: type }),
}); });
return { return {
...l, ...l,
items: ensureArray(l.items), items: ensureArray(l.items),
shares: ensureArray(l.shares),
}; };
}, },
@@ -657,6 +685,22 @@ export const api = {
await request(`/api/v1/lists/${listId}`, { method: 'DELETE' }); await request(`/api/v1/lists/${listId}`, { method: 'DELETE' });
}, },
async getListShares(listId: string): Promise<ListShare[]> {
const res = await request<ListShare[]>(`/api/v1/lists/${listId}/shares`);
return ensureArray(res);
},
async addListShare(listId: string, target: { targetType: 'team' | 'user'; targetId: string; role?: string }): Promise<ListShare> {
return request<ListShare>(`/api/v1/lists/${listId}/shares`, {
method: 'POST',
body: JSON.stringify(target),
});
},
async removeListShare(listId: string, shareId: string): Promise<void> {
await request(`/api/v1/lists/${listId}/shares/${shareId}`, { method: 'DELETE' });
},
async createListItem(listId: string, content: string, category = '', quantity = ''): Promise<ListItem> { async createListItem(listId: string, content: string, category = '', quantity = ''): Promise<ListItem> {
return request<ListItem>(`/api/v1/lists/${listId}/items`, { return request<ListItem>(`/api/v1/lists/${listId}/items`, {
method: 'POST', 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 --- // --- ORGANIZATIONS & TEAMS ---
async getOrganizations(): Promise<Organization[]> { async getOrganizations(): Promise<Organization[]> {
const res = await request<Organization[]>('/api/v1/organizations'); const res = await request<Organization[]>('/api/v1/organizations');
@@ -905,11 +959,11 @@ let wsReconnectTimer: any = null;
let wsListeners: ((msg: WSMessage) => void)[] = []; let wsListeners: ((msg: WSMessage) => void)[] = [];
let manualDisconnect = false; let manualDisconnect = false;
export function buildWebSocketURL(spaceId?: string): string { export function buildWebSocketURL(listId?: string): string {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const token = getToken() || ''; const token = getToken() || '';
if (spaceId) { if (listId) {
return `${protocol}//${window.location.host}/api/v1/ws/spaces/${encodeURIComponent(spaceId)}?token=${encodeURIComponent(token)}`; 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)}`; return `${protocol}//${window.location.host}/api/v1/ws?token=${encodeURIComponent(token)}`;
} }
+20 -1
View File
@@ -60,6 +60,7 @@ export interface Thought {
notes?: string; notes?: string;
subtasks?: SubTask[]; subtasks?: SubTask[];
alarmSent?: boolean; alarmSent?: boolean;
sortOrder?: number;
createdAt: string; createdAt: string;
triagedAt?: string; triagedAt?: string;
} }
@@ -295,13 +296,30 @@ export interface ListItem {
completedAt?: string; completedAt?: string;
} }
export interface ListShare {
id: string;
listId: string;
targetType: 'team' | 'user';
targetId: string;
targetName?: string;
targetEmail?: string;
role: 'editor' | 'viewer';
createdBy?: string;
createdAt: string;
}
export interface SharedList { export interface SharedList {
id: string; id: string;
spaceId: string; userId?: string;
ownerName?: string;
isOwner?: boolean;
role?: string;
spaceId?: string;
title: string; title: string;
icon: string; icon: string;
listType: 'shopping' | 'checklist' | 'vacation'; listType: 'shopping' | 'checklist' | 'vacation';
items: ListItem[]; items: ListItem[];
shares?: ListShare[];
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
} }
@@ -331,6 +349,7 @@ export interface WSMessage<T = any> {
event: string; event: string;
space_id?: string; space_id?: string;
list_id?: string; list_id?: string;
listId?: string;
payload: T; payload: T;
timestamp: string; timestamp: string;
} }
+4
View File
@@ -1,10 +1,14 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import App from './App'; import App from './App';
import { MobileActionProvider } from './context/MobileActionContext';
import './index.css'; import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<MobileActionProvider>
<App /> <App />
</MobileActionProvider>
</React.StrictMode> </React.StrictMode>
); );
+173 -182
View File
@@ -63,6 +63,7 @@ func (s *Server) Router() http.Handler {
r.Post("/api/v1/webhooks/ingest", s.handleWebhookIngest) r.Post("/api/v1/webhooks/ingest", s.handleWebhookIngest)
r.Get("/api/v1/ws", s.handleWebSocket) r.Get("/api/v1/ws", s.handleWebSocket)
r.Get("/api/v1/ws/spaces/{spaceId}", s.handleWebSocket) r.Get("/api/v1/ws/spaces/{spaceId}", s.handleWebSocket)
r.Get("/api/v1/ws/lists/{listId}", s.handleWebSocket)
// Authenticated API Routes // Authenticated API Routes
r.Group(func(r chi.Router) { 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.Get("/api/v1/triage/deck", s.handleGetTriageDeck)
r.Patch("/api/v1/triage/{id}", s.handleTriageAction) r.Patch("/api/v1/triage/{id}", s.handleTriageAction)
r.Get("/api/v1/tasks", s.handleGetTasks) 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 // Task Lists & Shares
r.Get("/api/v1/task-lists", s.handleGetTaskLists) 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.Post("/api/v1/documents/{id}/lock/heartbeat", s.handleHeartbeatDocLock)
r.Delete("/api/v1/documents/{id}/lock", s.handleUnlockDoc) r.Delete("/api/v1/documents/{id}/lock", s.handleUnlockDoc)
// Shared Spaces & Realtime Lists // Shared Lists & Realtime Collaboration
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)
r.Get("/api/v1/lists", s.handleGetAllLists) 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.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.Post("/api/v1/lists/{id}/items", s.handleCreateListItem)
r.Patch("/api/v1/lists/{id}/items/{itemId}", s.handleToggleListItem) r.Patch("/api/v1/lists/{id}/items/{itemId}", s.handleToggleListItem)
r.Delete("/api/v1/lists/{id}/items/{itemId}", s.handleDeleteListItem) 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) r.Post("/api/v1/lists/{id}/clear-completed", s.handleClearCompletedItems)
// Organizations & Teams // 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 { if converted, convErr := s.store.UpdateThoughtTriage(userID, thought.ID, "keep", target, targetListID, dueDate, taskListID); convErr == nil && converted != nil {
thought = converted thought = converted
if target == "list_item" && targetListID != "" { if target == "list_item" && targetListID != "" {
if lst, lErr := s.store.GetListByID(targetListID); lErr == nil && lst != nil { if lst, lErr := s.store.GetListByID(userID, targetListID); lErr == nil && lst != nil {
s.wsHub.Broadcast(lst.SpaceID, lst.ID, "ITEM_CREATED", lst) 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 converted to a list item, broadcast via WebSocket to the space
if convertTo == "list_item" && targetListID != "" { if convertTo == "list_item" && targetListID != "" {
if lst, err := s.store.GetListByID(targetListID); err == nil && lst != nil { if lst, err := s.store.GetListByID(userID, targetListID); err == nil && lst != nil {
s.wsHub.Broadcast(lst.SpaceID, lst.ID, "ITEM_CREATED", lst) 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) 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) { func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context()) userID := auth.GetUserID(r.Context())
thoughtID := chi.URLParam(r, "id") 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}) writeJSON(w, http.StatusOK, map[string]bool{"unlocked": released})
} }
// --- SPACES & REALTIME LISTS --- // --- REALTIME LISTS & SHARING ---
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)
}
func (s *Server) handleGetAllLists(w http.ResponseWriter, r *http.Request) { func (s *Server) handleGetAllLists(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context()) userID := auth.GetUserID(r.Context())
@@ -1229,8 +1114,19 @@ func (s *Server) handleGetAllLists(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, lists) 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) { func (s *Server) handleCreateList(w http.ResponseWriter, r *http.Request) {
spaceID := chi.URLParam(r, "id") userID := auth.GetUserID(r.Context())
var req struct { var req struct {
Title string `json:"title"` Title string `json:"title"`
Icon string `json:"icon"` Icon string `json:"icon"`
@@ -1241,28 +1137,96 @@ func (s *Server) handleCreateList(w http.ResponseWriter, r *http.Request) {
return 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 { if err != nil {
writeError(w, http.StatusBadRequest, err.Error()) writeError(w, http.StatusBadRequest, err.Error())
return return
} }
s.wsHub.Broadcast(spaceID, lst.ID, "LIST_CREATED", lst) s.wsHub.BroadcastListEvent(lst.ID, "LIST_CREATED", lst)
writeJSON(w, http.StatusCreated, lst) writeJSON(w, http.StatusCreated, lst)
} }
func (s *Server) handleDeleteList(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDeleteList(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
listID := chi.URLParam(r, "id") listID := chi.URLParam(r, "id")
lst, _ := s.store.GetListByID(listID) if err := s.store.DeleteList(userID, listID); err != nil {
if err := s.store.DeleteList(listID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
} }
if lst != nil { s.wsHub.BroadcastListEvent(listID, "LIST_DELETED", map[string]string{"listId": listID})
s.wsHub.Broadcast(lst.SpaceID, listID, "LIST_DELETED", map[string]string{"listId": listID})
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) 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) { func (s *Server) handleCreateListItem(w http.ResponseWriter, r *http.Request) {
listID := chi.URLParam(r, "id") listID := chi.URLParam(r, "id")
var req struct { var req struct {
@@ -1281,9 +1245,7 @@ func (s *Server) handleCreateListItem(w http.ResponseWriter, r *http.Request) {
return return
} }
if lst, err := s.store.GetListByID(listID); err == nil && lst != nil { s.wsHub.BroadcastListEvent(listID, "ITEM_CREATED", item)
s.wsHub.Broadcast(lst.SpaceID, listID, "ITEM_CREATED", item)
}
writeJSON(w, http.StatusCreated, 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) user, _ := s.store.GetUserByID(userID)
userName := "Familie" userName := "Mitglied"
if user != nil { if user != nil {
userName = user.DisplayName userName = user.DisplayName
} }
@@ -1312,9 +1274,7 @@ func (s *Server) handleToggleListItem(w http.ResponseWriter, r *http.Request) {
return return
} }
if lst, err := s.store.GetListByID(listID); err == nil && lst != nil { s.wsHub.BroadcastListEvent(listID, "ITEM_TOGGLED", item)
s.wsHub.Broadcast(lst.SpaceID, listID, "ITEM_TOGGLED", item)
}
writeJSON(w, http.StatusOK, item) writeJSON(w, http.StatusOK, item)
} }
@@ -1328,12 +1288,10 @@ func (s *Server) handleDeleteListItem(w http.ResponseWriter, r *http.Request) {
return return
} }
if lst, err := s.store.GetListByID(listID); err == nil && lst != nil { s.wsHub.BroadcastListEvent(listID, "ITEM_DELETED", map[string]string{
s.wsHub.Broadcast(lst.SpaceID, listID, "ITEM_DELETED", map[string]string{
"itemId": itemID, "itemId": itemID,
"listId": listID, "listId": listID,
}) })
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
} }
@@ -1345,14 +1303,47 @@ func (s *Server) handleClearCompletedItems(w http.ResponseWriter, r *http.Reques
return return
} }
if lst, err := s.store.GetListByID(listID); err == nil && lst != nil { s.wsHub.BroadcastListEvent(listID, "LIST_CLEARED", map[string]interface{}{
s.wsHub.Broadcast(lst.SpaceID, listID, "LIST_CLEARED", lst) "listId": listID,
} "removed": removed,
})
writeJSON(w, http.StatusOK, map[string]int64{"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) { 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") token := r.URL.Query().Get("token")
if token == "" { if token == "" {
if authHdr := r.Header.Get("Authorization"); strings.HasPrefix(authHdr, "Bearer ") { 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 userID = claims.UserID
} }
} }
s.wsHub.ServeWS(w, r, spaceID, userID) s.wsHub.ServeWS(w, r, roomID, userID)
} }
// --- API KEYS & WEBHOOK INGESTION --- // --- API KEYS & WEBHOOK INGESTION ---
+39 -41
View File
@@ -117,7 +117,6 @@ func TestFirstBootSetupAndInviteRegistrationAndNonNullSlices(t *testing.T) {
"/api/v1/tasks", "/api/v1/tasks",
"/api/v1/organizations", "/api/v1/organizations",
"/api/v1/teams", "/api/v1/teams",
"/api/v1/spaces",
"/api/v1/lists", "/api/v1/lists",
} }
for _, ep := range arrayEndpoints { for _, ep := range arrayEndpoints {
@@ -190,7 +189,6 @@ func TestConcurrentInitialLoadAndListsNoDeadlock(t *testing.T) {
"/api/v1/auth/me", "/api/v1/auth/me",
"/api/v1/triage/deck", "/api/v1/triage/deck",
"/api/v1/lists", "/api/v1/lists",
"/api/v1/spaces",
"/api/v1/tasks", "/api/v1/tasks",
"/api/v1/documents", "/api/v1/documents",
} }
@@ -557,104 +555,104 @@ func TestSpaceMembersDirectInvitationAPI(t *testing.T) {
t.Fatalf("failed to generate token: %v", err) t.Fatalf("failed to generate token: %v", err)
} }
// 1. Admin creates a space // 1. Admin creates a list
createSpaceBody := []byte(`{"name":"Einkauf WG","icon":"🛒","description":"Gemeinsame Einkaufsliste"}`) createListBody := []byte(`{"title":"Einkauf WG","icon":"🛒","listType":"shopping"}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/spaces", bytes.NewReader(createSpaceBody)) req := httptest.NewRequest(http.MethodPost, "/api/v1/lists", bytes.NewReader(createListBody))
req.Header.Set("Authorization", "Bearer "+adminToken) req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
router.ServeHTTP(rec, req) router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated { 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 var createdList domain.SharedList
_ = json.Unmarshal(rec.Body.Bytes(), &createdSpace) _ = json.Unmarshal(rec.Body.Bytes(), &createdList)
if createdSpace.ID == "" { if createdList.ID == "" {
t.Fatalf("expected created space with ID, got %+v", createdSpace) t.Fatalf("expected created list with ID, got %+v", createdList)
} }
// 2. Admin invites normalUser by username "user" // 2. Admin shares list with normalUser by username "user"
addMemberBody := []byte(`{"userId":"user","role":"editor"}`) addShareBody := []byte(`{"targetType":"user","targetId":"user","role":"editor"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/spaces/"+createdSpace.ID+"/members", bytes.NewReader(addMemberBody)) req = httptest.NewRequest(http.MethodPost, "/api/v1/lists/"+createdList.ID+"/shares", bytes.NewReader(addShareBody))
req.Header.Set("Authorization", "Bearer "+adminToken) req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder() rec = httptest.NewRecorder()
router.ServeHTTP(rec, req) router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated { 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 var newShare domain.ListShare
_ = json.Unmarshal(rec.Body.Bytes(), &newMember) _ = json.Unmarshal(rec.Body.Bytes(), &newShare)
if newMember.UserID != normalUser.ID { if newShare.TargetID != normalUser.ID {
t.Fatalf("expected member userId %s, got %s", normalUser.ID, newMember.UserID) t.Fatalf("expected share targetId %s, got %s", normalUser.ID, newShare.TargetID)
} }
// 3. User checks their spaces; the shared space must be listed // 3. User checks their lists; the shared list must be listed
req = httptest.NewRequest(http.MethodGet, "/api/v1/spaces", nil) req = httptest.NewRequest(http.MethodGet, "/api/v1/lists", nil)
req.Header.Set("Authorization", "Bearer "+userToken) req.Header.Set("Authorization", "Bearer "+userToken)
rec = httptest.NewRecorder() rec = httptest.NewRecorder()
router.ServeHTTP(rec, req) router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK { 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 var userLists []domain.SharedList
_ = json.Unmarshal(rec.Body.Bytes(), &userSpaces) _ = json.Unmarshal(rec.Body.Bytes(), &userLists)
found := false found := false
for _, s := range userSpaces { for _, l := range userLists {
if s.ID == createdSpace.ID { if l.ID == createdList.ID {
found = true found = true
break break
} }
} }
if !found { 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 // 4. Admin removes user share from list
req = httptest.NewRequest(http.MethodDelete, "/api/v1/spaces/"+createdSpace.ID+"/members/"+normalUser.ID, nil) req = httptest.NewRequest(http.MethodDelete, "/api/v1/lists/"+createdList.ID+"/shares/"+newShare.ID, nil)
req.Header.Set("Authorization", "Bearer "+adminToken) req.Header.Set("Authorization", "Bearer "+adminToken)
rec = httptest.NewRecorder() rec = httptest.NewRecorder()
router.ServeHTTP(rec, req) router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK { 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") team, err := store.CreateTeam("", "WG Team", "Haushalt")
if err != nil { if err != nil {
t.Fatalf("failed to create team: %v", err) t.Fatalf("failed to create team: %v", err)
} }
_ = store.AddTeamMember(team.ID, normalUser.ID, "member") _ = store.AddTeamMember(team.ID, normalUser.ID, "member")
addTeamBody := []byte(`{"teamId":"` + team.ID + `","role":"editor"}`) addTeamBody := []byte(`{"targetType":"team","targetId":"` + team.ID + `","role":"editor"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/spaces/"+createdSpace.ID+"/members", bytes.NewReader(addTeamBody)) req = httptest.NewRequest(http.MethodPost, "/api/v1/lists/"+createdList.ID+"/shares", bytes.NewReader(addTeamBody))
req.Header.Set("Authorization", "Bearer "+adminToken) req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder() rec = httptest.NewRecorder()
router.ServeHTTP(rec, req) router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated && rec.Code != http.StatusOK { 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 // User should see the list again because their team was added
req = httptest.NewRequest(http.MethodGet, "/api/v1/spaces", nil) req = httptest.NewRequest(http.MethodGet, "/api/v1/lists", nil)
req.Header.Set("Authorization", "Bearer "+userToken) req.Header.Set("Authorization", "Bearer "+userToken)
rec = httptest.NewRecorder() rec = httptest.NewRecorder()
router.ServeHTTP(rec, req) router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK { 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 userLists = nil
_ = json.Unmarshal(rec.Body.Bytes(), &userSpaces) _ = json.Unmarshal(rec.Body.Bytes(), &userLists)
found = false found = false
for _, s := range userSpaces { for _, l := range userLists {
if s.ID == createdSpace.ID { if l.ID == createdList.ID {
found = true found = true
break break
} }
} }
if !found { 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)
} }
} }
+23 -29
View File
@@ -154,10 +154,24 @@ type Thought struct {
Notes string `json:"notes,omitempty"` Notes string `json:"notes,omitempty"`
Subtasks []SubTask `json:"subtasks"` Subtasks []SubTask `json:"subtasks"`
AlarmSent bool `json:"alarmSent,omitempty"` AlarmSent bool `json:"alarmSent,omitempty"`
SortOrder int `json:"sortOrder"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
TriagedAt string `json:"triagedAt,omitempty"` TriagedAt string `json:"triagedAt,omitempty"`
} }
type ThoughtReorderItem struct {
ID string `json:"id"`
SortOrder int `json:"sortOrder"`
KanbanStatus *string `json:"kanbanStatus,omitempty"`
}
type ListItemReorderItem struct {
ID string `json:"id"`
SortOrder int `json:"sortOrder"`
Category *string `json:"category,omitempty"`
Content *string `json:"content,omitempty"`
}
type TaskListShare struct { type TaskListShare struct {
ID string `json:"id"` ID string `json:"id"`
TaskListID string `json:"taskListId"` TaskListID string `json:"taskListId"`
@@ -324,40 +338,18 @@ type TagDetails struct {
TotalMatches int `json:"totalMatches"` TotalMatches int `json:"totalMatches"`
} }
type SpaceMember struct { type ListShare 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 {
ID string `json:"id"` ID string `json:"id"`
SpaceID string `json:"spaceId"` ListID string `json:"listId"`
TargetType string `json:"targetType"` // team | user TargetType string `json:"targetType"` // team | user
TargetID string `json:"targetId"` TargetID string `json:"targetId"`
TargetName string `json:"targetName,omitempty"` TargetName string `json:"targetName,omitempty"`
Email string `json:"email,omitempty"`
Role string `json:"role"` // editor | viewer Role string `json:"role"` // editor | viewer
CreatedBy string `json:"createdBy"` CreatedBy string `json:"createdBy"`
CreatedAt string `json:"createdAt"` 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 { type ListItem struct {
ID string `json:"id"` ID string `json:"id"`
ListID string `json:"listId"` ListID string `json:"listId"`
@@ -374,11 +366,15 @@ type ListItem struct {
type SharedList struct { type SharedList struct {
ID string `json:"id"` ID string `json:"id"`
SpaceID string `json:"spaceId"` UserID string `json:"userId"`
OwnerName string `json:"ownerName,omitempty"`
IsOwner bool `json:"isOwner"`
Role string `json:"role,omitempty"` // editor | viewer
Title string `json:"title"` Title string `json:"title"`
Icon string `json:"icon"` Icon string `json:"icon"`
ListType string `json:"listType"` // shopping | checklist | vacation ListType string `json:"listType"` // shopping | checklist | vacation
Items []ListItem `json:"items"` Items []ListItem `json:"items"`
Shares []ListShare `json:"shares,omitempty"`
UpdatedAt string `json:"updatedAt"` UpdatedAt string `json:"updatedAt"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
} }
@@ -397,8 +393,7 @@ type APIKey struct {
type WSMessage struct { type WSMessage struct {
Event string `json:"event"` // ITEM_CREATED | ITEM_UPDATED | ITEM_TOGGLED | ITEM_DELETED | LIST_CLEARED | USER_PRESENCE Event string `json:"event"` // ITEM_CREATED | ITEM_UPDATED | ITEM_TOGGLED | ITEM_DELETED | LIST_CLEARED | USER_PRESENCE
SpaceID string `json:"space_id"` ListID string `json:"list_id"`
ListID string `json:"list_id,omitempty"`
Payload interface{} `json:"payload"` Payload interface{} `json:"payload"`
Timestamp string `json:"timestamp"` Timestamp string `json:"timestamp"`
} }
@@ -419,7 +414,6 @@ type FullExport struct {
User User `json:"user"` User User `json:"user"`
Thoughts []Thought `json:"thoughts"` Thoughts []Thought `json:"thoughts"`
Documents []Document `json:"documents"` Documents []Document `json:"documents"`
Spaces []Space `json:"spaces"`
Lists []SharedList `json:"lists"` Lists []SharedList `json:"lists"`
TaskLists []TaskList `json:"taskLists,omitempty"` TaskLists []TaskList `json:"taskLists,omitempty"`
} }
+315 -427
View File
@@ -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 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 ( CREATE TABLE IF NOT EXISTS organizations (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
@@ -259,16 +237,32 @@ func (s *Store) migrate() error {
CREATE TABLE IF NOT EXISTS lists ( CREATE TABLE IF NOT EXISTS lists (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
space_id TEXT NOT NULL, user_id TEXT NOT NULL,
title TEXT NOT NULL, title TEXT NOT NULL,
icon TEXT NOT NULL DEFAULT '🛒', icon TEXT NOT NULL DEFAULT '🛒',
list_type TEXT NOT NULL DEFAULT 'shopping', list_type TEXT NOT NULL DEFAULT 'shopping',
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
created_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 ( CREATE TABLE IF NOT EXISTS list_items (
id TEXT PRIMARY KEY, 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 notes TEXT NOT NULL DEFAULT '';",
"ALTER TABLE thoughts ADD COLUMN alarm_sent INTEGER NOT NULL DEFAULT 0;", "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 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 '';", "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_team ON doc_team_shares(team_id);",
"CREATE INDEX IF NOT EXISTS idx_doc_team_shares_doc ON doc_team_shares(doc_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));", "ALTER TABLE lists ADD COLUMN user_id TEXT NOT NULL DEFAULT '';",
"CREATE INDEX IF NOT EXISTS idx_space_team_shares_team ON space_team_shares(team_id);", "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_space_team_shares_space ON space_team_shares(space_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 { for _, q := range columnMigrations {
_, _ = s.db.Exec(q) _, _ = 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 // 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`) _, _ = 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,14 +444,11 @@ func (s *Store) SeedStarterContent(user *domain.User) error {
_, _ = s.UpdateThoughtTriage(user.ID, keptTask.ID, "keep", "task", "", now[:10], "") _, _ = 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) shopList, err := s.CreateList(user.ID, "Wocheneinkauf Supermarkt", "🛒", "shopping")
if err == nil && familySpace != nil {
shopList, err := s.CreateList(familySpace.ID, "Wocheneinkauf Supermarkt", "🛒", "shopping")
if err == nil && shopList != nil { if err == nil && shopList != nil {
_, _ = s.CreateListItem(shopList.ID, "Hafermilch (Barista Edition)", "Kühlregal", "3x", 0) _, _ = s.CreateListItem(shopList.ID, "Hafermilch (Barista Edition)", "Kühlregal", "3x", 0)
_, _ = s.CreateListItem(shopList.ID, "Espressobohnen (100% Arabica)", "Kaffee & Tee", "1 kg", 1) _, _ = s.CreateListItem(shopList.ID, "Espressobohnen (100% Arabica)", "Kaffee & Tee", "1 kg", 1)
} }
}
starterBlocks := []domain.DocumentBlock{ starterBlocks := []domain.DocumentBlock{
{ID: "blk_1", Type: "callout", Content: "Willkommen in deinem MindSift Workspace! Hier wachsen flüchtige Gedanken aus deiner Inbox zu strukturierten Projekten heran."}, {ID: "blk_1", Type: "callout", Content: "Willkommen in deinem MindSift Workspace! Hier wachsen flüchtige Gedanken aus deiner Inbox zu strukturierten Projekten heran."},
@@ -692,12 +691,8 @@ func (s *Store) GetDiscoverableUsers(viewerID string) ([]domain.User, error) {
SELECT tm2.user_id FROM team_members tm1 SELECT tm2.user_id FROM team_members tm1
JOIN team_members tm2 ON tm1.team_id = tm2.team_id JOIN team_members tm2 ON tm1.team_id = tm2.team_id
WHERE tm1.user_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 { if err != nil {
return []domain.User{*viewer}, nil return []domain.User{*viewer}, nil
@@ -842,16 +837,17 @@ func scanThought(rows interface {
}) (*domain.Thought, error) { }) (*domain.Thought, error) {
t := &domain.Thought{} t := &domain.Thought{}
var tagsStr, subtasksStr, statusStr string var tagsStr, subtasksStr, statusStr string
var isCompleted, alarmSent int var isCompleted, alarmSent, sortOrder int
err := rows.Scan( err := rows.Scan(
&t.ID, &t.UserID, &t.Content, &tagsStr, &t.Priority, &t.Source, &t.ID, &t.UserID, &t.Content, &tagsStr, &t.Priority, &t.Source,
&statusStr, &isCompleted, &t.ConvertedType, &t.ConvertedRefID, &statusStr, &isCompleted, &t.ConvertedType, &t.ConvertedRefID,
&t.DueDate, &t.TaskListID, &t.KanbanStatus, &t.Notes, &alarmSent, &t.DueDate, &t.TaskListID, &t.KanbanStatus, &t.Notes, &alarmSent,
&t.CreatedAt, &t.TriagedAt, &subtasksStr, &t.CreatedAt, &t.TriagedAt, &subtasksStr, &sortOrder,
) )
if err != nil { if err != nil {
return nil, err return nil, err
} }
t.SortOrder = sortOrder
t.Status = domain.ThoughtStatus(statusStr) t.Status = domain.ThoughtStatus(statusStr)
t.IsCompleted = isCompleted != 0 t.IsCompleted = isCompleted != 0
t.AlarmSent = alarmSent != 0 t.AlarmSent = alarmSent != 0
@@ -879,15 +875,14 @@ func (s *Store) accessibleTaskListIDsQuery() string {
SELECT task_list_id FROM task_list_shares SELECT task_list_id FROM task_list_shares
WHERE (target_type = 'user' AND target_id = ?) 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 (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) { func (s *Store) GetThoughtByID(userID, thoughtID string) (*domain.Thought, error) {
row := s.db.QueryRow( 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()+`))`, 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) return scanThought(row)
} }
@@ -901,16 +896,16 @@ func (s *Store) GetThoughts(userID, statusFilter string, limit int) ([]domain.Th
if statusFilter != "" && statusFilter != "all" { if statusFilter != "" && statusFilter != "all" {
rows, err = s.db.Query( 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 = ? 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, userID, statusFilter, limit,
) )
} else { } else {
rows, err = s.db.Query( 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 = ? FROM thoughts WHERE user_id = ?
ORDER BY created_at DESC LIMIT ?`, ORDER BY sort_order ASC, created_at DESC LIMIT ?`,
userID, limit, userID, limit,
) )
} }
@@ -934,22 +929,22 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er
var err error var err error
if taskListID != "" { if taskListID != "" {
rows, err = s.db.Query( 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 FROM thoughts
WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`)) WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
AND task_list_id = ? AND task_list_id = ?
AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task')) 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`, ORDER BY is_completed ASC, sort_order ASC, created_at DESC`,
userID, userID, userID, userID, userID, userID, taskListID, userID, userID, userID, userID, userID, taskListID,
) )
} else { } else {
rows, err = s.db.Query( 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 FROM thoughts
WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`)) WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task')) 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`, ORDER BY is_completed ASC, sort_order ASC, created_at DESC`,
userID, userID, userID, userID, userID, userID, userID, userID, userID, userID, userID,
) )
} }
if err != nil { if err != nil {
@@ -967,6 +962,37 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er
return result, nil 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 // 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) { func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, targetListID, dueDate, taskListID string) (*domain.Thought, error) {
thought, err := s.GetThoughtByID(userID, thoughtID) thought, err := s.GetThoughtByID(userID, thoughtID)
@@ -2190,339 +2216,9 @@ func (s *Store) GetCollaborators(currentUserID string) ([]domain.User, error) {
return users, nil return users, nil
} }
// --- SPACES & REALTIME LISTS --- // --- STANDALONE REALTIME LISTS & SHARING ---
func (s *Store) CreateSpace(ownerID, name, icon, description string, isPersonal bool) (*domain.Space, error) { func (s *Store) CreateList(userID, title, icon, listType string) (*domain.SharedList, 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) {
if strings.TrimSpace(title) == "" { if strings.TrimSpace(title) == "" {
return nil, errors.New("list title required") 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() now := domain.NowISO()
lst := &domain.SharedList{ lst := &domain.SharedList{
ID: domain.NewID("lst"), ID: domain.NewID("lst"),
SpaceID: spaceID, UserID: userID,
IsOwner: true,
Role: "editor",
Title: strings.TrimSpace(title), Title: strings.TrimSpace(title),
Icon: icon, Icon: icon,
ListType: listType, ListType: listType,
Items: []domain.ListItem{}, Items: []domain.ListItem{},
Shares: []domain.ListShare{},
UpdatedAt: now, UpdatedAt: now,
CreatedAt: now, CreatedAt: now,
} }
_, err := s.db.Exec( _, err := s.db.Exec(
`INSERT INTO lists (id, space_id, title, icon, list_type, updated_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, `INSERT INTO lists (id, user_id, title, icon, list_type, updated_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
lst.ID, lst.SpaceID, lst.Title, lst.Icon, lst.ListType, lst.UpdatedAt, lst.CreatedAt, lst.ID, lst.UserID, lst.Title, lst.Icon, lst.ListType, lst.UpdatedAt, lst.CreatedAt,
) )
if err != nil { if err != nil {
return nil, err return nil, err
} }
_ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, userID).Scan(&lst.OwnerName)
return lst, nil return lst, nil
} }
func (s *Store) GetListsForSpace(spaceID string) ([]domain.SharedList, error) { func (s *Store) AddListShare(currentUserID, listID, targetType, targetID, role string) (*domain.ListShare, error) {
rows, err := s.db.Query( if targetType != "team" && targetType != "user" {
`SELECT id, space_id, title, icon, list_type, updated_at, created_at return nil, errors.New("ungültiger Freigabetyp")
FROM lists WHERE space_id = ? ORDER BY created_at ASC`, }
spaceID, 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 { if err != nil {
return nil, err 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 = ?`,
for rows.Next() { listID, targetType, targetID).Scan(&share.ID, &share.CreatedAt)
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)
}
}
_ = rows.Close()
for i := range lists { if targetType == "team" {
lists[i].Items, _ = s.GetListItems(lists[i].ID) _ = 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 lists, nil 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 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)
}
}
return shares, nil
} }
func (s *Store) GetAllListsForUser(userID string) ([]domain.SharedList, error) { func (s *Store) GetAllListsForUser(userID string) ([]domain.SharedList, error) {
rows, err := s.db.Query( 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 FROM lists l
LEFT JOIN space_members sm ON sm.space_id = l.space_id AND sm.user_id = ? LEFT JOIN users u ON u.id = l.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 = ?) LEFT JOIN list_shares ls ON ls.list_id = l.id AND (
WHERE sm.user_id = ? OR sts.team_id IS NOT NULL (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`, ORDER BY l.updated_at DESC`,
userID, userID, userID, userID, userID, userID, userID,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@@ -2596,7 +2400,12 @@ func (s *Store) GetAllListsForUser(userID string) ([]domain.SharedList, error) {
lists := make([]domain.SharedList, 0) lists := make([]domain.SharedList, 0)
for rows.Next() { for rows.Next() {
var l domain.SharedList 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) lists = append(lists, l)
} }
} }
@@ -2604,25 +2413,68 @@ func (s *Store) GetAllListsForUser(userID string) ([]domain.SharedList, error) {
for i := range lists { for i := range lists {
lists[i].Items, _ = s.GetListItems(lists[i].ID) lists[i].Items, _ = s.GetListItems(lists[i].ID)
lists[i].Shares, _ = s.GetListShares(lists[i].ID)
} }
return lists, nil 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 var l domain.SharedList
err := s.db.QueryRow( var ownerName sql.NullString
`SELECT id, space_id, title, icon, list_type, updated_at, created_at FROM lists WHERE id = ?`, var effectiveRole string
listID,
).Scan(&l.ID, &l.SpaceID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt) 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 { if err != nil {
return nil, err return nil, err
} }
l.OwnerName = ownerName.String
l.IsOwner = (userID == "" || l.UserID == userID)
l.Role = effectiveRole
l.Items, _ = s.GetListItems(l.ID) l.Items, _ = s.GetListItems(l.ID)
l.Shares, _ = s.GetListShares(l.ID)
return &l, nil return &l, nil
} }
func (s *Store) DeleteList(listID string) error { func (s *Store) DeleteList(userID, listID string) error {
_, err := s.db.Exec(`DELETE FROM lists WHERE id = ?`, listID) 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 return err
} }
@@ -2751,6 +2603,44 @@ func (s *Store) ClearCompletedListItems(listID string) (int64, error) {
return res.RowsAffected() 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 --- // --- API KEYS & WEBHOOKS ---
func (s *Store) CreateAPIKey(userID, name string) (*domain.APIKey, error) { 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(` _ = s.db.QueryRow(`
SELECT COUNT(DISTINCT l.id) SELECT COUNT(DISTINCT l.id)
FROM lists l FROM lists l
LEFT JOIN space_members sm ON sm.space_id = l.space_id AND sm.user_id = ? LEFT JOIN list_shares ls ON ls.list_id = l.id AND (
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 = ?) (ls.target_type = 'user' AND ls.target_id = ?)
WHERE sm.user_id = ? OR sts.team_id IS NOT NULL`, 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) userID, userID, userID).Scan(&st.ListsCount)
// Count open (uncompleted) To-Dos across accessible workspace documents (owned or shared) // 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)) + "%" q := "%" + strings.ToLower(strings.TrimSpace(query)) + "%"
tRows, err := s.db.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`, FROM thoughts WHERE user_id = ? AND LOWER(content) LIKE ? ORDER BY created_at DESC LIMIT 30`,
userID, q, userID, q,
) )
@@ -2908,7 +2800,6 @@ func (s *Store) ExportUserData(userID string) (*domain.FullExport, error) {
} }
thoughts, _ := s.GetThoughts(userID, "all", 5000) thoughts, _ := s.GetThoughts(userID, "all", 5000)
docs, _ := s.GetDocuments(userID) docs, _ := s.GetDocuments(userID)
spaces, _ := s.GetSpacesForUser(userID)
lists, _ := s.GetAllListsForUser(userID) lists, _ := s.GetAllListsForUser(userID)
taskLists, _ := s.GetTaskLists(userID) taskLists, _ := s.GetTaskLists(userID)
@@ -2918,7 +2809,6 @@ func (s *Store) ExportUserData(userID string) (*domain.FullExport, error) {
User: *user, User: *user,
Thoughts: thoughts, Thoughts: thoughts,
Documents: docs, Documents: docs,
Spaces: spaces,
Lists: lists, Lists: lists,
TaskLists: taskLists, TaskLists: taskLists,
}, nil }, nil
@@ -3127,9 +3017,8 @@ func (s *Store) GetTaskLists(userID string) ([]domain.TaskList, error) {
WHERE (target_type = 'user' AND target_id = ?) 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 (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`, ORDER BY updated_at DESC`,
userID, userID, userID, userID, userID, userID, userID, userID, userID,
) )
if err != nil { if err != nil {
return []domain.TaskList{}, err 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 = ?) 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 (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) ).Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.TeamID, &tl.Title, &tl.Icon, &tl.Color, &colsJSON, &tl.CreatedAt, &tl.UpdatedAt)
if err != nil { if err != nil {
return nil, err return nil, err
+17 -21
View File
@@ -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) t.Fatalf("expected converted document, got status=%s type=%s ref=%s", triaged.Status, triaged.ConvertedType, triaged.ConvertedRefID)
} }
// 3. Test Shared Space & Shopping List // 3. Test Standalone Shopping List & Sharing
sp, err := store.CreateSpace(user.ID, "Test WG", "🍕", "Test Space", false) lst, err := store.CreateList(user.ID, "REWE Einkauf", "🛒", "shopping")
if err != nil {
t.Fatalf("CreateSpace failed: %v", err)
}
lst, err := store.CreateList(sp.ID, "REWE Einkauf", "🛒", "shopping")
if err != nil { if err != nil {
t.Fatalf("CreateList failed: %v", err) t.Fatalf("CreateList failed: %v", err)
} }
@@ -66,26 +62,26 @@ func TestStoreLifecycle(t *testing.T) {
t.Fatalf("ToggleListItem failed: %v", err) t.Fatalf("ToggleListItem failed: %v", err)
} }
// Test AddSpaceMember & RemoveSpaceMember // Test AddListShare & RemoveListShare
u2, err := store.CreateUser("collab_space@example.com", "hash", "Collab User", "member") u2, err := store.CreateUser("collab_space@example.com", "hash", "Collab User", "member")
if err != nil { if err != nil {
t.Fatalf("CreateUser u2 failed: %v", err) t.Fatalf("CreateUser u2 failed: %v", err)
} }
member, err := store.AddSpaceMember(user.ID, sp.ID, u2.ID, "editor") share, err := store.AddListShare(user.ID, lst.ID, "user", u2.ID, "editor")
if err != nil || member.UserID != u2.ID { if err != nil || share.TargetID != u2.ID {
t.Fatalf("AddSpaceMember failed: %v", err) t.Fatalf("AddListShare failed: %v", err)
} }
members, err := store.GetSpaceMembers(sp.ID) shares, err := store.GetListShares(lst.ID)
if err != nil || len(members) != 2 { if err != nil || len(shares) != 1 {
t.Fatalf("expected 2 members, got %d", len(members)) 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 { if err != nil {
t.Fatalf("RemoveSpaceMember failed: %v", err) t.Fatalf("RemoveListShare failed: %v", err)
} }
membersAfter, _ := store.GetSpaceMembers(sp.ID) sharesAfter, _ := store.GetListShares(lst.ID)
if len(membersAfter) != 1 { if len(sharesAfter) != 0 {
t.Fatalf("expected 1 member after remove, got %d", len(membersAfter)) t.Fatalf("expected 0 shares after remove, got %d", len(sharesAfter))
} }
// 4. Test Webhook API Key generation & validation // 4. Test Webhook API Key generation & validation
@@ -99,7 +95,7 @@ func TestStoreLifecycle(t *testing.T) {
} }
// 5. Test TaskLists CRUD // 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 == "" { if err != nil || taskList.ID == "" {
t.Fatalf("CreateTaskList failed: %v", err) t.Fatalf("CreateTaskList failed: %v", err)
} }
@@ -160,8 +156,8 @@ func TestStoreLifecycle(t *testing.T) {
} }
// Share with user2 as "read" // Share with user2 as "read"
share, err := store.CreateDocShare(user.ID, newDoc.ID, user2.ID, "read") docShare, err := store.CreateDocShare(user.ID, newDoc.ID, user2.ID, "read")
if err != nil || share.Permission != "read" { if err != nil || docShare.Permission != "read" {
t.Fatalf("CreateDocShare failed: %v", err) t.Fatalf("CreateDocShare failed: %v", err)
} }
+24 -18
View File
@@ -22,7 +22,7 @@ var upgrader = websocket.Upgrader{
type Client struct { type Client struct {
hub *Hub hub *Hub
conn *websocket.Conn conn *websocket.Conn
spaceID string roomID string
userID string userID string
send chan []byte send chan []byte
} }
@@ -37,7 +37,7 @@ type docLockItem struct {
type Hub struct { type Hub struct {
mu sync.RWMutex mu sync.RWMutex
spaceRooms map[string]map[*Client]bool rooms map[string]map[*Client]bool
userRooms map[string]map[*Client]bool userRooms map[string]map[*Client]bool
allClients map[*Client]bool allClients map[*Client]bool
locks map[string]*docLockItem locks map[string]*docLockItem
@@ -45,7 +45,7 @@ type Hub struct {
func NewHub() *Hub { func NewHub() *Hub {
h := &Hub{ h := &Hub{
spaceRooms: make(map[string]map[*Client]bool), rooms: make(map[string]map[*Client]bool),
userRooms: make(map[string]map[*Client]bool), userRooms: make(map[string]map[*Client]bool),
allClients: make(map[*Client]bool), allClients: make(map[*Client]bool),
locks: make(map[string]*docLockItem), locks: make(map[string]*docLockItem),
@@ -57,11 +57,11 @@ func NewHub() *Hub {
func (h *Hub) Register(c *Client) { func (h *Hub) Register(c *Client) {
h.mu.Lock() h.mu.Lock()
h.allClients[c] = true h.allClients[c] = true
if c.spaceID != "" { if c.roomID != "" {
if h.spaceRooms[c.spaceID] == nil { if h.rooms[c.roomID] == nil {
h.spaceRooms[c.spaceID] = make(map[*Client]bool) 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 c.userID != "" && c.userID != "anonymous" {
if h.userRooms[c.userID] == nil { if h.userRooms[c.userID] == nil {
@@ -72,8 +72,8 @@ func (h *Hub) Register(c *Client) {
onlineCount := len(h.allClients) onlineCount := len(h.allClients)
h.mu.Unlock() h.mu.Unlock()
if c.spaceID != "" { if c.roomID != "" {
h.Broadcast(c.spaceID, "", "USER_PRESENCE", map[string]interface{}{ h.Broadcast(c.roomID, "", "USER_PRESENCE", map[string]interface{}{
"onlineCount": onlineCount, "onlineCount": onlineCount,
}) })
} }
@@ -82,11 +82,11 @@ func (h *Hub) Register(c *Client) {
func (h *Hub) Unregister(c *Client) { func (h *Hub) Unregister(c *Client) {
h.mu.Lock() h.mu.Lock()
delete(h.allClients, c) delete(h.allClients, c)
if c.spaceID != "" { if c.roomID != "" {
if room, ok := h.spaceRooms[c.spaceID]; ok { if room, ok := h.rooms[c.roomID]; ok {
delete(room, c) delete(room, c)
if len(room) == 0 { 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{}) { func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) {
msg := domain.WSMessage{ msg := domain.WSMessage{
Event: event, Event: event,
SpaceID: spaceID,
ListID: listID, ListID: listID,
Payload: payload, Payload: payload,
Timestamp: domain.NowISO(), Timestamp: domain.NowISO(),
@@ -310,10 +309,12 @@ func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) {
h.mu.RLock() h.mu.RLock()
defer h.mu.RUnlock() defer h.mu.RUnlock()
room, ok := h.spaceRooms[spaceID] targetRoom := listID
if !ok { if targetRoom == "" {
return targetRoom = spaceID
} }
if room, ok := h.rooms[targetRoom]; ok {
for client := range room { for client := range room {
select { select {
case client.send <- data: case client.send <- data:
@@ -322,8 +323,13 @@ func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) {
} }
} }
} }
}
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) conn, err := upgrader.Upgrade(w, r, nil)
if err != nil { if err != nil {
log.Printf("[ws] upgrade error: %v", err) 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{ client := &Client{
hub: h, hub: h,
conn: conn, conn: conn,
spaceID: spaceID, roomID: roomID,
userID: userID, userID: userID,
send: make(chan []byte, 128), send: make(chan []byte, 128),
} }
+2
View File
@@ -24,6 +24,7 @@ CREATE TABLE IF NOT EXISTS thoughts (
converted_type TEXT DEFAULT '', converted_type TEXT DEFAULT '',
converted_ref_id TEXT DEFAULT '', converted_ref_id TEXT DEFAULT '',
due_date TEXT DEFAULT '', due_date TEXT DEFAULT '',
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
triaged_at TEXT DEFAULT '', triaged_at TEXT DEFAULT '',
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
@@ -31,6 +32,7 @@ CREATE TABLE IF NOT EXISTS thoughts (
CREATE INDEX IF NOT EXISTS idx_thoughts_user_status ON thoughts(user_id, status, created_at DESC); CREATE INDEX IF NOT EXISTS idx_thoughts_user_status ON thoughts(user_id, status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_thoughts_user_due ON thoughts(user_id, due_date); CREATE INDEX IF NOT EXISTS idx_thoughts_user_due ON thoughts(user_id, due_date);
CREATE INDEX IF NOT EXISTS idx_thoughts_list_sort ON thoughts(task_list_id, sort_order ASC, created_at DESC);
CREATE TABLE IF NOT EXISTS spaces ( CREATE TABLE IF NOT EXISTS spaces (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB