Dev #4
No files matched your search
@@ -8,86 +8,149 @@
|
||||
[](#)
|
||||
[](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)
|
||||
|
||||
---
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 💡 Die Philosophie: *Erst raus aus dem Kopf – später sortieren*
|
||||
|
||||
Klassische Produktivitäts- und Notiz-Apps scheitern im Alltag oft an derselben Hürde: **Sie verlangen Struktur im falschen Moment.**
|
||||
Wenn dir mitten im Gespräch, unterwegs oder kurz vor Feierabend ein wichtiger Gedanke kommt, willst du keinen Ordner auswählen, keine Datenbank-Eigenschaften pflegen und kein Formular ausfüllen. Du willst den Gedanken in drei Sekunden loswerden – und sicher sein, dass er nicht verloren geht.
|
||||
Wenn dir mitten im Gespräch, unterwegs auf dem Smartphone oder kurz vor Feierabend ein wichtiger Gedanke kommt, willst du keinen Ordner suchen, keine Datenbankfelder ausfüllen und keine starren Formulare bedienen. Du willst den Gedanken in drei Sekunden festhalten – und sicher sein, dass er verlässlich da ist, wenn du Zeit hast.
|
||||
|
||||
**MindSift trennt das Erfassen radikal vom Organisieren:**
|
||||
|
||||
1. **Sofort abladen (Quick Dump):** Oben in der Eingabeleiste tippen, `Enter` drücken, fertig. Dein Kopf ist wieder frei. Alternativ kannst du einen Eintrag schon beim Schreiben direkt einer Aufgabenliste, Einkaufsliste oder Notiz zuweisen.
|
||||
2. **Spielerisch sichten (Triage):** Wenn du später Ruhe hast, sichtest du deinen Gedanken-Stapel Karte für Karte per Tastatur oder Swipe. Nach links verwerfen, auf „Später“ schieben oder mit einem Tastendruck in eine Aufgabe, ein Dokument oder einen Listeneintrag verwandeln.
|
||||
3. **Strukturiert vertiefen:** Aus spontanen Notizen werden hierarchische Outliner-Dokumente, vernetzte Notizbäume, Kanban-Boards oder geteilte Listen für Familie und Team.
|
||||
1. **Sofort abladen (Quick Dump):** In die globale Eingabeleiste tippen, `Enter` drücken, Kopf wieder frei. Du kannst Gedanken aber auch direkt beim Eintippen gezielt einer Aufgabenliste, Einkaufsliste oder Notiz zuweisen.
|
||||
2. **Spielerisch sichten (Triage):** In ruhigen Momenten sichtest du deinen Stapel Karte für Karte – am Desktop per Tastaturkürzel oder auf dem Smartphone per Wischgeste. Mit einer Taste nach links verwerfen, auf „Später“ vertagen oder direkt in eine Aufgabe, Notiz oder Liste umwandeln.
|
||||
3. **Strukturiert vertiefen:** Aus losen Ideen werden vernetzte Outliner-Dokumente, hierarchische Unteraufgaben im Kanban-Board oder geteilte Listen für Familie, WG und Team.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Was MindSift auszeichnet
|
||||
|
||||
### ⚡ Quick-Dump & Triage-Deck (Das 3-Sekunden-Prinzip)
|
||||
* **Blitz-Erfassung:** Die Schnelleingabeleiste sitzt prominent ganz oben im Header – jederzeit erreichbar per Klick oder Tastendruck (`C` oder `/`).
|
||||
* **Entscheiden mit 1 Taste:** Im Triage-Deck steht immer genau ein Gedanke im Fokus:
|
||||
|
||||
* **Blitz-Erfassung:** Die Schnelleingabeleiste sitzt prominent im Header – jederzeit blitzschnell per Mausklick oder Tastendruck (`C` oder `/`) fokussierbar.
|
||||
* **Tastatur-Triage am Desktop:**
|
||||
* `H` oder `←`: Wegwerfen / Erledigt
|
||||
* `L` oder `→`: Sofort zur Aufgabe machen
|
||||
* `T`: Termin-Aufgabe mit Fälligkeitsdatum
|
||||
* `D`: In Workspace-Notiz umwandeln
|
||||
* `Leertaste`: Später (ans Ende des Stapels schieben)
|
||||
* **Mobile-First & Touch-optimiert:** Unterwegs kannst du Gedanken per Wischgeste (Swipe nach links zum Verwerfen, Swipe nach rechts zum Behalten) sortieren.
|
||||
|
||||
### 📝 Workspace: Der hierarchische Logseq-Outliner
|
||||
* **Jeder Gedanke ein Bullet-Point:** Schreibe Notizen als flexible, hierarchische Gliederung:
|
||||
<p align="center">
|
||||
<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
|
||||
* `Tab` / `Shift + Tab`: Unterordnen (einrücken) und ausrücken
|
||||
* `Shift + Enter`: Weicher Zeilenumbruch innerhalb desselben Blocks
|
||||
* **Zoom-in Hoisting:** Klicke auf den Bullet-Punkt (`•`), um tief in einen Ast hineinzuzoomen (Fokus-Modus). Über die dezenten Breadcrumbs oben springst du jederzeit wieder zurück.
|
||||
* **Slash-Befehle (`/`):** Tippe einfach `/` für `/todo`, `/h1`, `/h2`, `/h3`, `/code`, `/table`, `/callout` oder `/progress`.
|
||||
* **Datei-Explorer mit Drag & Drop:** Verschiebe Dokumente mit der Maus direkt per Drag & Drop in Ordner oder zurück ins Hauptverzeichnis.
|
||||
* **Angepinnte Favoriten:** Hefte wichtige Notizen mit einem Klick an – sie erscheinen prominent ganz oben im Explorer.
|
||||
* **100 % Standard-Markdown auf der Festplatte:** Alle Notizen werden unter `/data/workspace/<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!
|
||||
* **🔍 Fokus-Modus & Zoom-in (Hoisting):**
|
||||
* Klicke auf einen Bullet-Punkt (`•`), um **isoliert in diesen Ast hineinzuzoomen**.
|
||||
* Der gewählte Punkt wird zur prominenten Überschrift, sodass du dich ohne jede Ablenkung auf genau dieses Thema konzentrieren kannst.
|
||||
* Eine klickbare Breadcrumb-Leiste oben zeigt deinen aktuellen Pfad und führt dich mit einem Klick zurück in übergeordnete Ebenen oder ins Hauptdokument.
|
||||
* **Intuitives Drag & Drop für Bullet-Points:**
|
||||
* Verschiebe Absätze und ganze Gedankenbäume mühelos mit der Maus.
|
||||
* **Unterpunkte per Drop erstellen:** Ziehst du einen Bullet-Point direkt auf einen anderen Aufzählungspunkt, wird er automatisch als Unter-Bullet eingerückt. Eingeklappte Eltern-Punkte klappen beim Hineinlegen automatisch auf.
|
||||
* Untergeordnete Unterpunkte werden beim Ziehen automatisch als zusammenhängender Ast mitbewegt.
|
||||
* **Echtzeit-Schreib-Lock (Kollisionsschutz):**
|
||||
* Sobald du einen Bullet-Point per Drag & Drop greifst oder anfängst zu tippen, wird sofort ein Schreib-Lock für das Dokument aktiviert.
|
||||
* Andere Nutzer sehen live, wer das Dokument gerade bearbeitet, und Kollisionen bei gleichzeitigen Änderungen werden zuverlässig verhindert.
|
||||
* **Slash-Befehle (`/`):** Tippe einfach `/` für `/todo`, `/h1`, `/h2`, `/h3`, `/code`, `/callout` oder `/divider`.
|
||||
* **Vernetzung & Hashtags:** Verlinke Notizen untereinander mit Wiki-Links (`[[Notiztitel]]`) und tagge Gedanken mit `#Hashtags`.
|
||||
* **Explorer mit Ordnern & Favoriten:** Organisiere Dokumente hierarchisch in Ordnern, verschiebe Notizen per Drag & Drop und hefte deine wichtigsten Dokumente als Favoriten ganz oben an.
|
||||
* **100 % Standard-Markdown:** Jedes Dokument wird lokal unter `./data/workspace/...` als saubere `.md`-Datei mit 2-Leerzeichen-Einrückung abgelegt – jederzeit lesbar und kompatibel mit Obsidian, Logseq oder VS Code.
|
||||
|
||||
### 📋 Globale To-Dos: Das automatische Aufgaben-Rollup
|
||||
* **Alles auf einen Blick:** MindSift durchsucht all deine Notizen und sammelt sämtliche Checkboxen (`- [ ]`) in einem zentralen Rollup-Dashboard.
|
||||
* **Ordner-Drilldown:** Isoliere einzelne Projekt-Ordner mit einem Klick.
|
||||
* **Echtzeit-2-Wege-Sync:** Hakst du eine Aufgabe im Rollup ab, wird sie im selben Augenblick direkt in der physischen `.md`-Datei auf deiner Festplatte als `- [x]` aktualisiert!
|
||||
---
|
||||
|
||||
### ✅ Aufgabenlisten & Kanban-Board
|
||||
* **Mehrere Listen:** Trenne Berufliches, Privates, Homelab oder Team-Projekte sauber in eigenen Listen.
|
||||
* **Liste oder Kanban:** Wechsle mit einem Klick zwischen kompakter Listenansicht und dem **3-Spalten-Kanban-Board** (*Zu erledigen*, *In Arbeit*, *Erledigt*).
|
||||
* **Teilen & Avatar-Stack:** Teile Aufgabenlisten mit Spaces und Teams. Der integrierte Avatar-Stack zeigt mit kleinen Initialen-Bubbles und Tooltips sofort, wer Zugriff hat.
|
||||
|
||||
### 🛒 Geteilte Echtzeit-Listen (Einkauf & Checklisten)
|
||||
* **Gemeinsam abhaken:** Ob Wocheneinkauf oder Urlaubs-Packliste – teile Listen in Echtzeit über WebSocket.
|
||||
* **Live-Synchronisation:** Wird ein Artikel im Laden abgehakt, verschwindet er sofort bei allen anderen Teilnehmern.
|
||||
Behalte offene Aufgaben, Projekte und Termine im Blick – wahlweise als strukturierte Liste oder als visuelles Kanban-Board.
|
||||
|
||||
### 🏢 Organisationen, Teams & Administration
|
||||
* **Geführte Ersteinrichtung:** Beim allerersten Start führt dich MindSift automatisch durch die Einrichtung deines Administrator-Kontos.
|
||||
* **3-Stufen-Rollensystem:** Server-Admin, Team-Admin und reguläres Mitglied.
|
||||
* **Einladungscodes & Registrierungs-Schalter:** Schalte die öffentliche Registrierung bequem per Schiebeschalter ab und lade neue Mitglieder gezielt per Einladungscode ein.
|
||||
* **Server-Isolation:** Bestimme zentral, ob Nutzer strikt privat für sich arbeiten, sich nur innerhalb ihrer Teams sehen oder serverweit kooperieren.
|
||||
<p align="center">
|
||||
<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);" />
|
||||
</p>
|
||||
|
||||
### 🔔 Multi-Channel-Alarme
|
||||
* **Pünktlich erinnert:** Automatische Benachrichtigung vor Fälligkeit wahlweise per **E-Mail (SMTP)**, **Discord-Webhook** oder **eigenem Webhook-Endpunkt**.
|
||||
* **Eigene API-Schlüssel:** Sende System-Alarme, Server-Logs oder Skripte per einfachem `curl`-Befehl direkt in deine Inbox.
|
||||
* **Flexible Ansichten (Liste & Kanban):** Wechsle mit einem Klick zwischen kompakter Listenansicht und dem interaktiven Kanban-Board (*Zu erledigen*, *In Arbeit*, *Wartend*, *Erledigt*).
|
||||
* **Unteraufgaben (Subtasks):**
|
||||
* Gliedere komplexe Aufgaben in beliebig viele Teilaufgaben.
|
||||
* Ein visueller Fortschrittsbalken und eine Prozentanzeige zeigen dir auf einen Blick, wie weit die Aufgabe gediehen ist.
|
||||
* **Freies Drag & Drop:**
|
||||
* Sortiere Aufgaben vertikal in deiner bevorzugten Reihenfolge.
|
||||
* Im Kanban-Board verschiebst du Karten per Drag & Drop direkt zwischen Status-Spalten oder ordnest sie innerhalb einer Spalte um.
|
||||
* Kein mühsames Suchen nach Greifern: Klicke und ziehe eine Aufgabe einfach direkt mit der Maus – ein kurzer Klick ohne Ziehen öffnet das Detail-Modal zum Bearbeiten.
|
||||
* **Prioritäten & Termine:** Hebe dringende oder wichtige Aufgaben mit Farbcodes und Icons hervor (🔥 Dringend, ⚠️ Wichtig). Setze Fälligkeitsdaten und filtere nach Hashtags.
|
||||
* **Geteilte Aufgabenlisten:** Erstelle separate Listen für Beruf, Homelab oder Familie und teile sie gezielt mit Gruppen, Teams oder einzelnen Nutzern.
|
||||
|
||||
### 🎨 Single-File CSS Theming & Hilfe
|
||||
* **1 Datei = 1 Theme:** Vollständiges Design-System gesteuert über genau eine CSS-Datei (`dark`, `light`, `high-contrast`, `soft`).
|
||||
* **Integrierte Hilfeseite:** Ein Klick auf das Profil-Menü oder den Hilfe-Button öffnet eine strukturierte Dokumentation inklusive Tastatur-Cheat-Sheet.
|
||||
---
|
||||
|
||||
### 🛒 Geteilte Listen (Einkauf & Checklisten)
|
||||
|
||||
Ob Wochenmarkt, Baumarkt oder Urlaubs-Packliste – erstelle geteilte Listen, die für gemeinsames Arbeiten in Echtzeit optimiert sind.
|
||||
|
||||
<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
|
||||
|
||||
MindSift läuft als leichtgewichtiger, einzelner Container und bringt Webserver, API, Datenbank und Frontend direkt mit.
|
||||
MindSift läuft als leichtgewichtiger, einzelner Container und bringt Webserver, API, SQLite-Datenbank und Frontend direkt mit.
|
||||
|
||||
### Option 1: Der 1-Zeiler (Empfohlen)
|
||||
|
||||
Führe diesen Befehl im Terminal deines Servers aus. Er erstellt das Verzeichnis, lädt die Konfiguration, erzeugt ein sicheres JWT-Geheimnis und startet den Container:
|
||||
Führe diesen Befehl im Terminal deines Servers aus. Er erstellt das Verzeichnis, lädt die Konfiguration, generiert ein sicheres JWT-Geheimnis und startet den Container:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.dsit.me/sandrodach/MindSift/raw/branch/dev/install.sh | bash
|
||||
@@ -163,7 +226,7 @@ MindSift erkennt den Erststart automatisch und führt dich durch den Einrichtung
|
||||
## 🔒 Deine Daten gehören dir
|
||||
|
||||
* **100 % Self-Hosted:** Keine fremden Cloud-Dienste, keine Telemetrie, kein Tracking.
|
||||
* **Kein Vendor-Lock-in:** Alle Notizen liegen als lesbare `.md`-Dateien im Ordner `./data/workspace`. Backups können jederzeit als JSON exportiert werden.
|
||||
* **Kein Vendor-Lock-in:** Alle Notizen liegen als lesbare `.md`-Dateien im Ordner `./data/workspace`. Backups können jederzeit exportiert werden.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+134
-6
@@ -44,6 +44,8 @@ import { UserSettingsView } from './features/settings/UserSettingsView';
|
||||
import { AdminSettingsView } from './features/settings/AdminSettingsView';
|
||||
import { HelpView } from './features/help/HelpView';
|
||||
import { GroupsView } from './features/teams/GroupsView';
|
||||
import { useMobileActions } from './context/MobileActionContext';
|
||||
import { MobileHeaderMenu } from './components/MobileHeaderMenu';
|
||||
|
||||
type ActiveTab =
|
||||
| 'triage'
|
||||
@@ -129,6 +131,107 @@ export const App: React.FC = () => {
|
||||
// Mobile Navigation & Quick-Action State
|
||||
const [showMobileCapture, setShowMobileCapture] = useState(false);
|
||||
const [showMobileProfile, setShowMobileProfile] = useState(false);
|
||||
const { isMenuOpen, setIsMenuOpen, closeMenu, setGroups } = useMobileActions();
|
||||
|
||||
useEffect(() => {
|
||||
closeMenu();
|
||||
if (activeTab === 'triage') {
|
||||
setGroups([
|
||||
{
|
||||
id: 'triage-actions',
|
||||
title: 'Schnellaktionen',
|
||||
items: [
|
||||
{
|
||||
id: 'new-thought',
|
||||
label: 'Neuen Gedanken erfassen',
|
||||
icon: <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
|
||||
const [isStandaloneApp, setIsStandaloneApp] = useState<boolean>(() => {
|
||||
@@ -409,27 +512,50 @@ export const App: React.FC = () => {
|
||||
const isServerAdmin = user.role === 'admin';
|
||||
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 (
|
||||
<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 */}
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
setActiveTab('triage');
|
||||
triggerHaptic(12);
|
||||
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"
|
||||
title="Zur Startseite (Triage)"
|
||||
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 ${
|
||||
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" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-bold text-base text-main tracking-tight">
|
||||
{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>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
@@ -715,6 +841,8 @@ export const App: React.FC = () => {
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<MobileHeaderMenu activeTabLabel={activeTabLabelMap[activeTab]} />
|
||||
|
||||
{/* 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">
|
||||
<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,
|
||||
Trash2,
|
||||
Lock,
|
||||
Share2,
|
||||
Check,
|
||||
} from 'lucide-react';
|
||||
import { User, Team, UnifiedShare } from '../lib/types';
|
||||
@@ -114,7 +113,7 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
|
||||
resourceIcon
|
||||
)
|
||||
) : (
|
||||
<Share2 className="w-4 h-4" />
|
||||
<Users className="w-4 h-4" />
|
||||
)}
|
||||
</div>
|
||||
<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,
|
||||
LogIn,
|
||||
Server,
|
||||
Share2,
|
||||
Users,
|
||||
Check,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
@@ -235,7 +235,7 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
||||
|
||||
<div>
|
||||
<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>
|
||||
</label>
|
||||
<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="p-3 bg-canvas border border-border rounded-card space-y-2">
|
||||
<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>
|
||||
</h3>
|
||||
<p className="text-muted leading-relaxed">
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -13,7 +13,6 @@ import {
|
||||
Send,
|
||||
AlertCircle,
|
||||
UserPlus,
|
||||
Share2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
SystemSettings,
|
||||
@@ -690,7 +689,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
{/* Sharing Isolation Cards */}
|
||||
<div className="space-y-2">
|
||||
<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>
|
||||
</label>
|
||||
|
||||
|
||||
@@ -194,7 +194,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -202,9 +202,9 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
onConvertToDoc(task);
|
||||
}}
|
||||
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>
|
||||
</button>
|
||||
<button
|
||||
@@ -216,17 +216,18 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
}
|
||||
}}
|
||||
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
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
<X className="w-5 h-5 sm:w-4 sm:h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,8 +13,6 @@ import {
|
||||
Edit3,
|
||||
X,
|
||||
Layers,
|
||||
Share,
|
||||
Share2,
|
||||
Users,
|
||||
CheckSquare,
|
||||
User as UserIcon,
|
||||
@@ -32,6 +30,7 @@ import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||
import { TaskDetailModal } from './TaskDetailModal';
|
||||
import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS } from './ManageColumnsModal';
|
||||
import { UserAutocomplete } from '../UserAutocomplete';
|
||||
import { useMobileActions } from '../../context/MobileActionContext';
|
||||
|
||||
const getInitials = (name?: string): string => {
|
||||
if (!name) return '?';
|
||||
@@ -65,15 +64,21 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
const [discarded, setDiscarded] = useState<Thought[]>([]);
|
||||
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
|
||||
const [quickTitle, setQuickTitle] = useState('');
|
||||
const [quickDueDate, setQuickDueDate] = useState('');
|
||||
const quickInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const { setGroups } = useMobileActions();
|
||||
|
||||
// New TaskList modal state
|
||||
const [showNewListModal, setShowNewListModal] = useState(false);
|
||||
const [newListTitle, setNewListTitle] = useState('');
|
||||
const [newListIcon, setNewListIcon] = useState('📋');
|
||||
const [newListShareTarget, setNewListShareTarget] = useState('');
|
||||
|
||||
// Share TaskList modal state
|
||||
const [showShareListModal, setShowShareListModal] = useState(false);
|
||||
@@ -103,7 +108,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
// Hashtag filtering, grouping and sorting state
|
||||
const [selectedTag, setSelectedTag] = useState<string | null>(null);
|
||||
const [groupBy, setGroupBy] = useState<'none' | 'tag' | 'status' | 'priority' | 'due'>('none');
|
||||
const [sortBy, setSortBy] = useState<'created' | 'due' | 'priority' | 'alpha'>('created');
|
||||
const [sortBy, setSortBy] = useState<'custom' | 'created' | 'due' | 'priority' | 'alpha'>('custom');
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
@@ -189,6 +194,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
setTasks((prev) => prev.filter((t) => t.id !== deletedId));
|
||||
onRefreshStatsRef.current();
|
||||
}
|
||||
} else if (msg.event === 'TASK_REORDERED') {
|
||||
loadData();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -260,6 +267,14 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
if (selectedTag) {
|
||||
result = result.filter((t) => Array.isArray(t.tags) && t.tags.includes(selectedTag));
|
||||
}
|
||||
if (sortBy === 'custom') {
|
||||
return [...result].sort((a, b) => {
|
||||
const orderA = a.sortOrder ?? 0;
|
||||
const orderB = b.sortOrder ?? 0;
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
return b.createdAt.localeCompare(a.createdAt);
|
||||
});
|
||||
}
|
||||
return [...result].sort((a, b) => {
|
||||
if (sortBy === 'due') {
|
||||
if (!a.dueDate && !b.dueDate) return 0;
|
||||
@@ -536,32 +551,164 @@ export const TasksView: React.FC<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) => {
|
||||
e.preventDefault();
|
||||
const title = newListTitle.trim();
|
||||
if (!title) return;
|
||||
try {
|
||||
let teamId: string | undefined = undefined;
|
||||
let userId: string | undefined = undefined;
|
||||
if (newListShareTarget.startsWith('team:')) {
|
||||
teamId = newListShareTarget.replace('team:', '');
|
||||
} else if (newListShareTarget.startsWith('user:')) {
|
||||
userId = newListShareTarget.replace('user:', '');
|
||||
}
|
||||
const created = await api.createTaskList({
|
||||
title,
|
||||
icon: newListIcon,
|
||||
teamId: teamId,
|
||||
});
|
||||
if (userId && created?.id) {
|
||||
await api.addTaskListShare(created.id, { targetType: 'user', targetId: userId });
|
||||
}
|
||||
const freshLists = await api.getTaskLists();
|
||||
setTaskLists(freshLists);
|
||||
setSelectedListId(created.id);
|
||||
setShowNewListModal(false);
|
||||
setNewListTitle('');
|
||||
setNewListShareTarget('');
|
||||
setNewListIcon('📋');
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Fehler beim Erstellen der Aufgabenliste');
|
||||
}
|
||||
@@ -667,6 +814,141 @@ export const TasksView: React.FC<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) => {
|
||||
if (!task.subtasks || task.subtasks.length === 0) return null;
|
||||
const total = task.subtasks.length;
|
||||
@@ -704,30 +986,56 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
// Clean Task Row in List View
|
||||
const renderTaskRow = (task: Thought) => {
|
||||
const isCompleted = task.isCompleted;
|
||||
const isDragging = draggingTaskId === task.id;
|
||||
const isTarget = dragOverTaskTarget?.taskId === task.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
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' : ''
|
||||
}`}
|
||||
} ${isDragging ? 'opacity-30 border border-dashed border-accent/40' : ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
{/* Checkbox */}
|
||||
{/* 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-center gap-3 sm:gap-2.5 min-w-0 flex-1">
|
||||
{/* Checkbox (larger touch target on mobile) */}
|
||||
<button
|
||||
type="button"
|
||||
draggable={false}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
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
|
||||
? 'bg-status-ok border-status-ok text-on-accent'
|
||||
: 'border-strong hover:border-accent text-transparent'
|
||||
}`}
|
||||
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>
|
||||
|
||||
{/* Clickable Title & Metadata */}
|
||||
@@ -836,10 +1144,11 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Controls */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{/* Action Controls (Desktop only - on mobile, tapping the task opens Detail Modal) */}
|
||||
<div className="hidden sm:flex items-center gap-1 flex-shrink-0" onMouseDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
type="button"
|
||||
draggable={false}
|
||||
onClick={() => {
|
||||
setDetailTaskId(task.id);
|
||||
setShowDetailModal(true);
|
||||
@@ -851,6 +1160,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
draggable={false}
|
||||
onClick={() => handleConvertToDoc(task)}
|
||||
title="Als Notiz vertiefen"
|
||||
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
|
||||
type="button"
|
||||
draggable={false}
|
||||
onClick={() => handleDeletePermanent(task.id)}
|
||||
title="Aufgabe löschen"
|
||||
className="text-faint hover:text-status-err p-1.5 transition-colors"
|
||||
@@ -872,8 +1183,36 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-3">
|
||||
{/* Top Header: Task Lists Tabs & View Mode Switcher */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2">
|
||||
{/* Mobile Compact Context Bar (replaces cluttered button rows; options live in MindSift Logo Menu) */}
|
||||
<div className="flex sm:hidden items-center justify-between gap-2 px-3 py-2 rounded-btn bg-panel border border-border">
|
||||
<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 */}
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<button
|
||||
@@ -990,7 +1329,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
className="btn-icon"
|
||||
title="Aufgabenliste teilen und Berechtigungen"
|
||||
>
|
||||
<Share className="w-3.5 h-3.5" />
|
||||
<Users className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -1027,32 +1366,33 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
{/* Quick Add Task Input */}
|
||||
<form
|
||||
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" />
|
||||
<input
|
||||
ref={quickInputRef}
|
||||
type="text"
|
||||
value={quickTitle}
|
||||
onChange={(e) => setQuickTitle(e.target.value)}
|
||||
placeholder={
|
||||
currentSelectedList
|
||||
? `Aufgabe zu "${currentSelectedList.title}" hinzufügen… (Enter)`
|
||||
: 'Neue Aufgabe anlegen… (Enter)'
|
||||
? `Aufgabe zu "${currentSelectedList.title}" hinzufügen…`
|
||||
: 'Neue Aufgabe anlegen…'
|
||||
}
|
||||
className="flex-1 bg-transparent px-2 py-1 text-sm text-main placeholder-faint focus:outline-none"
|
||||
className="flex-1 bg-transparent px-2 py-1.5 sm:py-1 text-sm text-main placeholder-faint focus:outline-none min-w-0"
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<input
|
||||
type="date"
|
||||
value={quickDueDate}
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
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
|
||||
</button>
|
||||
@@ -1134,6 +1474,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
onChange={(e) => setSortBy(e.target.value as any)}
|
||||
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="due">Fälligkeitsdatum</option>
|
||||
<option value="priority">Priorität</option>
|
||||
@@ -1186,37 +1527,6 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
</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">
|
||||
<button
|
||||
type="button"
|
||||
@@ -1341,7 +1651,20 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
});
|
||||
|
||||
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 */}
|
||||
<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">
|
||||
@@ -1363,15 +1686,38 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
|
||||
{/* Tasks List in Column */}
|
||||
<div className="p-2 space-y-2 flex-1 overflow-y-auto max-h-[70vh]">
|
||||
{colTasks.map((task) => (
|
||||
{colTasks.map((task) => {
|
||||
const isDragging = draggingTaskId === task.id;
|
||||
const isTarget = dragOverTaskTarget?.taskId === task.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
draggable
|
||||
onDragStart={(e) => handleTaskDragStart(e, task)}
|
||||
onDragEnd={() => {
|
||||
setDraggingTaskId(null);
|
||||
setDragOverTaskTarget(null);
|
||||
setDragOverKanbanCol(null);
|
||||
}}
|
||||
onDragOver={(e) => handleTaskDragOver(e, task.id)}
|
||||
onDragLeave={(e) => handleTaskDragLeave(e, task.id)}
|
||||
onDrop={(e) => handleDropOnTask(e, task, col.id)}
|
||||
onClick={() => {
|
||||
setDetailTaskId(task.id);
|
||||
setShowDetailModal(true);
|
||||
}}
|
||||
className="panel-surface p-2.5 space-y-2 hover:border-strong transition-all cursor-pointer shadow-sm group"
|
||||
className={`panel-surface p-2.5 space-y-2 hover:border-strong transition-all cursor-grab active:cursor-grabbing shadow-sm group relative ${
|
||||
isDragging ? 'opacity-30 border border-dashed border-accent/40' : ''
|
||||
}`}
|
||||
>
|
||||
{/* Drop insertion line indicator */}
|
||||
{isTarget && dragOverTaskTarget.position === 'before' && (
|
||||
<div className="absolute top-0 left-0 right-0 h-0.5 bg-accent z-20 pointer-events-none rounded-full shadow-[0_0_8px_rgba(var(--accent-rgb),0.6)]" />
|
||||
)}
|
||||
{isTarget && dragOverTaskTarget.position === 'after' && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-accent z-20 pointer-events-none rounded-full shadow-[0_0_8px_rgba(var(--accent-rgb),0.6)]" />
|
||||
)}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p
|
||||
className={`text-xs font-medium break-words flex-1 leading-snug ${
|
||||
@@ -1380,7 +1726,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
>
|
||||
{task.content}
|
||||
</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
|
||||
onClick={() => handleConvertToDoc(task)}
|
||||
title="Als Notiz vertiefen"
|
||||
@@ -1481,7 +1827,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
{colTasks.length === 0 && (
|
||||
<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) */
|
||||
<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 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
|
||||
onClick={() => setFilter('open')}
|
||||
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>
|
||||
|
||||
{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" />
|
||||
Papierkorb leeren
|
||||
</button>
|
||||
|
||||
@@ -45,8 +45,6 @@ import {
|
||||
Unlock,
|
||||
Eye,
|
||||
Edit3,
|
||||
Share,
|
||||
Share2,
|
||||
Hash,
|
||||
User as UserIcon,
|
||||
} from 'lucide-react';
|
||||
@@ -55,6 +53,7 @@ import { api, getToken, triggerHaptic } from '../../lib/api';
|
||||
import { CodeBlockItem } from './CodeBlockItem';
|
||||
import { UserAutocomplete } from '../UserAutocomplete';
|
||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||
import { useMobileActions } from '../../context/MobileActionContext';
|
||||
|
||||
interface WorkspaceViewProps {
|
||||
currentUser?: User | null;
|
||||
@@ -101,6 +100,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
initialDocId,
|
||||
onStatsChange,
|
||||
}) => {
|
||||
const { setGroups } = useMobileActions();
|
||||
const [showMobileFileDrawer, setShowMobileFileDrawer] = useState(false);
|
||||
|
||||
// Top Workspace Tab Mode: 'editor' vs 'todos' vs 'tags'
|
||||
const [workspaceMode, setWorkspaceMode] = useState<'editor' | 'todos' | 'tags'>('editor');
|
||||
const [selectedTag, setSelectedTag] = useState<string | null>(null);
|
||||
@@ -171,6 +173,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
|
||||
// Drag and Drop reordering state for blocks
|
||||
const [draggingBlockId, setDraggingBlockId] = useState<string | null>(null);
|
||||
const [dragOverBlockTarget, setDragOverBlockTarget] = useState<{ blockId: string; position: 'before' | 'after' | 'inside' } | null>(null);
|
||||
|
||||
// --- GLOBAL TO-DO ROLLUP STATE ---
|
||||
const [todoFilter, setTodoFilter] = useState<'all' | 'open' | 'done'>('open');
|
||||
@@ -197,6 +200,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
const idleUnlockTimerRef = useRef<any>(null);
|
||||
const currentDocIdRef = useRef<string | null>(null);
|
||||
const isEditorFocusedRef = useRef(false);
|
||||
const isDraggingBlockRef = useRef(false);
|
||||
|
||||
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 (holdsLockRef.current) {
|
||||
if (holdsLockRef.current && !force) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
@@ -285,10 +289,14 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
if (idleUnlockTimerRef.current) {
|
||||
clearTimeout(idleUnlockTimerRef.current);
|
||||
}
|
||||
// If a block is currently being dragged, do not schedule an unlock
|
||||
if (isDraggingBlockRef.current) {
|
||||
return;
|
||||
}
|
||||
// Short grace period: if user clicked into another block in the doc, focus will cancel this.
|
||||
// If user clicked outside or stopped editing, save and release the lock immediately!
|
||||
idleUnlockTimerRef.current = setTimeout(async () => {
|
||||
if (!isEditorFocusedRef.current && holdsLockRef.current) {
|
||||
if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) {
|
||||
await flushPendingSave();
|
||||
await releaseMyLock();
|
||||
}
|
||||
@@ -338,8 +346,15 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
pendingSaveDocRef.current = null;
|
||||
setSaveState('saved');
|
||||
}
|
||||
if (!isEditorFocusedRef.current && holdsLockRef.current) {
|
||||
releaseMyLock();
|
||||
if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) {
|
||||
if (idleUnlockTimerRef.current) {
|
||||
clearTimeout(idleUnlockTimerRef.current);
|
||||
}
|
||||
idleUnlockTimerRef.current = setTimeout(async () => {
|
||||
if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) {
|
||||
await releaseMyLock();
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -514,13 +529,13 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
// Window blur / visibility change listener: release lock when user leaves tab/window
|
||||
useEffect(() => {
|
||||
const handleWindowBlur = () => {
|
||||
if (!isEditorFocusedRef.current && holdsLockRef.current) {
|
||||
if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) {
|
||||
flushPendingSave();
|
||||
releaseMyLock();
|
||||
}
|
||||
};
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
if (document.visibilityState === 'hidden' && !isDraggingBlockRef.current) {
|
||||
isEditorFocusedRef.current = false;
|
||||
flushPendingSave();
|
||||
releaseMyLock();
|
||||
@@ -1911,35 +1926,187 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
return childTodos;
|
||||
};
|
||||
|
||||
// Drag & Drop Handlers on the Bullet Dot
|
||||
const handleDragStart = (e: React.DragEvent, blockId: string) => {
|
||||
// Helper: Get subtree count (root + all indented children)
|
||||
const getBlockSubtreeCount = (blocks: DocumentBlock[], startIndex: number): number => {
|
||||
if (startIndex < 0 || startIndex >= blocks.length) return 0;
|
||||
const rootLevel = blocks[startIndex].level || 0;
|
||||
let count = 1;
|
||||
for (let i = startIndex + 1; i < blocks.length; i++) {
|
||||
if ((blocks[i].level || 0) > rootLevel) {
|
||||
count++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
};
|
||||
|
||||
// Helper: Check if target block is a descendant of source block
|
||||
const isDescendantBlock = (blocks: DocumentBlock[], sourceIdx: number, targetBlockId: string): boolean => {
|
||||
if (sourceIdx < 0 || sourceIdx >= blocks.length) return false;
|
||||
const subtreeCount = getBlockSubtreeCount(blocks, sourceIdx);
|
||||
for (let i = sourceIdx; i < sourceIdx + subtreeCount; i++) {
|
||||
if (blocks[i]?.id === targetBlockId) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Drag & Drop Handlers on the Bullet row / Grip / Dot
|
||||
const handleBlockDragStart = (e: React.DragEvent, blockId: string) => {
|
||||
e.stopPropagation();
|
||||
if (isReadOnly) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
isDraggingBlockRef.current = true;
|
||||
if (idleUnlockTimerRef.current) {
|
||||
clearTimeout(idleUnlockTimerRef.current);
|
||||
idleUnlockTimerRef.current = null;
|
||||
}
|
||||
// Activate write lock immediately upon dragging (optimistic UI + backend broadcast)
|
||||
setHoldsLock(true);
|
||||
ensureLock(true);
|
||||
|
||||
e.dataTransfer.setData('application/mindsift-block-id', blockId);
|
||||
e.dataTransfer.setData('text/plain', blockId);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
setDraggingBlockId(blockId);
|
||||
};
|
||||
|
||||
const handleDropOnBlock = (e: React.DragEvent, targetBlockId: string) => {
|
||||
e.preventDefault();
|
||||
const sourceId = e.dataTransfer.getData('text/plain') || draggingBlockId;
|
||||
if (!selectedDoc || !sourceId || sourceId === targetBlockId) {
|
||||
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.stopPropagation();
|
||||
if (!draggingBlockId || draggingBlockId === targetBlockId) return;
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const relY = e.clientY - rect.top;
|
||||
const height = rect.height;
|
||||
|
||||
let position: 'before' | 'after' | 'inside';
|
||||
if (relY < height * 0.25) {
|
||||
position = 'before';
|
||||
} else if (relY > height * 0.75) {
|
||||
position = 'after';
|
||||
} else {
|
||||
position = 'inside';
|
||||
}
|
||||
|
||||
if (
|
||||
!dragOverBlockTarget ||
|
||||
dragOverBlockTarget.blockId !== targetBlockId ||
|
||||
dragOverBlockTarget.position !== position
|
||||
) {
|
||||
setDragOverBlockTarget({ blockId: targetBlockId, position });
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlockDragLeave = (e: React.DragEvent, targetBlockId: string) => {
|
||||
e.stopPropagation();
|
||||
if (dragOverBlockTarget?.blockId === targetBlockId) {
|
||||
setDragOverBlockTarget(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlockDrop = (e: React.DragEvent, targetBlockId: string, explicitPosition?: 'before' | 'after' | 'inside') => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const sourceId =
|
||||
e.dataTransfer.getData('application/mindsift-block-id') ||
|
||||
e.dataTransfer.getData('text/plain') ||
|
||||
draggingBlockId;
|
||||
|
||||
const position = explicitPosition || dragOverBlockTarget?.position || 'after';
|
||||
isDraggingBlockRef.current = false;
|
||||
setDraggingBlockId(null);
|
||||
setDragOverBlockTarget(null);
|
||||
|
||||
if (!selectedDoc || !sourceId || sourceId === targetBlockId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const blocks = [...selectedDoc.blocks];
|
||||
const sourceIdx = blocks.findIndex((b) => b.id === sourceId);
|
||||
const targetIdx = blocks.findIndex((b) => b.id === targetBlockId);
|
||||
if (sourceIdx === -1 || targetIdx === -1) {
|
||||
setDraggingBlockId(null);
|
||||
if (sourceIdx === -1) return;
|
||||
|
||||
// Prevent circular drag into one's own children
|
||||
if (isDescendantBlock(blocks, sourceIdx, targetBlockId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [moved] = blocks.splice(sourceIdx, 1);
|
||||
// Align moved block's level to target
|
||||
moved.level = blocks[targetIdx]?.level || 0;
|
||||
blocks.splice(targetIdx, 0, moved);
|
||||
// Extract the entire subtree (parent + all indented children)
|
||||
const subtreeCount = getBlockSubtreeCount(blocks, sourceIdx);
|
||||
const subtree = blocks.splice(sourceIdx, subtreeCount);
|
||||
|
||||
// Find new index of target block after splice
|
||||
const newTargetIdx = blocks.findIndex((b) => b.id === targetBlockId);
|
||||
if (newTargetIdx === -1) return;
|
||||
|
||||
const targetBlock = blocks[newTargetIdx];
|
||||
|
||||
if (position === 'inside') {
|
||||
// Nest directly under targetBlock as its sub-bullet
|
||||
const targetLevel = (targetBlock.level || 0) + 1;
|
||||
const oldRootLevel = subtree[0].level || 0;
|
||||
const levelDiff = targetLevel - oldRootLevel;
|
||||
|
||||
for (const b of subtree) {
|
||||
b.level = Math.max(0, (b.level || 0) + levelDiff);
|
||||
}
|
||||
|
||||
// Automatically uncollapse targetBlock so the newly added sub-bullet is visible
|
||||
if (targetBlock.collapsed) {
|
||||
blocks[newTargetIdx] = { ...targetBlock, collapsed: false };
|
||||
}
|
||||
|
||||
// Insert immediately below targetBlock
|
||||
blocks.splice(newTargetIdx + 1, 0, ...subtree);
|
||||
} else {
|
||||
const targetLevel = targetBlock.level || 0;
|
||||
const oldRootLevel = subtree[0].level || 0;
|
||||
const levelDiff = targetLevel - oldRootLevel;
|
||||
|
||||
// Adjust levels of all blocks in subtree to match target hierarchy
|
||||
for (const b of subtree) {
|
||||
b.level = Math.max(0, (b.level || 0) + levelDiff);
|
||||
}
|
||||
|
||||
// Calculate insertion index
|
||||
let insertIdx = newTargetIdx;
|
||||
if (position === 'after') {
|
||||
const targetSubtreeCount = getBlockSubtreeCount(blocks, newTargetIdx);
|
||||
insertIdx = newTargetIdx + targetSubtreeCount;
|
||||
}
|
||||
|
||||
blocks.splice(insertIdx, 0, ...subtree);
|
||||
}
|
||||
|
||||
persistDocument({ ...selectedDoc, blocks }, true);
|
||||
setDraggingBlockId(null);
|
||||
|
||||
if (idleUnlockTimerRef.current) {
|
||||
clearTimeout(idleUnlockTimerRef.current);
|
||||
}
|
||||
idleUnlockTimerRef.current = setTimeout(async () => {
|
||||
if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) {
|
||||
await flushPendingSave();
|
||||
await releaseMyLock();
|
||||
}
|
||||
}, 2500);
|
||||
};
|
||||
|
||||
// Export full document as portable Logseq-indented Markdown
|
||||
@@ -2551,6 +2718,132 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
return workspaceTags.filter((t) => t.tag.toLowerCase().includes(q));
|
||||
}, [workspaceTags, tagSearchQuery]);
|
||||
|
||||
// Register context-sensitive mobile menu groups behind the MindSift logo
|
||||
useEffect(() => {
|
||||
const openTodosCount = allWorkspaceTodos.filter((t) => !t.block.checked).length;
|
||||
setGroups([
|
||||
{
|
||||
id: 'ws-view-files',
|
||||
title: 'Workspace-Ansicht & Dateien',
|
||||
items: [
|
||||
{
|
||||
id: 'ws-open-tree',
|
||||
label: 'Dateibaum & Ordner öffnen',
|
||||
icon: <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 isSelected = selectedDoc?.id === doc.id && workspaceMode === 'editor';
|
||||
const isDragging = draggingDocId === doc.id;
|
||||
@@ -2569,9 +2862,10 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
setSelectedDoc(doc);
|
||||
setZoomedBlockId(null);
|
||||
setWorkspaceMode('editor');
|
||||
setShowMobileFileDrawer(false);
|
||||
}}
|
||||
style={{ paddingLeft: `${depth * 14 + 16}px` }}
|
||||
className={`group flex items-center justify-between py-1.5 pr-2 border-l-2 transition-all cursor-pointer text-xs ${
|
||||
className={`group flex items-center justify-between py-2.5 sm:py-1.5 pr-2 border-l-2 transition-all cursor-pointer text-sm sm:text-xs ${
|
||||
isDragging ? 'opacity-40 scale-[0.98]' : ''
|
||||
} ${
|
||||
isSelected
|
||||
@@ -2581,15 +2875,15 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
title={`${doc.title || 'Notiz'} (Ziehen zum Verschieben in Ordner)`}
|
||||
>
|
||||
<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>
|
||||
{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 && (
|
||||
<span
|
||||
title={`Geteilt von ${doc.ownerName || 'anderem Nutzer'} (${doc.permission === 'read' ? 'Nur Lesen' : 'Schreibrecht'})`}
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
@@ -2653,7 +2947,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
onDragEnd={() => setDraggingFolderPath(null)}
|
||||
onClick={() => toggleFolder(node.path)}
|
||||
style={{ paddingLeft: `${node.depth * 14 + 8}px` }}
|
||||
className={`group flex items-center justify-between py-1.5 pr-2 cursor-pointer transition-colors text-xs ${
|
||||
className={`group flex items-center justify-between py-2.5 sm:py-1.5 pr-2 cursor-pointer transition-colors text-sm sm:text-xs ${
|
||||
isDraggingThisFolder ? 'opacity-40 scale-[0.98]' : ''
|
||||
} ${
|
||||
isDragOver
|
||||
@@ -2665,31 +2959,32 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="text-faint hover:text-muted">
|
||||
{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>
|
||||
{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>
|
||||
</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
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowMobileFileDrawer(false);
|
||||
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"
|
||||
>
|
||||
<FilePlus className="w-3 h-3" />
|
||||
<FilePlus className="w-3.5 h-3.5 sm:w-3 sm:h-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -2697,10 +2992,10 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
e.stopPropagation();
|
||||
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"
|
||||
>
|
||||
<FolderPlus className="w-3 h-3" />
|
||||
<FolderPlus className="w-3.5 h-3.5 sm:w-3 sm:h-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -2708,10 +3003,10 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
e.stopPropagation();
|
||||
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"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
<Trash2 className="w-3.5 h-3.5 sm:w-3 sm:h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2719,9 +3014,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
{isCreatingSubfolder && (
|
||||
<div
|
||||
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
|
||||
type="text"
|
||||
autoFocus
|
||||
@@ -2732,23 +3027,23 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
if (e.key === 'Escape') setIsCreatingFolder(false);
|
||||
}}
|
||||
placeholder="Unterordner…"
|
||||
className="flex-1 h-6 px-1.5 input-field text-xs"
|
||||
className="flex-1 h-8 sm:h-6 px-2 sm:px-1.5 input-field text-xs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
<Check className="w-4 h-4 sm:w-3.5 sm:h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
<X className="w-4 h-4 sm:w-3.5 sm:h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -2775,8 +3070,52 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-2">
|
||||
{/* Top Navigation Mode Switcher: Notizen & Outliner vs. Globales To-Do Dashboard */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2">
|
||||
{/* Mobile Compact Workspace Bar (Options & mode switching live in MindSift Logo Menu) */}
|
||||
<div className="flex sm:hidden items-center justify-between gap-2 px-3 py-2 rounded-btn bg-panel border border-border">
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
@@ -2852,38 +3191,71 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
{/* ===================================================================== */}
|
||||
{workspaceMode === 'editor' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-2">
|
||||
{/* Left Document & Folder Explorer Tree */}
|
||||
<div className="lg:col-span-4 panel-surface flex flex-col self-start">
|
||||
<div className="p-2 panel-header space-y-2">
|
||||
{/* Mobile Backdrop for File Tree Slide-Over Drawer */}
|
||||
{showMobileFileDrawer && (
|
||||
<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">
|
||||
<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
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Dateien durchsuchen…"
|
||||
className="w-full h-7 pl-7 pr-2 input-field text-xs"
|
||||
className="w-full h-9 sm:h-7 pl-8 sm:pl-7 pr-2 input-field text-sm sm:text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCreateDoc(null)}
|
||||
className="flex-1 btn-action"
|
||||
onClick={() => {
|
||||
setShowMobileFileDrawer(false);
|
||||
handleCreateDoc(null);
|
||||
}}
|
||||
className="flex-1 btn-action min-h-[38px] sm:min-h-0 justify-center"
|
||||
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>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
@@ -2891,7 +3263,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
|
||||
{isCreatingFolder && newFolderParent === null && (
|
||||
<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
|
||||
type="text"
|
||||
autoFocus
|
||||
@@ -2902,23 +3274,23 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
if (e.key === 'Escape') setIsCreatingFolder(false);
|
||||
}}
|
||||
placeholder="Neuer Ordnername…"
|
||||
className="flex-1 h-6 px-1.5 input-field text-xs"
|
||||
className="flex-1 h-8 sm:h-6 px-2 sm:px-1.5 input-field text-xs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
<Check className="w-4 h-4 sm:w-3.5 sm:h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
<X className="w-4 h-4 sm:w-3.5 sm:h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -2933,8 +3305,11 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCreateDoc(null)}
|
||||
className="btn-action"
|
||||
onClick={() => {
|
||||
setShowMobileFileDrawer(false);
|
||||
handleCreateDoc(null);
|
||||
}}
|
||||
className="btn-action min-h-[38px] sm:min-h-0"
|
||||
>
|
||||
<FilePlus className="w-3.5 h-3.5 text-muted" />
|
||||
<span>Neue Datei</span>
|
||||
@@ -2942,7 +3317,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
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" />
|
||||
<span>Neuer Ordner</span>
|
||||
@@ -2950,7 +3325,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
</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 */}
|
||||
{(draggingDocId || draggingFolderPath) && (
|
||||
<div
|
||||
@@ -2997,7 +3372,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
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'}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
@@ -3029,8 +3404,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
setSelectedDoc(doc);
|
||||
setZoomedBlockId(null);
|
||||
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
|
||||
? 'bg-panel-elevated border-l-2 border-accent text-main font-medium'
|
||||
: 'text-muted hover:text-main hover:bg-canvas/50'
|
||||
@@ -3038,7 +3414,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
title="Angepinnte Notiz öffnen"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
<button
|
||||
@@ -3047,10 +3423,10 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
e.stopPropagation();
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
@@ -3066,7 +3442,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
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'}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-accent">
|
||||
@@ -3080,13 +3456,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
/>
|
||||
</button>
|
||||
{!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) => (
|
||||
<button
|
||||
key={wt.tag}
|
||||
type="button"
|
||||
onClick={() => openTagPage(wt.tag)}
|
||||
className={`px-2 py-0.5 rounded-pill text-[11px] font-medium border flex items-center gap-1 transition-colors ${
|
||||
onClick={() => {
|
||||
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
|
||||
? 'bg-accent/20 border-accent text-accent font-semibold'
|
||||
: '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"
|
||||
title="Notiz teilen und Berechtigungen"
|
||||
>
|
||||
<Share className="w-3.5 h-3.5" />
|
||||
<Users className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -3413,16 +3792,50 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
{visibleBlocks.map(({ block, originalIndex }) => {
|
||||
const blockLevel = Math.max(0, (block.level || 0) - (zoomedBlock ? (zoomedBlock.level || 0) + 1 : 0));
|
||||
const isChildrenPresent = hasChildren(selectedDoc.blocks, originalIndex);
|
||||
const isDraggingThis = draggingBlockId === block.id;
|
||||
const isTargetThis = dragOverBlockTarget?.blockId === block.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={block.id}
|
||||
style={{ paddingLeft: `${blockLevel * 22}px` }}
|
||||
className="group flex items-start gap-1 py-0.5 relative transition-all"
|
||||
onDragOver={(e) => !isReadOnly && handleBlockDragOver(e, block.id)}
|
||||
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
|
||||
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"
|
||||
>
|
||||
<GripVertical className="w-3 h-3" />
|
||||
@@ -3433,32 +3846,58 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
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'}
|
||||
>
|
||||
{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>
|
||||
) : (
|
||||
<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
|
||||
draggable={!isReadOnly}
|
||||
onDragStart={(e) => !isReadOnly && handleDragStart(e, block.id)}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => !isReadOnly && handleDropOnBlock(e, block.id)}
|
||||
onMouseDown={() => {
|
||||
if (!isReadOnly) {
|
||||
if (idleUnlockTimerRef.current) {
|
||||
clearTimeout(idleUnlockTimerRef.current);
|
||||
idleUnlockTimerRef.current = null;
|
||||
}
|
||||
ensureLock(true);
|
||||
}
|
||||
}}
|
||||
onDragStart={(e) => !isReadOnly && handleBlockDragStart(e, block.id)}
|
||||
onDragEnd={handleBlockDragEnd}
|
||||
onDragOver={(e) => {
|
||||
if (!isReadOnly && draggingBlockId && draggingBlockId !== block.id) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOverBlockTarget({ blockId: block.id, position: 'inside' });
|
||||
}
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
if (!isReadOnly) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleBlockDrop(e, block.id, 'inside');
|
||||
}
|
||||
}}
|
||||
onClick={() => handleZoomTo(block.id)}
|
||||
className={`w-4 h-4 mt-1 rounded-pill flex items-center justify-center transition-colors flex-shrink-0 ${
|
||||
isReadOnly
|
||||
? 'cursor-pointer text-muted'
|
||||
: 'hover:bg-accent/20 cursor-pointer text-muted hover:text-accent'
|
||||
: 'hover:bg-accent/20 cursor-grab active:cursor-grabbing text-muted hover:text-accent'
|
||||
} ${
|
||||
isTargetThis && dragOverBlockTarget.position === 'inside'
|
||||
? 'scale-125 bg-accent/30 ring-2 ring-accent'
|
||||
: ''
|
||||
}`}
|
||||
title="Klick: In diesen Ast zoomen · Ziehen: Verschieben"
|
||||
title="Klick: Zoomen · Ziehen: Verschieben · Hierher ziehen: Als Unterpunkt einordnen"
|
||||
>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-pill transition-colors ${
|
||||
|
||||
+60
-6
@@ -8,6 +8,7 @@ import {
|
||||
DocLockInfo,
|
||||
KanbanColumn,
|
||||
ListItem,
|
||||
ListShare,
|
||||
Organization,
|
||||
PriorityLevel,
|
||||
SharedList,
|
||||
@@ -309,6 +310,14 @@ export const api = {
|
||||
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(
|
||||
thoughtId: string,
|
||||
patch: {
|
||||
@@ -639,17 +648,36 @@ export const api = {
|
||||
return ensureArray(res).map((l) => ({
|
||||
...l,
|
||||
items: ensureArray(l.items),
|
||||
shares: ensureArray(l.shares),
|
||||
}));
|
||||
},
|
||||
|
||||
async createList(spaceId: string, title: string, icon: string, listType: 'shopping' | 'checklist' | 'vacation'): Promise<SharedList> {
|
||||
const l = await request<SharedList>(`/api/v1/spaces/${spaceId}/lists`, {
|
||||
async getListById(listId: string): Promise<SharedList> {
|
||||
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',
|
||||
body: JSON.stringify({ title, icon, listType }),
|
||||
body: JSON.stringify({ title, icon, listType: type }),
|
||||
});
|
||||
return {
|
||||
...l,
|
||||
items: ensureArray(l.items),
|
||||
shares: ensureArray(l.shares),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -657,6 +685,22 @@ export const api = {
|
||||
await request(`/api/v1/lists/${listId}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
async getListShares(listId: string): Promise<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> {
|
||||
return request<ListItem>(`/api/v1/lists/${listId}/items`, {
|
||||
method: 'POST',
|
||||
@@ -681,6 +725,16 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async reorderListItems(
|
||||
listId: string,
|
||||
items: { id: string; sortOrder: number; category?: string; content?: string }[]
|
||||
): Promise<{ success: boolean }> {
|
||||
return request<{ success: boolean }>(`/api/v1/lists/${listId}/items/reorder`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ items }),
|
||||
});
|
||||
},
|
||||
|
||||
// --- ORGANIZATIONS & TEAMS ---
|
||||
async getOrganizations(): Promise<Organization[]> {
|
||||
const res = await request<Organization[]>('/api/v1/organizations');
|
||||
@@ -905,11 +959,11 @@ let wsReconnectTimer: any = null;
|
||||
let wsListeners: ((msg: WSMessage) => void)[] = [];
|
||||
let manualDisconnect = false;
|
||||
|
||||
export function buildWebSocketURL(spaceId?: string): string {
|
||||
export function buildWebSocketURL(listId?: string): string {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const token = getToken() || '';
|
||||
if (spaceId) {
|
||||
return `${protocol}//${window.location.host}/api/v1/ws/spaces/${encodeURIComponent(spaceId)}?token=${encodeURIComponent(token)}`;
|
||||
if (listId) {
|
||||
return `${protocol}//${window.location.host}/api/v1/ws/lists/${encodeURIComponent(listId)}?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
return `${protocol}//${window.location.host}/api/v1/ws?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ export interface Thought {
|
||||
notes?: string;
|
||||
subtasks?: SubTask[];
|
||||
alarmSent?: boolean;
|
||||
sortOrder?: number;
|
||||
createdAt: string;
|
||||
triagedAt?: string;
|
||||
}
|
||||
@@ -295,13 +296,30 @@ export interface ListItem {
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export interface ListShare {
|
||||
id: string;
|
||||
listId: string;
|
||||
targetType: 'team' | 'user';
|
||||
targetId: string;
|
||||
targetName?: string;
|
||||
targetEmail?: string;
|
||||
role: 'editor' | 'viewer';
|
||||
createdBy?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SharedList {
|
||||
id: string;
|
||||
spaceId: string;
|
||||
userId?: string;
|
||||
ownerName?: string;
|
||||
isOwner?: boolean;
|
||||
role?: string;
|
||||
spaceId?: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
listType: 'shopping' | 'checklist' | 'vacation';
|
||||
items: ListItem[];
|
||||
shares?: ListShare[];
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -331,6 +349,7 @@ export interface WSMessage<T = any> {
|
||||
event: string;
|
||||
space_id?: string;
|
||||
list_id?: string;
|
||||
listId?: string;
|
||||
payload: T;
|
||||
timestamp: string;
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import { MobileActionProvider } from './context/MobileActionContext';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<MobileActionProvider>
|
||||
<App />
|
||||
</MobileActionProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
+173
-182
@@ -63,6 +63,7 @@ func (s *Server) Router() http.Handler {
|
||||
r.Post("/api/v1/webhooks/ingest", s.handleWebhookIngest)
|
||||
r.Get("/api/v1/ws", s.handleWebSocket)
|
||||
r.Get("/api/v1/ws/spaces/{spaceId}", s.handleWebSocket)
|
||||
r.Get("/api/v1/ws/lists/{listId}", s.handleWebSocket)
|
||||
|
||||
// Authenticated API Routes
|
||||
r.Group(func(r chi.Router) {
|
||||
@@ -88,6 +89,8 @@ func (s *Server) Router() http.Handler {
|
||||
r.Get("/api/v1/triage/deck", s.handleGetTriageDeck)
|
||||
r.Patch("/api/v1/triage/{id}", s.handleTriageAction)
|
||||
r.Get("/api/v1/tasks", s.handleGetTasks)
|
||||
r.Post("/api/v1/tasks/reorder", s.handleReorderTasks)
|
||||
r.Post("/api/v1/task-lists/{id}/tasks/reorder", s.handleReorderTasks)
|
||||
|
||||
// Task Lists & Shares
|
||||
r.Get("/api/v1/task-lists", s.handleGetTaskLists)
|
||||
@@ -125,21 +128,19 @@ func (s *Server) Router() http.Handler {
|
||||
r.Post("/api/v1/documents/{id}/lock/heartbeat", s.handleHeartbeatDocLock)
|
||||
r.Delete("/api/v1/documents/{id}/lock", s.handleUnlockDoc)
|
||||
|
||||
// Shared Spaces & Realtime Lists
|
||||
r.Get("/api/v1/spaces", s.handleGetSpaces)
|
||||
r.Post("/api/v1/spaces", s.handleCreateSpace)
|
||||
r.Post("/api/v1/spaces/join", s.handleJoinSpace)
|
||||
r.Get("/api/v1/spaces/{id}/shares", s.handleGetSpaceShares)
|
||||
r.Post("/api/v1/spaces/{id}/members", s.handleAddSpaceMember)
|
||||
r.Delete("/api/v1/spaces/{id}/members/{userId}", s.handleRemoveSpaceMember)
|
||||
r.Get("/api/v1/spaces/{id}/lists", s.handleGetSpaceLists)
|
||||
r.Post("/api/v1/spaces/{id}/lists", s.handleCreateList)
|
||||
|
||||
// Shared Lists & Realtime Collaboration
|
||||
r.Get("/api/v1/lists", s.handleGetAllLists)
|
||||
r.Post("/api/v1/lists", s.handleCreateList)
|
||||
r.Get("/api/v1/lists/{id}", s.handleGetListByID)
|
||||
r.Delete("/api/v1/lists/{id}", s.handleDeleteList)
|
||||
r.Get("/api/v1/lists/{id}/shares", s.handleGetListShares)
|
||||
r.Post("/api/v1/lists/{id}/shares", s.handleCreateListShare)
|
||||
r.Delete("/api/v1/lists/{id}/shares/{shareId}", s.handleDeleteListShare)
|
||||
r.Post("/api/v1/lists/{id}/items", s.handleCreateListItem)
|
||||
r.Patch("/api/v1/lists/{id}/items/{itemId}", s.handleToggleListItem)
|
||||
r.Delete("/api/v1/lists/{id}/items/{itemId}", s.handleDeleteListItem)
|
||||
r.Post("/api/v1/lists/{id}/items/reorder", s.handleReorderListItems)
|
||||
r.Put("/api/v1/lists/{id}/items/reorder", s.handleReorderListItems)
|
||||
r.Post("/api/v1/lists/{id}/clear-completed", s.handleClearCompletedItems)
|
||||
|
||||
// Organizations & Teams
|
||||
@@ -584,8 +585,8 @@ func (s *Server) handleCaptureInbox(w http.ResponseWriter, r *http.Request) {
|
||||
if converted, convErr := s.store.UpdateThoughtTriage(userID, thought.ID, "keep", target, targetListID, dueDate, taskListID); convErr == nil && converted != nil {
|
||||
thought = converted
|
||||
if target == "list_item" && targetListID != "" {
|
||||
if lst, lErr := s.store.GetListByID(targetListID); lErr == nil && lst != nil {
|
||||
s.wsHub.Broadcast(lst.SpaceID, lst.ID, "ITEM_CREATED", lst)
|
||||
if lst, lErr := s.store.GetListByID(userID, targetListID); lErr == nil && lst != nil {
|
||||
s.wsHub.BroadcastListEvent(lst.ID, "LIST_UPDATED", lst)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -661,8 +662,8 @@ func (s *Server) handleTriageAction(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// If converted to a list item, broadcast via WebSocket to the space
|
||||
if convertTo == "list_item" && targetListID != "" {
|
||||
if lst, err := s.store.GetListByID(targetListID); err == nil && lst != nil {
|
||||
s.wsHub.Broadcast(lst.SpaceID, lst.ID, "ITEM_CREATED", lst)
|
||||
if lst, err := s.store.GetListByID(userID, targetListID); err == nil && lst != nil {
|
||||
s.wsHub.BroadcastListEvent(lst.ID, "LIST_UPDATED", lst)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -681,6 +682,34 @@ func (s *Server) handleGetTasks(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, tasks)
|
||||
}
|
||||
|
||||
func (s *Server) handleReorderTasks(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
taskListID := chi.URLParam(r, "id")
|
||||
if taskListID == "" {
|
||||
taskListID = r.URL.Query().Get("taskListId")
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Items []domain.ThoughtReorderItem `json:"items"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.ReorderTasks(taskListID, req.Items); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.wsHub.BroadcastToAll("TASK_REORDERED", map[string]interface{}{
|
||||
"taskListId": taskListID,
|
||||
"items": req.Items,
|
||||
"userId": userID,
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
thoughtID := chi.URLParam(r, "id")
|
||||
@@ -1073,151 +1102,7 @@ func (s *Server) handleUnlockDoc(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"unlocked": released})
|
||||
}
|
||||
|
||||
// --- SPACES & REALTIME LISTS ---
|
||||
|
||||
func (s *Server) handleGetSpaces(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
spaces, err := s.store.GetSpacesForUser(userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, spaces)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateSpace(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
|
||||
sp, err := s.store.CreateSpace(userID, req.Name, req.Icon, req.Description, false)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, sp)
|
||||
}
|
||||
|
||||
func (s *Server) handleJoinSpace(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
var req struct {
|
||||
InviteCode string `json:"inviteCode"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
|
||||
sp, err := s.store.JoinSpaceByInviteCode(userID, req.InviteCode)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.wsHub.BroadcastToUser(userID, "SPACE_JOINED", map[string]interface{}{"spaceId": sp.ID})
|
||||
s.wsHub.Broadcast(sp.ID, "", "MEMBER_ADDED", map[string]interface{}{"spaceId": sp.ID, "userId": userID})
|
||||
|
||||
writeJSON(w, http.StatusOK, sp)
|
||||
}
|
||||
|
||||
func (s *Server) handleAddSpaceMember(w http.ResponseWriter, r *http.Request) {
|
||||
currentUserID := auth.GetUserID(r.Context())
|
||||
spaceID := chi.URLParam(r, "id")
|
||||
var req struct {
|
||||
UserID string `json:"userId"`
|
||||
Email string `json:"email"`
|
||||
TeamID string `json:"teamId"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Role != "owner" && req.Role != "editor" && req.Role != "viewer" {
|
||||
req.Role = "editor"
|
||||
}
|
||||
|
||||
if req.TeamID != "" {
|
||||
share, err := s.store.CreateSpaceTeamShare(currentUserID, spaceID, req.TeamID, req.Role)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
members, _ := s.store.GetTeamMembers(req.TeamID)
|
||||
for _, tm := range members {
|
||||
s.wsHub.BroadcastToUser(tm.UserID, "SPACE_JOINED", map[string]interface{}{"spaceId": spaceID})
|
||||
}
|
||||
s.wsHub.Broadcast(spaceID, "", "SPACE_SHARED", share)
|
||||
writeJSON(w, http.StatusCreated, share)
|
||||
return
|
||||
}
|
||||
|
||||
target := req.UserID
|
||||
if target == "" {
|
||||
target = req.Email
|
||||
}
|
||||
if target == "" {
|
||||
writeError(w, http.StatusBadRequest, "Benutzer-ID, Benutzername, E-Mail oder Team erforderlich")
|
||||
return
|
||||
}
|
||||
|
||||
member, err := s.store.AddSpaceMember(currentUserID, spaceID, target, req.Role)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.wsHub.BroadcastToUser(member.UserID, "SPACE_JOINED", map[string]interface{}{"spaceId": spaceID})
|
||||
s.wsHub.Broadcast(spaceID, "", "MEMBER_ADDED", member)
|
||||
|
||||
writeJSON(w, http.StatusCreated, member)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetSpaceShares(w http.ResponseWriter, r *http.Request) {
|
||||
spaceID := chi.URLParam(r, "id")
|
||||
shares, err := s.store.GetSpaceShares(spaceID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, shares)
|
||||
}
|
||||
|
||||
func (s *Server) handleRemoveSpaceMember(w http.ResponseWriter, r *http.Request) {
|
||||
currentUserID := auth.GetUserID(r.Context())
|
||||
spaceID := chi.URLParam(r, "id")
|
||||
targetUserID := chi.URLParam(r, "userId")
|
||||
|
||||
// If prefixed with sm_, strip it for individual space member removal
|
||||
cleanTargetID := strings.TrimPrefix(targetUserID, "sm_")
|
||||
|
||||
if err := s.store.RemoveSpaceMember(currentUserID, spaceID, cleanTargetID); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.wsHub.BroadcastToUser(cleanTargetID, "SPACE_LEFT", map[string]interface{}{"spaceId": spaceID})
|
||||
s.wsHub.Broadcast(spaceID, "", "MEMBER_REMOVED", map[string]interface{}{"spaceId": spaceID, "userId": cleanTargetID})
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetSpaceLists(w http.ResponseWriter, r *http.Request) {
|
||||
spaceID := chi.URLParam(r, "id")
|
||||
lists, err := s.store.GetListsForSpace(spaceID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, lists)
|
||||
}
|
||||
// --- REALTIME LISTS & SHARING ---
|
||||
|
||||
func (s *Server) handleGetAllLists(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
@@ -1229,8 +1114,19 @@ func (s *Server) handleGetAllLists(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, lists)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetListByID(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
listID := chi.URLParam(r, "id")
|
||||
lst, err := s.store.GetListByID(userID, listID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "Liste nicht gefunden")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, lst)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateList(w http.ResponseWriter, r *http.Request) {
|
||||
spaceID := chi.URLParam(r, "id")
|
||||
userID := auth.GetUserID(r.Context())
|
||||
var req struct {
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon"`
|
||||
@@ -1241,28 +1137,96 @@ func (s *Server) handleCreateList(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
lst, err := s.store.CreateList(spaceID, req.Title, req.Icon, req.ListType)
|
||||
lst, err := s.store.CreateList(userID, req.Title, req.Icon, req.ListType)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.wsHub.Broadcast(spaceID, lst.ID, "LIST_CREATED", lst)
|
||||
s.wsHub.BroadcastListEvent(lst.ID, "LIST_CREATED", lst)
|
||||
writeJSON(w, http.StatusCreated, lst)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteList(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
listID := chi.URLParam(r, "id")
|
||||
lst, _ := s.store.GetListByID(listID)
|
||||
if err := s.store.DeleteList(listID); err != nil {
|
||||
if err := s.store.DeleteList(userID, listID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if lst != nil {
|
||||
s.wsHub.Broadcast(lst.SpaceID, listID, "LIST_DELETED", map[string]string{"listId": listID})
|
||||
}
|
||||
s.wsHub.BroadcastListEvent(listID, "LIST_DELETED", map[string]string{"listId": listID})
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetListShares(w http.ResponseWriter, r *http.Request) {
|
||||
listID := chi.URLParam(r, "id")
|
||||
shares, err := s.store.GetListShares(listID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, shares)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateListShare(w http.ResponseWriter, r *http.Request) {
|
||||
currentUserID := auth.GetUserID(r.Context())
|
||||
listID := chi.URLParam(r, "id")
|
||||
|
||||
var req struct {
|
||||
TargetType string `json:"targetType"` // "user" or "team"
|
||||
TargetID string `json:"targetId"` // userId/email or teamId
|
||||
Role string `json:"role"` // "editor" or "viewer"
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
|
||||
targetType := strings.ToLower(strings.TrimSpace(req.TargetType))
|
||||
if targetType != "team" {
|
||||
targetType = "user"
|
||||
}
|
||||
|
||||
role := strings.ToLower(strings.TrimSpace(req.Role))
|
||||
if role != "viewer" && role != "editor" {
|
||||
role = "editor"
|
||||
}
|
||||
|
||||
share, err := s.store.AddListShare(currentUserID, listID, targetType, req.TargetID, role)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if targetType == "team" {
|
||||
members, _ := s.store.GetTeamMembers(share.TargetID)
|
||||
for _, tm := range members {
|
||||
s.wsHub.BroadcastToUser(tm.UserID, "LIST_SHARED", share)
|
||||
}
|
||||
} else {
|
||||
s.wsHub.BroadcastToUser(share.TargetID, "LIST_SHARED", share)
|
||||
}
|
||||
s.wsHub.BroadcastListEvent(listID, "LIST_SHARED", share)
|
||||
|
||||
writeJSON(w, http.StatusCreated, share)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteListShare(w http.ResponseWriter, r *http.Request) {
|
||||
currentUserID := auth.GetUserID(r.Context())
|
||||
listID := chi.URLParam(r, "id")
|
||||
shareID := chi.URLParam(r, "shareId")
|
||||
|
||||
if err := s.store.RemoveListShare(currentUserID, listID, shareID); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.wsHub.BroadcastListEvent(listID, "LIST_UNSHARED", map[string]string{
|
||||
"listId": listID,
|
||||
"shareId": shareID,
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateListItem(w http.ResponseWriter, r *http.Request) {
|
||||
listID := chi.URLParam(r, "id")
|
||||
var req struct {
|
||||
@@ -1281,9 +1245,7 @@ func (s *Server) handleCreateListItem(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if lst, err := s.store.GetListByID(listID); err == nil && lst != nil {
|
||||
s.wsHub.Broadcast(lst.SpaceID, listID, "ITEM_CREATED", item)
|
||||
}
|
||||
s.wsHub.BroadcastListEvent(listID, "ITEM_CREATED", item)
|
||||
writeJSON(w, http.StatusCreated, item)
|
||||
}
|
||||
|
||||
@@ -1301,7 +1263,7 @@ func (s *Server) handleToggleListItem(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
user, _ := s.store.GetUserByID(userID)
|
||||
userName := "Familie"
|
||||
userName := "Mitglied"
|
||||
if user != nil {
|
||||
userName = user.DisplayName
|
||||
}
|
||||
@@ -1312,9 +1274,7 @@ func (s *Server) handleToggleListItem(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if lst, err := s.store.GetListByID(listID); err == nil && lst != nil {
|
||||
s.wsHub.Broadcast(lst.SpaceID, listID, "ITEM_TOGGLED", item)
|
||||
}
|
||||
s.wsHub.BroadcastListEvent(listID, "ITEM_TOGGLED", item)
|
||||
writeJSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
@@ -1328,12 +1288,10 @@ func (s *Server) handleDeleteListItem(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if lst, err := s.store.GetListByID(listID); err == nil && lst != nil {
|
||||
s.wsHub.Broadcast(lst.SpaceID, listID, "ITEM_DELETED", map[string]string{
|
||||
s.wsHub.BroadcastListEvent(listID, "ITEM_DELETED", map[string]string{
|
||||
"itemId": itemID,
|
||||
"listId": listID,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||
}
|
||||
|
||||
@@ -1345,14 +1303,47 @@ func (s *Server) handleClearCompletedItems(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
|
||||
if lst, err := s.store.GetListByID(listID); err == nil && lst != nil {
|
||||
s.wsHub.Broadcast(lst.SpaceID, listID, "LIST_CLEARED", lst)
|
||||
}
|
||||
s.wsHub.BroadcastListEvent(listID, "LIST_CLEARED", map[string]interface{}{
|
||||
"listId": listID,
|
||||
"removed": removed,
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]int64{"removed": removed})
|
||||
}
|
||||
|
||||
func (s *Server) handleReorderListItems(w http.ResponseWriter, r *http.Request) {
|
||||
listID := chi.URLParam(r, "id")
|
||||
var req struct {
|
||||
Items []domain.ListItemReorderItem `json:"items"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.ReorderListItems(listID, req.Items); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.wsHub.BroadcastListEvent(listID, "ITEM_REORDERED", map[string]interface{}{
|
||||
"listId": listID,
|
||||
"items": req.Items,
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
spaceID := chi.URLParam(r, "spaceId")
|
||||
roomID := chi.URLParam(r, "listId")
|
||||
if roomID == "" {
|
||||
roomID = chi.URLParam(r, "spaceId")
|
||||
}
|
||||
if roomID == "" {
|
||||
roomID = r.URL.Query().Get("listId")
|
||||
}
|
||||
if roomID == "" {
|
||||
roomID = r.URL.Query().Get("spaceId")
|
||||
}
|
||||
|
||||
token := r.URL.Query().Get("token")
|
||||
if token == "" {
|
||||
if authHdr := r.Header.Get("Authorization"); strings.HasPrefix(authHdr, "Bearer ") {
|
||||
@@ -1365,7 +1356,7 @@ func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
userID = claims.UserID
|
||||
}
|
||||
}
|
||||
s.wsHub.ServeWS(w, r, spaceID, userID)
|
||||
s.wsHub.ServeWS(w, r, roomID, userID)
|
||||
}
|
||||
|
||||
// --- API KEYS & WEBHOOK INGESTION ---
|
||||
|
||||
+39
-41
@@ -117,7 +117,6 @@ func TestFirstBootSetupAndInviteRegistrationAndNonNullSlices(t *testing.T) {
|
||||
"/api/v1/tasks",
|
||||
"/api/v1/organizations",
|
||||
"/api/v1/teams",
|
||||
"/api/v1/spaces",
|
||||
"/api/v1/lists",
|
||||
}
|
||||
for _, ep := range arrayEndpoints {
|
||||
@@ -190,7 +189,6 @@ func TestConcurrentInitialLoadAndListsNoDeadlock(t *testing.T) {
|
||||
"/api/v1/auth/me",
|
||||
"/api/v1/triage/deck",
|
||||
"/api/v1/lists",
|
||||
"/api/v1/spaces",
|
||||
"/api/v1/tasks",
|
||||
"/api/v1/documents",
|
||||
}
|
||||
@@ -557,104 +555,104 @@ func TestSpaceMembersDirectInvitationAPI(t *testing.T) {
|
||||
t.Fatalf("failed to generate token: %v", err)
|
||||
}
|
||||
|
||||
// 1. Admin creates a space
|
||||
createSpaceBody := []byte(`{"name":"Einkauf WG","icon":"🛒","description":"Gemeinsame Einkaufsliste"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/spaces", bytes.NewReader(createSpaceBody))
|
||||
// 1. Admin creates a list
|
||||
createListBody := []byte(`{"title":"Einkauf WG","icon":"🛒","listType":"shopping"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/lists", bytes.NewReader(createListBody))
|
||||
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201 Created from /api/v1/spaces, got %d: %s", rec.Code, rec.Body.String())
|
||||
t.Fatalf("expected 201 Created from /api/v1/lists, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var createdSpace domain.Space
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &createdSpace)
|
||||
if createdSpace.ID == "" {
|
||||
t.Fatalf("expected created space with ID, got %+v", createdSpace)
|
||||
var createdList domain.SharedList
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &createdList)
|
||||
if createdList.ID == "" {
|
||||
t.Fatalf("expected created list with ID, got %+v", createdList)
|
||||
}
|
||||
|
||||
// 2. Admin invites normalUser by username "user"
|
||||
addMemberBody := []byte(`{"userId":"user","role":"editor"}`)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/spaces/"+createdSpace.ID+"/members", bytes.NewReader(addMemberBody))
|
||||
// 2. Admin shares list with normalUser by username "user"
|
||||
addShareBody := []byte(`{"targetType":"user","targetId":"user","role":"editor"}`)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/lists/"+createdList.ID+"/shares", bytes.NewReader(addShareBody))
|
||||
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201 Created from add space member, got %d: %s", rec.Code, rec.Body.String())
|
||||
t.Fatalf("expected 201 Created from add list share, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var newMember domain.SpaceMember
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &newMember)
|
||||
if newMember.UserID != normalUser.ID {
|
||||
t.Fatalf("expected member userId %s, got %s", normalUser.ID, newMember.UserID)
|
||||
var newShare domain.ListShare
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &newShare)
|
||||
if newShare.TargetID != normalUser.ID {
|
||||
t.Fatalf("expected share targetId %s, got %s", normalUser.ID, newShare.TargetID)
|
||||
}
|
||||
|
||||
// 3. User checks their spaces; the shared space must be listed
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/spaces", nil)
|
||||
// 3. User checks their lists; the shared list must be listed
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/lists", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+userToken)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK for user spaces, got %d", rec.Code)
|
||||
t.Fatalf("expected 200 OK for user lists, got %d", rec.Code)
|
||||
}
|
||||
var userSpaces []domain.Space
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &userSpaces)
|
||||
var userLists []domain.SharedList
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &userLists)
|
||||
found := false
|
||||
for _, s := range userSpaces {
|
||||
if s.ID == createdSpace.ID {
|
||||
for _, l := range userLists {
|
||||
if l.ID == createdList.ID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected space %s to be visible to user, got %+v", createdSpace.ID, userSpaces)
|
||||
t.Fatalf("expected list %s to be visible to user, got %+v", createdList.ID, userLists)
|
||||
}
|
||||
|
||||
// 4. Admin removes user from space
|
||||
req = httptest.NewRequest(http.MethodDelete, "/api/v1/spaces/"+createdSpace.ID+"/members/"+normalUser.ID, nil)
|
||||
// 4. Admin removes user share from list
|
||||
req = httptest.NewRequest(http.MethodDelete, "/api/v1/lists/"+createdList.ID+"/shares/"+newShare.ID, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK for remove member, got %d: %s", rec.Code, rec.Body.String())
|
||||
t.Fatalf("expected 200 OK for remove share, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// 5. Test adding a team to a space
|
||||
// 5. Test sharing list with a team
|
||||
team, err := store.CreateTeam("", "WG Team", "Haushalt")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create team: %v", err)
|
||||
}
|
||||
_ = store.AddTeamMember(team.ID, normalUser.ID, "member")
|
||||
|
||||
addTeamBody := []byte(`{"teamId":"` + team.ID + `","role":"editor"}`)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/spaces/"+createdSpace.ID+"/members", bytes.NewReader(addTeamBody))
|
||||
addTeamBody := []byte(`{"targetType":"team","targetId":"` + team.ID + `","role":"editor"}`)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/lists/"+createdList.ID+"/shares", bytes.NewReader(addTeamBody))
|
||||
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated && rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 201 or 200 for add space team, got %d: %s", rec.Code, rec.Body.String())
|
||||
t.Fatalf("expected 201 or 200 for add list team share, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// User should see the space again because their team was added
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/spaces", nil)
|
||||
// User should see the list again because their team was added
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/lists", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+userToken)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK for user spaces after team add, got %d", rec.Code)
|
||||
t.Fatalf("expected 200 OK for user lists after team share add, got %d", rec.Code)
|
||||
}
|
||||
userSpaces = nil
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &userSpaces)
|
||||
userLists = nil
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &userLists)
|
||||
found = false
|
||||
for _, s := range userSpaces {
|
||||
if s.ID == createdSpace.ID {
|
||||
for _, l := range userLists {
|
||||
if l.ID == createdList.ID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected space %s to be visible to user via team, got %+v", createdSpace.ID, userSpaces)
|
||||
t.Fatalf("expected list %s to be visible to user via team share, got %+v", createdList.ID, userLists)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+23
-29
@@ -154,10 +154,24 @@ type Thought struct {
|
||||
Notes string `json:"notes,omitempty"`
|
||||
Subtasks []SubTask `json:"subtasks"`
|
||||
AlarmSent bool `json:"alarmSent,omitempty"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
TriagedAt string `json:"triagedAt,omitempty"`
|
||||
}
|
||||
|
||||
type ThoughtReorderItem struct {
|
||||
ID string `json:"id"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
KanbanStatus *string `json:"kanbanStatus,omitempty"`
|
||||
}
|
||||
|
||||
type ListItemReorderItem struct {
|
||||
ID string `json:"id"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
Category *string `json:"category,omitempty"`
|
||||
Content *string `json:"content,omitempty"`
|
||||
}
|
||||
|
||||
type TaskListShare struct {
|
||||
ID string `json:"id"`
|
||||
TaskListID string `json:"taskListId"`
|
||||
@@ -324,40 +338,18 @@ type TagDetails struct {
|
||||
TotalMatches int `json:"totalMatches"`
|
||||
}
|
||||
|
||||
type SpaceMember struct {
|
||||
SpaceID string `json:"spaceId"`
|
||||
UserID string `json:"userId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"` // owner | editor | viewer
|
||||
JoinedAt string `json:"joinedAt"`
|
||||
}
|
||||
|
||||
type SpaceShare struct {
|
||||
type ListShare struct {
|
||||
ID string `json:"id"`
|
||||
SpaceID string `json:"spaceId"`
|
||||
ListID string `json:"listId"`
|
||||
TargetType string `json:"targetType"` // team | user
|
||||
TargetID string `json:"targetId"`
|
||||
TargetName string `json:"targetName,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Role string `json:"role"` // editor | viewer
|
||||
CreatedBy string `json:"createdBy"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type Space struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"`
|
||||
Description string `json:"description"`
|
||||
InviteCode string `json:"inviteCode"`
|
||||
IsPersonal bool `json:"isPersonal"`
|
||||
OwnerID string `json:"ownerId"`
|
||||
Members []SpaceMember `json:"members"`
|
||||
Shares []SpaceShare `json:"shares,omitempty"`
|
||||
ListsCount int `json:"listsCount"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type ListItem struct {
|
||||
ID string `json:"id"`
|
||||
ListID string `json:"listId"`
|
||||
@@ -374,11 +366,15 @@ type ListItem struct {
|
||||
|
||||
type SharedList struct {
|
||||
ID string `json:"id"`
|
||||
SpaceID string `json:"spaceId"`
|
||||
UserID string `json:"userId"`
|
||||
OwnerName string `json:"ownerName,omitempty"`
|
||||
IsOwner bool `json:"isOwner"`
|
||||
Role string `json:"role,omitempty"` // editor | viewer
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon"`
|
||||
ListType string `json:"listType"` // shopping | checklist | vacation
|
||||
Items []ListItem `json:"items"`
|
||||
Shares []ListShare `json:"shares,omitempty"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
@@ -397,8 +393,7 @@ type APIKey struct {
|
||||
|
||||
type WSMessage struct {
|
||||
Event string `json:"event"` // ITEM_CREATED | ITEM_UPDATED | ITEM_TOGGLED | ITEM_DELETED | LIST_CLEARED | USER_PRESENCE
|
||||
SpaceID string `json:"space_id"`
|
||||
ListID string `json:"list_id,omitempty"`
|
||||
ListID string `json:"list_id"`
|
||||
Payload interface{} `json:"payload"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
@@ -419,7 +414,6 @@ type FullExport struct {
|
||||
User User `json:"user"`
|
||||
Thoughts []Thought `json:"thoughts"`
|
||||
Documents []Document `json:"documents"`
|
||||
Spaces []Space `json:"spaces"`
|
||||
Lists []SharedList `json:"lists"`
|
||||
TaskLists []TaskList `json:"taskLists,omitempty"`
|
||||
}
|
||||
+315
-427
@@ -137,28 +137,6 @@ func (s *Store) migrate() error {
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_task_lists_user ON task_lists(user_id, updated_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS spaces (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
icon TEXT NOT NULL DEFAULT '🏠',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
invite_code TEXT UNIQUE NOT NULL,
|
||||
is_personal INTEGER NOT NULL DEFAULT 0,
|
||||
owner_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(owner_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS space_members (
|
||||
space_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'editor',
|
||||
joined_at TEXT NOT NULL,
|
||||
PRIMARY KEY (space_id, user_id),
|
||||
FOREIGN KEY(space_id) REFERENCES spaces(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS organizations (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
@@ -259,16 +237,32 @@ func (s *Store) migrate() error {
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lists (
|
||||
id TEXT PRIMARY KEY,
|
||||
space_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
icon TEXT NOT NULL DEFAULT '🛒',
|
||||
list_type TEXT NOT NULL DEFAULT 'shopping',
|
||||
updated_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(space_id) REFERENCES spaces(id) ON DELETE CASCADE
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_lists_space ON lists(space_id, updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_lists_user ON lists(user_id, updated_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS list_shares (
|
||||
id TEXT PRIMARY KEY,
|
||||
list_id TEXT NOT NULL,
|
||||
target_type TEXT NOT NULL CHECK(target_type IN ('team', 'user')),
|
||||
target_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'editor' CHECK(role IN ('editor', 'viewer')),
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(list_id, target_type, target_id),
|
||||
FOREIGN KEY(list_id) REFERENCES lists(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_list_shares_target ON list_shares(target_type, target_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_list_shares_list ON list_shares(list_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS list_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -325,6 +319,8 @@ func (s *Store) migrate() error {
|
||||
"ALTER TABLE thoughts ADD COLUMN notes TEXT NOT NULL DEFAULT '';",
|
||||
"ALTER TABLE thoughts ADD COLUMN alarm_sent INTEGER NOT NULL DEFAULT 0;",
|
||||
"ALTER TABLE thoughts ADD COLUMN subtasks TEXT NOT NULL DEFAULT '[]';",
|
||||
"ALTER TABLE thoughts ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0;",
|
||||
"CREATE INDEX IF NOT EXISTS idx_thoughts_list_sort ON thoughts(task_list_id, sort_order ASC, created_at DESC);",
|
||||
|
||||
"ALTER TABLE documents ADD COLUMN folder_path TEXT NOT NULL DEFAULT '';",
|
||||
|
||||
@@ -342,14 +338,20 @@ func (s *Store) migrate() error {
|
||||
"CREATE INDEX IF NOT EXISTS idx_doc_team_shares_team ON doc_team_shares(team_id);",
|
||||
"CREATE INDEX IF NOT EXISTS idx_doc_team_shares_doc ON doc_team_shares(doc_id);",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS space_team_shares (id TEXT PRIMARY KEY, space_id TEXT NOT NULL, team_id TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'editor', created_at TEXT NOT NULL, created_by TEXT NOT NULL, UNIQUE(space_id, team_id));",
|
||||
"CREATE INDEX IF NOT EXISTS idx_space_team_shares_team ON space_team_shares(team_id);",
|
||||
"CREATE INDEX IF NOT EXISTS idx_space_team_shares_space ON space_team_shares(space_id);",
|
||||
"ALTER TABLE lists ADD COLUMN user_id TEXT NOT NULL DEFAULT '';",
|
||||
"CREATE TABLE IF NOT EXISTS list_shares (id TEXT PRIMARY KEY, list_id TEXT NOT NULL, target_type TEXT NOT NULL CHECK(target_type IN ('team', 'user')), target_id TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'editor' CHECK(role IN ('editor', 'viewer')), created_at TEXT NOT NULL, created_by TEXT NOT NULL, UNIQUE(list_id, target_type, target_id));",
|
||||
"CREATE INDEX IF NOT EXISTS idx_list_shares_target ON list_shares(target_type, target_id);",
|
||||
"CREATE INDEX IF NOT EXISTS idx_list_shares_list ON list_shares(list_id);",
|
||||
}
|
||||
for _, q := range columnMigrations {
|
||||
_, _ = s.db.Exec(q)
|
||||
}
|
||||
|
||||
// Clean up legacy space_id from documents and task_lists if empty or orphaned
|
||||
_, _ = s.db.Exec(`DROP TABLE IF EXISTS spaces`)
|
||||
_, _ = s.db.Exec(`DROP TABLE IF EXISTS space_members`)
|
||||
_, _ = s.db.Exec(`DROP TABLE IF EXISTS space_team_shares`)
|
||||
|
||||
// Backfill username for existing accounts where username is empty
|
||||
_, _ = s.db.Exec(`UPDATE users SET username = LOWER(SUBSTR(email, 1, INSTR(email, '@') - 1)) WHERE (username IS NULL OR username = '') AND INSTR(email, '@') > 0`)
|
||||
|
||||
@@ -442,14 +444,11 @@ func (s *Store) SeedStarterContent(user *domain.User) error {
|
||||
_, _ = s.UpdateThoughtTriage(user.ID, keptTask.ID, "keep", "task", "", now[:10], "")
|
||||
}
|
||||
|
||||
familySpace, err := s.CreateSpace(user.ID, "Familie & Zuhause", "🏡", "Geteilter Bereich für Einkäufe, Haushalt & Urlaubsplanung", false)
|
||||
if err == nil && familySpace != nil {
|
||||
shopList, err := s.CreateList(familySpace.ID, "Wocheneinkauf Supermarkt", "🛒", "shopping")
|
||||
shopList, err := s.CreateList(user.ID, "Wocheneinkauf Supermarkt", "🛒", "shopping")
|
||||
if err == nil && shopList != nil {
|
||||
_, _ = s.CreateListItem(shopList.ID, "Hafermilch (Barista Edition)", "Kühlregal", "3x", 0)
|
||||
_, _ = s.CreateListItem(shopList.ID, "Espressobohnen (100% Arabica)", "Kaffee & Tee", "1 kg", 1)
|
||||
}
|
||||
}
|
||||
|
||||
starterBlocks := []domain.DocumentBlock{
|
||||
{ID: "blk_1", Type: "callout", Content: "Willkommen in deinem MindSift Workspace! Hier wachsen flüchtige Gedanken aus deiner Inbox zu strukturierten Projekten heran."},
|
||||
@@ -692,12 +691,8 @@ func (s *Store) GetDiscoverableUsers(viewerID string) ([]domain.User, error) {
|
||||
SELECT tm2.user_id FROM team_members tm1
|
||||
JOIN team_members tm2 ON tm1.team_id = tm2.team_id
|
||||
WHERE tm1.user_id = ?
|
||||
UNION
|
||||
SELECT sm2.user_id FROM space_members sm1
|
||||
JOIN space_members sm2 ON sm1.space_id = sm2.space_id
|
||||
WHERE sm1.user_id = ?
|
||||
)`,
|
||||
viewerID, viewerID, viewerID,
|
||||
viewerID, viewerID,
|
||||
)
|
||||
if err != nil {
|
||||
return []domain.User{*viewer}, nil
|
||||
@@ -842,16 +837,17 @@ func scanThought(rows interface {
|
||||
}) (*domain.Thought, error) {
|
||||
t := &domain.Thought{}
|
||||
var tagsStr, subtasksStr, statusStr string
|
||||
var isCompleted, alarmSent int
|
||||
var isCompleted, alarmSent, sortOrder int
|
||||
err := rows.Scan(
|
||||
&t.ID, &t.UserID, &t.Content, &tagsStr, &t.Priority, &t.Source,
|
||||
&statusStr, &isCompleted, &t.ConvertedType, &t.ConvertedRefID,
|
||||
&t.DueDate, &t.TaskListID, &t.KanbanStatus, &t.Notes, &alarmSent,
|
||||
&t.CreatedAt, &t.TriagedAt, &subtasksStr,
|
||||
&t.CreatedAt, &t.TriagedAt, &subtasksStr, &sortOrder,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.SortOrder = sortOrder
|
||||
t.Status = domain.ThoughtStatus(statusStr)
|
||||
t.IsCompleted = isCompleted != 0
|
||||
t.AlarmSent = alarmSent != 0
|
||||
@@ -879,15 +875,14 @@ func (s *Store) accessibleTaskListIDsQuery() string {
|
||||
SELECT task_list_id FROM task_list_shares
|
||||
WHERE (target_type = 'user' AND target_id = ?)
|
||||
OR (target_type = 'team' AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
||||
)
|
||||
OR (space_id != '' AND space_id IN (SELECT space_id FROM space_members WHERE user_id = ?))`
|
||||
)`
|
||||
}
|
||||
|
||||
func (s *Store) GetThoughtByID(userID, thoughtID string) (*domain.Thought, error) {
|
||||
row := s.db.QueryRow(
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order
|
||||
FROM thoughts WHERE id = ? AND (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))`,
|
||||
thoughtID, userID, userID, userID, userID, userID, userID,
|
||||
thoughtID, userID, userID, userID, userID, userID,
|
||||
)
|
||||
return scanThought(row)
|
||||
}
|
||||
@@ -901,16 +896,16 @@ func (s *Store) GetThoughts(userID, statusFilter string, limit int) ([]domain.Th
|
||||
|
||||
if statusFilter != "" && statusFilter != "all" {
|
||||
rows, err = s.db.Query(
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order
|
||||
FROM thoughts WHERE user_id = ? AND status = ?
|
||||
ORDER BY CASE priority WHEN 'urgent' THEN 1 WHEN 'high' THEN 2 WHEN 'normal' THEN 3 ELSE 4 END, created_at DESC LIMIT ?`,
|
||||
ORDER BY CASE priority WHEN 'urgent' THEN 1 WHEN 'high' THEN 2 WHEN 'normal' THEN 3 ELSE 4 END, sort_order ASC, created_at DESC LIMIT ?`,
|
||||
userID, statusFilter, limit,
|
||||
)
|
||||
} else {
|
||||
rows, err = s.db.Query(
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order
|
||||
FROM thoughts WHERE user_id = ?
|
||||
ORDER BY created_at DESC LIMIT ?`,
|
||||
ORDER BY sort_order ASC, created_at DESC LIMIT ?`,
|
||||
userID, limit,
|
||||
)
|
||||
}
|
||||
@@ -934,22 +929,22 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er
|
||||
var err error
|
||||
if taskListID != "" {
|
||||
rows, err = s.db.Query(
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order
|
||||
FROM thoughts
|
||||
WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
|
||||
AND task_list_id = ?
|
||||
AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))
|
||||
ORDER BY is_completed ASC, CASE WHEN due_date = '' THEN 1 ELSE 0 END, due_date ASC, created_at DESC`,
|
||||
userID, userID, userID, userID, userID, userID, taskListID,
|
||||
ORDER BY is_completed ASC, sort_order ASC, created_at DESC`,
|
||||
userID, userID, userID, userID, userID, taskListID,
|
||||
)
|
||||
} else {
|
||||
rows, err = s.db.Query(
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order
|
||||
FROM thoughts
|
||||
WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
|
||||
AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))
|
||||
ORDER BY is_completed ASC, CASE WHEN due_date = '' THEN 1 ELSE 0 END, due_date ASC, created_at DESC`,
|
||||
userID, userID, userID, userID, userID, userID,
|
||||
ORDER BY is_completed ASC, sort_order ASC, created_at DESC`,
|
||||
userID, userID, userID, userID, userID,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
@@ -967,6 +962,37 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Store) ReorderTasks(taskListID string, items []domain.ThoughtReorderItem) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
for _, it := range items {
|
||||
if it.KanbanStatus != nil && *it.KanbanStatus != "" {
|
||||
isCompleted := 0
|
||||
if *it.KanbanStatus == "done" {
|
||||
isCompleted = 1
|
||||
}
|
||||
_, err = tx.Exec(
|
||||
`UPDATE thoughts SET sort_order = ?, kanban_status = ?, is_completed = ? WHERE id = ?`,
|
||||
it.SortOrder, *it.KanbanStatus, isCompleted, it.ID,
|
||||
)
|
||||
} else {
|
||||
_, err = tx.Exec(
|
||||
`UPDATE thoughts SET sort_order = ? WHERE id = ?`,
|
||||
it.SortOrder, it.ID,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// UpdateThoughtTriage handles swipe left (discard), swipe right (keep), restore, and conversion to task/document/list_item
|
||||
func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, targetListID, dueDate, taskListID string) (*domain.Thought, error) {
|
||||
thought, err := s.GetThoughtByID(userID, thoughtID)
|
||||
@@ -2190,339 +2216,9 @@ func (s *Store) GetCollaborators(currentUserID string) ([]domain.User, error) {
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// --- SPACES & REALTIME LISTS ---
|
||||
// --- STANDALONE REALTIME LISTS & SHARING ---
|
||||
|
||||
func (s *Store) CreateSpace(ownerID, name, icon, description string, isPersonal bool) (*domain.Space, error) {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return nil, errors.New("space name required")
|
||||
}
|
||||
if icon == "" {
|
||||
icon = "🏠"
|
||||
}
|
||||
now := domain.NowISO()
|
||||
inviteCode := strings.ToUpper(domain.NewID("inv")[4:12])
|
||||
personalInt := 0
|
||||
if isPersonal {
|
||||
personalInt = 1
|
||||
}
|
||||
|
||||
sp := &domain.Space{
|
||||
ID: domain.NewID("spc"),
|
||||
Name: strings.TrimSpace(name),
|
||||
Icon: icon,
|
||||
Description: strings.TrimSpace(description),
|
||||
InviteCode: inviteCode,
|
||||
IsPersonal: isPersonal,
|
||||
OwnerID: ownerID,
|
||||
CreatedAt: now,
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO spaces (id, name, icon, description, invite_code, is_personal, owner_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
sp.ID, sp.Name, sp.Icon, sp.Description, sp.InviteCode, personalInt, sp.OwnerID, sp.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, _ = s.db.Exec(
|
||||
`INSERT INTO space_members (space_id, user_id, role, joined_at) VALUES (?, ?, 'owner', ?)`,
|
||||
sp.ID, ownerID, now,
|
||||
)
|
||||
return s.GetSpaceByID(sp.ID)
|
||||
}
|
||||
|
||||
func (s *Store) JoinSpaceByInviteCode(userID, inviteCode string) (*domain.Space, error) {
|
||||
inviteCode = strings.ToUpper(strings.TrimSpace(inviteCode))
|
||||
var spaceID string
|
||||
err := s.db.QueryRow(`SELECT id FROM spaces WHERE invite_code = ?`, inviteCode).Scan(&spaceID)
|
||||
if err != nil {
|
||||
return nil, errors.New("Ungültiger Einladungscode")
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(
|
||||
`INSERT OR IGNORE INTO space_members (space_id, user_id, role, joined_at) VALUES (?, ?, 'editor', ?)`,
|
||||
spaceID, userID, domain.NowISO(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetSpaceByID(spaceID)
|
||||
}
|
||||
|
||||
func (s *Store) GetSpaceByID(spaceID string) (*domain.Space, error) {
|
||||
sp := &domain.Space{}
|
||||
var personalInt int
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, name, icon, description, invite_code, is_personal, owner_id, created_at FROM spaces WHERE id = ?`,
|
||||
spaceID,
|
||||
).Scan(&sp.ID, &sp.Name, &sp.Icon, &sp.Description, &sp.InviteCode, &personalInt, &sp.OwnerID, &sp.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sp.IsPersonal = personalInt != 0
|
||||
sp.Members, _ = s.GetSpaceMembers(spaceID)
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM lists WHERE space_id = ?`, spaceID).Scan(&sp.ListsCount)
|
||||
return sp, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetSpaceMembers(spaceID string) ([]domain.SpaceMember, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT sm.space_id, sm.user_id, u.display_name, u.email, sm.role, sm.joined_at
|
||||
FROM space_members sm
|
||||
JOIN users u ON u.id = sm.user_id
|
||||
WHERE sm.space_id = ?
|
||||
ORDER BY sm.joined_at ASC`,
|
||||
spaceID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
members := make([]domain.SpaceMember, 0)
|
||||
for rows.Next() {
|
||||
var m domain.SpaceMember
|
||||
if err := rows.Scan(&m.SpaceID, &m.UserID, &m.DisplayName, &m.Email, &m.Role, &m.JoinedAt); err == nil {
|
||||
members = append(members, m)
|
||||
}
|
||||
}
|
||||
return members, nil
|
||||
}
|
||||
|
||||
func (s *Store) AddSpaceMember(currentUserID, spaceID, targetUserIdentifier, role string) (*domain.SpaceMember, error) {
|
||||
// 1. Check if current user is member of space
|
||||
var myRole string
|
||||
err := s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, currentUserID).Scan(&myRole)
|
||||
if err != nil {
|
||||
return nil, errors.New("keine Berechtigung für diesen Bereich")
|
||||
}
|
||||
|
||||
// 2. Resolve target user by ID, username or email
|
||||
cleanTarget := strings.TrimSpace(targetUserIdentifier)
|
||||
var targetUser *domain.User
|
||||
if strings.HasPrefix(cleanTarget, "usr_") {
|
||||
targetUser, _ = s.GetUserByID(cleanTarget)
|
||||
}
|
||||
if targetUser == nil {
|
||||
targetUser, _ = s.GetUserByLogin(cleanTarget)
|
||||
}
|
||||
if targetUser == nil {
|
||||
targetUser, err = s.GetUserByID(cleanTarget)
|
||||
}
|
||||
if err != nil || targetUser == nil {
|
||||
return nil, errors.New("benutzer nicht gefunden")
|
||||
}
|
||||
|
||||
// Protect existing owner from role downgrade!
|
||||
var existingRole string
|
||||
_ = s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, targetUser.ID).Scan(&existingRole)
|
||||
if existingRole == "owner" {
|
||||
role = "owner"
|
||||
} else if role != "owner" && role != "editor" && role != "viewer" {
|
||||
role = "editor"
|
||||
}
|
||||
|
||||
now := domain.NowISO()
|
||||
_, err = s.db.Exec(
|
||||
`INSERT INTO space_members (space_id, user_id, role, joined_at) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(space_id, user_id) DO UPDATE SET role = excluded.role`,
|
||||
spaceID, targetUser.ID, role, now,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &domain.SpaceMember{
|
||||
SpaceID: spaceID,
|
||||
UserID: targetUser.ID,
|
||||
DisplayName: targetUser.DisplayName,
|
||||
Email: targetUser.Email,
|
||||
Role: role,
|
||||
JoinedAt: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateSpaceTeamShare(currentUserID, spaceID, teamID, role string) (*domain.SpaceShare, error) {
|
||||
var myRole string
|
||||
err := s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, currentUserID).Scan(&myRole)
|
||||
if err != nil || (myRole != "owner" && myRole != "editor") {
|
||||
return nil, errors.New("keine Berechtigung für diesen Bereich")
|
||||
}
|
||||
|
||||
cleanTeamID := strings.TrimSpace(teamID)
|
||||
var teamName string
|
||||
err = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, cleanTeamID).Scan(&teamName)
|
||||
if err != nil {
|
||||
return nil, errors.New("team nicht gefunden")
|
||||
}
|
||||
|
||||
if role != "owner" && role != "editor" && role != "viewer" {
|
||||
role = "editor"
|
||||
}
|
||||
|
||||
now := domain.NowISO()
|
||||
shareID := domain.NewID("spshare")
|
||||
_, err = s.db.Exec(
|
||||
`INSERT INTO space_team_shares (id, space_id, team_id, role, created_at, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(space_id, team_id) DO UPDATE SET role = excluded.role`,
|
||||
shareID, spaceID, cleanTeamID, role, now, currentUserID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &domain.SpaceShare{
|
||||
ID: shareID,
|
||||
SpaceID: spaceID,
|
||||
TargetType: "team",
|
||||
TargetID: cleanTeamID,
|
||||
TargetName: teamName,
|
||||
Role: role,
|
||||
CreatedBy: currentUserID,
|
||||
CreatedAt: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetSpaceShares(spaceID string) ([]domain.SpaceShare, error) {
|
||||
shares := make([]domain.SpaceShare, 0)
|
||||
|
||||
// 1. Team shares
|
||||
tRows, err := s.db.Query(
|
||||
`SELECT s.id, s.space_id, s.team_id, t.name, s.role, s.created_by, s.created_at
|
||||
FROM space_team_shares s
|
||||
JOIN teams t ON s.team_id = t.id
|
||||
WHERE s.space_id = ?
|
||||
ORDER BY s.created_at ASC`,
|
||||
spaceID,
|
||||
)
|
||||
if err == nil {
|
||||
for tRows.Next() {
|
||||
var sh domain.SpaceShare
|
||||
if err := tRows.Scan(&sh.ID, &sh.SpaceID, &sh.TargetID, &sh.TargetName, &sh.Role, &sh.CreatedBy, &sh.CreatedAt); err == nil {
|
||||
sh.TargetType = "team"
|
||||
shares = append(shares, sh)
|
||||
}
|
||||
}
|
||||
_ = tRows.Close()
|
||||
}
|
||||
|
||||
// 2. Individual invited members (non-owner members)
|
||||
var ownerID string
|
||||
_ = s.db.QueryRow(`SELECT owner_id FROM spaces WHERE id = ?`, spaceID).Scan(&ownerID)
|
||||
|
||||
mRows, err := s.db.Query(
|
||||
`SELECT sm.user_id, u.display_name, u.email, sm.role, sm.joined_at
|
||||
FROM space_members sm
|
||||
JOIN users u ON u.id = sm.user_id
|
||||
WHERE sm.space_id = ? AND sm.user_id != ?
|
||||
ORDER BY sm.joined_at ASC`,
|
||||
spaceID, ownerID,
|
||||
)
|
||||
if err == nil {
|
||||
for mRows.Next() {
|
||||
var uid, dName, email, role, joinedAt string
|
||||
if err := mRows.Scan(&uid, &dName, &email, &role, &joinedAt); err == nil {
|
||||
name := dName
|
||||
if name == "" {
|
||||
name = email
|
||||
}
|
||||
shares = append(shares, domain.SpaceShare{
|
||||
ID: "sm_" + uid,
|
||||
SpaceID: spaceID,
|
||||
TargetType: "user",
|
||||
TargetID: uid,
|
||||
TargetName: name,
|
||||
Role: role,
|
||||
CreatedAt: joinedAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
_ = mRows.Close()
|
||||
}
|
||||
|
||||
return shares, nil
|
||||
}
|
||||
|
||||
func (s *Store) RemoveSpaceTeamShare(currentUserID, spaceID, targetID string) error {
|
||||
var myRole string
|
||||
err := s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, currentUserID).Scan(&myRole)
|
||||
if err != nil || (myRole != "owner" && myRole != "editor") {
|
||||
return errors.New("keine Berechtigung zum Entfernen der Freigabe")
|
||||
}
|
||||
|
||||
cleanTarget := strings.TrimSpace(targetID)
|
||||
_, err = s.db.Exec(`DELETE FROM space_team_shares WHERE space_id = ? AND (id = ? OR team_id = ?)`, spaceID, cleanTarget, cleanTarget)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) RemoveSpaceMember(currentUserID, spaceID, targetUserID string) error {
|
||||
var myRole string
|
||||
err := s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, currentUserID).Scan(&myRole)
|
||||
if err != nil {
|
||||
return errors.New("keine Berechtigung für diesen Bereich")
|
||||
}
|
||||
|
||||
cleanTarget := strings.TrimSpace(targetUserID)
|
||||
if strings.HasPrefix(cleanTarget, "team_") || strings.HasPrefix(cleanTarget, "spshare_") {
|
||||
return s.RemoveSpaceTeamShare(currentUserID, spaceID, cleanTarget)
|
||||
}
|
||||
|
||||
if currentUserID != cleanTarget && myRole != "owner" {
|
||||
return errors.New("nur der Eigentümer kann andere Mitglieder entfernen")
|
||||
}
|
||||
|
||||
var targetRole string
|
||||
_ = s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, cleanTarget).Scan(&targetRole)
|
||||
if targetRole == "owner" {
|
||||
var ownerCount int
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM space_members WHERE space_id = ? AND role = 'owner'`, spaceID).Scan(&ownerCount)
|
||||
if ownerCount <= 1 {
|
||||
return errors.New("der letzte Eigentümer kann den Bereich nicht verlassen")
|
||||
}
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(`DELETE FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, cleanTarget)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetSpacesForUser(userID string) ([]domain.Space, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT s.id, s.name, s.icon, s.description, s.invite_code, s.is_personal, s.owner_id, s.created_at
|
||||
FROM spaces s
|
||||
LEFT JOIN space_members sm ON sm.space_id = s.id AND sm.user_id = ?
|
||||
LEFT JOIN space_team_shares sts ON sts.space_id = s.id AND sts.team_id IN (SELECT team_id FROM team_members WHERE user_id = ?)
|
||||
WHERE sm.user_id = ? OR sts.team_id IS NOT NULL
|
||||
GROUP BY s.id
|
||||
ORDER BY s.created_at ASC`,
|
||||
userID, userID, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
spaces := make([]domain.Space, 0)
|
||||
for rows.Next() {
|
||||
var sp domain.Space
|
||||
var personalInt int
|
||||
if err := rows.Scan(&sp.ID, &sp.Name, &sp.Icon, &sp.Description, &sp.InviteCode, &personalInt, &sp.OwnerID, &sp.CreatedAt); err == nil {
|
||||
sp.IsPersonal = personalInt != 0
|
||||
spaces = append(spaces, sp)
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
|
||||
for i := range spaces {
|
||||
spaces[i].Members, _ = s.GetSpaceMembers(spaces[i].ID)
|
||||
spaces[i].Shares, _ = s.GetSpaceShares(spaces[i].ID)
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM lists WHERE space_id = ?`, spaces[i].ID).Scan(&spaces[i].ListsCount)
|
||||
}
|
||||
return spaces, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateList(spaceID, title, icon, listType string) (*domain.SharedList, error) {
|
||||
func (s *Store) CreateList(userID, title, icon, listType string) (*domain.SharedList, error) {
|
||||
if strings.TrimSpace(title) == "" {
|
||||
return nil, errors.New("list title required")
|
||||
}
|
||||
@@ -2535,59 +2231,167 @@ func (s *Store) CreateList(spaceID, title, icon, listType string) (*domain.Share
|
||||
now := domain.NowISO()
|
||||
lst := &domain.SharedList{
|
||||
ID: domain.NewID("lst"),
|
||||
SpaceID: spaceID,
|
||||
UserID: userID,
|
||||
IsOwner: true,
|
||||
Role: "editor",
|
||||
Title: strings.TrimSpace(title),
|
||||
Icon: icon,
|
||||
ListType: listType,
|
||||
Items: []domain.ListItem{},
|
||||
Shares: []domain.ListShare{},
|
||||
UpdatedAt: now,
|
||||
CreatedAt: now,
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO lists (id, space_id, title, icon, list_type, updated_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
lst.ID, lst.SpaceID, lst.Title, lst.Icon, lst.ListType, lst.UpdatedAt, lst.CreatedAt,
|
||||
`INSERT INTO lists (id, user_id, title, icon, list_type, updated_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
lst.ID, lst.UserID, lst.Title, lst.Icon, lst.ListType, lst.UpdatedAt, lst.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, userID).Scan(&lst.OwnerName)
|
||||
return lst, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetListsForSpace(spaceID string) ([]domain.SharedList, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, space_id, title, icon, list_type, updated_at, created_at
|
||||
FROM lists WHERE space_id = ? ORDER BY created_at ASC`,
|
||||
spaceID,
|
||||
func (s *Store) AddListShare(currentUserID, listID, targetType, targetID, role string) (*domain.ListShare, error) {
|
||||
if targetType != "team" && targetType != "user" {
|
||||
return nil, errors.New("ungültiger Freigabetyp")
|
||||
}
|
||||
targetID = strings.TrimSpace(targetID)
|
||||
if targetID == "" {
|
||||
return nil, errors.New("Ziel-ID erforderlich")
|
||||
}
|
||||
if role != "viewer" && role != "editor" {
|
||||
role = "editor"
|
||||
}
|
||||
|
||||
if targetType == "user" {
|
||||
var targetUser *domain.User
|
||||
if strings.HasPrefix(targetID, "usr_") {
|
||||
targetUser, _ = s.GetUserByID(targetID)
|
||||
}
|
||||
if targetUser == nil {
|
||||
targetUser, _ = s.GetUserByLogin(targetID)
|
||||
}
|
||||
if targetUser == nil {
|
||||
targetUser, _ = s.GetUserByEmail(targetID)
|
||||
}
|
||||
if targetUser == nil {
|
||||
return nil, fmt.Errorf("benutzer nicht gefunden: %s", targetID)
|
||||
}
|
||||
targetID = targetUser.ID
|
||||
}
|
||||
|
||||
// Verify current user is owner or editor of the list
|
||||
var ownerID string
|
||||
err := s.db.QueryRow(`SELECT user_id FROM lists WHERE id = ?`, listID).Scan(&ownerID)
|
||||
if err != nil {
|
||||
return nil, errors.New("liste nicht gefunden")
|
||||
}
|
||||
if ownerID != currentUserID {
|
||||
var myRole string
|
||||
err = s.db.QueryRow(`SELECT role FROM list_shares WHERE list_id = ? AND target_type = 'user' AND target_id = ?`, listID, currentUserID).Scan(&myRole)
|
||||
if err != nil || myRole != "editor" {
|
||||
return nil, errors.New("keine Berechtigung zum Teilen dieser Liste")
|
||||
}
|
||||
}
|
||||
|
||||
now := domain.NowISO()
|
||||
share := &domain.ListShare{
|
||||
ID: domain.NewID("lsh"),
|
||||
ListID: listID,
|
||||
TargetType: targetType,
|
||||
TargetID: targetID,
|
||||
Role: role,
|
||||
CreatedBy: currentUserID,
|
||||
CreatedAt: now,
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(
|
||||
`INSERT INTO list_shares (id, list_id, target_type, target_id, role, created_by, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(list_id, target_type, target_id) DO UPDATE SET role = excluded.role`,
|
||||
share.ID, share.ListID, share.TargetType, share.TargetID, share.Role, share.CreatedBy, share.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lists := make([]domain.SharedList, 0)
|
||||
for rows.Next() {
|
||||
var l domain.SharedList
|
||||
if err := rows.Scan(&l.ID, &l.SpaceID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt); err == nil {
|
||||
lists = append(lists, l)
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
_ = 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)
|
||||
|
||||
for i := range lists {
|
||||
lists[i].Items, _ = s.GetListItems(lists[i].ID)
|
||||
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 lists, nil
|
||||
return share, nil
|
||||
}
|
||||
|
||||
func (s *Store) RemoveListShare(currentUserID, listID, shareIDOrTargetID string) error {
|
||||
var ownerID string
|
||||
err := s.db.QueryRow(`SELECT user_id FROM lists WHERE id = ?`, listID).Scan(&ownerID)
|
||||
if err != nil {
|
||||
return errors.New("liste nicht gefunden")
|
||||
}
|
||||
if ownerID != currentUserID {
|
||||
return errors.New("nur der Eigentümer kann Freigaben entfernen")
|
||||
}
|
||||
|
||||
cleanTarget := strings.TrimSpace(shareIDOrTargetID)
|
||||
_, err = s.db.Exec(
|
||||
`DELETE FROM list_shares WHERE list_id = ? AND (id = ? OR target_id = ?)`,
|
||||
listID, cleanTarget, cleanTarget,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetListShares(listID string) ([]domain.ListShare, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, list_id, target_type, target_id, role, created_by, created_at
|
||||
FROM list_shares WHERE list_id = ? ORDER BY created_at ASC`,
|
||||
listID,
|
||||
)
|
||||
if err != nil {
|
||||
return []domain.ListShare{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
shares := make([]domain.ListShare, 0)
|
||||
for rows.Next() {
|
||||
var sh domain.ListShare
|
||||
if err := rows.Scan(&sh.ID, &sh.ListID, &sh.TargetType, &sh.TargetID, &sh.Role, &sh.CreatedBy, &sh.CreatedAt); err == nil {
|
||||
if sh.TargetType == "team" {
|
||||
_ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, sh.TargetID).Scan(&sh.TargetName)
|
||||
} else {
|
||||
_ = s.db.QueryRow(`SELECT display_name, email FROM users WHERE id = ?`, sh.TargetID).Scan(&sh.TargetName, &sh.Email)
|
||||
}
|
||||
shares = append(shares, sh)
|
||||
}
|
||||
}
|
||||
return shares, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetAllListsForUser(userID string) ([]domain.SharedList, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT DISTINCT l.id, l.space_id, l.title, l.icon, l.list_type, l.updated_at, l.created_at
|
||||
`SELECT DISTINCT l.id, l.user_id, l.title, l.icon, l.list_type, l.updated_at, l.created_at,
|
||||
u.display_name,
|
||||
CASE
|
||||
WHEN l.user_id = ? THEN 'owner'
|
||||
WHEN ls.role IS NOT NULL THEN ls.role
|
||||
ELSE 'editor'
|
||||
END as effective_role
|
||||
FROM lists l
|
||||
LEFT JOIN space_members sm ON sm.space_id = l.space_id AND sm.user_id = ?
|
||||
LEFT JOIN space_team_shares sts ON sts.space_id = l.space_id AND sts.team_id IN (SELECT team_id FROM team_members WHERE user_id = ?)
|
||||
WHERE sm.user_id = ? OR sts.team_id IS NOT NULL
|
||||
LEFT JOIN users u ON u.id = l.user_id
|
||||
LEFT JOIN list_shares ls ON ls.list_id = l.id AND (
|
||||
(ls.target_type = 'user' AND ls.target_id = ?)
|
||||
OR (ls.target_type = 'team' AND ls.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
||||
)
|
||||
WHERE l.user_id = ?
|
||||
OR ls.id IS NOT NULL
|
||||
ORDER BY l.updated_at DESC`,
|
||||
userID, userID, userID,
|
||||
userID, userID, userID, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -2596,7 +2400,12 @@ func (s *Store) GetAllListsForUser(userID string) ([]domain.SharedList, error) {
|
||||
lists := make([]domain.SharedList, 0)
|
||||
for rows.Next() {
|
||||
var l domain.SharedList
|
||||
if err := rows.Scan(&l.ID, &l.SpaceID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt); err == nil {
|
||||
var ownerName sql.NullString
|
||||
var effectiveRole string
|
||||
if err := rows.Scan(&l.ID, &l.UserID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt, &ownerName, &effectiveRole); err == nil {
|
||||
l.OwnerName = ownerName.String
|
||||
l.IsOwner = (l.UserID == userID)
|
||||
l.Role = effectiveRole
|
||||
lists = append(lists, l)
|
||||
}
|
||||
}
|
||||
@@ -2604,25 +2413,68 @@ func (s *Store) GetAllListsForUser(userID string) ([]domain.SharedList, error) {
|
||||
|
||||
for i := range lists {
|
||||
lists[i].Items, _ = s.GetListItems(lists[i].ID)
|
||||
lists[i].Shares, _ = s.GetListShares(lists[i].ID)
|
||||
}
|
||||
return lists, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetListByID(listID string) (*domain.SharedList, error) {
|
||||
func (s *Store) GetListByID(userID, listID string) (*domain.SharedList, error) {
|
||||
var l domain.SharedList
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, space_id, title, icon, list_type, updated_at, created_at FROM lists WHERE id = ?`,
|
||||
listID,
|
||||
).Scan(&l.ID, &l.SpaceID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt)
|
||||
var ownerName sql.NullString
|
||||
var effectiveRole string
|
||||
|
||||
var query string
|
||||
var args []interface{}
|
||||
if userID == "" {
|
||||
query = `SELECT l.id, l.user_id, l.title, l.icon, l.list_type, l.updated_at, l.created_at,
|
||||
u.display_name, 'owner' as effective_role
|
||||
FROM lists l
|
||||
LEFT JOIN users u ON u.id = l.user_id
|
||||
WHERE l.id = ?`
|
||||
args = []interface{}{listID}
|
||||
} else {
|
||||
query = `SELECT l.id, l.user_id, l.title, l.icon, l.list_type, l.updated_at, l.created_at,
|
||||
u.display_name,
|
||||
CASE
|
||||
WHEN l.user_id = ? THEN 'owner'
|
||||
WHEN ls.role IS NOT NULL THEN ls.role
|
||||
ELSE 'editor'
|
||||
END as effective_role
|
||||
FROM lists l
|
||||
LEFT JOIN users u ON u.id = l.user_id
|
||||
LEFT JOIN list_shares ls ON ls.list_id = l.id AND (
|
||||
(ls.target_type = 'user' AND ls.target_id = ?)
|
||||
OR (ls.target_type = 'team' AND ls.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
||||
)
|
||||
WHERE l.id = ? AND (
|
||||
l.user_id = ?
|
||||
OR ls.id IS NOT NULL
|
||||
)`
|
||||
args = []interface{}{userID, userID, userID, listID, userID}
|
||||
}
|
||||
|
||||
err := s.db.QueryRow(query, args...).Scan(&l.ID, &l.UserID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt, &ownerName, &effectiveRole)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
l.OwnerName = ownerName.String
|
||||
l.IsOwner = (userID == "" || l.UserID == userID)
|
||||
l.Role = effectiveRole
|
||||
l.Items, _ = s.GetListItems(l.ID)
|
||||
l.Shares, _ = s.GetListShares(l.ID)
|
||||
return &l, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteList(listID string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM lists WHERE id = ?`, listID)
|
||||
func (s *Store) DeleteList(userID, listID string) error {
|
||||
var ownerID string
|
||||
err := s.db.QueryRow(`SELECT user_id FROM lists WHERE id = ?`, listID).Scan(&ownerID)
|
||||
if err != nil {
|
||||
return errors.New("liste nicht gefunden")
|
||||
}
|
||||
if ownerID != userID {
|
||||
return errors.New("nur der Eigentümer kann die Liste löschen")
|
||||
}
|
||||
_, err = s.db.Exec(`DELETE FROM lists WHERE id = ?`, listID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -2751,6 +2603,44 @@ func (s *Store) ClearCompletedListItems(listID string) (int64, error) {
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (s *Store) ReorderListItems(listID string, items []domain.ListItemReorderItem) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
for _, it := range items {
|
||||
if it.Category != nil && it.Content != nil {
|
||||
_, err = tx.Exec(
|
||||
`UPDATE list_items SET sort_order = ?, category = ?, content = ? WHERE id = ? AND list_id = ?`,
|
||||
it.SortOrder, *it.Category, *it.Content, it.ID, listID,
|
||||
)
|
||||
} else if it.Category != nil {
|
||||
_, err = tx.Exec(
|
||||
`UPDATE list_items SET sort_order = ?, category = ? WHERE id = ? AND list_id = ?`,
|
||||
it.SortOrder, *it.Category, it.ID, listID,
|
||||
)
|
||||
} else if it.Content != nil {
|
||||
_, err = tx.Exec(
|
||||
`UPDATE list_items SET sort_order = ?, content = ? WHERE id = ? AND list_id = ?`,
|
||||
it.SortOrder, *it.Content, it.ID, listID,
|
||||
)
|
||||
} else {
|
||||
_, err = tx.Exec(
|
||||
`UPDATE list_items SET sort_order = ? WHERE id = ? AND list_id = ?`,
|
||||
it.SortOrder, it.ID, listID,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = tx.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, domain.NowISO(), listID)
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// --- API KEYS & WEBHOOKS ---
|
||||
|
||||
func (s *Store) CreateAPIKey(userID, name string) (*domain.APIKey, error) {
|
||||
@@ -2829,9 +2719,11 @@ func (s *Store) GetDashboardStats(userID string) (*domain.DashboardStats, error)
|
||||
_ = s.db.QueryRow(`
|
||||
SELECT COUNT(DISTINCT l.id)
|
||||
FROM lists l
|
||||
LEFT JOIN space_members sm ON sm.space_id = l.space_id AND sm.user_id = ?
|
||||
LEFT JOIN space_team_shares sts ON sts.space_id = l.space_id AND sts.team_id IN (SELECT team_id FROM team_members WHERE user_id = ?)
|
||||
WHERE sm.user_id = ? OR sts.team_id IS NOT NULL`,
|
||||
LEFT JOIN list_shares ls ON ls.list_id = l.id AND (
|
||||
(ls.target_type = 'user' AND ls.target_id = ?)
|
||||
OR (ls.target_type = 'team' AND ls.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
||||
)
|
||||
WHERE l.user_id = ? OR ls.id IS NOT NULL`,
|
||||
userID, userID, userID).Scan(&st.ListsCount)
|
||||
|
||||
// Count open (uncompleted) To-Dos across accessible workspace documents (owned or shared)
|
||||
@@ -2865,7 +2757,7 @@ func (s *Store) SearchAll(userID, query string) ([]domain.Thought, []domain.Docu
|
||||
q := "%" + strings.ToLower(strings.TrimSpace(query)) + "%"
|
||||
|
||||
tRows, err := s.db.Query(
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order
|
||||
FROM thoughts WHERE user_id = ? AND LOWER(content) LIKE ? ORDER BY created_at DESC LIMIT 30`,
|
||||
userID, q,
|
||||
)
|
||||
@@ -2908,7 +2800,6 @@ func (s *Store) ExportUserData(userID string) (*domain.FullExport, error) {
|
||||
}
|
||||
thoughts, _ := s.GetThoughts(userID, "all", 5000)
|
||||
docs, _ := s.GetDocuments(userID)
|
||||
spaces, _ := s.GetSpacesForUser(userID)
|
||||
lists, _ := s.GetAllListsForUser(userID)
|
||||
taskLists, _ := s.GetTaskLists(userID)
|
||||
|
||||
@@ -2918,7 +2809,6 @@ func (s *Store) ExportUserData(userID string) (*domain.FullExport, error) {
|
||||
User: *user,
|
||||
Thoughts: thoughts,
|
||||
Documents: docs,
|
||||
Spaces: spaces,
|
||||
Lists: lists,
|
||||
TaskLists: taskLists,
|
||||
}, nil
|
||||
@@ -3127,9 +3017,8 @@ func (s *Store) GetTaskLists(userID string) ([]domain.TaskList, error) {
|
||||
WHERE (target_type = 'user' AND target_id = ?)
|
||||
OR (target_type = 'team' AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
||||
)
|
||||
OR (space_id != '' AND space_id IN (SELECT space_id FROM space_members WHERE user_id = ?))
|
||||
ORDER BY updated_at DESC`,
|
||||
userID, userID, userID, userID, userID,
|
||||
userID, userID, userID, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return []domain.TaskList{}, err
|
||||
@@ -3192,9 +3081,8 @@ func (s *Store) GetTaskListByID(userID, listID string) (*domain.TaskList, error)
|
||||
WHERE (target_type = 'user' AND target_id = ?)
|
||||
OR (target_type = 'team' AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
||||
)
|
||||
OR (space_id != '' AND space_id IN (SELECT space_id FROM space_members WHERE user_id = ?))
|
||||
)`,
|
||||
listID, userID, userID, userID, userID, userID,
|
||||
listID, userID, userID, userID, userID,
|
||||
).Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.TeamID, &tl.Title, &tl.Icon, &tl.Color, &colsJSON, &tl.CreatedAt, &tl.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -48,12 +48,8 @@ func TestStoreLifecycle(t *testing.T) {
|
||||
t.Fatalf("expected converted document, got status=%s type=%s ref=%s", triaged.Status, triaged.ConvertedType, triaged.ConvertedRefID)
|
||||
}
|
||||
|
||||
// 3. Test Shared Space & Shopping List
|
||||
sp, err := store.CreateSpace(user.ID, "Test WG", "🍕", "Test Space", false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSpace failed: %v", err)
|
||||
}
|
||||
lst, err := store.CreateList(sp.ID, "REWE Einkauf", "🛒", "shopping")
|
||||
// 3. Test Standalone Shopping List & Sharing
|
||||
lst, err := store.CreateList(user.ID, "REWE Einkauf", "🛒", "shopping")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateList failed: %v", err)
|
||||
}
|
||||
@@ -66,26 +62,26 @@ func TestStoreLifecycle(t *testing.T) {
|
||||
t.Fatalf("ToggleListItem failed: %v", err)
|
||||
}
|
||||
|
||||
// Test AddSpaceMember & RemoveSpaceMember
|
||||
// Test AddListShare & RemoveListShare
|
||||
u2, err := store.CreateUser("collab_space@example.com", "hash", "Collab User", "member")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser u2 failed: %v", err)
|
||||
}
|
||||
member, err := store.AddSpaceMember(user.ID, sp.ID, u2.ID, "editor")
|
||||
if err != nil || member.UserID != u2.ID {
|
||||
t.Fatalf("AddSpaceMember failed: %v", err)
|
||||
share, err := store.AddListShare(user.ID, lst.ID, "user", u2.ID, "editor")
|
||||
if err != nil || share.TargetID != u2.ID {
|
||||
t.Fatalf("AddListShare failed: %v", err)
|
||||
}
|
||||
members, err := store.GetSpaceMembers(sp.ID)
|
||||
if err != nil || len(members) != 2 {
|
||||
t.Fatalf("expected 2 members, got %d", len(members))
|
||||
shares, err := store.GetListShares(lst.ID)
|
||||
if err != nil || len(shares) != 1 {
|
||||
t.Fatalf("expected 1 share, got %d", len(shares))
|
||||
}
|
||||
err = store.RemoveSpaceMember(user.ID, sp.ID, u2.ID)
|
||||
err = store.RemoveListShare(user.ID, lst.ID, share.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveSpaceMember failed: %v", err)
|
||||
t.Fatalf("RemoveListShare failed: %v", err)
|
||||
}
|
||||
membersAfter, _ := store.GetSpaceMembers(sp.ID)
|
||||
if len(membersAfter) != 1 {
|
||||
t.Fatalf("expected 1 member after remove, got %d", len(membersAfter))
|
||||
sharesAfter, _ := store.GetListShares(lst.ID)
|
||||
if len(sharesAfter) != 0 {
|
||||
t.Fatalf("expected 0 shares after remove, got %d", len(sharesAfter))
|
||||
}
|
||||
|
||||
// 4. Test Webhook API Key generation & validation
|
||||
@@ -99,7 +95,7 @@ func TestStoreLifecycle(t *testing.T) {
|
||||
}
|
||||
|
||||
// 5. Test TaskLists CRUD
|
||||
taskList, err := store.CreateTaskList(user.ID, sp.ID, "", "Haus & Garten", "🏡", "#10b981")
|
||||
taskList, err := store.CreateTaskList(user.ID, "", "", "Haus & Garten", "🏡", "#10b981")
|
||||
if err != nil || taskList.ID == "" {
|
||||
t.Fatalf("CreateTaskList failed: %v", err)
|
||||
}
|
||||
@@ -160,8 +156,8 @@ func TestStoreLifecycle(t *testing.T) {
|
||||
}
|
||||
|
||||
// Share with user2 as "read"
|
||||
share, err := store.CreateDocShare(user.ID, newDoc.ID, user2.ID, "read")
|
||||
if err != nil || share.Permission != "read" {
|
||||
docShare, err := store.CreateDocShare(user.ID, newDoc.ID, user2.ID, "read")
|
||||
if err != nil || docShare.Permission != "read" {
|
||||
t.Fatalf("CreateDocShare failed: %v", err)
|
||||
}
|
||||
|
||||
|
||||
+24
-18
@@ -22,7 +22,7 @@ var upgrader = websocket.Upgrader{
|
||||
type Client struct {
|
||||
hub *Hub
|
||||
conn *websocket.Conn
|
||||
spaceID string
|
||||
roomID string
|
||||
userID string
|
||||
send chan []byte
|
||||
}
|
||||
@@ -37,7 +37,7 @@ type docLockItem struct {
|
||||
|
||||
type Hub struct {
|
||||
mu sync.RWMutex
|
||||
spaceRooms map[string]map[*Client]bool
|
||||
rooms map[string]map[*Client]bool
|
||||
userRooms map[string]map[*Client]bool
|
||||
allClients map[*Client]bool
|
||||
locks map[string]*docLockItem
|
||||
@@ -45,7 +45,7 @@ type Hub struct {
|
||||
|
||||
func NewHub() *Hub {
|
||||
h := &Hub{
|
||||
spaceRooms: make(map[string]map[*Client]bool),
|
||||
rooms: make(map[string]map[*Client]bool),
|
||||
userRooms: make(map[string]map[*Client]bool),
|
||||
allClients: make(map[*Client]bool),
|
||||
locks: make(map[string]*docLockItem),
|
||||
@@ -57,11 +57,11 @@ func NewHub() *Hub {
|
||||
func (h *Hub) Register(c *Client) {
|
||||
h.mu.Lock()
|
||||
h.allClients[c] = true
|
||||
if c.spaceID != "" {
|
||||
if h.spaceRooms[c.spaceID] == nil {
|
||||
h.spaceRooms[c.spaceID] = make(map[*Client]bool)
|
||||
if c.roomID != "" {
|
||||
if h.rooms[c.roomID] == nil {
|
||||
h.rooms[c.roomID] = make(map[*Client]bool)
|
||||
}
|
||||
h.spaceRooms[c.spaceID][c] = true
|
||||
h.rooms[c.roomID][c] = true
|
||||
}
|
||||
if c.userID != "" && c.userID != "anonymous" {
|
||||
if h.userRooms[c.userID] == nil {
|
||||
@@ -72,8 +72,8 @@ func (h *Hub) Register(c *Client) {
|
||||
onlineCount := len(h.allClients)
|
||||
h.mu.Unlock()
|
||||
|
||||
if c.spaceID != "" {
|
||||
h.Broadcast(c.spaceID, "", "USER_PRESENCE", map[string]interface{}{
|
||||
if c.roomID != "" {
|
||||
h.Broadcast(c.roomID, "", "USER_PRESENCE", map[string]interface{}{
|
||||
"onlineCount": onlineCount,
|
||||
})
|
||||
}
|
||||
@@ -82,11 +82,11 @@ func (h *Hub) Register(c *Client) {
|
||||
func (h *Hub) Unregister(c *Client) {
|
||||
h.mu.Lock()
|
||||
delete(h.allClients, c)
|
||||
if c.spaceID != "" {
|
||||
if room, ok := h.spaceRooms[c.spaceID]; ok {
|
||||
if c.roomID != "" {
|
||||
if room, ok := h.rooms[c.roomID]; ok {
|
||||
delete(room, c)
|
||||
if len(room) == 0 {
|
||||
delete(h.spaceRooms, c.spaceID)
|
||||
delete(h.rooms, c.roomID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -297,7 +297,6 @@ func (h *Hub) BroadcastDocEvent(docID, event string, payload interface{}) {
|
||||
func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) {
|
||||
msg := domain.WSMessage{
|
||||
Event: event,
|
||||
SpaceID: spaceID,
|
||||
ListID: listID,
|
||||
Payload: payload,
|
||||
Timestamp: domain.NowISO(),
|
||||
@@ -310,10 +309,12 @@ func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
room, ok := h.spaceRooms[spaceID]
|
||||
if !ok {
|
||||
return
|
||||
targetRoom := listID
|
||||
if targetRoom == "" {
|
||||
targetRoom = spaceID
|
||||
}
|
||||
|
||||
if room, ok := h.rooms[targetRoom]; ok {
|
||||
for client := range room {
|
||||
select {
|
||||
case client.send <- data:
|
||||
@@ -322,8 +323,13 @@ func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request, spaceID, userID string) {
|
||||
func (h *Hub) BroadcastListEvent(listID, event string, payload interface{}) {
|
||||
h.Broadcast("", listID, event, payload)
|
||||
}
|
||||
|
||||
func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request, roomID, userID string) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Printf("[ws] upgrade error: %v", err)
|
||||
@@ -333,7 +339,7 @@ func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request, spaceID, userID st
|
||||
client := &Client{
|
||||
hub: h,
|
||||
conn: conn,
|
||||
spaceID: spaceID,
|
||||
roomID: roomID,
|
||||
userID: userID,
|
||||
send: make(chan []byte, 128),
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ CREATE TABLE IF NOT EXISTS thoughts (
|
||||
converted_type TEXT DEFAULT '',
|
||||
converted_ref_id TEXT DEFAULT '',
|
||||
due_date TEXT DEFAULT '',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
triaged_at TEXT DEFAULT '',
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
@@ -31,6 +32,7 @@ CREATE TABLE IF NOT EXISTS thoughts (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_thoughts_user_status ON thoughts(user_id, status, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_thoughts_user_due ON thoughts(user_id, due_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_thoughts_list_sort ON thoughts(task_list_id, sort_order ASC, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS spaces (
|
||||
id TEXT PRIMARY KEY,
|
||||
|
||||
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