Dev #4

Merged
sandrodach merged 12 commits from dev into main 2026-10-25 21:09:45 +01:00
27 changed files with 2934 additions and 1311 deletions

No files matched your search

+103 -40
View File
@@ -8,86 +8,149 @@
[![Markdown](https://img.shields.io/badge/Datenhoheit-Pure%20Markdown%20%26%20Outliner-18181b?style=flat-square)](#)
[![License](https://img.shields.io/badge/Lizenz-MIT-22c55e?style=flat-square)](LICENSE)
**MindSift** ist deine zentrale, kompromisslos schnelle Schaltstelle für spontane Gedanken, strukturierte Aufgaben, hierarchische Outliner-Notizen und gemeinsame Echtzeit-Listen — ablenkungsfrei gestaltet, lokal als Standard-Markdown gespeichert und zu 100 % unter deiner eigenen Kontrolle.
**MindSift** ist deine zentrale, kompromisslos schnelle Schaltstelle für spontane Gedanken, strukturierte Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen mit Zoom-Fokus und gemeinsame Echtzeit-Listen — ablenkungsfrei gestaltet, lokal als Standard-Markdown gespeichert und zu 100 % unter deiner eigenen Kontrolle.
<br />
<p align="center">
<img src="screenshots/Triage_desktop.png" alt="MindSift Triage Desktop" width="85%" style="border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.3);" />
</p>
[Philosophie](#-die-philosophie) • [Funktionen](#-was-mindsift-auszeichnet) • [Schnellstart](#-schnellstart) • [Für wen?](#-für-wen-ist-mindsift-gemacht)
---
</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.
---
+137 -9
View File
@@ -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>
<span className="font-bold text-base text-main tracking-tight">
{authStatus.instanceName || 'MindSift'}
</span>
<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">
+1 -1
View File
@@ -234,7 +234,7 @@ export const HelpView: React.FC<HelpViewProps> = () => {
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-xs">
<div className="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>
+427 -80
View File
@@ -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) => (
<div
key={task.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"
>
{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-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>
+531 -92
View File
@@ -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) => {
const handleBlockDragEnd = () => {
isDraggingBlockRef.current = false;
setDraggingBlockId(null);
setDragOverBlockTarget(null);
if (idleUnlockTimerRef.current) {
clearTimeout(idleUnlockTimerRef.current);
}
idleUnlockTimerRef.current = setTimeout(async () => {
if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) {
await flushPendingSave();
await releaseMyLock();
}
}, 2000);
};
const handleBlockDragOver = (e: React.DragEvent, targetBlockId: string) => {
e.preventDefault();
const sourceId = e.dataTransfer.getData('text/plain') || draggingBlockId;
e.stopPropagation();
if (!draggingBlockId || draggingBlockId === targetBlockId) return;
const rect = e.currentTarget.getBoundingClientRect();
const relY = e.clientY - rect.top;
const height = rect.height;
let position: 'before' | 'after' | 'inside';
if (relY < height * 0.25) {
position = 'before';
} else if (relY > height * 0.75) {
position = 'after';
} else {
position = 'inside';
}
if (
!dragOverBlockTarget ||
dragOverBlockTarget.blockId !== targetBlockId ||
dragOverBlockTarget.position !== position
) {
setDragOverBlockTarget({ blockId: targetBlockId, position });
}
};
const handleBlockDragLeave = (e: React.DragEvent, targetBlockId: string) => {
e.stopPropagation();
if (dragOverBlockTarget?.blockId === targetBlockId) {
setDragOverBlockTarget(null);
}
};
const handleBlockDrop = (e: React.DragEvent, targetBlockId: string, explicitPosition?: 'before' | 'after' | 'inside') => {
e.preventDefault();
e.stopPropagation();
const sourceId =
e.dataTransfer.getData('application/mindsift-block-id') ||
e.dataTransfer.getData('text/plain') ||
draggingBlockId;
const position = explicitPosition || dragOverBlockTarget?.position || 'after';
isDraggingBlockRef.current = false;
setDraggingBlockId(null);
setDragOverBlockTarget(null);
if (!selectedDoc || !sourceId || sourceId === targetBlockId) {
setDraggingBlockId(null);
return;
}
const blocks = [...selectedDoc.blocks];
const sourceIdx = blocks.findIndex((b) => b.id === sourceId);
const targetIdx = blocks.findIndex((b) => b.id === targetBlockId);
if (sourceIdx === -1 || targetIdx === -1) {
setDraggingBlockId(null);
if (sourceIdx === -1) return;
// Prevent circular drag into one's own children
if (isDescendantBlock(blocks, sourceIdx, targetBlockId)) {
return;
}
const [moved] = blocks.splice(sourceIdx, 1);
// Align moved block's level to target
moved.level = blocks[targetIdx]?.level || 0;
blocks.splice(targetIdx, 0, moved);
// Extract the entire subtree (parent + all indented children)
const subtreeCount = getBlockSubtreeCount(blocks, sourceIdx);
const subtree = blocks.splice(sourceIdx, subtreeCount);
// Find new index of target block after splice
const newTargetIdx = blocks.findIndex((b) => b.id === targetBlockId);
if (newTargetIdx === -1) return;
const targetBlock = blocks[newTargetIdx];
if (position === 'inside') {
// Nest directly under targetBlock as its sub-bullet
const targetLevel = (targetBlock.level || 0) + 1;
const oldRootLevel = subtree[0].level || 0;
const levelDiff = targetLevel - oldRootLevel;
for (const b of subtree) {
b.level = Math.max(0, (b.level || 0) + levelDiff);
}
// Automatically uncollapse targetBlock so the newly added sub-bullet is visible
if (targetBlock.collapsed) {
blocks[newTargetIdx] = { ...targetBlock, collapsed: false };
}
// Insert immediately below targetBlock
blocks.splice(newTargetIdx + 1, 0, ...subtree);
} else {
const targetLevel = targetBlock.level || 0;
const oldRootLevel = subtree[0].level || 0;
const levelDiff = targetLevel - oldRootLevel;
// Adjust levels of all blocks in subtree to match target hierarchy
for (const b of subtree) {
b.level = Math.max(0, (b.level || 0) + levelDiff);
}
// Calculate insertion index
let insertIdx = newTargetIdx;
if (position === 'after') {
const targetSubtreeCount = getBlockSubtreeCount(blocks, newTargetIdx);
insertIdx = newTargetIdx + targetSubtreeCount;
}
blocks.splice(insertIdx, 0, ...subtree);
}
persistDocument({ ...selectedDoc, blocks }, true);
setDraggingBlockId(null);
if (idleUnlockTimerRef.current) {
clearTimeout(idleUnlockTimerRef.current);
}
idleUnlockTimerRef.current = setTimeout(async () => {
if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) {
await flushPendingSave();
await releaseMyLock();
}
}, 2500);
};
// Export full document as portable Logseq-indented Markdown
@@ -2551,6 +2718,132 @@ export const WorkspaceView: React.FC<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
View File
@@ -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)}`;
}
+20 -1
View File
@@ -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;
}
+5 -1
View File
@@ -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>
<App />
<MobileActionProvider>
<App />
</MobileActionProvider>
</React.StrictMode>
);
+176 -185
View File
@@ -63,6 +63,7 @@ func (s *Server) Router() http.Handler {
r.Post("/api/v1/webhooks/ingest", s.handleWebhookIngest)
r.Get("/api/v1/ws", s.handleWebSocket)
r.Get("/api/v1/ws/spaces/{spaceId}", s.handleWebSocket)
r.Get("/api/v1/ws/lists/{listId}", s.handleWebSocket)
// Authenticated API Routes
r.Group(func(r chi.Router) {
@@ -88,6 +89,8 @@ func (s *Server) Router() http.Handler {
r.Get("/api/v1/triage/deck", s.handleGetTriageDeck)
r.Patch("/api/v1/triage/{id}", s.handleTriageAction)
r.Get("/api/v1/tasks", s.handleGetTasks)
r.Post("/api/v1/tasks/reorder", s.handleReorderTasks)
r.Post("/api/v1/task-lists/{id}/tasks/reorder", s.handleReorderTasks)
// Task Lists & Shares
r.Get("/api/v1/task-lists", s.handleGetTaskLists)
@@ -125,21 +128,19 @@ func (s *Server) Router() http.Handler {
r.Post("/api/v1/documents/{id}/lock/heartbeat", s.handleHeartbeatDocLock)
r.Delete("/api/v1/documents/{id}/lock", s.handleUnlockDoc)
// Shared Spaces & Realtime Lists
r.Get("/api/v1/spaces", s.handleGetSpaces)
r.Post("/api/v1/spaces", s.handleCreateSpace)
r.Post("/api/v1/spaces/join", s.handleJoinSpace)
r.Get("/api/v1/spaces/{id}/shares", s.handleGetSpaceShares)
r.Post("/api/v1/spaces/{id}/members", s.handleAddSpaceMember)
r.Delete("/api/v1/spaces/{id}/members/{userId}", s.handleRemoveSpaceMember)
r.Get("/api/v1/spaces/{id}/lists", s.handleGetSpaceLists)
r.Post("/api/v1/spaces/{id}/lists", s.handleCreateList)
// Shared Lists & Realtime Collaboration
r.Get("/api/v1/lists", s.handleGetAllLists)
r.Post("/api/v1/lists", s.handleCreateList)
r.Get("/api/v1/lists/{id}", s.handleGetListByID)
r.Delete("/api/v1/lists/{id}", s.handleDeleteList)
r.Get("/api/v1/lists/{id}/shares", s.handleGetListShares)
r.Post("/api/v1/lists/{id}/shares", s.handleCreateListShare)
r.Delete("/api/v1/lists/{id}/shares/{shareId}", s.handleDeleteListShare)
r.Post("/api/v1/lists/{id}/items", s.handleCreateListItem)
r.Patch("/api/v1/lists/{id}/items/{itemId}", s.handleToggleListItem)
r.Delete("/api/v1/lists/{id}/items/{itemId}", s.handleDeleteListItem)
r.Post("/api/v1/lists/{id}/items/reorder", s.handleReorderListItems)
r.Put("/api/v1/lists/{id}/items/reorder", s.handleReorderListItems)
r.Post("/api/v1/lists/{id}/clear-completed", s.handleClearCompletedItems)
// Organizations & Teams
@@ -584,8 +585,8 @@ func (s *Server) handleCaptureInbox(w http.ResponseWriter, r *http.Request) {
if converted, convErr := s.store.UpdateThoughtTriage(userID, thought.ID, "keep", target, targetListID, dueDate, taskListID); convErr == nil && converted != nil {
thought = converted
if target == "list_item" && targetListID != "" {
if lst, lErr := s.store.GetListByID(targetListID); lErr == nil && lst != nil {
s.wsHub.Broadcast(lst.SpaceID, lst.ID, "ITEM_CREATED", lst)
if lst, lErr := s.store.GetListByID(userID, targetListID); lErr == nil && lst != nil {
s.wsHub.BroadcastListEvent(lst.ID, "LIST_UPDATED", lst)
}
}
}
@@ -661,8 +662,8 @@ func (s *Server) handleTriageAction(w http.ResponseWriter, r *http.Request) {
// If converted to a list item, broadcast via WebSocket to the space
if convertTo == "list_item" && targetListID != "" {
if lst, err := s.store.GetListByID(targetListID); err == nil && lst != nil {
s.wsHub.Broadcast(lst.SpaceID, lst.ID, "ITEM_CREATED", lst)
if lst, err := s.store.GetListByID(userID, targetListID); err == nil && lst != nil {
s.wsHub.BroadcastListEvent(lst.ID, "LIST_UPDATED", lst)
}
}
@@ -681,6 +682,34 @@ func (s *Server) handleGetTasks(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, tasks)
}
func (s *Server) handleReorderTasks(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
taskListID := chi.URLParam(r, "id")
if taskListID == "" {
taskListID = r.URL.Query().Get("taskListId")
}
var req struct {
Items []domain.ThoughtReorderItem `json:"items"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
if err := s.store.ReorderTasks(taskListID, req.Items); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.wsHub.BroadcastToAll("TASK_REORDERED", map[string]interface{}{
"taskListId": taskListID,
"items": req.Items,
"userId": userID,
})
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}
func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
thoughtID := chi.URLParam(r, "id")
@@ -1073,151 +1102,7 @@ func (s *Server) handleUnlockDoc(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]bool{"unlocked": released})
}
// --- SPACES & REALTIME LISTS ---
func (s *Server) handleGetSpaces(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
spaces, err := s.store.GetSpacesForUser(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, spaces)
}
func (s *Server) handleCreateSpace(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
Name string `json:"name"`
Icon string `json:"icon"`
Description string `json:"description"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
sp, err := s.store.CreateSpace(userID, req.Name, req.Icon, req.Description, false)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, sp)
}
func (s *Server) handleJoinSpace(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
InviteCode string `json:"inviteCode"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
sp, err := s.store.JoinSpaceByInviteCode(userID, req.InviteCode)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastToUser(userID, "SPACE_JOINED", map[string]interface{}{"spaceId": sp.ID})
s.wsHub.Broadcast(sp.ID, "", "MEMBER_ADDED", map[string]interface{}{"spaceId": sp.ID, "userId": userID})
writeJSON(w, http.StatusOK, sp)
}
func (s *Server) handleAddSpaceMember(w http.ResponseWriter, r *http.Request) {
currentUserID := auth.GetUserID(r.Context())
spaceID := chi.URLParam(r, "id")
var req struct {
UserID string `json:"userId"`
Email string `json:"email"`
TeamID string `json:"teamId"`
Role string `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
if req.Role != "owner" && req.Role != "editor" && req.Role != "viewer" {
req.Role = "editor"
}
if req.TeamID != "" {
share, err := s.store.CreateSpaceTeamShare(currentUserID, spaceID, req.TeamID, req.Role)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
members, _ := s.store.GetTeamMembers(req.TeamID)
for _, tm := range members {
s.wsHub.BroadcastToUser(tm.UserID, "SPACE_JOINED", map[string]interface{}{"spaceId": spaceID})
}
s.wsHub.Broadcast(spaceID, "", "SPACE_SHARED", share)
writeJSON(w, http.StatusCreated, share)
return
}
target := req.UserID
if target == "" {
target = req.Email
}
if target == "" {
writeError(w, http.StatusBadRequest, "Benutzer-ID, Benutzername, E-Mail oder Team erforderlich")
return
}
member, err := s.store.AddSpaceMember(currentUserID, spaceID, target, req.Role)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastToUser(member.UserID, "SPACE_JOINED", map[string]interface{}{"spaceId": spaceID})
s.wsHub.Broadcast(spaceID, "", "MEMBER_ADDED", member)
writeJSON(w, http.StatusCreated, member)
}
func (s *Server) handleGetSpaceShares(w http.ResponseWriter, r *http.Request) {
spaceID := chi.URLParam(r, "id")
shares, err := s.store.GetSpaceShares(spaceID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, shares)
}
func (s *Server) handleRemoveSpaceMember(w http.ResponseWriter, r *http.Request) {
currentUserID := auth.GetUserID(r.Context())
spaceID := chi.URLParam(r, "id")
targetUserID := chi.URLParam(r, "userId")
// If prefixed with sm_, strip it for individual space member removal
cleanTargetID := strings.TrimPrefix(targetUserID, "sm_")
if err := s.store.RemoveSpaceMember(currentUserID, spaceID, cleanTargetID); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastToUser(cleanTargetID, "SPACE_LEFT", map[string]interface{}{"spaceId": spaceID})
s.wsHub.Broadcast(spaceID, "", "MEMBER_REMOVED", map[string]interface{}{"spaceId": spaceID, "userId": cleanTargetID})
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}
func (s *Server) handleGetSpaceLists(w http.ResponseWriter, r *http.Request) {
spaceID := chi.URLParam(r, "id")
lists, err := s.store.GetListsForSpace(spaceID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, lists)
}
// --- REALTIME LISTS & SHARING ---
func (s *Server) handleGetAllLists(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
@@ -1229,8 +1114,19 @@ func (s *Server) handleGetAllLists(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, lists)
}
func (s *Server) handleGetListByID(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
listID := chi.URLParam(r, "id")
lst, err := s.store.GetListByID(userID, listID)
if err != nil {
writeError(w, http.StatusNotFound, "Liste nicht gefunden")
return
}
writeJSON(w, http.StatusOK, lst)
}
func (s *Server) handleCreateList(w http.ResponseWriter, r *http.Request) {
spaceID := chi.URLParam(r, "id")
userID := auth.GetUserID(r.Context())
var req struct {
Title string `json:"title"`
Icon string `json:"icon"`
@@ -1241,28 +1137,96 @@ func (s *Server) handleCreateList(w http.ResponseWriter, r *http.Request) {
return
}
lst, err := s.store.CreateList(spaceID, req.Title, req.Icon, req.ListType)
lst, err := s.store.CreateList(userID, req.Title, req.Icon, req.ListType)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.Broadcast(spaceID, lst.ID, "LIST_CREATED", lst)
s.wsHub.BroadcastListEvent(lst.ID, "LIST_CREATED", lst)
writeJSON(w, http.StatusCreated, lst)
}
func (s *Server) handleDeleteList(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
listID := chi.URLParam(r, "id")
lst, _ := s.store.GetListByID(listID)
if err := s.store.DeleteList(listID); err != nil {
if err := s.store.DeleteList(userID, listID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if lst != nil {
s.wsHub.Broadcast(lst.SpaceID, listID, "LIST_DELETED", map[string]string{"listId": listID})
}
s.wsHub.BroadcastListEvent(listID, "LIST_DELETED", map[string]string{"listId": listID})
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handleGetListShares(w http.ResponseWriter, r *http.Request) {
listID := chi.URLParam(r, "id")
shares, err := s.store.GetListShares(listID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, shares)
}
func (s *Server) handleCreateListShare(w http.ResponseWriter, r *http.Request) {
currentUserID := auth.GetUserID(r.Context())
listID := chi.URLParam(r, "id")
var req struct {
TargetType string `json:"targetType"` // "user" or "team"
TargetID string `json:"targetId"` // userId/email or teamId
Role string `json:"role"` // "editor" or "viewer"
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
targetType := strings.ToLower(strings.TrimSpace(req.TargetType))
if targetType != "team" {
targetType = "user"
}
role := strings.ToLower(strings.TrimSpace(req.Role))
if role != "viewer" && role != "editor" {
role = "editor"
}
share, err := s.store.AddListShare(currentUserID, listID, targetType, req.TargetID, role)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if targetType == "team" {
members, _ := s.store.GetTeamMembers(share.TargetID)
for _, tm := range members {
s.wsHub.BroadcastToUser(tm.UserID, "LIST_SHARED", share)
}
} else {
s.wsHub.BroadcastToUser(share.TargetID, "LIST_SHARED", share)
}
s.wsHub.BroadcastListEvent(listID, "LIST_SHARED", share)
writeJSON(w, http.StatusCreated, share)
}
func (s *Server) handleDeleteListShare(w http.ResponseWriter, r *http.Request) {
currentUserID := auth.GetUserID(r.Context())
listID := chi.URLParam(r, "id")
shareID := chi.URLParam(r, "shareId")
if err := s.store.RemoveListShare(currentUserID, listID, shareID); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastListEvent(listID, "LIST_UNSHARED", map[string]string{
"listId": listID,
"shareId": shareID,
})
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}
func (s *Server) handleCreateListItem(w http.ResponseWriter, r *http.Request) {
listID := chi.URLParam(r, "id")
var req struct {
@@ -1281,9 +1245,7 @@ func (s *Server) handleCreateListItem(w http.ResponseWriter, r *http.Request) {
return
}
if lst, err := s.store.GetListByID(listID); err == nil && lst != nil {
s.wsHub.Broadcast(lst.SpaceID, listID, "ITEM_CREATED", item)
}
s.wsHub.BroadcastListEvent(listID, "ITEM_CREATED", item)
writeJSON(w, http.StatusCreated, item)
}
@@ -1301,7 +1263,7 @@ func (s *Server) handleToggleListItem(w http.ResponseWriter, r *http.Request) {
}
user, _ := s.store.GetUserByID(userID)
userName := "Familie"
userName := "Mitglied"
if user != nil {
userName = user.DisplayName
}
@@ -1312,9 +1274,7 @@ func (s *Server) handleToggleListItem(w http.ResponseWriter, r *http.Request) {
return
}
if lst, err := s.store.GetListByID(listID); err == nil && lst != nil {
s.wsHub.Broadcast(lst.SpaceID, listID, "ITEM_TOGGLED", item)
}
s.wsHub.BroadcastListEvent(listID, "ITEM_TOGGLED", item)
writeJSON(w, http.StatusOK, item)
}
@@ -1328,12 +1288,10 @@ func (s *Server) handleDeleteListItem(w http.ResponseWriter, r *http.Request) {
return
}
if lst, err := s.store.GetListByID(listID); err == nil && lst != nil {
s.wsHub.Broadcast(lst.SpaceID, listID, "ITEM_DELETED", map[string]string{
"itemId": itemID,
"listId": listID,
})
}
s.wsHub.BroadcastListEvent(listID, "ITEM_DELETED", map[string]string{
"itemId": itemID,
"listId": listID,
})
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
@@ -1345,14 +1303,47 @@ func (s *Server) handleClearCompletedItems(w http.ResponseWriter, r *http.Reques
return
}
if lst, err := s.store.GetListByID(listID); err == nil && lst != nil {
s.wsHub.Broadcast(lst.SpaceID, listID, "LIST_CLEARED", lst)
}
s.wsHub.BroadcastListEvent(listID, "LIST_CLEARED", map[string]interface{}{
"listId": listID,
"removed": removed,
})
writeJSON(w, http.StatusOK, map[string]int64{"removed": removed})
}
func (s *Server) handleReorderListItems(w http.ResponseWriter, r *http.Request) {
listID := chi.URLParam(r, "id")
var req struct {
Items []domain.ListItemReorderItem `json:"items"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Invalid request body")
return
}
if err := s.store.ReorderListItems(listID, req.Items); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.wsHub.BroadcastListEvent(listID, "ITEM_REORDERED", map[string]interface{}{
"listId": listID,
"items": req.Items,
})
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
spaceID := chi.URLParam(r, "spaceId")
roomID := chi.URLParam(r, "listId")
if roomID == "" {
roomID = chi.URLParam(r, "spaceId")
}
if roomID == "" {
roomID = r.URL.Query().Get("listId")
}
if roomID == "" {
roomID = r.URL.Query().Get("spaceId")
}
token := r.URL.Query().Get("token")
if token == "" {
if authHdr := r.Header.Get("Authorization"); strings.HasPrefix(authHdr, "Bearer ") {
@@ -1365,7 +1356,7 @@ func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
userID = claims.UserID
}
}
s.wsHub.ServeWS(w, r, spaceID, userID)
s.wsHub.ServeWS(w, r, roomID, userID)
}
// --- API KEYS & WEBHOOK INGESTION ---
+39 -41
View File
@@ -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)
}
}
+30 -36
View File
@@ -154,10 +154,24 @@ type Thought struct {
Notes string `json:"notes,omitempty"`
Subtasks []SubTask `json:"subtasks"`
AlarmSent bool `json:"alarmSent,omitempty"`
SortOrder int `json:"sortOrder"`
CreatedAt string `json:"createdAt"`
TriagedAt string `json:"triagedAt,omitempty"`
}
type ThoughtReorderItem struct {
ID string `json:"id"`
SortOrder int `json:"sortOrder"`
KanbanStatus *string `json:"kanbanStatus,omitempty"`
}
type ListItemReorderItem struct {
ID string `json:"id"`
SortOrder int `json:"sortOrder"`
Category *string `json:"category,omitempty"`
Content *string `json:"content,omitempty"`
}
type TaskListShare struct {
ID string `json:"id"`
TaskListID string `json:"taskListId"`
@@ -324,40 +338,18 @@ type TagDetails struct {
TotalMatches int `json:"totalMatches"`
}
type SpaceMember struct {
SpaceID string `json:"spaceId"`
UserID string `json:"userId"`
DisplayName string `json:"displayName"`
Email string `json:"email"`
Role string `json:"role"` // owner | editor | viewer
JoinedAt string `json:"joinedAt"`
}
type SpaceShare struct {
type ListShare struct {
ID string `json:"id"`
SpaceID string `json:"spaceId"`
ListID string `json:"listId"`
TargetType string `json:"targetType"` // team | user
TargetID string `json:"targetId"`
TargetName string `json:"targetName,omitempty"`
Email string `json:"email,omitempty"`
Role string `json:"role"` // editor | viewer
CreatedBy string `json:"createdBy"`
CreatedAt string `json:"createdAt"`
}
type Space struct {
ID string `json:"id"`
Name string `json:"name"`
Icon string `json:"icon"`
Description string `json:"description"`
InviteCode string `json:"inviteCode"`
IsPersonal bool `json:"isPersonal"`
OwnerID string `json:"ownerId"`
Members []SpaceMember `json:"members"`
Shares []SpaceShare `json:"shares,omitempty"`
ListsCount int `json:"listsCount"`
CreatedAt string `json:"createdAt"`
}
type ListItem struct {
ID string `json:"id"`
ListID string `json:"listId"`
@@ -373,14 +365,18 @@ type ListItem struct {
}
type SharedList struct {
ID string `json:"id"`
SpaceID string `json:"spaceId"`
Title string `json:"title"`
Icon string `json:"icon"`
ListType string `json:"listType"` // shopping | checklist | vacation
Items []ListItem `json:"items"`
UpdatedAt string `json:"updatedAt"`
CreatedAt string `json:"createdAt"`
ID string `json:"id"`
UserID string `json:"userId"`
OwnerName string `json:"ownerName,omitempty"`
IsOwner bool `json:"isOwner"`
Role string `json:"role,omitempty"` // editor | viewer
Title string `json:"title"`
Icon string `json:"icon"`
ListType string `json:"listType"` // shopping | checklist | vacation
Items []ListItem `json:"items"`
Shares []ListShare `json:"shares,omitempty"`
UpdatedAt string `json:"updatedAt"`
CreatedAt string `json:"createdAt"`
}
type APIKey struct {
@@ -397,8 +393,7 @@ type APIKey struct {
type WSMessage struct {
Event string `json:"event"` // ITEM_CREATED | ITEM_UPDATED | ITEM_TOGGLED | ITEM_DELETED | LIST_CLEARED | USER_PRESENCE
SpaceID string `json:"space_id"`
ListID string `json:"list_id,omitempty"`
ListID string `json:"list_id"`
Payload interface{} `json:"payload"`
Timestamp string `json:"timestamp"`
}
@@ -419,7 +414,6 @@ type FullExport struct {
User User `json:"user"`
Thoughts []Thought `json:"thoughts"`
Documents []Document `json:"documents"`
Spaces []Space `json:"spaces"`
Lists []SharedList `json:"lists"`
TaskLists []TaskList `json:"taskLists,omitempty"`
}
+318 -430
View File
@@ -137,28 +137,6 @@ func (s *Store) migrate() error {
);
CREATE INDEX IF NOT EXISTS idx_task_lists_user ON task_lists(user_id, updated_at DESC);
CREATE TABLE IF NOT EXISTS spaces (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
icon TEXT NOT NULL DEFAULT '🏠',
description TEXT NOT NULL DEFAULT '',
invite_code TEXT UNIQUE NOT NULL,
is_personal INTEGER NOT NULL DEFAULT 0,
owner_id TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY(owner_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS space_members (
space_id TEXT NOT NULL,
user_id TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'editor',
joined_at TEXT NOT NULL,
PRIMARY KEY (space_id, user_id),
FOREIGN KEY(space_id) REFERENCES spaces(id) ON DELETE CASCADE,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS organizations (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
@@ -259,16 +237,32 @@ func (s *Store) migrate() error {
CREATE TABLE IF NOT EXISTS lists (
id TEXT PRIMARY KEY,
space_id TEXT NOT NULL,
user_id TEXT NOT NULL,
title TEXT NOT NULL,
icon TEXT NOT NULL DEFAULT '🛒',
list_type TEXT NOT NULL DEFAULT 'shopping',
updated_at TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY(space_id) REFERENCES spaces(id) ON DELETE CASCADE
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_lists_space ON lists(space_id, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_lists_user ON lists(user_id, updated_at DESC);
CREATE TABLE IF NOT EXISTS list_shares (
id TEXT PRIMARY KEY,
list_id TEXT NOT NULL,
target_type TEXT NOT NULL CHECK(target_type IN ('team', 'user')),
target_id TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'editor' CHECK(role IN ('editor', 'viewer')),
created_by TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE(list_id, target_type, target_id),
FOREIGN KEY(list_id) REFERENCES lists(id) ON DELETE CASCADE,
FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_list_shares_target ON list_shares(target_type, target_id);
CREATE INDEX IF NOT EXISTS idx_list_shares_list ON list_shares(list_id);
CREATE TABLE IF NOT EXISTS list_items (
id TEXT PRIMARY KEY,
@@ -325,6 +319,8 @@ func (s *Store) migrate() error {
"ALTER TABLE thoughts ADD COLUMN notes TEXT NOT NULL DEFAULT '';",
"ALTER TABLE thoughts ADD COLUMN alarm_sent INTEGER NOT NULL DEFAULT 0;",
"ALTER TABLE thoughts ADD COLUMN subtasks TEXT NOT NULL DEFAULT '[]';",
"ALTER TABLE thoughts ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0;",
"CREATE INDEX IF NOT EXISTS idx_thoughts_list_sort ON thoughts(task_list_id, sort_order ASC, created_at DESC);",
"ALTER TABLE documents ADD COLUMN folder_path TEXT NOT NULL DEFAULT '';",
@@ -342,14 +338,20 @@ func (s *Store) migrate() error {
"CREATE INDEX IF NOT EXISTS idx_doc_team_shares_team ON doc_team_shares(team_id);",
"CREATE INDEX IF NOT EXISTS idx_doc_team_shares_doc ON doc_team_shares(doc_id);",
"CREATE TABLE IF NOT EXISTS space_team_shares (id TEXT PRIMARY KEY, space_id TEXT NOT NULL, team_id TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'editor', created_at TEXT NOT NULL, created_by TEXT NOT NULL, UNIQUE(space_id, team_id));",
"CREATE INDEX IF NOT EXISTS idx_space_team_shares_team ON space_team_shares(team_id);",
"CREATE INDEX IF NOT EXISTS idx_space_team_shares_space ON space_team_shares(space_id);",
"ALTER TABLE lists ADD COLUMN user_id TEXT NOT NULL DEFAULT '';",
"CREATE TABLE IF NOT EXISTS list_shares (id TEXT PRIMARY KEY, list_id TEXT NOT NULL, target_type TEXT NOT NULL CHECK(target_type IN ('team', 'user')), target_id TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'editor' CHECK(role IN ('editor', 'viewer')), created_at TEXT NOT NULL, created_by TEXT NOT NULL, UNIQUE(list_id, target_type, target_id));",
"CREATE INDEX IF NOT EXISTS idx_list_shares_target ON list_shares(target_type, target_id);",
"CREATE INDEX IF NOT EXISTS idx_list_shares_list ON list_shares(list_id);",
}
for _, q := range columnMigrations {
_, _ = s.db.Exec(q)
}
// Clean up legacy space_id from documents and task_lists if empty or orphaned
_, _ = s.db.Exec(`DROP TABLE IF EXISTS spaces`)
_, _ = s.db.Exec(`DROP TABLE IF EXISTS space_members`)
_, _ = s.db.Exec(`DROP TABLE IF EXISTS space_team_shares`)
// Backfill username for existing accounts where username is empty
_, _ = s.db.Exec(`UPDATE users SET username = LOWER(SUBSTR(email, 1, INSTR(email, '@') - 1)) WHERE (username IS NULL OR username = '') AND INSTR(email, '@') > 0`)
@@ -442,13 +444,10 @@ func (s *Store) SeedStarterContent(user *domain.User) error {
_, _ = s.UpdateThoughtTriage(user.ID, keptTask.ID, "keep", "task", "", now[:10], "")
}
familySpace, err := s.CreateSpace(user.ID, "Familie & Zuhause", "🏡", "Geteilter Bereich für Einkäufe, Haushalt & Urlaubsplanung", false)
if err == nil && familySpace != nil {
shopList, err := s.CreateList(familySpace.ID, "Wocheneinkauf Supermarkt", "🛒", "shopping")
if err == nil && shopList != nil {
_, _ = s.CreateListItem(shopList.ID, "Hafermilch (Barista Edition)", "Kühlregal", "3x", 0)
_, _ = s.CreateListItem(shopList.ID, "Espressobohnen (100% Arabica)", "Kaffee & Tee", "1 kg", 1)
}
shopList, err := s.CreateList(user.ID, "Wocheneinkauf Supermarkt", "🛒", "shopping")
if err == nil && shopList != nil {
_, _ = s.CreateListItem(shopList.ID, "Hafermilch (Barista Edition)", "Kühlregal", "3x", 0)
_, _ = s.CreateListItem(shopList.ID, "Espressobohnen (100% Arabica)", "Kaffee & Tee", "1 kg", 1)
}
starterBlocks := []domain.DocumentBlock{
@@ -692,12 +691,8 @@ func (s *Store) GetDiscoverableUsers(viewerID string) ([]domain.User, error) {
SELECT tm2.user_id FROM team_members tm1
JOIN team_members tm2 ON tm1.team_id = tm2.team_id
WHERE tm1.user_id = ?
UNION
SELECT sm2.user_id FROM space_members sm1
JOIN space_members sm2 ON sm1.space_id = sm2.space_id
WHERE sm1.user_id = ?
)`,
viewerID, viewerID, viewerID,
viewerID, viewerID,
)
if err != nil {
return []domain.User{*viewer}, nil
@@ -842,16 +837,17 @@ func scanThought(rows interface {
}) (*domain.Thought, error) {
t := &domain.Thought{}
var tagsStr, subtasksStr, statusStr string
var isCompleted, alarmSent int
var isCompleted, alarmSent, sortOrder int
err := rows.Scan(
&t.ID, &t.UserID, &t.Content, &tagsStr, &t.Priority, &t.Source,
&statusStr, &isCompleted, &t.ConvertedType, &t.ConvertedRefID,
&t.DueDate, &t.TaskListID, &t.KanbanStatus, &t.Notes, &alarmSent,
&t.CreatedAt, &t.TriagedAt, &subtasksStr,
&t.CreatedAt, &t.TriagedAt, &subtasksStr, &sortOrder,
)
if err != nil {
return nil, err
}
t.SortOrder = sortOrder
t.Status = domain.ThoughtStatus(statusStr)
t.IsCompleted = isCompleted != 0
t.AlarmSent = alarmSent != 0
@@ -873,21 +869,20 @@ func scanThought(rows interface {
func (s *Store) accessibleTaskListIDsQuery() string {
return `SELECT id FROM task_lists
WHERE user_id = ?
WHERE user_id = ?
OR (team_id != '' AND team_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
OR id IN (
SELECT task_list_id FROM task_list_shares
WHERE (target_type = 'user' AND target_id = ?)
OR (target_type = 'team' AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
)
OR (space_id != '' AND space_id IN (SELECT space_id FROM space_members WHERE user_id = ?))`
)`
}
func (s *Store) GetThoughtByID(userID, thoughtID string) (*domain.Thought, error) {
row := s.db.QueryRow(
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order
FROM thoughts WHERE id = ? AND (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))`,
thoughtID, userID, userID, userID, userID, userID, userID,
thoughtID, userID, userID, userID, userID, userID,
)
return scanThought(row)
}
@@ -901,16 +896,16 @@ func (s *Store) GetThoughts(userID, statusFilter string, limit int) ([]domain.Th
if statusFilter != "" && statusFilter != "all" {
rows, err = s.db.Query(
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order
FROM thoughts WHERE user_id = ? AND status = ?
ORDER BY CASE priority WHEN 'urgent' THEN 1 WHEN 'high' THEN 2 WHEN 'normal' THEN 3 ELSE 4 END, created_at DESC LIMIT ?`,
ORDER BY CASE priority WHEN 'urgent' THEN 1 WHEN 'high' THEN 2 WHEN 'normal' THEN 3 ELSE 4 END, sort_order ASC, created_at DESC LIMIT ?`,
userID, statusFilter, limit,
)
} else {
rows, err = s.db.Query(
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order
FROM thoughts WHERE user_id = ?
ORDER BY created_at DESC LIMIT ?`,
ORDER BY sort_order ASC, created_at DESC LIMIT ?`,
userID, limit,
)
}
@@ -934,22 +929,22 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er
var err error
if taskListID != "" {
rows, err = s.db.Query(
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order
FROM thoughts
WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
AND task_list_id = ?
AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))
ORDER BY is_completed ASC, CASE WHEN due_date = '' THEN 1 ELSE 0 END, due_date ASC, created_at DESC`,
userID, userID, userID, userID, userID, userID, taskListID,
ORDER BY is_completed ASC, sort_order ASC, created_at DESC`,
userID, userID, userID, userID, userID, taskListID,
)
} else {
rows, err = s.db.Query(
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order
FROM thoughts
WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))
ORDER BY is_completed ASC, CASE WHEN due_date = '' THEN 1 ELSE 0 END, due_date ASC, created_at DESC`,
userID, userID, userID, userID, userID, userID,
ORDER BY is_completed ASC, sort_order ASC, created_at DESC`,
userID, userID, userID, userID, userID,
)
}
if err != nil {
@@ -967,6 +962,37 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er
return result, nil
}
func (s *Store) ReorderTasks(taskListID string, items []domain.ThoughtReorderItem) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, it := range items {
if it.KanbanStatus != nil && *it.KanbanStatus != "" {
isCompleted := 0
if *it.KanbanStatus == "done" {
isCompleted = 1
}
_, err = tx.Exec(
`UPDATE thoughts SET sort_order = ?, kanban_status = ?, is_completed = ? WHERE id = ?`,
it.SortOrder, *it.KanbanStatus, isCompleted, it.ID,
)
} else {
_, err = tx.Exec(
`UPDATE thoughts SET sort_order = ? WHERE id = ?`,
it.SortOrder, it.ID,
)
}
if err != nil {
return err
}
}
return tx.Commit()
}
// UpdateThoughtTriage handles swipe left (discard), swipe right (keep), restore, and conversion to task/document/list_item
func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, targetListID, dueDate, taskListID string) (*domain.Thought, error) {
thought, err := s.GetThoughtByID(userID, thoughtID)
@@ -2190,339 +2216,9 @@ func (s *Store) GetCollaborators(currentUserID string) ([]domain.User, error) {
return users, nil
}
// --- SPACES & REALTIME LISTS ---
// --- STANDALONE REALTIME LISTS & SHARING ---
func (s *Store) CreateSpace(ownerID, name, icon, description string, isPersonal bool) (*domain.Space, error) {
if strings.TrimSpace(name) == "" {
return nil, errors.New("space name required")
}
if icon == "" {
icon = "🏠"
}
now := domain.NowISO()
inviteCode := strings.ToUpper(domain.NewID("inv")[4:12])
personalInt := 0
if isPersonal {
personalInt = 1
}
sp := &domain.Space{
ID: domain.NewID("spc"),
Name: strings.TrimSpace(name),
Icon: icon,
Description: strings.TrimSpace(description),
InviteCode: inviteCode,
IsPersonal: isPersonal,
OwnerID: ownerID,
CreatedAt: now,
}
_, err := s.db.Exec(
`INSERT INTO spaces (id, name, icon, description, invite_code, is_personal, owner_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
sp.ID, sp.Name, sp.Icon, sp.Description, sp.InviteCode, personalInt, sp.OwnerID, sp.CreatedAt,
)
if err != nil {
return nil, err
}
_, _ = s.db.Exec(
`INSERT INTO space_members (space_id, user_id, role, joined_at) VALUES (?, ?, 'owner', ?)`,
sp.ID, ownerID, now,
)
return s.GetSpaceByID(sp.ID)
}
func (s *Store) JoinSpaceByInviteCode(userID, inviteCode string) (*domain.Space, error) {
inviteCode = strings.ToUpper(strings.TrimSpace(inviteCode))
var spaceID string
err := s.db.QueryRow(`SELECT id FROM spaces WHERE invite_code = ?`, inviteCode).Scan(&spaceID)
if err != nil {
return nil, errors.New("Ungültiger Einladungscode")
}
_, err = s.db.Exec(
`INSERT OR IGNORE INTO space_members (space_id, user_id, role, joined_at) VALUES (?, ?, 'editor', ?)`,
spaceID, userID, domain.NowISO(),
)
if err != nil {
return nil, err
}
return s.GetSpaceByID(spaceID)
}
func (s *Store) GetSpaceByID(spaceID string) (*domain.Space, error) {
sp := &domain.Space{}
var personalInt int
err := s.db.QueryRow(
`SELECT id, name, icon, description, invite_code, is_personal, owner_id, created_at FROM spaces WHERE id = ?`,
spaceID,
).Scan(&sp.ID, &sp.Name, &sp.Icon, &sp.Description, &sp.InviteCode, &personalInt, &sp.OwnerID, &sp.CreatedAt)
if err != nil {
return nil, err
}
sp.IsPersonal = personalInt != 0
sp.Members, _ = s.GetSpaceMembers(spaceID)
_ = s.db.QueryRow(`SELECT COUNT(*) FROM lists WHERE space_id = ?`, spaceID).Scan(&sp.ListsCount)
return sp, nil
}
func (s *Store) GetSpaceMembers(spaceID string) ([]domain.SpaceMember, error) {
rows, err := s.db.Query(
`SELECT sm.space_id, sm.user_id, u.display_name, u.email, sm.role, sm.joined_at
FROM space_members sm
JOIN users u ON u.id = sm.user_id
WHERE sm.space_id = ?
ORDER BY sm.joined_at ASC`,
spaceID,
)
if err != nil {
return nil, err
}
defer rows.Close()
members := make([]domain.SpaceMember, 0)
for rows.Next() {
var m domain.SpaceMember
if err := rows.Scan(&m.SpaceID, &m.UserID, &m.DisplayName, &m.Email, &m.Role, &m.JoinedAt); err == nil {
members = append(members, m)
}
}
return members, nil
}
func (s *Store) AddSpaceMember(currentUserID, spaceID, targetUserIdentifier, role string) (*domain.SpaceMember, error) {
// 1. Check if current user is member of space
var myRole string
err := s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, currentUserID).Scan(&myRole)
if err != nil {
return nil, errors.New("keine Berechtigung für diesen Bereich")
}
// 2. Resolve target user by ID, username or email
cleanTarget := strings.TrimSpace(targetUserIdentifier)
var targetUser *domain.User
if strings.HasPrefix(cleanTarget, "usr_") {
targetUser, _ = s.GetUserByID(cleanTarget)
}
if targetUser == nil {
targetUser, _ = s.GetUserByLogin(cleanTarget)
}
if targetUser == nil {
targetUser, err = s.GetUserByID(cleanTarget)
}
if err != nil || targetUser == nil {
return nil, errors.New("benutzer nicht gefunden")
}
// Protect existing owner from role downgrade!
var existingRole string
_ = s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, targetUser.ID).Scan(&existingRole)
if existingRole == "owner" {
role = "owner"
} else if role != "owner" && role != "editor" && role != "viewer" {
role = "editor"
}
now := domain.NowISO()
_, err = s.db.Exec(
`INSERT INTO space_members (space_id, user_id, role, joined_at) VALUES (?, ?, ?, ?)
ON CONFLICT(space_id, user_id) DO UPDATE SET role = excluded.role`,
spaceID, targetUser.ID, role, now,
)
if err != nil {
return nil, err
}
return &domain.SpaceMember{
SpaceID: spaceID,
UserID: targetUser.ID,
DisplayName: targetUser.DisplayName,
Email: targetUser.Email,
Role: role,
JoinedAt: now,
}, nil
}
func (s *Store) CreateSpaceTeamShare(currentUserID, spaceID, teamID, role string) (*domain.SpaceShare, error) {
var myRole string
err := s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, currentUserID).Scan(&myRole)
if err != nil || (myRole != "owner" && myRole != "editor") {
return nil, errors.New("keine Berechtigung für diesen Bereich")
}
cleanTeamID := strings.TrimSpace(teamID)
var teamName string
err = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, cleanTeamID).Scan(&teamName)
if err != nil {
return nil, errors.New("team nicht gefunden")
}
if role != "owner" && role != "editor" && role != "viewer" {
role = "editor"
}
now := domain.NowISO()
shareID := domain.NewID("spshare")
_, err = s.db.Exec(
`INSERT INTO space_team_shares (id, space_id, team_id, role, created_at, created_by)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(space_id, team_id) DO UPDATE SET role = excluded.role`,
shareID, spaceID, cleanTeamID, role, now, currentUserID,
)
if err != nil {
return nil, err
}
return &domain.SpaceShare{
ID: shareID,
SpaceID: spaceID,
TargetType: "team",
TargetID: cleanTeamID,
TargetName: teamName,
Role: role,
CreatedBy: currentUserID,
CreatedAt: now,
}, nil
}
func (s *Store) GetSpaceShares(spaceID string) ([]domain.SpaceShare, error) {
shares := make([]domain.SpaceShare, 0)
// 1. Team shares
tRows, err := s.db.Query(
`SELECT s.id, s.space_id, s.team_id, t.name, s.role, s.created_by, s.created_at
FROM space_team_shares s
JOIN teams t ON s.team_id = t.id
WHERE s.space_id = ?
ORDER BY s.created_at ASC`,
spaceID,
)
if err == nil {
for tRows.Next() {
var sh domain.SpaceShare
if err := tRows.Scan(&sh.ID, &sh.SpaceID, &sh.TargetID, &sh.TargetName, &sh.Role, &sh.CreatedBy, &sh.CreatedAt); err == nil {
sh.TargetType = "team"
shares = append(shares, sh)
}
}
_ = tRows.Close()
}
// 2. Individual invited members (non-owner members)
var ownerID string
_ = s.db.QueryRow(`SELECT owner_id FROM spaces WHERE id = ?`, spaceID).Scan(&ownerID)
mRows, err := s.db.Query(
`SELECT sm.user_id, u.display_name, u.email, sm.role, sm.joined_at
FROM space_members sm
JOIN users u ON u.id = sm.user_id
WHERE sm.space_id = ? AND sm.user_id != ?
ORDER BY sm.joined_at ASC`,
spaceID, ownerID,
)
if err == nil {
for mRows.Next() {
var uid, dName, email, role, joinedAt string
if err := mRows.Scan(&uid, &dName, &email, &role, &joinedAt); err == nil {
name := dName
if name == "" {
name = email
}
shares = append(shares, domain.SpaceShare{
ID: "sm_" + uid,
SpaceID: spaceID,
TargetType: "user",
TargetID: uid,
TargetName: name,
Role: role,
CreatedAt: joinedAt,
})
}
}
_ = mRows.Close()
}
return shares, nil
}
func (s *Store) RemoveSpaceTeamShare(currentUserID, spaceID, targetID string) error {
var myRole string
err := s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, currentUserID).Scan(&myRole)
if err != nil || (myRole != "owner" && myRole != "editor") {
return errors.New("keine Berechtigung zum Entfernen der Freigabe")
}
cleanTarget := strings.TrimSpace(targetID)
_, err = s.db.Exec(`DELETE FROM space_team_shares WHERE space_id = ? AND (id = ? OR team_id = ?)`, spaceID, cleanTarget, cleanTarget)
return err
}
func (s *Store) RemoveSpaceMember(currentUserID, spaceID, targetUserID string) error {
var myRole string
err := s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, currentUserID).Scan(&myRole)
if err != nil {
return errors.New("keine Berechtigung für diesen Bereich")
}
cleanTarget := strings.TrimSpace(targetUserID)
if strings.HasPrefix(cleanTarget, "team_") || strings.HasPrefix(cleanTarget, "spshare_") {
return s.RemoveSpaceTeamShare(currentUserID, spaceID, cleanTarget)
}
if currentUserID != cleanTarget && myRole != "owner" {
return errors.New("nur der Eigentümer kann andere Mitglieder entfernen")
}
var targetRole string
_ = s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, cleanTarget).Scan(&targetRole)
if targetRole == "owner" {
var ownerCount int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM space_members WHERE space_id = ? AND role = 'owner'`, spaceID).Scan(&ownerCount)
if ownerCount <= 1 {
return errors.New("der letzte Eigentümer kann den Bereich nicht verlassen")
}
}
_, err = s.db.Exec(`DELETE FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, cleanTarget)
return err
}
func (s *Store) GetSpacesForUser(userID string) ([]domain.Space, error) {
rows, err := s.db.Query(
`SELECT s.id, s.name, s.icon, s.description, s.invite_code, s.is_personal, s.owner_id, s.created_at
FROM spaces s
LEFT JOIN space_members sm ON sm.space_id = s.id AND sm.user_id = ?
LEFT JOIN space_team_shares sts ON sts.space_id = s.id AND sts.team_id IN (SELECT team_id FROM team_members WHERE user_id = ?)
WHERE sm.user_id = ? OR sts.team_id IS NOT NULL
GROUP BY s.id
ORDER BY s.created_at ASC`,
userID, userID, userID,
)
if err != nil {
return nil, err
}
spaces := make([]domain.Space, 0)
for rows.Next() {
var sp domain.Space
var personalInt int
if err := rows.Scan(&sp.ID, &sp.Name, &sp.Icon, &sp.Description, &sp.InviteCode, &personalInt, &sp.OwnerID, &sp.CreatedAt); err == nil {
sp.IsPersonal = personalInt != 0
spaces = append(spaces, sp)
}
}
_ = rows.Close()
for i := range spaces {
spaces[i].Members, _ = s.GetSpaceMembers(spaces[i].ID)
spaces[i].Shares, _ = s.GetSpaceShares(spaces[i].ID)
_ = s.db.QueryRow(`SELECT COUNT(*) FROM lists WHERE space_id = ?`, spaces[i].ID).Scan(&spaces[i].ListsCount)
}
return spaces, nil
}
func (s *Store) CreateList(spaceID, title, icon, listType string) (*domain.SharedList, error) {
func (s *Store) CreateList(userID, title, icon, listType string) (*domain.SharedList, error) {
if strings.TrimSpace(title) == "" {
return nil, errors.New("list title required")
}
@@ -2535,59 +2231,167 @@ func (s *Store) CreateList(spaceID, title, icon, listType string) (*domain.Share
now := domain.NowISO()
lst := &domain.SharedList{
ID: domain.NewID("lst"),
SpaceID: spaceID,
UserID: userID,
IsOwner: true,
Role: "editor",
Title: strings.TrimSpace(title),
Icon: icon,
ListType: listType,
Items: []domain.ListItem{},
Shares: []domain.ListShare{},
UpdatedAt: now,
CreatedAt: now,
}
_, err := s.db.Exec(
`INSERT INTO lists (id, space_id, title, icon, list_type, updated_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
lst.ID, lst.SpaceID, lst.Title, lst.Icon, lst.ListType, lst.UpdatedAt, lst.CreatedAt,
`INSERT INTO lists (id, user_id, title, icon, list_type, updated_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
lst.ID, lst.UserID, lst.Title, lst.Icon, lst.ListType, lst.UpdatedAt, lst.CreatedAt,
)
if err != nil {
return nil, err
}
_ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, userID).Scan(&lst.OwnerName)
return lst, nil
}
func (s *Store) GetListsForSpace(spaceID string) ([]domain.SharedList, error) {
rows, err := s.db.Query(
`SELECT id, space_id, title, icon, list_type, updated_at, created_at
FROM lists WHERE space_id = ? ORDER BY created_at ASC`,
spaceID,
func (s *Store) AddListShare(currentUserID, listID, targetType, targetID, role string) (*domain.ListShare, error) {
if targetType != "team" && targetType != "user" {
return nil, errors.New("ungültiger Freigabetyp")
}
targetID = strings.TrimSpace(targetID)
if targetID == "" {
return nil, errors.New("Ziel-ID erforderlich")
}
if role != "viewer" && role != "editor" {
role = "editor"
}
if targetType == "user" {
var targetUser *domain.User
if strings.HasPrefix(targetID, "usr_") {
targetUser, _ = s.GetUserByID(targetID)
}
if targetUser == nil {
targetUser, _ = s.GetUserByLogin(targetID)
}
if targetUser == nil {
targetUser, _ = s.GetUserByEmail(targetID)
}
if targetUser == nil {
return nil, fmt.Errorf("benutzer nicht gefunden: %s", targetID)
}
targetID = targetUser.ID
}
// Verify current user is owner or editor of the list
var ownerID string
err := s.db.QueryRow(`SELECT user_id FROM lists WHERE id = ?`, listID).Scan(&ownerID)
if err != nil {
return nil, errors.New("liste nicht gefunden")
}
if ownerID != currentUserID {
var myRole string
err = s.db.QueryRow(`SELECT role FROM list_shares WHERE list_id = ? AND target_type = 'user' AND target_id = ?`, listID, currentUserID).Scan(&myRole)
if err != nil || myRole != "editor" {
return nil, errors.New("keine Berechtigung zum Teilen dieser Liste")
}
}
now := domain.NowISO()
share := &domain.ListShare{
ID: domain.NewID("lsh"),
ListID: listID,
TargetType: targetType,
TargetID: targetID,
Role: role,
CreatedBy: currentUserID,
CreatedAt: now,
}
_, err = s.db.Exec(
`INSERT INTO list_shares (id, list_id, target_type, target_id, role, created_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(list_id, target_type, target_id) DO UPDATE SET role = excluded.role`,
share.ID, share.ListID, share.TargetType, share.TargetID, share.Role, share.CreatedBy, share.CreatedAt,
)
if err != nil {
return nil, err
}
lists := make([]domain.SharedList, 0)
_ = s.db.QueryRow(`SELECT id, created_at FROM list_shares WHERE list_id = ? AND target_type = ? AND target_id = ?`,
listID, targetType, targetID).Scan(&share.ID, &share.CreatedAt)
if targetType == "team" {
_ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, targetID).Scan(&share.TargetName)
} else {
_ = s.db.QueryRow(`SELECT display_name, email FROM users WHERE id = ?`, targetID).Scan(&share.TargetName, &share.Email)
}
return share, nil
}
func (s *Store) RemoveListShare(currentUserID, listID, shareIDOrTargetID string) error {
var ownerID string
err := s.db.QueryRow(`SELECT user_id FROM lists WHERE id = ?`, listID).Scan(&ownerID)
if err != nil {
return errors.New("liste nicht gefunden")
}
if ownerID != currentUserID {
return errors.New("nur der Eigentümer kann Freigaben entfernen")
}
cleanTarget := strings.TrimSpace(shareIDOrTargetID)
_, err = s.db.Exec(
`DELETE FROM list_shares WHERE list_id = ? AND (id = ? OR target_id = ?)`,
listID, cleanTarget, cleanTarget,
)
return err
}
func (s *Store) GetListShares(listID string) ([]domain.ListShare, error) {
rows, err := s.db.Query(
`SELECT id, list_id, target_type, target_id, role, created_by, created_at
FROM list_shares WHERE list_id = ? ORDER BY created_at ASC`,
listID,
)
if err != nil {
return []domain.ListShare{}, err
}
defer rows.Close()
shares := make([]domain.ListShare, 0)
for rows.Next() {
var l domain.SharedList
if err := rows.Scan(&l.ID, &l.SpaceID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt); err == nil {
lists = append(lists, l)
var sh domain.ListShare
if err := rows.Scan(&sh.ID, &sh.ListID, &sh.TargetType, &sh.TargetID, &sh.Role, &sh.CreatedBy, &sh.CreatedAt); err == nil {
if sh.TargetType == "team" {
_ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, sh.TargetID).Scan(&sh.TargetName)
} else {
_ = s.db.QueryRow(`SELECT display_name, email FROM users WHERE id = ?`, sh.TargetID).Scan(&sh.TargetName, &sh.Email)
}
shares = append(shares, sh)
}
}
_ = rows.Close()
for i := range lists {
lists[i].Items, _ = s.GetListItems(lists[i].ID)
}
return lists, nil
return shares, nil
}
func (s *Store) GetAllListsForUser(userID string) ([]domain.SharedList, error) {
rows, err := s.db.Query(
`SELECT DISTINCT l.id, l.space_id, l.title, l.icon, l.list_type, l.updated_at, l.created_at
`SELECT DISTINCT l.id, l.user_id, l.title, l.icon, l.list_type, l.updated_at, l.created_at,
u.display_name,
CASE
WHEN l.user_id = ? THEN 'owner'
WHEN ls.role IS NOT NULL THEN ls.role
ELSE 'editor'
END as effective_role
FROM lists l
LEFT JOIN space_members sm ON sm.space_id = l.space_id AND sm.user_id = ?
LEFT JOIN space_team_shares sts ON sts.space_id = l.space_id AND sts.team_id IN (SELECT team_id FROM team_members WHERE user_id = ?)
WHERE sm.user_id = ? OR sts.team_id IS NOT NULL
LEFT JOIN users u ON u.id = l.user_id
LEFT JOIN list_shares ls ON ls.list_id = l.id AND (
(ls.target_type = 'user' AND ls.target_id = ?)
OR (ls.target_type = 'team' AND ls.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
)
WHERE l.user_id = ?
OR ls.id IS NOT NULL
ORDER BY l.updated_at DESC`,
userID, userID, userID,
userID, userID, userID, userID,
)
if err != nil {
return nil, err
@@ -2596,7 +2400,12 @@ func (s *Store) GetAllListsForUser(userID string) ([]domain.SharedList, error) {
lists := make([]domain.SharedList, 0)
for rows.Next() {
var l domain.SharedList
if err := rows.Scan(&l.ID, &l.SpaceID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt); err == nil {
var ownerName sql.NullString
var effectiveRole string
if err := rows.Scan(&l.ID, &l.UserID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt, &ownerName, &effectiveRole); err == nil {
l.OwnerName = ownerName.String
l.IsOwner = (l.UserID == userID)
l.Role = effectiveRole
lists = append(lists, l)
}
}
@@ -2604,25 +2413,68 @@ func (s *Store) GetAllListsForUser(userID string) ([]domain.SharedList, error) {
for i := range lists {
lists[i].Items, _ = s.GetListItems(lists[i].ID)
lists[i].Shares, _ = s.GetListShares(lists[i].ID)
}
return lists, nil
}
func (s *Store) GetListByID(listID string) (*domain.SharedList, error) {
func (s *Store) GetListByID(userID, listID string) (*domain.SharedList, error) {
var l domain.SharedList
err := s.db.QueryRow(
`SELECT id, space_id, title, icon, list_type, updated_at, created_at FROM lists WHERE id = ?`,
listID,
).Scan(&l.ID, &l.SpaceID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt)
var ownerName sql.NullString
var effectiveRole string
var query string
var args []interface{}
if userID == "" {
query = `SELECT l.id, l.user_id, l.title, l.icon, l.list_type, l.updated_at, l.created_at,
u.display_name, 'owner' as effective_role
FROM lists l
LEFT JOIN users u ON u.id = l.user_id
WHERE l.id = ?`
args = []interface{}{listID}
} else {
query = `SELECT l.id, l.user_id, l.title, l.icon, l.list_type, l.updated_at, l.created_at,
u.display_name,
CASE
WHEN l.user_id = ? THEN 'owner'
WHEN ls.role IS NOT NULL THEN ls.role
ELSE 'editor'
END as effective_role
FROM lists l
LEFT JOIN users u ON u.id = l.user_id
LEFT JOIN list_shares ls ON ls.list_id = l.id AND (
(ls.target_type = 'user' AND ls.target_id = ?)
OR (ls.target_type = 'team' AND ls.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
)
WHERE l.id = ? AND (
l.user_id = ?
OR ls.id IS NOT NULL
)`
args = []interface{}{userID, userID, userID, listID, userID}
}
err := s.db.QueryRow(query, args...).Scan(&l.ID, &l.UserID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt, &ownerName, &effectiveRole)
if err != nil {
return nil, err
}
l.OwnerName = ownerName.String
l.IsOwner = (userID == "" || l.UserID == userID)
l.Role = effectiveRole
l.Items, _ = s.GetListItems(l.ID)
l.Shares, _ = s.GetListShares(l.ID)
return &l, nil
}
func (s *Store) DeleteList(listID string) error {
_, err := s.db.Exec(`DELETE FROM lists WHERE id = ?`, listID)
func (s *Store) DeleteList(userID, listID string) error {
var ownerID string
err := s.db.QueryRow(`SELECT user_id FROM lists WHERE id = ?`, listID).Scan(&ownerID)
if err != nil {
return errors.New("liste nicht gefunden")
}
if ownerID != userID {
return errors.New("nur der Eigentümer kann die Liste löschen")
}
_, err = s.db.Exec(`DELETE FROM lists WHERE id = ?`, listID)
return err
}
@@ -2751,6 +2603,44 @@ func (s *Store) ClearCompletedListItems(listID string) (int64, error) {
return res.RowsAffected()
}
func (s *Store) ReorderListItems(listID string, items []domain.ListItemReorderItem) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, it := range items {
if it.Category != nil && it.Content != nil {
_, err = tx.Exec(
`UPDATE list_items SET sort_order = ?, category = ?, content = ? WHERE id = ? AND list_id = ?`,
it.SortOrder, *it.Category, *it.Content, it.ID, listID,
)
} else if it.Category != nil {
_, err = tx.Exec(
`UPDATE list_items SET sort_order = ?, category = ? WHERE id = ? AND list_id = ?`,
it.SortOrder, *it.Category, it.ID, listID,
)
} else if it.Content != nil {
_, err = tx.Exec(
`UPDATE list_items SET sort_order = ?, content = ? WHERE id = ? AND list_id = ?`,
it.SortOrder, *it.Content, it.ID, listID,
)
} else {
_, err = tx.Exec(
`UPDATE list_items SET sort_order = ? WHERE id = ? AND list_id = ?`,
it.SortOrder, it.ID, listID,
)
}
if err != nil {
return err
}
}
_, _ = tx.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, domain.NowISO(), listID)
return tx.Commit()
}
// --- API KEYS & WEBHOOKS ---
func (s *Store) CreateAPIKey(userID, name string) (*domain.APIKey, error) {
@@ -2829,9 +2719,11 @@ func (s *Store) GetDashboardStats(userID string) (*domain.DashboardStats, error)
_ = s.db.QueryRow(`
SELECT COUNT(DISTINCT l.id)
FROM lists l
LEFT JOIN space_members sm ON sm.space_id = l.space_id AND sm.user_id = ?
LEFT JOIN space_team_shares sts ON sts.space_id = l.space_id AND sts.team_id IN (SELECT team_id FROM team_members WHERE user_id = ?)
WHERE sm.user_id = ? OR sts.team_id IS NOT NULL`,
LEFT JOIN list_shares ls ON ls.list_id = l.id AND (
(ls.target_type = 'user' AND ls.target_id = ?)
OR (ls.target_type = 'team' AND ls.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
)
WHERE l.user_id = ? OR ls.id IS NOT NULL`,
userID, userID, userID).Scan(&st.ListsCount)
// Count open (uncompleted) To-Dos across accessible workspace documents (owned or shared)
@@ -2865,7 +2757,7 @@ func (s *Store) SearchAll(userID, query string) ([]domain.Thought, []domain.Docu
q := "%" + strings.ToLower(strings.TrimSpace(query)) + "%"
tRows, err := s.db.Query(
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order
FROM thoughts WHERE user_id = ? AND LOWER(content) LIKE ? ORDER BY created_at DESC LIMIT 30`,
userID, q,
)
@@ -2908,7 +2800,6 @@ func (s *Store) ExportUserData(userID string) (*domain.FullExport, error) {
}
thoughts, _ := s.GetThoughts(userID, "all", 5000)
docs, _ := s.GetDocuments(userID)
spaces, _ := s.GetSpacesForUser(userID)
lists, _ := s.GetAllListsForUser(userID)
taskLists, _ := s.GetTaskLists(userID)
@@ -2918,7 +2809,6 @@ func (s *Store) ExportUserData(userID string) (*domain.FullExport, error) {
User: *user,
Thoughts: thoughts,
Documents: docs,
Spaces: spaces,
Lists: lists,
TaskLists: taskLists,
}, nil
@@ -3127,9 +3017,8 @@ func (s *Store) GetTaskLists(userID string) ([]domain.TaskList, error) {
WHERE (target_type = 'user' AND target_id = ?)
OR (target_type = 'team' AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
)
OR (space_id != '' AND space_id IN (SELECT space_id FROM space_members WHERE user_id = ?))
ORDER BY updated_at DESC`,
userID, userID, userID, userID, userID,
userID, userID, userID, userID,
)
if err != nil {
return []domain.TaskList{}, err
@@ -3192,9 +3081,8 @@ func (s *Store) GetTaskListByID(userID, listID string) (*domain.TaskList, error)
WHERE (target_type = 'user' AND target_id = ?)
OR (target_type = 'team' AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
)
OR (space_id != '' AND space_id IN (SELECT space_id FROM space_members WHERE user_id = ?))
)`,
listID, userID, userID, userID, userID, userID,
listID, userID, userID, userID, userID,
).Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.TeamID, &tl.Title, &tl.Icon, &tl.Color, &colsJSON, &tl.CreatedAt, &tl.UpdatedAt)
if err != nil {
return nil, err
+17 -21
View File
@@ -48,12 +48,8 @@ func TestStoreLifecycle(t *testing.T) {
t.Fatalf("expected converted document, got status=%s type=%s ref=%s", triaged.Status, triaged.ConvertedType, triaged.ConvertedRefID)
}
// 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)
}
+29 -23
View File
@@ -22,7 +22,7 @@ var upgrader = websocket.Upgrader{
type Client struct {
hub *Hub
conn *websocket.Conn
spaceID string
roomID string
userID string
send chan []byte
}
@@ -37,7 +37,7 @@ type docLockItem struct {
type Hub struct {
mu sync.RWMutex
spaceRooms map[string]map[*Client]bool
rooms map[string]map[*Client]bool
userRooms map[string]map[*Client]bool
allClients map[*Client]bool
locks map[string]*docLockItem
@@ -45,7 +45,7 @@ type Hub struct {
func NewHub() *Hub {
h := &Hub{
spaceRooms: make(map[string]map[*Client]bool),
rooms: make(map[string]map[*Client]bool),
userRooms: make(map[string]map[*Client]bool),
allClients: make(map[*Client]bool),
locks: make(map[string]*docLockItem),
@@ -57,11 +57,11 @@ func NewHub() *Hub {
func (h *Hub) Register(c *Client) {
h.mu.Lock()
h.allClients[c] = true
if c.spaceID != "" {
if h.spaceRooms[c.spaceID] == nil {
h.spaceRooms[c.spaceID] = make(map[*Client]bool)
if c.roomID != "" {
if h.rooms[c.roomID] == nil {
h.rooms[c.roomID] = make(map[*Client]bool)
}
h.spaceRooms[c.spaceID][c] = true
h.rooms[c.roomID][c] = true
}
if c.userID != "" && c.userID != "anonymous" {
if h.userRooms[c.userID] == nil {
@@ -72,8 +72,8 @@ func (h *Hub) Register(c *Client) {
onlineCount := len(h.allClients)
h.mu.Unlock()
if c.spaceID != "" {
h.Broadcast(c.spaceID, "", "USER_PRESENCE", map[string]interface{}{
if c.roomID != "" {
h.Broadcast(c.roomID, "", "USER_PRESENCE", map[string]interface{}{
"onlineCount": onlineCount,
})
}
@@ -82,11 +82,11 @@ func (h *Hub) Register(c *Client) {
func (h *Hub) Unregister(c *Client) {
h.mu.Lock()
delete(h.allClients, c)
if c.spaceID != "" {
if room, ok := h.spaceRooms[c.spaceID]; ok {
if c.roomID != "" {
if room, ok := h.rooms[c.roomID]; ok {
delete(room, c)
if len(room) == 0 {
delete(h.spaceRooms, c.spaceID)
delete(h.rooms, c.roomID)
}
}
}
@@ -297,7 +297,6 @@ func (h *Hub) BroadcastDocEvent(docID, event string, payload interface{}) {
func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) {
msg := domain.WSMessage{
Event: event,
SpaceID: spaceID,
ListID: listID,
Payload: payload,
Timestamp: domain.NowISO(),
@@ -310,20 +309,27 @@ func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) {
h.mu.RLock()
defer h.mu.RUnlock()
room, ok := h.spaceRooms[spaceID]
if !ok {
return
targetRoom := listID
if targetRoom == "" {
targetRoom = spaceID
}
for client := range room {
select {
case client.send <- data:
default:
// Slow client buffer full
if room, ok := h.rooms[targetRoom]; ok {
for client := range room {
select {
case client.send <- data:
default:
// Slow client buffer full
}
}
}
}
func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request, spaceID, userID string) {
func (h *Hub) BroadcastListEvent(listID, event string, payload interface{}) {
h.Broadcast("", listID, event, payload)
}
func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request, roomID, userID string) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("[ws] upgrade error: %v", err)
@@ -333,7 +339,7 @@ func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request, spaceID, userID st
client := &Client{
hub: h,
conn: conn,
spaceID: spaceID,
roomID: roomID,
userID: userID,
send: make(chan []byte, 128),
}
+2
View File
@@ -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