Dev #4
No files matched your search
@@ -8,86 +8,149 @@
|
|||||||
[](#)
|
[](#)
|
||||||
[](LICENSE)
|
[](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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+137
-9
@@ -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>
|
||||||
<span className="font-bold text-base text-main tracking-tight">
|
<div className="flex items-center gap-1.5">
|
||||||
{authStatus.instanceName || 'MindSift'}
|
<span className="font-bold text-base text-main tracking-tight">
|
||||||
</span>
|
{authStatus.instanceName || 'MindSift'}
|
||||||
|
</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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) => {
|
||||||
<div
|
const isDragging = draggingTaskId === task.id;
|
||||||
key={task.id}
|
const isTarget = dragOverTaskTarget?.taskId === task.id;
|
||||||
onClick={() => {
|
|
||||||
setDetailTaskId(task.id);
|
return (
|
||||||
setShowDetailModal(true);
|
<div
|
||||||
}}
|
key={task.id}
|
||||||
className="panel-surface p-2.5 space-y-2 hover:border-strong transition-all cursor-pointer shadow-sm group"
|
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={() => {
|
||||||
|
setDetailTaskId(task.id);
|
||||||
|
setShowDetailModal(true);
|
||||||
|
}}
|
||||||
|
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>
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
isDraggingBlockRef.current = false;
|
||||||
|
setDraggingBlockId(null);
|
||||||
|
setDragOverBlockTarget(null);
|
||||||
|
if (idleUnlockTimerRef.current) {
|
||||||
|
clearTimeout(idleUnlockTimerRef.current);
|
||||||
|
}
|
||||||
|
idleUnlockTimerRef.current = setTimeout(async () => {
|
||||||
|
if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) {
|
||||||
|
await flushPendingSave();
|
||||||
|
await releaseMyLock();
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlockDragOver = (e: React.DragEvent, targetBlockId: string) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const sourceId = e.dataTransfer.getData('text/plain') || draggingBlockId;
|
e.stopPropagation();
|
||||||
|
if (!draggingBlockId || draggingBlockId === targetBlockId) return;
|
||||||
|
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const relY = e.clientY - rect.top;
|
||||||
|
const height = rect.height;
|
||||||
|
|
||||||
|
let position: 'before' | 'after' | 'inside';
|
||||||
|
if (relY < height * 0.25) {
|
||||||
|
position = 'before';
|
||||||
|
} else if (relY > height * 0.75) {
|
||||||
|
position = 'after';
|
||||||
|
} else {
|
||||||
|
position = 'inside';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!dragOverBlockTarget ||
|
||||||
|
dragOverBlockTarget.blockId !== targetBlockId ||
|
||||||
|
dragOverBlockTarget.position !== position
|
||||||
|
) {
|
||||||
|
setDragOverBlockTarget({ blockId: targetBlockId, position });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlockDragLeave = (e: React.DragEvent, targetBlockId: string) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (dragOverBlockTarget?.blockId === targetBlockId) {
|
||||||
|
setDragOverBlockTarget(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlockDrop = (e: React.DragEvent, targetBlockId: string, explicitPosition?: 'before' | 'after' | 'inside') => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const sourceId =
|
||||||
|
e.dataTransfer.getData('application/mindsift-block-id') ||
|
||||||
|
e.dataTransfer.getData('text/plain') ||
|
||||||
|
draggingBlockId;
|
||||||
|
|
||||||
|
const position = explicitPosition || dragOverBlockTarget?.position || 'after';
|
||||||
|
isDraggingBlockRef.current = false;
|
||||||
|
setDraggingBlockId(null);
|
||||||
|
setDragOverBlockTarget(null);
|
||||||
|
|
||||||
if (!selectedDoc || !sourceId || sourceId === targetBlockId) {
|
if (!selectedDoc || !sourceId || sourceId === targetBlockId) {
|
||||||
setDraggingBlockId(null);
|
|
||||||
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
@@ -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)}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
<App />
|
<MobileActionProvider>
|
||||||
|
<App />
|
||||||
|
</MobileActionProvider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|
||||||
+176
-185
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+30
-36
@@ -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"`
|
||||||
@@ -373,14 +365,18 @@ 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"`
|
||||||
Title string `json:"title"`
|
OwnerName string `json:"ownerName,omitempty"`
|
||||||
Icon string `json:"icon"`
|
IsOwner bool `json:"isOwner"`
|
||||||
ListType string `json:"listType"` // shopping | checklist | vacation
|
Role string `json:"role,omitempty"` // editor | viewer
|
||||||
Items []ListItem `json:"items"`
|
Title string `json:"title"`
|
||||||
UpdatedAt string `json:"updatedAt"`
|
Icon string `json:"icon"`
|
||||||
CreatedAt string `json:"createdAt"`
|
ListType string `json:"listType"` // shopping | checklist | vacation
|
||||||
|
Items []ListItem `json:"items"`
|
||||||
|
Shares []ListShare `json:"shares,omitempty"`
|
||||||
|
UpdatedAt string `json:"updatedAt"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type APIKey struct {
|
type APIKey struct {
|
||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
+318
-430
@@ -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,13 +444,10 @@ 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 {
|
if err == nil && shopList != nil {
|
||||||
shopList, err := s.CreateList(familySpace.ID, "Wocheneinkauf Supermarkt", "🛒", "shopping")
|
_, _ = s.CreateListItem(shopList.ID, "Hafermilch (Barista Edition)", "Kühlregal", "3x", 0)
|
||||||
if err == nil && shopList != nil {
|
_, _ = s.CreateListItem(shopList.ID, "Espressobohnen (100% Arabica)", "Kaffee & Tee", "1 kg", 1)
|
||||||
_, _ = s.CreateListItem(shopList.ID, "Hafermilch (Barista Edition)", "Kühlregal", "3x", 0)
|
|
||||||
_, _ = s.CreateListItem(shopList.ID, "Espressobohnen (100% Arabica)", "Kaffee & Tee", "1 kg", 1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
starterBlocks := []domain.DocumentBlock{
|
starterBlocks := []domain.DocumentBlock{
|
||||||
@@ -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
|
||||||
@@ -873,21 +869,20 @@ func scanThought(rows interface {
|
|||||||
|
|
||||||
func (s *Store) accessibleTaskListIDsQuery() string {
|
func (s *Store) accessibleTaskListIDsQuery() string {
|
||||||
return `SELECT id FROM task_lists
|
return `SELECT id FROM task_lists
|
||||||
WHERE user_id = ?
|
WHERE user_id = ?
|
||||||
OR (team_id != '' AND team_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
OR (team_id != '' AND team_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
||||||
OR id IN (
|
OR id IN (
|
||||||
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 = ?`,
|
||||||
|
listID, targetType, targetID).Scan(&share.ID, &share.CreatedAt)
|
||||||
|
|
||||||
|
if targetType == "team" {
|
||||||
|
_ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, targetID).Scan(&share.TargetName)
|
||||||
|
} else {
|
||||||
|
_ = s.db.QueryRow(`SELECT display_name, email FROM users WHERE id = ?`, targetID).Scan(&share.TargetName, &share.Email)
|
||||||
|
}
|
||||||
|
return share, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) RemoveListShare(currentUserID, listID, shareIDOrTargetID string) error {
|
||||||
|
var ownerID string
|
||||||
|
err := s.db.QueryRow(`SELECT user_id FROM lists WHERE id = ?`, listID).Scan(&ownerID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("liste nicht gefunden")
|
||||||
|
}
|
||||||
|
if ownerID != currentUserID {
|
||||||
|
return errors.New("nur der Eigentümer kann Freigaben entfernen")
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanTarget := strings.TrimSpace(shareIDOrTargetID)
|
||||||
|
_, err = s.db.Exec(
|
||||||
|
`DELETE FROM list_shares WHERE list_id = ? AND (id = ? OR target_id = ?)`,
|
||||||
|
listID, cleanTarget, cleanTarget,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetListShares(listID string) ([]domain.ListShare, error) {
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
`SELECT id, list_id, target_type, target_id, role, created_by, created_at
|
||||||
|
FROM list_shares WHERE list_id = ? ORDER BY created_at ASC`,
|
||||||
|
listID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return []domain.ListShare{}, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
shares := make([]domain.ListShare, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var l domain.SharedList
|
var sh domain.ListShare
|
||||||
if err := rows.Scan(&l.ID, &l.SpaceID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt); err == nil {
|
if err := rows.Scan(&sh.ID, &sh.ListID, &sh.TargetType, &sh.TargetID, &sh.Role, &sh.CreatedBy, &sh.CreatedAt); err == nil {
|
||||||
lists = append(lists, l)
|
if sh.TargetType == "team" {
|
||||||
|
_ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, sh.TargetID).Scan(&sh.TargetName)
|
||||||
|
} else {
|
||||||
|
_ = s.db.QueryRow(`SELECT display_name, email FROM users WHERE id = ?`, sh.TargetID).Scan(&sh.TargetName, &sh.Email)
|
||||||
|
}
|
||||||
|
shares = append(shares, sh)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = rows.Close()
|
return shares, nil
|
||||||
|
|
||||||
for i := range lists {
|
|
||||||
lists[i].Items, _ = s.GetListItems(lists[i].ID)
|
|
||||||
}
|
|
||||||
return lists, 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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+29
-23
@@ -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,20 +309,27 @@ 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
|
||||||
}
|
}
|
||||||
for client := range room {
|
|
||||||
select {
|
if room, ok := h.rooms[targetRoom]; ok {
|
||||||
case client.send <- data:
|
for client := range room {
|
||||||
default:
|
select {
|
||||||
// Slow client buffer full
|
case client.send <- data:
|
||||||
|
default:
|
||||||
|
// Slow client buffer full
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request, spaceID, userID string) {
|
func (h *Hub) BroadcastListEvent(listID, event string, payload interface{}) {
|
||||||
|
h.Broadcast("", listID, event, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request, roomID, userID string) {
|
||||||
conn, err := upgrader.Upgrade(w, r, nil)
|
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),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 |
Reference in new issue
Block a user