Compare commits
31
Commits
5db126128e
...
8a32cdd525
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a32cdd525 | ||
|
|
6896a29285 | ||
|
|
554029a099 | ||
|
|
c85f31eee5 | ||
|
|
1c71b09f20 | ||
|
|
6a261e3453 | ||
|
|
fdbf691a8d | ||
|
|
5cf6b59377 | ||
|
|
6129681035 | ||
|
|
63a5885155 | ||
|
|
fd95c2f8da | ||
|
|
94acb2638e | ||
|
|
d39d1d1122 | ||
|
|
df2f2bac67 | ||
|
|
bce103e2c6 | ||
|
|
122ebccf8d | ||
|
|
e7eb94be45 | ||
|
|
2ecf71cc42 | ||
|
|
fd89f92b0c | ||
|
|
04fa774d10 | ||
|
|
aea05e868a | ||
|
|
1bdd7d245d | ||
|
|
266026da4c | ||
|
|
183199401d | ||
|
|
ee8d586e2f | ||
|
|
a1c7a65d84 | ||
|
|
996f27133c | ||
|
|
0e06cc5577 | ||
|
|
50b017f387 | ||
|
|
f7e70b649f | ||
|
|
8769aceb05 |
No files matched your search
@@ -58,6 +58,7 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
avatar_url TEXT NOT NULL DEFAULT '', -- Profilbild URL oder leer
|
||||
role TEXT NOT NULL DEFAULT 'member', -- 'admin' | 'member'
|
||||
is_active INTEGER NOT NULL DEFAULT 1, -- 1 = aktiv, 0 = gesperrt
|
||||
token_version INTEGER NOT NULL DEFAULT 1, -- Erhöht bei Passwortwechsel/Rechteentzug zur Token-Invalidierung
|
||||
alarm_channels TEXT NOT NULL DEFAULT 'discord', -- Kommagetrennt: 'email', 'discord', 'webhook'
|
||||
alarm_email TEXT NOT NULL DEFAULT '', -- Ziel-E-Mail für Alarme
|
||||
discord_webhook_url TEXT NOT NULL DEFAULT '', -- Discord Webhook URL
|
||||
@@ -111,6 +112,19 @@ CREATE TABLE IF NOT EXISTS api_keys (
|
||||
);
|
||||
```
|
||||
|
||||
#### `revoked_tokens`
|
||||
Sperrliste für explizit abgemeldete JWTs (Blacklist bis zum regulären Token-Ablaufdatum).
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS revoked_tokens (
|
||||
token_hash TEXT PRIMARY KEY, -- SHA-256 Hash des Tokens
|
||||
user_id TEXT NOT NULL, -- Benutzer-ID
|
||||
expires_at TEXT NOT NULL, -- ISO 8601 Ablaufdatum
|
||||
revoked_at TEXT NOT NULL -- ISO 8601 Sperrzeitpunkt
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_revoked_tokens_expires ON revoked_tokens(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_revoked_tokens_user ON revoked_tokens(user_id);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.2 Gruppen & Organisations-Teams
|
||||
|
||||
+10
-8
@@ -15,7 +15,7 @@ Diese Datei dokumentiert eine ehrliche und schonungslose technische Analyse alle
|
||||
| **FLAW-05** | Frontend DOM | Fehlende Virtualisierung im Outliner-Baum | **Mittel** | Frame-Drops und Lags bei Dokumenten mit >500 Blöcken auf Mobilgeräten |
|
||||
| **FLAW-06** | SQLite Concurrency | Single-Writer-Bottleneck bei hohem parallelem Schreibaufkommen | **Mittel** | `SQLITE_BUSY` Fehler bei vielen gleichzeitigen Team-Nutzern |
|
||||
| **FLAW-07** | Benachrichtigungen | Fehlende Dead-Letter-Queue für Webhook- & E-Mail-Alarme | **Niedrig** | Verlorene Benachrichtigungen bei temporärem Ausfall von Drittsystemen |
|
||||
| **FLAW-08** | Sicherheit / Auth | Statische JWT-Tokens ohne rotierende Refresh-Tokens & Blacklisting | **Niedrig** | Keine sofortige Sitzungsinvalidierung bei Rechteentzug |
|
||||
| **FLAW-08** | Sicherheit / Auth | Statische JWT-Tokens ohne Blacklisting & Token-Versionierung | **Gelöst (v2.2)** | Behoben durch `token_version`-Spalte in `users`, `revoked_tokens`-Sperrliste und sofortige Sitzungsinvalidierung bei Passwortänderung/Logout/Rechteentzug |
|
||||
|
||||
---
|
||||
|
||||
@@ -112,13 +112,15 @@ Diese Datei dokumentiert eine ehrliche und schonungslose technische Analyse alle
|
||||
|
||||
---
|
||||
|
||||
### FLAW-08: Statische JWT-Tokens ohne Blacklisting
|
||||
- **Betroffene Komponenten:** [`architecture.md`](./architecture.md), `internal/api/auth.go`
|
||||
- **Ursache:** Nach dem Login wird ein JWT ausgestellt, das clientseitig im `localStorage` gespeichert wird. Der Server prüft lediglich die kryptografische Signatur des Tokens, führt aber keine serverseitige Sitzungsliste.
|
||||
- **Folgen:** Wird einem Benutzer die Berechtigung entzogen oder ändert er sein Passwort, bleibt ein bereits ausgestellter Token bis zu seinem Ablaufdatum (z. B. 7 Tage) gültig.
|
||||
- **Empfohlene Lösung:**
|
||||
1. Einführung einer `revoked_tokens`-Tabelle oder einer `token_version`-Spalte in der `users`-Tabelle.
|
||||
2. Implementierung von kurzlebigen Access-Tokens (15 Min.) in Verbindung mit rotierenden Refresh-Tokens in `httpOnly`-Cookies.
|
||||
### FLAW-08: [GELÖST / IMPLEMENTIERT] JWT-Sicherheit, Token-Blacklisting & Token-Versionierung
|
||||
- **Status:** **Gelöst via `token_version`-Spalte in `users`, `revoked_tokens`-Blacklist und serverseitige Invalidierungs-Endpunkte**
|
||||
- **Betroffene Komponenten:** [`architecture.md`](./architecture.md), [`database.md`](./database.md), `internal/api/server.go`, `internal/auth/auth.go`, `internal/storage/sqlite.go`, `frontend/src/lib/api.ts`
|
||||
- **Ursache (Historisch):** Nach dem Login wurde ein statisches JWT ausgestellt, das lediglich kryptografisch signiert war. Bei Passwortänderungen, Rechteänderungen oder Konto-Deaktivierung blieben bereits ausgestellte Tokens bis zum Ablaufdatum gültig.
|
||||
- **Implementierte Lösung:**
|
||||
1. **`token_version`-Spalte in `users`:** Jedes ausgestellte JWT führt die `tokenVersion` des Benutzers im Claim. Bei einer Passwortänderung (`UpdateUserPassword`), Rechterückstufung oder Deaktivierung wird `token_version = token_version + 1` atomar hochgezählt. Alle alten Tokens auf beliebigen Geräten werden sofort mit `401 Unauthorized` abgewiesen.
|
||||
2. **Tabelle `revoked_tokens` für Einzelsitzungen:** Beim Klick auf Logout (`POST /api/v1/auth/logout`) wird das spezifische JWT via SHA-256-Hash in die Sperrliste eingetragen, bis sein reguläres Ablaufdatum erreicht ist.
|
||||
3. **"Alle Sitzungen beenden" (`POST /api/v1/auth/revoke-all`):** Ermöglicht es dem Benutzer in den Einstellungen ("Profil & Sicherheit") oder einem Administrator, mit einem Klick alle aktiven Sitzungen auf sämtlichen Browsern und Mobilgeräten zu terminieren.
|
||||
4. **Konsistente Prüfung bei WebSockets & SSE:** Auch bei Echtzeit-Verbindungen (`/api/v1/ws`, `/api/v1/ws/docs/:id`, `/api/v1/events`) wird der Benutzerstatus, die Sperrliste und die aktuelle `token_version` überprüft. Outdated oder gesperrte Clients werden sofort abgewiesen.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+16
-4
@@ -5,6 +5,7 @@ import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
@@ -58,9 +59,15 @@ func main() {
|
||||
|
||||
srv := api.NewServer(cfg, store, wsHub, staticFS)
|
||||
|
||||
serverCtx, serverCancel := context.WithCancel(context.Background())
|
||||
defer serverCancel()
|
||||
|
||||
httpServer := &http.Server{
|
||||
Addr: ":" + cfg.Port,
|
||||
Handler: srv.Router(),
|
||||
Addr: ":" + cfg.Port,
|
||||
Handler: srv.Router(),
|
||||
BaseContext: func(l net.Listener) context.Context {
|
||||
return serverCtx
|
||||
},
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
@@ -79,7 +86,12 @@ func main() {
|
||||
<-stop
|
||||
|
||||
slog.Info("Shutting down MindSift server gracefully...")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
serverCancel()
|
||||
wsHub.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
_ = httpServer.Shutdown(ctx)
|
||||
if err := httpServer.Shutdown(ctx); err != nil {
|
||||
slog.Warn("HTTP server shutdown finished with error", "error", err)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<rect width="512" height="512" rx="128" fill="#0b0f19"/>
|
||||
<circle cx="256" cy="256" r="164" fill="none" stroke="#8ab4f8" stroke-width="36"/>
|
||||
<path d="M184 266 L236 318 L338 194" fill="none" stroke="#c58af9" stroke-width="40" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect width="512" height="512" rx="128" fill="#8ab4f8"/>
|
||||
<g transform="translate(80, 80) scale(14.666)" fill="none" stroke="#0b0f19" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/>
|
||||
<path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"/>
|
||||
<path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4"/>
|
||||
<path d="M17.599 6.5a3 3 0 0 0 .399-1.375"/>
|
||||
<path d="M6.003 5.125A3 3 0 0 0 6.401 6.5"/>
|
||||
<path d="M3.477 10.896a4 4 0 0 1 .585-.396"/>
|
||||
<path d="M19.938 10.5a4 4 0 0 1 .585.396"/>
|
||||
<path d="M6 18a4 4 0 0 1-1.967-.516"/>
|
||||
<path d="M19.967 17.484A4 4 0 0 1 18 18"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 374 B After Width: | Height: | Size: 851 B |
+80
-59
@@ -127,6 +127,8 @@ export const App: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<ActiveTab>('triage');
|
||||
const [previousTab, setPreviousTab] = useState<ActiveTab>('triage');
|
||||
const [selectedDocId, setSelectedDocId] = useState<string | null>(null);
|
||||
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||
const [captureFocusCount, setCaptureFocusCount] = useState(0);
|
||||
const [refreshRevision, setRefreshRevision] = useState(0);
|
||||
|
||||
@@ -143,6 +145,7 @@ export const App: React.FC = () => {
|
||||
const [mobileVoiceActive, setMobileVoiceActive] = useState(false);
|
||||
const [isVoiceCaptured, setIsVoiceCaptured] = useState(false);
|
||||
const mobileRecognitionRef = useRef<any>(null);
|
||||
const mobileBaseVoiceRef = useRef('');
|
||||
|
||||
const toggleMobileVoice = () => {
|
||||
const SpeechRecognition =
|
||||
@@ -157,6 +160,7 @@ export const App: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
mobileBaseVoiceRef.current = mobileTriageContent.replace(/\s*\[…\]$/, '').trim();
|
||||
const recognition = new SpeechRecognition();
|
||||
recognition.lang = 'de-DE';
|
||||
recognition.continuous = false;
|
||||
@@ -170,10 +174,8 @@ export const App: React.FC = () => {
|
||||
for (let i = 0; i < event.results.length; i++) {
|
||||
transcript += event.results[i][0].transcript;
|
||||
}
|
||||
setMobileTriageContent((prev) => {
|
||||
const base = prev.replace(/\s*\[…\]$/, '').trim();
|
||||
return base ? `${base} ${transcript}` : transcript;
|
||||
});
|
||||
const base = mobileBaseVoiceRef.current;
|
||||
setMobileTriageContent(base ? `${base} ${transcript}` : transcript);
|
||||
setIsVoiceCaptured(true);
|
||||
};
|
||||
recognition.onerror = () => setMobileVoiceActive(false);
|
||||
@@ -249,13 +251,6 @@ export const App: React.FC = () => {
|
||||
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',
|
||||
@@ -263,6 +258,13 @@ export const App: React.FC = () => {
|
||||
badge: stats.docsCount,
|
||||
onClick: () => setActiveTab('workspace'),
|
||||
},
|
||||
{
|
||||
id: 'goto-tasks',
|
||||
label: 'Aufgaben & Kanban',
|
||||
icon: <CheckSquare className="w-4 h-4" />,
|
||||
badge: stats.tasksOpenCount,
|
||||
onClick: () => setActiveTab('tasks'),
|
||||
},
|
||||
{
|
||||
id: 'goto-lists',
|
||||
label: 'Einkaufslisten',
|
||||
@@ -297,18 +299,18 @@ export const App: React.FC = () => {
|
||||
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-tasks',
|
||||
label: 'Zu den Aufgaben',
|
||||
icon: <CheckSquare className="w-4 h-4" />,
|
||||
onClick: () => setActiveTab('tasks'),
|
||||
},
|
||||
{
|
||||
id: 'goto-lists',
|
||||
label: 'Zu den Listen',
|
||||
@@ -549,11 +551,17 @@ export const App: React.FC = () => {
|
||||
refreshAll();
|
||||
};
|
||||
|
||||
const handleOpenDocument = (docId: string) => {
|
||||
const handleOpenDocument = (docId: string, blockId?: string) => {
|
||||
setSelectedDocId(docId);
|
||||
setSelectedBlockId(blockId || null);
|
||||
setActiveTab('workspace');
|
||||
};
|
||||
|
||||
const handleOpenTask = useCallback((taskId: string) => {
|
||||
setSelectedTaskId(taskId);
|
||||
setActiveTab('tasks');
|
||||
}, []);
|
||||
|
||||
const handleTriggerAddButton = () => {
|
||||
triggerHaptic(20);
|
||||
if (window.innerWidth < 640) {
|
||||
@@ -567,7 +575,11 @@ export const App: React.FC = () => {
|
||||
const handleLogout = async () => {
|
||||
triggerHaptic(20);
|
||||
disconnectWebSocket();
|
||||
clearToken();
|
||||
try {
|
||||
await api.logout();
|
||||
} catch {
|
||||
clearToken();
|
||||
}
|
||||
setUser(null);
|
||||
setShowProfileMenu(false);
|
||||
setActiveTab('triage');
|
||||
@@ -632,9 +644,12 @@ export const App: React.FC = () => {
|
||||
<Brain className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-bold text-base text-main tracking-tight">
|
||||
{authStatus.instanceName || 'MindSift'}
|
||||
</span>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-bold text-base text-main tracking-tight leading-none">
|
||||
{authStatus.instanceName || 'MindSift'}
|
||||
</span>
|
||||
<span className="text-[9px] text-muted font-mono leading-tight mt-0.5">v0.1</span>
|
||||
</div>
|
||||
<span className="text-[11px] font-semibold text-accent bg-accent/15 px-1.5 py-0.5 rounded-md">
|
||||
{activeTabLabelMap[activeTab]}
|
||||
</span>
|
||||
@@ -694,9 +709,12 @@ export const App: React.FC = () => {
|
||||
<div className="w-7 h-7 bg-accent text-on-accent rounded-btn flex items-center justify-center">
|
||||
<Brain className="w-4 h-4" />
|
||||
</div>
|
||||
<span className="font-bold text-sm sm:text-base text-main tracking-tight leading-none">
|
||||
{authStatus.instanceName || 'MindSift'}
|
||||
</span>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-bold text-sm sm:text-base text-main tracking-tight leading-none">
|
||||
{authStatus.instanceName || 'MindSift'}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted font-mono leading-tight mt-0.5">v0.1</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick-Capture Input Bubble directly in Header (above Tabs) */}
|
||||
@@ -862,24 +880,6 @@ export const App: React.FC = () => {
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
setActiveTab('tasks');
|
||||
}}
|
||||
className={`tab-btn ${activeTab === 'tasks' ? 'tab-btn-active' : ''}`}
|
||||
>
|
||||
<CheckSquare className="w-4 h-4" />
|
||||
<span className={`relative inline-flex items-center ${stats.tasksOpenCount > 0 ? 'pr-2' : ''}`}>
|
||||
<span>Aufgaben</span>
|
||||
{stats.tasksOpenCount > 0 && (
|
||||
<span className="absolute -top-1.5 -right-1 min-w-[15px] h-[15px] px-0.5 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-sm pointer-events-none">
|
||||
{stats.tasksOpenCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
@@ -898,6 +898,24 @@ export const App: React.FC = () => {
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
setActiveTab('tasks');
|
||||
}}
|
||||
className={`tab-btn ${activeTab === 'tasks' ? 'tab-btn-active' : ''}`}
|
||||
>
|
||||
<CheckSquare className="w-4 h-4" />
|
||||
<span className={`relative inline-flex items-center ${stats.tasksOpenCount > 0 ? 'pr-2' : ''}`}>
|
||||
<span>Aufgaben</span>
|
||||
{stats.tasksOpenCount > 0 && (
|
||||
<span className="absolute -top-1.5 -right-1 min-w-[15px] h-[15px] px-0.5 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-sm pointer-events-none">
|
||||
{stats.tasksOpenCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
@@ -950,6 +968,7 @@ export const App: React.FC = () => {
|
||||
onRefreshStats={refreshAll}
|
||||
onOpenDocument={handleOpenDocument}
|
||||
refreshTrigger={refreshRevision}
|
||||
initialSelectedTaskId={selectedTaskId}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -957,7 +976,9 @@ export const App: React.FC = () => {
|
||||
<WorkspaceView
|
||||
currentUser={user}
|
||||
initialDocId={selectedDocId}
|
||||
initialBlockId={selectedBlockId}
|
||||
onStatsChange={refreshAll}
|
||||
onOpenTask={handleOpenTask}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1024,27 +1045,27 @@ export const App: React.FC = () => {
|
||||
<span className="text-[10px] leading-tight">Triage</span>
|
||||
</button>
|
||||
|
||||
{/* Col 2: Aufgaben */}
|
||||
{/* Col 2: Workspace */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
setActiveTab('tasks');
|
||||
setActiveTab('workspace');
|
||||
}}
|
||||
className={`min-h-[48px] rounded-xl flex flex-col items-center justify-center gap-0.5 transition-colors relative cursor-pointer active:scale-95 ${
|
||||
activeTab === 'tasks' ? 'text-accent font-semibold' : 'text-muted hover:text-main'
|
||||
activeTab === 'workspace' ? 'text-accent font-semibold' : 'text-muted hover:text-main'
|
||||
}`}
|
||||
title="Aufgaben"
|
||||
title="Workspace"
|
||||
>
|
||||
<div className="relative">
|
||||
<CheckSquare className="w-5 h-5" />
|
||||
{stats.tasksOpenCount > 0 && (
|
||||
<FileText className="w-5 h-5" />
|
||||
{(stats.docsTodosOpenCount ?? 0) > 0 && (
|
||||
<span className="absolute -top-1.5 -right-2.5 min-w-[16px] h-[16px] px-1 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-xs pointer-events-none">
|
||||
{stats.tasksOpenCount}
|
||||
{stats.docsTodosOpenCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] leading-tight">Aufgaben</span>
|
||||
<span className="text-[10px] leading-tight">Workspace</span>
|
||||
</button>
|
||||
|
||||
{/* Col 3 (EXACT CENTER): + Quick-Add FAB */}
|
||||
@@ -1066,27 +1087,27 @@ export const App: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Col 4: Workspace */}
|
||||
{/* Col 4: Aufgaben */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
setActiveTab('workspace');
|
||||
setActiveTab('tasks');
|
||||
}}
|
||||
className={`min-h-[48px] rounded-xl flex flex-col items-center justify-center gap-0.5 transition-colors relative cursor-pointer active:scale-95 ${
|
||||
activeTab === 'workspace' ? 'text-accent font-semibold' : 'text-muted hover:text-main'
|
||||
activeTab === 'tasks' ? 'text-accent font-semibold' : 'text-muted hover:text-main'
|
||||
}`}
|
||||
title="Workspace"
|
||||
title="Aufgaben"
|
||||
>
|
||||
<div className="relative">
|
||||
<FileText className="w-5 h-5" />
|
||||
{(stats.docsTodosOpenCount ?? 0) > 0 && (
|
||||
<CheckSquare className="w-5 h-5" />
|
||||
{stats.tasksOpenCount > 0 && (
|
||||
<span className="absolute -top-1.5 -right-2.5 min-w-[16px] h-[16px] px-1 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-xs pointer-events-none">
|
||||
{stats.docsTodosOpenCount}
|
||||
{stats.tasksOpenCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] leading-tight">Workspace</span>
|
||||
<span className="text-[10px] leading-tight">Aufgaben</span>
|
||||
</button>
|
||||
|
||||
{/* Col 5: Listen */}
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface UnifiedShareModalProps {
|
||||
teams: Team[];
|
||||
collaborators: User[];
|
||||
allowRoleSelection?: boolean; // Default true (editor vs viewer)
|
||||
fixedPermission?: 'editor' | 'viewer'; // When allowRoleSelection is false, force fixedPermission (default 'viewer' for documents, 'editor' for tasks/lists)
|
||||
onAddShare: (targetType: 'team' | 'user', targetId: string, permission: 'editor' | 'viewer') => Promise<void>;
|
||||
onRemoveShare: (shareId: string) => Promise<void>;
|
||||
}
|
||||
@@ -40,13 +41,17 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
|
||||
teams,
|
||||
collaborators,
|
||||
allowRoleSelection = true,
|
||||
fixedPermission,
|
||||
onAddShare,
|
||||
onRemoveShare,
|
||||
}) => {
|
||||
const effectiveFixedPerm = fixedPermission || 'viewer';
|
||||
const [shareType, setShareType] = useState<'team' | 'user'>('team');
|
||||
const [selectedTeamId, setSelectedTeamId] = useState('');
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
const [selectedPermission, setSelectedPermission] = useState<'editor' | 'viewer'>('editor');
|
||||
const [selectedPermission, setSelectedPermission] = useState<'editor' | 'viewer'>(
|
||||
allowRoleSelection ? 'editor' : effectiveFixedPerm
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -63,7 +68,8 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await onAddShare(shareType, targetId, selectedPermission);
|
||||
const permToUse = allowRoleSelection ? selectedPermission : effectiveFixedPerm;
|
||||
await onAddShare(shareType, targetId, permToUse);
|
||||
setSelectedTeamId('');
|
||||
setSelectedUser(null);
|
||||
} catch (err: any) {
|
||||
@@ -196,7 +202,7 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{allowRoleSelection && (
|
||||
{allowRoleSelection ? (
|
||||
<select
|
||||
value={selectedPermission}
|
||||
onChange={(e) => setSelectedPermission(e.target.value as 'editor' | 'viewer')}
|
||||
@@ -206,6 +212,20 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
|
||||
<option value="editor" className="bg-panel text-main">Bearbeiten</option>
|
||||
<option value="viewer" className="bg-panel text-main">Nur Lesen</option>
|
||||
</select>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-muted px-2 py-1 bg-panel rounded border border-border shrink-0 self-center">
|
||||
{effectiveFixedPerm === 'editor' ? (
|
||||
<>
|
||||
<Check className="w-3 h-3 text-accent" />
|
||||
Bearbeiten
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Lock className="w-3 h-3 text-faint" />
|
||||
Nur Lesen
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
@@ -220,7 +240,7 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
|
||||
placeholder="Benutzer suchen (Name oder @Benutzername)…"
|
||||
/>
|
||||
</div>
|
||||
{allowRoleSelection && (
|
||||
{allowRoleSelection ? (
|
||||
<select
|
||||
value={selectedPermission}
|
||||
onChange={(e) => setSelectedPermission(e.target.value as 'editor' | 'viewer')}
|
||||
@@ -230,6 +250,20 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
|
||||
<option value="editor" className="bg-panel text-main">Bearbeiten</option>
|
||||
<option value="viewer" className="bg-panel text-main">Nur Lesen</option>
|
||||
</select>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-muted px-2 py-1 bg-panel rounded border border-border shrink-0 self-center">
|
||||
{effectiveFixedPerm === 'editor' ? (
|
||||
<>
|
||||
<Check className="w-3 h-3 text-accent" />
|
||||
Bearbeiten
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Lock className="w-3 h-3 text-faint" />
|
||||
Nur Lesen
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -52,6 +52,8 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const containerRef = useRef<HTMLFormElement>(null);
|
||||
const recognitionRef = useRef<any>(null);
|
||||
const baseVoiceTextRef = useRef('');
|
||||
const hasVoiceInputRef = useRef(false);
|
||||
|
||||
const adjustHeight = (el?: HTMLTextAreaElement | null) => {
|
||||
const targetEl = el || inputRef.current;
|
||||
@@ -131,6 +133,9 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
}
|
||||
|
||||
try {
|
||||
baseVoiceTextRef.current = content.replace(/\s*\[…\]$/, '').trim();
|
||||
hasVoiceInputRef.current = true;
|
||||
|
||||
const recognition = new SpeechRecognition();
|
||||
recognition.lang = 'de-DE';
|
||||
recognition.continuous = false;
|
||||
@@ -146,10 +151,8 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
for (let i = 0; i < event.results.length; i++) {
|
||||
transcript += event.results[i][0].transcript;
|
||||
}
|
||||
setContent((prev) => {
|
||||
const base = prev.replace(/\s*\[…\]$/, '').trim();
|
||||
return base ? `${base} ${transcript}` : transcript;
|
||||
});
|
||||
const base = baseVoiceTextRef.current;
|
||||
setContent(base ? `${base} ${transcript}` : transcript);
|
||||
};
|
||||
|
||||
recognition.onerror = () => {
|
||||
@@ -180,7 +183,8 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
overrideListId || targetListId || (lists.length > 0 ? lists[0].id : '');
|
||||
const capturedPriority = priority;
|
||||
const capturedDueDate = dueDate;
|
||||
const capturedSource = isListening ? 'voice' : 'manual';
|
||||
const capturedSource = hasVoiceInputRef.current || isListening ? 'voice' : 'manual';
|
||||
hasVoiceInputRef.current = false;
|
||||
|
||||
// Optimistically clear input immediately so the UI feels instant (<10ms) in all browsers
|
||||
setContent('');
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Sparkles,
|
||||
X,
|
||||
Hash,
|
||||
ShoppingCart,
|
||||
} from 'lucide-react';
|
||||
import { ListItem, SharedList, ListShare, Team, User, WSMessage } from '../../lib/types';
|
||||
import { api, buildWebSocketURL, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api';
|
||||
@@ -84,6 +85,8 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
const [newItemText, setNewItemText] = useState('');
|
||||
const [newItemQty, setNewItemQty] = useState('');
|
||||
const [isSubmittingItem, setIsSubmittingItem] = useState(false);
|
||||
const [editingQtyItemId, setEditingQtyItemId] = useState<string | null>(null);
|
||||
const [editingQtyValue, setEditingQtyValue] = useState('');
|
||||
|
||||
const [wsConnected, setWsConnected] = useState(false);
|
||||
const [realtimeTransport, setRealtimeTransport] = useState<RealtimeTransport>('none');
|
||||
@@ -173,7 +176,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
return { ...l, items: deduplicateItems([newItem, ...l.items]) };
|
||||
})
|
||||
);
|
||||
} else if (msg.event === 'ITEM_TOGGLED' && msgListId) {
|
||||
} else if ((msg.event === 'ITEM_TOGGLED' || msg.event === 'ITEM_UPDATED') && msgListId) {
|
||||
const updatedItem: ListItem = msg.payload;
|
||||
setLists((prev) =>
|
||||
prev.map((l) => {
|
||||
@@ -272,6 +275,26 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveQuantity = async (itemId: string, quantity: string) => {
|
||||
setEditingQtyItemId(null);
|
||||
if (!activeList) return;
|
||||
const trimmed = quantity.trim();
|
||||
try {
|
||||
const updated = await api.updateListItem(activeList.id, itemId, { quantity: trimmed });
|
||||
setLists((prev) =>
|
||||
prev.map((l) => {
|
||||
if (l.id !== activeList.id) return l;
|
||||
return {
|
||||
...l,
|
||||
items: l.items.map((i) => (i.id === itemId ? { ...i, quantity: updated.quantity } : i)),
|
||||
};
|
||||
})
|
||||
);
|
||||
} catch (err: any) {
|
||||
console.error('Fehler beim Aktualisieren der Menge', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleItem = async (item: ListItem) => {
|
||||
if (!activeList) return;
|
||||
triggerHaptic(10);
|
||||
@@ -953,41 +976,61 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
</div>
|
||||
|
||||
{showNewListModal && (
|
||||
<form
|
||||
onSubmit={handleCreateList}
|
||||
className="p-2 panel-surface flex flex-wrap items-center gap-2"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{['🛒', '🏔️', '🍕', '🎉', '🧰', '🎁'].map((ic) => (
|
||||
<div className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm panel-surface shadow-panel p-4 space-y-3">
|
||||
<div className="flex items-center justify-between border-b border-border pb-2">
|
||||
<h3 className="text-sm font-semibold text-main flex items-center gap-1.5">
|
||||
<ShoppingCart className="w-4 h-4 text-accent" />
|
||||
<span>Neue Einkaufsliste erstellen</span>
|
||||
</h3>
|
||||
<button
|
||||
key={ic}
|
||||
type="button"
|
||||
onClick={() => setNewListIcon(ic)}
|
||||
className={`w-9 h-9 flex items-center justify-center rounded-btn transition-colors ${
|
||||
newListIcon === ic
|
||||
? 'bg-accent/20 border border-accent text-accent'
|
||||
: 'bg-canvas hover:bg-panel-elevated border border-border'
|
||||
}`}
|
||||
onClick={() => setShowNewListModal(false)}
|
||||
className="text-muted hover:text-main"
|
||||
>
|
||||
{ic}
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCreateList} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs text-muted mb-1">Titel der Liste</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={newListIcon}
|
||||
onChange={(e) => setNewListIcon(e.target.value)}
|
||||
maxLength={2}
|
||||
className="w-10 h-8 text-center input-field text-sm"
|
||||
title="Icon (Emoji)"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={newListTitle}
|
||||
onChange={(e) => setNewListTitle(e.target.value)}
|
||||
placeholder="z.B. Supermarkt, Baumarkt, Drogerie"
|
||||
autoFocus
|
||||
required
|
||||
className="flex-1 h-8 input-field px-2.5 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewListModal(false)}
|
||||
className="btn-action h-7 px-3 text-xs"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="submit" className="btn-primary h-7 px-3 text-xs">
|
||||
Erstellen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={newListTitle}
|
||||
onChange={(e) => setNewListTitle(e.target.value)}
|
||||
placeholder="Titel der Liste (z. B. Baumarkt, Drogerie, Camping)…"
|
||||
className="flex-1 h-9 px-3 input-field text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary h-9 px-4 text-xs sm:text-sm"
|
||||
>
|
||||
Liste erstellen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active Shared List View */}
|
||||
@@ -1064,8 +1107,8 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
<input
|
||||
type="text"
|
||||
value={newItemQty}
|
||||
onChange={(e) => setNewItemQty(e.target.value)}
|
||||
placeholder="Menge (z.B. 2x)"
|
||||
onChange={(e) => setNewItemQty(e.target.value.replace(/[^0-9.,xX]/g, ''))}
|
||||
placeholder="Menge (z.B. 2)"
|
||||
className="flex-1 sm:flex-none sm:w-28 h-9 sm:h-7 px-2.5 sm:px-2 input-field text-xs"
|
||||
/>
|
||||
<button
|
||||
@@ -1175,10 +1218,45 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
{displayContent}
|
||||
</span>
|
||||
|
||||
{item.quantity && (
|
||||
<span className="badge-accent">
|
||||
{item.quantity}
|
||||
</span>
|
||||
{editingQtyItemId === item.id ? (
|
||||
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={editingQtyValue}
|
||||
onChange={(e) => setEditingQtyValue(e.target.value.replace(/[^0-9.,xX]/g, ''))}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
await handleSaveQuantity(item.id, editingQtyValue);
|
||||
} else if (e.key === 'Escape') {
|
||||
setEditingQtyItemId(null);
|
||||
}
|
||||
}}
|
||||
onBlur={async () => {
|
||||
await handleSaveQuantity(item.id, editingQtyValue);
|
||||
}}
|
||||
className="w-16 h-6 px-1.5 text-xs input-field font-semibold text-accent text-center"
|
||||
placeholder="Menge"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingQtyItemId(item.id);
|
||||
setEditingQtyValue(item.quantity || '');
|
||||
}}
|
||||
className={`text-xs px-2 py-0.5 rounded-full transition-colors ${
|
||||
item.quantity
|
||||
? 'badge-accent cursor-pointer hover:brightness-125'
|
||||
: 'opacity-0 group-hover:opacity-60 hover:!opacity-100 text-muted hover:text-accent border border-dashed border-border/70 hover:border-accent text-[11px]'
|
||||
}`}
|
||||
title="Menge bearbeiten"
|
||||
>
|
||||
{item.quantity || '+ Menge'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{cat && !group.label && (
|
||||
@@ -1253,8 +1331,45 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
{displayContent}
|
||||
</span>
|
||||
|
||||
{item.quantity && (
|
||||
<span className="text-xs text-faint">({item.quantity})</span>
|
||||
{editingQtyItemId === item.id ? (
|
||||
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={editingQtyValue}
|
||||
onChange={(e) => setEditingQtyValue(e.target.value.replace(/[^0-9.,xX]/g, ''))}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
await handleSaveQuantity(item.id, editingQtyValue);
|
||||
} else if (e.key === 'Escape') {
|
||||
setEditingQtyItemId(null);
|
||||
}
|
||||
}}
|
||||
onBlur={async () => {
|
||||
await handleSaveQuantity(item.id, editingQtyValue);
|
||||
}}
|
||||
className="w-16 h-6 px-1.5 text-xs input-field font-semibold text-accent text-center"
|
||||
placeholder="Menge"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingQtyItemId(item.id);
|
||||
setEditingQtyValue(item.quantity || '');
|
||||
}}
|
||||
className={`text-xs px-1.5 py-0.5 rounded transition-colors ${
|
||||
item.quantity
|
||||
? 'text-faint hover:text-main cursor-pointer'
|
||||
: 'opacity-0 group-hover:opacity-60 text-faint hover:text-main text-[11px]'
|
||||
}`}
|
||||
title="Menge bearbeiten"
|
||||
>
|
||||
{item.quantity ? `(${item.quantity})` : '+ Menge'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [profileMsg, setProfileMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
||||
const [savingProfile, setSavingProfile] = useState(false);
|
||||
const [revokingSessions, setRevokingSessions] = useState(false);
|
||||
const [isProcessingAvatar, setIsProcessingAvatar] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -155,6 +156,21 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevokeAllSessions = async () => {
|
||||
if (!window.confirm('Möchtest du wirklich alle aktiven Sitzungen auf allen Geräten beenden? Du wirst dabei auch auf diesem Gerät abgemeldet.')) {
|
||||
return;
|
||||
}
|
||||
setRevokingSessions(true);
|
||||
try {
|
||||
await api.revokeAllSessions();
|
||||
await api.logout();
|
||||
window.location.reload();
|
||||
} catch (err: any) {
|
||||
setProfileMsg({ type: 'err', text: err.message || 'Fehler beim Beenden der Sitzungen' });
|
||||
setRevokingSessions(false);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Handlers: Alarms ---
|
||||
const handleSaveAlarms = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -530,6 +546,32 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Sessions & Security */}
|
||||
<div className="pt-5 border-t border-border space-y-3">
|
||||
<div>
|
||||
<h3 className="text-xs font-bold text-main">Aktive Sitzungen & Sicherheit</h3>
|
||||
<p className="text-[11px] text-muted">
|
||||
Sitzungen werden durch kryptografische JWTs geschützt. Bei einer Passwortänderung werden alle alten Sitzungen automatisch ungültig.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 p-3.5 rounded-card border border-border bg-canvas">
|
||||
<div className="text-xs">
|
||||
<p className="font-semibold text-main">Alle Sitzungen sofort beenden</p>
|
||||
<p className="text-[11px] text-faint">
|
||||
Invalidiert alle ausgestellten Tokens auf allen Geräten und Browsern.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRevokeAllSessions}
|
||||
disabled={revokingSessions}
|
||||
className="btn-danger h-8 px-3 text-xs shrink-0 cursor-pointer"
|
||||
>
|
||||
{revokingSessions ? 'Beende…' : 'Alle Sitzungen beenden'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { Thought, SubTask, TaskList, KanbanColumn } from '../../lib/types';
|
||||
import { triggerHaptic } from '../../lib/api';
|
||||
import { MarkdownViewer } from '../../lib/markdown';
|
||||
|
||||
interface TaskDetailModalProps {
|
||||
task: Thought | null;
|
||||
@@ -36,6 +37,7 @@ interface TaskDetailModalProps {
|
||||
onAddSubtask: (task: Thought, title: string) => Promise<void>;
|
||||
onDeleteSubtask: (task: Thought, subtaskId: string) => Promise<void>;
|
||||
onConvertToDoc: (task: Thought) => void;
|
||||
onOpenDocument?: (docId: string, blockId?: string) => void;
|
||||
onDeleteTask: (taskId: string) => void;
|
||||
}
|
||||
|
||||
@@ -56,6 +58,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
onAddSubtask,
|
||||
onDeleteSubtask,
|
||||
onConvertToDoc,
|
||||
onOpenDocument,
|
||||
onDeleteTask,
|
||||
}) => {
|
||||
if (!isOpen || !task) return null;
|
||||
@@ -202,18 +205,35 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onConvertToDoc(task);
|
||||
}}
|
||||
title="Als Dokument / Notiz vertiefen"
|
||||
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-4 h-4 sm:w-3.5 sm:h-3.5 text-accent" />
|
||||
<span className="hidden sm:inline">Als Notiz vertiefen</span>
|
||||
</button>
|
||||
{(task.convertedType === 'document_block' || task.docId) ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
const docId = task.docId || (task.convertedRefId ? task.convertedRefId.split(':')[0] : '');
|
||||
const blockId = task.blockId || (task.convertedRefId ? task.convertedRefId.split(':')[1] : '');
|
||||
if (docId && onOpenDocument) onOpenDocument(docId, blockId);
|
||||
}}
|
||||
title="Zur verlinkten Notiz springen"
|
||||
className="btn-primary 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 font-semibold"
|
||||
>
|
||||
<FileText className="w-4 h-4 sm:w-3.5 sm:h-3.5" />
|
||||
<span>In Notiz öffnen</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onConvertToDoc(task);
|
||||
}}
|
||||
title="Als Dokument / Notiz vertiefen"
|
||||
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-4 h-4 sm:w-3.5 sm:h-3.5 text-accent" />
|
||||
<span className="hidden sm:inline">Als Notiz vertiefen</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -656,10 +676,10 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
setNotesDraft(task.notes || '');
|
||||
setIsEditingNotes(true);
|
||||
}}
|
||||
className="p-3 bg-canvas border border-border rounded-card text-xs text-muted leading-relaxed whitespace-pre-wrap cursor-pointer hover:border-strong transition-colors"
|
||||
className="p-3 bg-canvas border border-border rounded-card text-xs text-muted leading-relaxed cursor-pointer hover:border-strong transition-colors"
|
||||
title="Klicken zum Bearbeiten"
|
||||
>
|
||||
{task.notes}
|
||||
<MarkdownViewer content={task.notes} />
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
|
||||
@@ -46,8 +46,9 @@ const getInitials = (name?: string): string => {
|
||||
interface TasksViewProps {
|
||||
currentUser?: User | null;
|
||||
onRefreshStats: () => void;
|
||||
onOpenDocument: (docId: string) => void;
|
||||
onOpenDocument: (docId: string, blockId?: string) => void;
|
||||
refreshTrigger?: number;
|
||||
initialSelectedTaskId?: string | null;
|
||||
}
|
||||
|
||||
export const TasksView: React.FC<TasksViewProps> = ({
|
||||
@@ -55,6 +56,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
onRefreshStats,
|
||||
onOpenDocument,
|
||||
refreshTrigger = 0,
|
||||
initialSelectedTaskId,
|
||||
}) => {
|
||||
const [viewMode, setViewMode] = useState<'list' | 'kanban'>('list');
|
||||
const [filter, setFilter] = useState<'open' | 'done' | 'discarded'>('open');
|
||||
@@ -101,6 +103,18 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
// Detail modal state
|
||||
const [detailTaskId, setDetailTaskId] = useState<string | null>(null);
|
||||
const [showDetailModal, setShowDetailModal] = useState(false);
|
||||
const [extraTask, setExtraTask] = useState<Thought | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialSelectedTaskId) {
|
||||
setDetailTaskId(initialSelectedTaskId);
|
||||
setShowDetailModal(true);
|
||||
setFilter('open');
|
||||
api.getThought(initialSelectedTaskId).then((th) => {
|
||||
if (th) setExtraTask(th);
|
||||
}).catch(() => {});
|
||||
}
|
||||
}, [initialSelectedTaskId]);
|
||||
|
||||
// Manage columns modal state
|
||||
const [showManageColumnsModal, setShowManageColumnsModal] = useState(false);
|
||||
@@ -243,8 +257,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
|
||||
const detailTask = useMemo(() => {
|
||||
if (!detailTaskId) return null;
|
||||
return tasks.find((t) => t.id === detailTaskId) || null;
|
||||
}, [detailTaskId, tasks]);
|
||||
return tasks.find((t) => t.id === detailTaskId) || (extraTask?.id === detailTaskId ? extraTask : null);
|
||||
}, [detailTaskId, tasks, extraTask]);
|
||||
|
||||
const safeTeams = Array.isArray(teams) ? teams : [];
|
||||
const safeCollaborators = Array.isArray(collaborators) ? collaborators : [];
|
||||
@@ -1135,6 +1149,23 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
className="flex flex-wrap items-center gap-1.5 pt-0.5 empty:hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{(task.convertedType === 'document_block' || task.docId) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const docId = task.docId || (task.convertedRefId ? task.convertedRefId.split(':')[0] : '');
|
||||
const blockId = task.blockId || (task.convertedRefId ? task.convertedRefId.split(':')[1] : '');
|
||||
if (docId) onOpenDocument(docId, blockId);
|
||||
}}
|
||||
className="h-5 px-1.5 text-[10px] rounded-btn border border-accent/40 bg-accent/10 text-accent hover:bg-accent/20 cursor-pointer flex items-center gap-1 font-medium"
|
||||
title="In Notiz an der passenden Stelle öffnen"
|
||||
>
|
||||
<FileText className="w-2.5 h-2.5" />
|
||||
<span>In Notiz</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Kanban status selector pill (Desktop only - on mobile done via TaskDetailModal) */}
|
||||
<select
|
||||
value={task.kanbanStatus || (isCompleted ? 'done' : 'todo')}
|
||||
@@ -1978,6 +2009,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
teams={safeTeams}
|
||||
collaborators={safeCollaborators}
|
||||
allowRoleSelection={false}
|
||||
fixedPermission="editor"
|
||||
onAddShare={async (targetType, targetId) => {
|
||||
await api.addTaskListShare(sharingTaskList.id, {
|
||||
targetType,
|
||||
@@ -2029,6 +2061,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
}}
|
||||
activeColumns={activeColumns}
|
||||
taskLists={safeTaskLists}
|
||||
onOpenDocument={onOpenDocument}
|
||||
onUpdateTitle={handleUpdateTaskTitle}
|
||||
onUpdateStatus={handleSetKanbanStatus}
|
||||
onUpdatePriority={handleSetPriority}
|
||||
@@ -2163,8 +2196,24 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Badges: Priority, Status, Tags */}
|
||||
{/* Badges: In Notiz, Priority, Status, Tags */}
|
||||
<div className="flex flex-wrap items-center gap-1 pt-0.5" onClick={(e) => e.stopPropagation()}>
|
||||
{(task.convertedType === 'document_block' || task.docId) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const docId = task.docId || (task.convertedRefId ? task.convertedRefId.split(':')[0] : '');
|
||||
const blockId = task.blockId || (task.convertedRefId ? task.convertedRefId.split(':')[1] : '');
|
||||
if (docId) onOpenDocument(docId, blockId);
|
||||
}}
|
||||
className="h-4 px-1.5 text-[9px] rounded-btn border border-accent/40 bg-accent/10 text-accent hover:bg-accent/20 cursor-pointer flex items-center gap-1 font-medium shrink-0"
|
||||
title="In Notiz an der passenden Stelle öffnen"
|
||||
>
|
||||
<FileText className="w-2.5 h-2.5" />
|
||||
<span>In Notiz</span>
|
||||
</button>
|
||||
)}
|
||||
{task.priority === 'urgent' && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -7,18 +7,19 @@ import {
|
||||
FileText,
|
||||
Loader2,
|
||||
ArrowLeft,
|
||||
Hash,
|
||||
Lock,
|
||||
Home,
|
||||
ChevronRight,
|
||||
Minimize2,
|
||||
Sparkles,
|
||||
Plus,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { Document, DocumentBlock, User } from '../../lib/types';
|
||||
import { api } from '../../lib/api';
|
||||
import { useDocumentSync, DocSyncEvent, RemoteCursor, DocSyncStatus } from '../../lib/useDocumentSync';
|
||||
import { OutlinerTree } from './OutlinerTree';
|
||||
import { api, getToken } from '../../lib/api';
|
||||
import { OutlinerTree, OutlinerTreeHandle } from './OutlinerTree';
|
||||
|
||||
export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error';
|
||||
|
||||
export interface DocumentEditorProps {
|
||||
docId: string;
|
||||
@@ -33,10 +34,67 @@ export interface DocumentEditorProps {
|
||||
onSelectTag: (tag: string) => void;
|
||||
onWikiLinkClick?: (pageTitle: string) => void;
|
||||
onBackMobile?: () => void;
|
||||
onSyncStatusChange?: (status: DocSyncStatus) => void;
|
||||
onOpenTask?: (taskId: string) => void;
|
||||
onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void;
|
||||
}
|
||||
|
||||
const EMOJI_PALETTE = ['📝', '📄', '💡', '🚀', '📌', '📂', '🧠', '⚡', '🎯', '🛠️', '📊', '🔒'];
|
||||
interface EmojiItem {
|
||||
emoji: string;
|
||||
category: string;
|
||||
keywords: string;
|
||||
}
|
||||
|
||||
const EMOJI_CATALOG: EmojiItem[] = [
|
||||
// Notizen & Dokumente
|
||||
{ emoji: '📝', category: 'Notizen', keywords: 'notiz schreiben memo text note' },
|
||||
{ emoji: '📄', category: 'Notizen', keywords: 'dokument seite page doc' },
|
||||
{ emoji: '📋', category: 'Notizen', keywords: 'clipboard klemmbrett liste' },
|
||||
{ emoji: '📂', category: 'Notizen', keywords: 'ordner mappe folder datei' },
|
||||
{ emoji: '📁', category: 'Notizen', keywords: 'ordner folder directory' },
|
||||
{ emoji: '📌', category: 'Notizen', keywords: 'pin anheften bookmark wichtig' },
|
||||
{ emoji: '📍', category: 'Notizen', keywords: 'standort ort pin location' },
|
||||
{ emoji: '🏷️', category: 'Notizen', keywords: 'tag etikett label markierung' },
|
||||
{ emoji: '📚', category: 'Notizen', keywords: 'buch bibliothek lesen studium' },
|
||||
{ emoji: '📖', category: 'Notizen', keywords: 'buch open book wissen' },
|
||||
|
||||
// Ideen & Fokus
|
||||
{ emoji: '🧠', category: 'Ideen', keywords: 'gehirn brain denken wissen thought mind' },
|
||||
{ emoji: '💡', category: 'Ideen', keywords: 'idee inspiration lampe glühbirne idea' },
|
||||
{ emoji: '🎯', category: 'Ideen', keywords: 'ziel target fokus mission okr' },
|
||||
{ emoji: '🚀', category: 'Ideen', keywords: 'rakete start launch projekt rocket' },
|
||||
{ emoji: '⚡', category: 'Ideen', keywords: 'blitz energie schnell quick power' },
|
||||
{ emoji: '🔥', category: 'Ideen', keywords: 'feuer flame priorität hot wichtig' },
|
||||
{ emoji: '⭐', category: 'Ideen', keywords: 'stern favoriten star top' },
|
||||
{ emoji: '✨', category: 'Ideen', keywords: 'funkeln magie neu neuigkeit sparkles' },
|
||||
{ emoji: '🧩', category: 'Ideen', keywords: 'puzzle teil lösung problem' },
|
||||
{ emoji: '🔬', category: 'Ideen', keywords: 'mikroskop forschung analyse test' },
|
||||
|
||||
// Tech & Dev
|
||||
{ emoji: '💻', category: 'Tech', keywords: 'laptop computer dev code programming' },
|
||||
{ emoji: '🖥️', category: 'Tech', keywords: 'desktop monitor bildschirm workstation' },
|
||||
{ emoji: '🛠️', category: 'Tech', keywords: 'werkzeug tools optionen settings einstellungen' },
|
||||
{ emoji: '⚙️', category: 'Tech', keywords: 'zahnrad config settings system' },
|
||||
{ emoji: '📊', category: 'Tech', keywords: 'diagramm chart daten statistik dashboard' },
|
||||
{ emoji: '📈', category: 'Tech', keywords: 'wachstum trend kurve business' },
|
||||
{ emoji: '🔒', category: 'Tech', keywords: 'schloss sicherheit geheim privat secure' },
|
||||
{ emoji: '🔑', category: 'Tech', keywords: 'schlüssel key passwort access' },
|
||||
{ emoji: '🌐', category: 'Tech', keywords: 'web internet network welt globus' },
|
||||
{ emoji: '📦', category: 'Tech', keywords: 'paket box release lieferung package' },
|
||||
|
||||
// Alltag & Organisation
|
||||
{ emoji: '💬', category: 'Alltag', keywords: 'chat nachricht kommentar call sprechen' },
|
||||
{ emoji: '✉️', category: 'Alltag', keywords: 'mail email brief post nachricht' },
|
||||
{ emoji: '📅', category: 'Alltag', keywords: 'kalender datum termin plan event' },
|
||||
{ emoji: '⏰', category: 'Alltag', keywords: 'wecker uhr zeit deadline alarm' },
|
||||
{ emoji: '🛒', category: 'Alltag', keywords: 'warenkorb einkauf shopping liste' },
|
||||
{ emoji: '☕', category: 'Alltag', keywords: 'kaffee pause bistro morning' },
|
||||
{ emoji: '🏠', category: 'Alltag', keywords: 'haus heim home wohnung' },
|
||||
{ emoji: '🏢', category: 'Alltag', keywords: 'büro firma arbeit office work' },
|
||||
{ emoji: '✈️', category: 'Alltag', keywords: 'flugzeug reise urlaub trip travel' },
|
||||
{ emoji: '🎨', category: 'Alltag', keywords: 'kunst farbe design creative' },
|
||||
{ emoji: '🏆', category: 'Alltag', keywords: 'pokal erfolg win trophy award' },
|
||||
{ emoji: '✅', category: 'Alltag', keywords: 'haken check done erledigt' },
|
||||
];
|
||||
|
||||
function ensureNonEmptyBlocks(docId: string, blocks?: DocumentBlock[]): DocumentBlock[] {
|
||||
if (!blocks || blocks.length === 0) {
|
||||
@@ -144,7 +202,6 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
docId,
|
||||
currentUser,
|
||||
initialDoc,
|
||||
availableFolders,
|
||||
autoFocusTitle,
|
||||
initialFocusedBlockId,
|
||||
onDocMetadataChange,
|
||||
@@ -153,7 +210,8 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
onSelectTag,
|
||||
onWikiLinkClick,
|
||||
onBackMobile,
|
||||
onSyncStatusChange,
|
||||
onOpenTask,
|
||||
onSaveStatusChange,
|
||||
}) => {
|
||||
const [doc, setDoc] = useState<Document | null>(initialDoc || null);
|
||||
const [title, setTitle] = useState<string>(initialDoc?.title || '');
|
||||
@@ -163,13 +221,36 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
const [loading, setLoading] = useState<boolean>(!initialDoc);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false);
|
||||
const [emojiSearch, setEmojiSearch] = useState<string>('');
|
||||
const [zoomedBlockId, setZoomedBlockId] = useState<string | null>(null);
|
||||
|
||||
const titleInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const titleDebounceRef = useRef<number | null>(null);
|
||||
const filteredEmojis = useMemo(() => {
|
||||
const q = emojiSearch.trim().toLowerCase();
|
||||
if (!q) return EMOJI_CATALOG;
|
||||
return EMOJI_CATALOG.filter(
|
||||
(item) => item.keywords.includes(q) || item.emoji.includes(q) || item.category.toLowerCase().includes(q)
|
||||
);
|
||||
}, [emojiSearch]);
|
||||
|
||||
// AutoSave state (REST persistence)
|
||||
const [, setSaveStatus] = useState<DocSaveStatus>('saved');
|
||||
const [, setSaveErrorMessage] = useState<string | null>(null);
|
||||
const saveTimeoutRef = useRef<number | null>(null);
|
||||
const isSavingRef = useRef<boolean>(false);
|
||||
const lastSavedSnapshotRef = useRef<string>('');
|
||||
const isInitialLoadRef = useRef<boolean>(true);
|
||||
|
||||
const titleInputRef = useRef<HTMLTextAreaElement | HTMLInputElement | null>(null);
|
||||
const outlinerTreeRef = useRef<OutlinerTreeHandle | null>(null);
|
||||
const docRef = useRef<Document | null>(doc);
|
||||
docRef.current = doc;
|
||||
|
||||
const currentTitleRef = useRef<string>(title);
|
||||
currentTitleRef.current = title;
|
||||
const currentBlocksRef = useRef<DocumentBlock[]>(blocks);
|
||||
currentBlocksRef.current = blocks;
|
||||
const lastKnownUpdatedAtRef = useRef<string | null>(initialDoc?.updatedAt || null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && (autoFocusTitle || (doc?.title === 'Neue Notiz' && (!doc?.blocks || doc?.blocks.length <= 1)))) {
|
||||
const focusTitle = () => {
|
||||
@@ -202,186 +283,12 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
return computeBreadcrumbs(title || doc?.title || 'Dokument', blocks, zoomedBlockId);
|
||||
}, [title, doc?.title, blocks, zoomedBlockId]);
|
||||
|
||||
// Crucial fix: Collaborator with write permission can edit!
|
||||
// Strictly Read-Only for shared documents: only the document owner can edit!
|
||||
const canEdit = useMemo(() => {
|
||||
if (!doc) return true;
|
||||
if (doc.isOwner) return true;
|
||||
return doc.permission === 'owner' || doc.permission === 'write';
|
||||
return Boolean(doc.isOwner || doc.permission === 'owner');
|
||||
}, [doc]);
|
||||
|
||||
// Handle incoming WebSocket events for THIS document room
|
||||
const handleRemoteEvent = useCallback(
|
||||
(evt: DocSyncEvent) => {
|
||||
if (evt.docId && evt.docId !== docId) return;
|
||||
|
||||
switch (evt.type) {
|
||||
case 'doc_state': {
|
||||
if (evt.title !== undefined) {
|
||||
const isTitleActivelyEditing =
|
||||
titleInputRef.current !== null &&
|
||||
document.activeElement === titleInputRef.current &&
|
||||
typeof document !== 'undefined' &&
|
||||
document.hasFocus();
|
||||
if (!isTitleActivelyEditing) {
|
||||
setTitle(evt.title);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(evt.blocks) && evt.blocks.length > 0) {
|
||||
setBlocks(ensureNonEmptyBlocks(docId, evt.blocks));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'title_updated': {
|
||||
if (evt.title !== undefined) {
|
||||
const isTitleActivelyEditing =
|
||||
titleInputRef.current !== null &&
|
||||
document.activeElement === titleInputRef.current &&
|
||||
typeof document !== 'undefined' &&
|
||||
document.hasFocus();
|
||||
if (!isTitleActivelyEditing) {
|
||||
setTitle(evt.title);
|
||||
}
|
||||
if (docRef.current) {
|
||||
const updated = { ...docRef.current, title: evt.title };
|
||||
setDoc(updated);
|
||||
onDocMetadataChange(updated);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'block_created': {
|
||||
if (!evt.block) return;
|
||||
const incoming = evt.block;
|
||||
setBlocks((prev) => {
|
||||
if (prev.some((b) => b.id === incoming.id)) {
|
||||
return prev.map((b) => (b.id === incoming.id ? incoming : b));
|
||||
}
|
||||
const next = [...prev];
|
||||
if (evt.afterBlockId) {
|
||||
const idx = next.findIndex((b) => b.id === evt.afterBlockId);
|
||||
if (idx >= 0) {
|
||||
next.splice(idx + 1, 0, incoming);
|
||||
return ensureNonEmptyBlocks(docId, next);
|
||||
}
|
||||
}
|
||||
next.push(incoming);
|
||||
return ensureNonEmptyBlocks(docId, next);
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'block_updated': {
|
||||
const targetId = evt.blockId || evt.block?.id;
|
||||
if (!targetId) return;
|
||||
setBlocks((prev) =>
|
||||
prev.map((b) => {
|
||||
if (b.id !== targetId) return b;
|
||||
if (evt.block) {
|
||||
return { ...b, ...evt.block };
|
||||
}
|
||||
return {
|
||||
...b,
|
||||
content: evt.content !== undefined ? evt.content : b.content,
|
||||
level: evt.level !== undefined ? evt.level : b.level,
|
||||
checked: evt.checked !== undefined ? evt.checked : b.checked,
|
||||
collapsed: evt.collapsed !== undefined ? evt.collapsed : b.collapsed,
|
||||
};
|
||||
})
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'block_deleted': {
|
||||
if (!evt.blockId) return;
|
||||
setBlocks((prev) =>
|
||||
ensureNonEmptyBlocks(
|
||||
docId,
|
||||
prev.filter((b) => b.id !== evt.blockId)
|
||||
)
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'block_moved': {
|
||||
if (!evt.blockId || evt.newIndex === undefined) return;
|
||||
const targetIndex = evt.newIndex;
|
||||
setBlocks((prev) => {
|
||||
const fromIdx = prev.findIndex((b) => b.id === evt.blockId);
|
||||
if (fromIdx === -1) return prev;
|
||||
const copy = [...prev];
|
||||
const [moving] = copy.splice(fromIdx, 1);
|
||||
const updatedMoving: DocumentBlock = {
|
||||
...moving,
|
||||
level: evt.level !== undefined ? evt.level : moving.level,
|
||||
};
|
||||
const boundedIdx = Math.max(0, Math.min(targetIndex, copy.length));
|
||||
copy.splice(boundedIdx, 0, updatedMoving);
|
||||
return ensureNonEmptyBlocks(docId, copy);
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'blocks_replaced': {
|
||||
if (Array.isArray(evt.blocks)) {
|
||||
setBlocks(ensureNonEmptyBlocks(docId, evt.blocks));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
[docId, onDocMetadataChange]
|
||||
);
|
||||
|
||||
const {
|
||||
status: syncStatus,
|
||||
remoteCursors,
|
||||
sendCursorMoved,
|
||||
sendBlockCreated,
|
||||
sendBlockUpdated,
|
||||
sendBlockDeleted,
|
||||
sendBlockMoved,
|
||||
sendTitleUpdated,
|
||||
} = useDocumentSync(docId, handleRemoteEvent, canEdit, currentUser);
|
||||
|
||||
useEffect(() => {
|
||||
onSyncStatusChange?.(syncStatus);
|
||||
}, [syncStatus, onSyncStatusChange]);
|
||||
|
||||
// Fetch isolated document state on mount
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
if (!initialDoc) {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
|
||||
api
|
||||
.getDocument(docId)
|
||||
.then((fetched) => {
|
||||
if (isCancelled) return;
|
||||
setDoc(fetched);
|
||||
setTitle(fetched.title || 'Unbenannte Notiz');
|
||||
setBlocks(ensureNonEmptyBlocks(docId, fetched.blocks));
|
||||
setLoading(false);
|
||||
onDocMetadataChange(fetched);
|
||||
})
|
||||
.catch((err: any) => {
|
||||
if (isCancelled) return;
|
||||
setError(err?.message || 'Dokument konnte nicht geladen werden');
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
if (titleDebounceRef.current !== null) {
|
||||
window.clearTimeout(titleDebounceRef.current);
|
||||
titleDebounceRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [docId]);
|
||||
|
||||
// Extract all #tags present across the current blocks
|
||||
const extractedTags = useMemo(() => {
|
||||
const tagSet = new Set<string>();
|
||||
@@ -396,6 +303,174 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
return Array.from(tagSet);
|
||||
}, [blocks]);
|
||||
|
||||
const currentTagsRef = useRef<string[]>(extractedTags);
|
||||
currentTagsRef.current = extractedTags;
|
||||
|
||||
// AutoSave snapshot
|
||||
const currentSnapshot = useMemo(() => {
|
||||
return JSON.stringify({
|
||||
title,
|
||||
blocks,
|
||||
tags: extractedTags,
|
||||
});
|
||||
}, [title, blocks, extractedTags]);
|
||||
|
||||
const currentSnapshotRef = useRef<string>(currentSnapshot);
|
||||
currentSnapshotRef.current = currentSnapshot;
|
||||
|
||||
const executeAutoSave = useCallback(
|
||||
async (immediate: boolean = false) => {
|
||||
if (!docId || !canEdit) return;
|
||||
if (isSavingRef.current && !immediate) return;
|
||||
|
||||
const snapshot = currentSnapshotRef.current;
|
||||
if (snapshot === lastSavedSnapshotRef.current) {
|
||||
setSaveStatus('saved');
|
||||
onSaveStatusChange?.('saved');
|
||||
return;
|
||||
}
|
||||
|
||||
isSavingRef.current = true;
|
||||
setSaveStatus('saving');
|
||||
onSaveStatusChange?.('saving');
|
||||
|
||||
try {
|
||||
const updated = await api.updateDocument(docId, {
|
||||
title: currentTitleRef.current,
|
||||
blocks: currentBlocksRef.current,
|
||||
tags: currentTagsRef.current,
|
||||
});
|
||||
isSavingRef.current = false;
|
||||
lastSavedSnapshotRef.current = snapshot;
|
||||
lastKnownUpdatedAtRef.current = updated.updatedAt || null;
|
||||
setSaveStatus('saved');
|
||||
setSaveErrorMessage(null);
|
||||
onSaveStatusChange?.('saved');
|
||||
if (docRef.current) {
|
||||
docRef.current = updated;
|
||||
onDocMetadataChange(updated);
|
||||
}
|
||||
} catch (err: any) {
|
||||
isSavingRef.current = false;
|
||||
const msg = err?.message || 'Fehler beim Speichern';
|
||||
setSaveStatus('error');
|
||||
setSaveErrorMessage(msg);
|
||||
onSaveStatusChange?.('error', msg);
|
||||
}
|
||||
},
|
||||
[docId, canEdit, onSaveStatusChange, onDocMetadataChange]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialLoadRef.current) return;
|
||||
if (!canEdit) return;
|
||||
if (currentSnapshot === lastSavedSnapshotRef.current) return;
|
||||
|
||||
setSaveStatus('unsaved');
|
||||
onSaveStatusChange?.('unsaved');
|
||||
|
||||
if (saveTimeoutRef.current !== null) {
|
||||
window.clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
saveTimeoutRef.current = window.setTimeout(() => {
|
||||
saveTimeoutRef.current = null;
|
||||
executeAutoSave();
|
||||
}, 800);
|
||||
|
||||
return () => {
|
||||
if (saveTimeoutRef.current !== null) {
|
||||
window.clearTimeout(saveTimeoutRef.current);
|
||||
saveTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [currentSnapshot, canEdit, executeAutoSave, onSaveStatusChange]);
|
||||
|
||||
// Flush on page unload / pagehide via keepalive fetch
|
||||
useEffect(() => {
|
||||
const handleUnload = () => {
|
||||
if (saveTimeoutRef.current !== null) {
|
||||
window.clearTimeout(saveTimeoutRef.current);
|
||||
saveTimeoutRef.current = null;
|
||||
}
|
||||
const snapshot = currentSnapshotRef.current;
|
||||
if (snapshot !== lastSavedSnapshotRef.current && canEdit) {
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
fetch(`/api/v1/documents/${docId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: currentTitleRef.current,
|
||||
blocks: currentBlocksRef.current,
|
||||
tags: currentTagsRef.current,
|
||||
}),
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handleUnload);
|
||||
window.addEventListener('pagehide', handleUnload);
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleUnload);
|
||||
window.removeEventListener('pagehide', handleUnload);
|
||||
handleUnload();
|
||||
};
|
||||
}, [docId, canEdit]);
|
||||
|
||||
// Fetch document on mount
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
if (!initialDoc) {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
|
||||
api
|
||||
.getDocument(docId)
|
||||
.then((fetched) => {
|
||||
if (isCancelled) return;
|
||||
const normalized = ensureNonEmptyBlocks(docId, fetched.blocks);
|
||||
setDoc(fetched);
|
||||
setTitle(fetched.title || 'Unbenannte Notiz');
|
||||
setBlocks(normalized);
|
||||
setLoading(false);
|
||||
onDocMetadataChange(fetched);
|
||||
|
||||
const initialTags: string[] = [];
|
||||
for (const b of normalized) {
|
||||
const matches = b.content?.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g);
|
||||
if (matches) {
|
||||
for (const m of matches) {
|
||||
const tag = m.slice(1).toLowerCase();
|
||||
if (!initialTags.includes(tag)) initialTags.push(tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
lastSavedSnapshotRef.current = JSON.stringify({
|
||||
title: fetched.title || 'Unbenannte Notiz',
|
||||
blocks: normalized,
|
||||
tags: initialTags,
|
||||
});
|
||||
isInitialLoadRef.current = false;
|
||||
setSaveStatus('saved');
|
||||
onSaveStatusChange?.('saved');
|
||||
})
|
||||
.catch((err: any) => {
|
||||
if (isCancelled) return;
|
||||
setError(err?.message || 'Dokument konnte nicht geladen werden');
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [docId]);
|
||||
|
||||
// Keep parent sidebar metadata in sync when blocks/tags change
|
||||
useEffect(() => {
|
||||
if (!docRef.current) return;
|
||||
@@ -410,118 +485,154 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
onDocMetadataChange(updatedDoc);
|
||||
}, [blocks, extractedTags, title]);
|
||||
|
||||
// Active remote users in this room (excluding self)
|
||||
const activeUsers = useMemo(() => {
|
||||
const list: RemoteCursor[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const c of Object.values(remoteCursors)) {
|
||||
const key = c.userId || c.clientId;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
list.push(c);
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}, [remoteCursors]);
|
||||
|
||||
const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
if (!canEdit) return;
|
||||
const nextTitle = e.target.value;
|
||||
setTitle(nextTitle);
|
||||
|
||||
if (titleDebounceRef.current !== null) {
|
||||
window.clearTimeout(titleDebounceRef.current);
|
||||
}
|
||||
titleDebounceRef.current = window.setTimeout(() => {
|
||||
titleDebounceRef.current = null;
|
||||
sendTitleUpdated(nextTitle);
|
||||
}, 250);
|
||||
setTitle(e.target.value);
|
||||
};
|
||||
|
||||
const handleTitleBlur = () => {
|
||||
if (titleDebounceRef.current !== null) {
|
||||
window.clearTimeout(titleDebounceRef.current);
|
||||
titleDebounceRef.current = null;
|
||||
sendTitleUpdated(title);
|
||||
}
|
||||
executeAutoSave();
|
||||
};
|
||||
|
||||
// Flush title debouncing on window blur so background window never holds stale pending title
|
||||
useEffect(() => {
|
||||
const handleWindowBlur = () => {
|
||||
if (titleDebounceRef.current !== null) {
|
||||
window.clearTimeout(titleDebounceRef.current);
|
||||
titleDebounceRef.current = null;
|
||||
sendTitleUpdated(title);
|
||||
}
|
||||
};
|
||||
window.addEventListener('blur', handleWindowBlur);
|
||||
return () => window.removeEventListener('blur', handleWindowBlur);
|
||||
}, [title, sendTitleUpdated]);
|
||||
|
||||
const handleUpdateBlock = useCallback(
|
||||
(updatedBlock: DocumentBlock) => {
|
||||
setBlocks((prev) =>
|
||||
prev.map((b) => (b.id === updatedBlock.id ? updatedBlock : b))
|
||||
);
|
||||
sendBlockUpdated(updatedBlock);
|
||||
(updatedBlock: DocumentBlock, immediate: boolean = false) => {
|
||||
if (!canEdit) return;
|
||||
setBlocks((prev) => {
|
||||
const next = prev.map((b) => (b.id === updatedBlock.id ? updatedBlock : b));
|
||||
currentBlocksRef.current = next;
|
||||
return next;
|
||||
});
|
||||
if (immediate) {
|
||||
window.setTimeout(() => {
|
||||
executeAutoSave(true);
|
||||
}, 10);
|
||||
}
|
||||
},
|
||||
[sendBlockUpdated]
|
||||
[canEdit, executeAutoSave]
|
||||
);
|
||||
|
||||
const handleUpdateBlocks = useCallback(
|
||||
(updatedBlocks: DocumentBlock[]) => {
|
||||
if (!canEdit || updatedBlocks.length === 0) return;
|
||||
const map = new Map<string, DocumentBlock>();
|
||||
for (const b of updatedBlocks) {
|
||||
map.set(b.id, b);
|
||||
}
|
||||
setBlocks((prev) =>
|
||||
prev.map((b) => map.get(b.id) || b)
|
||||
);
|
||||
},
|
||||
[canEdit]
|
||||
);
|
||||
|
||||
const handleCreateBlock = useCallback(
|
||||
(newBlock: DocumentBlock, afterBlockId?: string) => {
|
||||
if (!canEdit) return;
|
||||
setBlocks((prev) => {
|
||||
const next = [...prev];
|
||||
if (afterBlockId) {
|
||||
const idx = next.findIndex((b) => b.id === afterBlockId);
|
||||
if (idx >= 0) {
|
||||
next.splice(idx + 1, 0, newBlock);
|
||||
const normalized = ensureNonEmptyBlocks(docId, next);
|
||||
sendBlockCreated(newBlock, afterBlockId, normalized);
|
||||
return normalized;
|
||||
return ensureNonEmptyBlocks(docId, next);
|
||||
}
|
||||
}
|
||||
next.push(newBlock);
|
||||
const normalized = ensureNonEmptyBlocks(docId, next);
|
||||
sendBlockCreated(newBlock, afterBlockId, normalized);
|
||||
return normalized;
|
||||
return ensureNonEmptyBlocks(docId, next);
|
||||
});
|
||||
},
|
||||
[docId, sendBlockCreated]
|
||||
[docId, canEdit]
|
||||
);
|
||||
|
||||
const handleInsertBlocks = useCallback(
|
||||
(afterBlockId: string, updatedCurrentBlock: DocumentBlock, newBlocks: DocumentBlock[]) => {
|
||||
if (!canEdit) return;
|
||||
setBlocks((prev) => {
|
||||
const next = [...prev];
|
||||
const idx = next.findIndex((b) => b.id === afterBlockId);
|
||||
if (idx >= 0) {
|
||||
next[idx] = updatedCurrentBlock;
|
||||
next.splice(idx + 1, 0, ...newBlocks);
|
||||
} else {
|
||||
next.push(updatedCurrentBlock, ...newBlocks);
|
||||
}
|
||||
const normalized = ensureNonEmptyBlocks(docId, next);
|
||||
currentBlocksRef.current = normalized;
|
||||
return normalized;
|
||||
});
|
||||
window.setTimeout(() => {
|
||||
executeAutoSave(true);
|
||||
}, 50);
|
||||
},
|
||||
[docId, canEdit, executeAutoSave]
|
||||
);
|
||||
|
||||
const handleDeleteBlock = useCallback(
|
||||
(blockId: string) => {
|
||||
if (!canEdit) return;
|
||||
setBlocks((prev) => {
|
||||
const filtered = prev.filter((b) => b.id !== blockId);
|
||||
const normalized = ensureNonEmptyBlocks(docId, filtered);
|
||||
sendBlockDeleted(blockId, normalized);
|
||||
return normalized;
|
||||
return ensureNonEmptyBlocks(docId, filtered);
|
||||
});
|
||||
},
|
||||
[docId, sendBlockDeleted]
|
||||
[docId, canEdit]
|
||||
);
|
||||
|
||||
const handleMoveBlock = useCallback(
|
||||
(blockId: string, newIndex: number, level?: number) => {
|
||||
(blockId: string, targetIndex: number, newLevel?: number) => {
|
||||
if (!canEdit) return;
|
||||
setBlocks((prev) => {
|
||||
const fromIdx = prev.findIndex((b) => b.id === blockId);
|
||||
if (fromIdx === -1) return prev;
|
||||
|
||||
const parentBlock = prev[fromIdx];
|
||||
const parentLevel = Math.max(0, parentBlock.level || 0);
|
||||
|
||||
// Calculate full subtree count (dragged block + all contiguous descendants)
|
||||
let count = 1;
|
||||
for (let i = fromIdx + 1; i < prev.length; i++) {
|
||||
if (Math.max(0, prev[i].level || 0) > parentLevel) {
|
||||
count++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent dropping inside its own subtree
|
||||
if (targetIndex >= fromIdx && targetIndex < fromIdx + count) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const copy = [...prev];
|
||||
const [moving] = copy.splice(fromIdx, 1);
|
||||
const updatedMoving: DocumentBlock = {
|
||||
...moving,
|
||||
level: level !== undefined ? level : moving.level,
|
||||
};
|
||||
const boundedIdx = Math.max(0, Math.min(newIndex, copy.length));
|
||||
copy.splice(boundedIdx, 0, updatedMoving);
|
||||
const normalized = ensureNonEmptyBlocks(docId, copy);
|
||||
sendBlockMoved(blockId, boundedIdx, updatedMoving.level, normalized);
|
||||
return normalized;
|
||||
const subtree = copy.splice(fromIdx, count);
|
||||
|
||||
// Adjust insertion index because removing elements from before shifts target index
|
||||
let insertIndex = targetIndex;
|
||||
if (fromIdx < targetIndex) {
|
||||
insertIndex = targetIndex - count;
|
||||
}
|
||||
insertIndex = Math.max(0, Math.min(insertIndex, copy.length));
|
||||
|
||||
// Calculate level adjustments if moving to a constrained position
|
||||
const prevBlockInPosition = insertIndex > 0 ? copy[insertIndex - 1] : null;
|
||||
const maxAllowed = prevBlockInPosition
|
||||
? Math.max(0, prevBlockInPosition.level || 0) + 1
|
||||
: 0;
|
||||
|
||||
const targetLevel = newLevel !== undefined ? newLevel : parentLevel;
|
||||
const clampedLevel = Math.min(targetLevel, maxAllowed);
|
||||
const levelDelta = clampedLevel - parentLevel;
|
||||
|
||||
const updatedSubtree = subtree.map((b) => ({
|
||||
...b,
|
||||
level: Math.max(0, (b.level || 0) + levelDelta),
|
||||
}));
|
||||
|
||||
copy.splice(insertIndex, 0, ...updatedSubtree);
|
||||
return ensureNonEmptyBlocks(docId, copy);
|
||||
});
|
||||
},
|
||||
[docId, sendBlockMoved]
|
||||
[docId, canEdit]
|
||||
);
|
||||
|
||||
const handleTogglePin = async () => {
|
||||
@@ -680,37 +791,9 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Status & Action Buttons */}
|
||||
{/* Right Action Buttons */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Active Presence Avatars (Immer rund: rounded-full mit Überlappung) */}
|
||||
{activeUsers.length > 0 && (
|
||||
<div className="flex items-center -space-x-1.5 mr-1" title="Aktive Bearbeiter im Dokument">
|
||||
{activeUsers.map((u) => {
|
||||
const initials = (u.userName || 'A')
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.slice(0, 2)
|
||||
.join('')
|
||||
.toUpperCase();
|
||||
return (
|
||||
<div key={u.clientId} className="relative group/avatar cursor-default">
|
||||
<div
|
||||
className="w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold text-white shadow-sm ring-2 ring-canvas"
|
||||
style={{ backgroundColor: u.userColor }}
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
{/* Tooltip */}
|
||||
<div className="pointer-events-none absolute bottom-full mb-1 left-1/2 -translate-x-1/2 opacity-0 group-hover/avatar:opacity-100 transition-opacity whitespace-nowrap bg-canvas text-main text-[10px] px-1.5 py-0.5 rounded-btn border border-border shadow-md z-30">
|
||||
{u.userName}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sharing Section (1:1 styling from SharedListsView) */}
|
||||
{/* Sharing Section */}
|
||||
{doc && (() => {
|
||||
const isOwner = doc.isOwner !== false && (doc.userId === currentUser?.id || !doc.userId || currentUser?.role === 'admin');
|
||||
const sharesCount = doc.sharedWith?.length || 0;
|
||||
@@ -790,14 +873,21 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Read-only notification banner */}
|
||||
{!canEdit && (
|
||||
<div className="flex items-center gap-2 px-4 sm:px-6 py-2 bg-status-warn/10 border-b border-status-warn/25 text-status-warn text-xs">
|
||||
<Lock className="w-3.5 h-3.5 shrink-0" />
|
||||
<span>Dieses Dokument wurde schreibgeschützt mit dir geteilt (Nur Lesen).</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="max-w-4xl w-full mx-auto px-4 sm:px-8 py-5 flex-1">
|
||||
<div className="w-full px-4 sm:px-8 py-5 flex-1">
|
||||
{zoomedBlock ? (
|
||||
/* =========================================================================
|
||||
FOKUS-MODUS & ZOOM-IN (HOISTED SUBTREE VIEW)
|
||||
========================================================================= */
|
||||
<div className="space-y-3">
|
||||
{/* Prominent Zoomed Ast-Heading */}
|
||||
<div className="flex items-start justify-between gap-3 pb-4 mb-2 border-b border-border">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
@@ -846,7 +936,6 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Subtree Children or Empty State */}
|
||||
{childBlocks.length === 0 ? (
|
||||
<div className="py-8 text-center text-xs text-faint border border-dashed border-border rounded-card my-2">
|
||||
<p className="mb-2">Dieser Ast hat noch keine Unterpunkte.</p>
|
||||
@@ -878,12 +967,13 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
canEdit={canEdit}
|
||||
levelOffset={levelOffset}
|
||||
onZoomBlock={setZoomedBlockId}
|
||||
remoteCursors={remoteCursors}
|
||||
sendCursorMoved={sendCursorMoved}
|
||||
onUpdateBlock={handleUpdateBlock}
|
||||
onUpdateBlocks={handleUpdateBlocks}
|
||||
onCreateBlock={handleCreateBlock}
|
||||
onInsertBlocks={handleInsertBlocks}
|
||||
onDeleteBlock={handleDeleteBlock}
|
||||
onMoveBlock={handleMoveBlock}
|
||||
onOpenTask={onOpenTask}
|
||||
onTagClick={onSelectTag}
|
||||
onWikiLinkClick={onWikiLinkClick}
|
||||
initialFocusedBlockId={initialFocusedBlockId}
|
||||
@@ -907,47 +997,119 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
{doc?.icon || '📝'}
|
||||
</button>
|
||||
{showEmojiPicker && canEdit && (
|
||||
<div className="absolute left-0 mt-1 p-2 panel-surface shadow-panel border border-border rounded-card grid grid-cols-6 gap-1 z-30 w-52">
|
||||
{EMOJI_PALETTE.map((emoji) => (
|
||||
<div
|
||||
className="absolute left-0 mt-1 p-2 panel-surface shadow-lg border border-border rounded-card z-30 w-72 space-y-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="relative">
|
||||
<Search className="w-3.5 h-3.5 text-faint absolute left-2 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={emojiSearch}
|
||||
onChange={(e) => setEmojiSearch(e.target.value)}
|
||||
placeholder="Icon suchen oder Emoji eingeben…"
|
||||
className="input-field w-full pl-7 pr-2 py-1 text-xs"
|
||||
/>
|
||||
</div>
|
||||
{doc?.icon && (
|
||||
<button
|
||||
key={emoji}
|
||||
type="button"
|
||||
onClick={() => handleSelectIcon(emoji)}
|
||||
className="p-1.5 rounded-btn hover:bg-panel-elevated text-base flex items-center justify-center cursor-pointer"
|
||||
onClick={() => {
|
||||
handleSelectIcon('');
|
||||
setShowEmojiPicker(false);
|
||||
setEmojiSearch('');
|
||||
}}
|
||||
className="w-full text-left px-2 py-1 text-status-warn/90 hover:bg-status-warn/10 rounded text-xs font-medium flex items-center gap-1.5 cursor-pointer"
|
||||
>
|
||||
{emoji}
|
||||
<Trash2 className="w-3 h-3" />
|
||||
<span>Icon entfernen</span>
|
||||
</button>
|
||||
))}
|
||||
)}
|
||||
{emojiSearch.trim() && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
handleSelectIcon(emojiSearch.trim());
|
||||
setShowEmojiPicker(false);
|
||||
setEmojiSearch('');
|
||||
}}
|
||||
className="w-full text-left px-2 py-1 bg-accent/10 hover:bg-accent/20 text-accent rounded text-xs font-medium flex items-center justify-between cursor-pointer"
|
||||
>
|
||||
<span>Eingabe als Icon nutzen</span>
|
||||
<span className="text-base">{emojiSearch.trim()}</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="max-h-48 overflow-y-auto grid grid-cols-6 gap-1 pr-1">
|
||||
{filteredEmojis.map((item) => (
|
||||
<button
|
||||
key={item.emoji}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
handleSelectIcon(item.emoji);
|
||||
setShowEmojiPicker(false);
|
||||
setEmojiSearch('');
|
||||
}}
|
||||
className="p-1.5 rounded-btn hover:bg-panel-elevated text-lg flex items-center justify-center cursor-pointer transition-colors"
|
||||
title={item.keywords}
|
||||
>
|
||||
{item.emoji}
|
||||
</button>
|
||||
))}
|
||||
{filteredEmojis.length === 0 && (
|
||||
<div className="col-span-6 text-center text-xs text-faint py-3">
|
||||
Kein passendes Standard-Icon
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={titleInputRef}
|
||||
type="text"
|
||||
<textarea
|
||||
ref={(el) => {
|
||||
titleInputRef.current = el;
|
||||
if (el) {
|
||||
el.style.height = 'auto';
|
||||
el.style.height = `${el.scrollHeight}px`;
|
||||
}
|
||||
}}
|
||||
rows={1}
|
||||
value={title}
|
||||
readOnly={!canEdit}
|
||||
autoFocus={autoFocusTitle}
|
||||
onChange={handleTitleChange}
|
||||
onChange={(e) => {
|
||||
handleTitleChange(e);
|
||||
e.target.style.height = 'auto';
|
||||
e.target.style.height = `${e.target.scrollHeight}px`;
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
outlinerTreeRef.current?.focusFirstBlock();
|
||||
}
|
||||
}}
|
||||
onBlur={handleTitleBlur}
|
||||
placeholder="Titel der Notiz…"
|
||||
className="flex-1 min-w-0 text-xl sm:text-2xl font-bold text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight"
|
||||
className="flex-1 min-w-0 text-xl sm:text-2xl font-bold text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight resize-none leading-snug whitespace-pre-wrap break-words [overflow-wrap:anywhere]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Logseq Outliner Tree */}
|
||||
<OutlinerTree
|
||||
ref={outlinerTreeRef}
|
||||
docId={docId}
|
||||
blocks={blocks}
|
||||
canEdit={canEdit}
|
||||
levelOffset={0}
|
||||
onZoomBlock={setZoomedBlockId}
|
||||
remoteCursors={remoteCursors}
|
||||
sendCursorMoved={sendCursorMoved}
|
||||
onUpdateBlock={handleUpdateBlock}
|
||||
onUpdateBlocks={handleUpdateBlocks}
|
||||
onCreateBlock={handleCreateBlock}
|
||||
onInsertBlocks={handleInsertBlocks}
|
||||
onDeleteBlock={handleDeleteBlock}
|
||||
onMoveBlock={handleMoveBlock}
|
||||
onOpenTask={onOpenTask}
|
||||
onTagClick={onSelectTag}
|
||||
onWikiLinkClick={onWikiLinkClick}
|
||||
initialFocusedBlockId={initialFocusedBlockId}
|
||||
|
||||
@@ -3,8 +3,10 @@ import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Check,
|
||||
CheckSquare,
|
||||
X,
|
||||
GripVertical,
|
||||
Trash2,
|
||||
MoreHorizontal,
|
||||
Code,
|
||||
Heading1,
|
||||
Heading2,
|
||||
@@ -19,8 +21,9 @@ import {
|
||||
FileText,
|
||||
} from 'lucide-react';
|
||||
import { DocumentBlock, BlockType } from '../../lib/types';
|
||||
import { RemoteCursor } from '../../lib/useDocumentSync';
|
||||
import { LinkModal } from './LinkModal';
|
||||
import { renderInlineMarkdown } from '../../lib/markdown';
|
||||
import { api } from '../../lib/api';
|
||||
|
||||
export interface SlashCommand {
|
||||
type: BlockType | 'link';
|
||||
@@ -41,22 +44,23 @@ const SLASH_COMMANDS: SlashCommand[] = [
|
||||
];
|
||||
|
||||
export interface OutlinerBlockProps {
|
||||
docId: string;
|
||||
block: DocumentBlock;
|
||||
index: number;
|
||||
isLast: boolean;
|
||||
maxAllowedLevel: number;
|
||||
hasChildren: boolean;
|
||||
isFocused: boolean;
|
||||
canEdit: boolean;
|
||||
activeRemoteCursors?: RemoteCursor[];
|
||||
sendCursorMoved?: (blockId: string | null, selectionStart?: number, selectionEnd?: number) => void;
|
||||
levelOffset?: number;
|
||||
onZoomBlock?: (blockId: string) => void;
|
||||
onFocusBlock: (blockId: string) => void;
|
||||
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
|
||||
onCreateBlockBelow: (afterBlockId: string, currentContent: string) => void;
|
||||
onDeleteBlock: (blockId: string) => void;
|
||||
onCreateBlockBelow: (afterBlockId: string, newContent?: string) => void;
|
||||
onDeleteBlock: (blockId: string, deleteSubtree?: boolean) => void;
|
||||
onIndentBlock: (blockId: string, delta: 1 | -1) => void;
|
||||
onNavigateFocus: (blockId: string, direction: 'up' | 'down') => void;
|
||||
onFocusBlock?: (blockId: string) => void;
|
||||
onOpenTask?: (taskId: string) => void;
|
||||
onTagClick?: (tag: string) => void;
|
||||
onWikiLinkClick?: (pageTitle: string) => void;
|
||||
onDragStartBlock?: (index: number) => void;
|
||||
@@ -65,24 +69,27 @@ export interface OutlinerBlockProps {
|
||||
isDragTarget?: boolean;
|
||||
isTargeted?: boolean;
|
||||
registerTextareaRef: (blockId: string, el: HTMLTextAreaElement | null) => void;
|
||||
onPasteMultiLines?: (blockId: string, textBefore: string, textAfter: string, rawLines: string[]) => void;
|
||||
}
|
||||
|
||||
export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
docId,
|
||||
block,
|
||||
index,
|
||||
isLast,
|
||||
maxAllowedLevel,
|
||||
hasChildren,
|
||||
isFocused,
|
||||
canEdit,
|
||||
activeRemoteCursors = [],
|
||||
sendCursorMoved,
|
||||
levelOffset = 0,
|
||||
onZoomBlock,
|
||||
onFocusBlock,
|
||||
onUpdateBlock,
|
||||
onCreateBlockBelow,
|
||||
onDeleteBlock,
|
||||
onIndentBlock,
|
||||
onNavigateFocus,
|
||||
onFocusBlock,
|
||||
onOpenTask,
|
||||
onTagClick,
|
||||
onWikiLinkClick,
|
||||
onDragStartBlock,
|
||||
@@ -91,11 +98,15 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
isDragTarget,
|
||||
isTargeted,
|
||||
registerTextareaRef,
|
||||
onPasteMultiLines,
|
||||
}) => {
|
||||
const [localContent, setLocalContent] = useState<string>(block.content || '');
|
||||
const [isLocallyFocused, setIsLocallyFocused] = useState<boolean>(false);
|
||||
const activeFocus = isFocused || isLocallyFocused;
|
||||
const [slashSearch, setSlashSearch] = useState<string | null>(null);
|
||||
const [slashIndex, setSlashIndex] = useState(0);
|
||||
const [showLinkModal, setShowLinkModal] = useState<boolean>(false);
|
||||
const [showTodoMenu, setShowTodoMenu] = useState<boolean>(false);
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const debounceTimerRef = useRef<number | null>(null);
|
||||
@@ -107,18 +118,17 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
// Callback refs to avoid stale closures in setTimeout, debounce flush, and event listeners
|
||||
const onUpdateBlockRef = useRef(onUpdateBlock);
|
||||
onUpdateBlockRef.current = onUpdateBlock;
|
||||
const onFocusBlockRef = useRef(onFocusBlock);
|
||||
onFocusBlockRef.current = onFocusBlock;
|
||||
const onCreateBlockBelowRef = useRef(onCreateBlockBelow);
|
||||
onCreateBlockBelowRef.current = onCreateBlockBelow;
|
||||
const onPasteMultiLinesRef = useRef(onPasteMultiLines);
|
||||
onPasteMultiLinesRef.current = onPasteMultiLines;
|
||||
const onDeleteBlockRef = useRef(onDeleteBlock);
|
||||
onDeleteBlockRef.current = onDeleteBlock;
|
||||
const onIndentBlockRef = useRef(onIndentBlock);
|
||||
onIndentBlockRef.current = onIndentBlock;
|
||||
const onNavigateFocusRef = useRef(onNavigateFocus);
|
||||
onNavigateFocusRef.current = onNavigateFocus;
|
||||
const sendCursorMovedRef = useRef(sendCursorMoved);
|
||||
sendCursorMovedRef.current = sendCursorMoved;
|
||||
const isPastingRef = useRef<boolean>(false);
|
||||
|
||||
// Track if this input currently has actual focus in an active browser window
|
||||
const isFocusedRef = useRef<boolean>(false);
|
||||
@@ -166,7 +176,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
flushDebounce(localContentRef.current);
|
||||
}
|
||||
isFocusedRef.current = false;
|
||||
sendCursorMovedRef.current?.(null);
|
||||
};
|
||||
|
||||
const handleWindowFocus = () => {
|
||||
@@ -203,7 +212,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
|
||||
useLayoutEffect(() => {
|
||||
adjustHeight();
|
||||
}, [localContent, block.type, block.level, adjustHeight]);
|
||||
}, [localContent, block.type, block.level, activeFocus, adjustHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('resize', adjustHeight);
|
||||
@@ -279,7 +288,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
}
|
||||
|
||||
setLocalContent(val);
|
||||
sendCursorMoved?.(block.id, e.target.selectionStart, e.target.selectionEnd);
|
||||
|
||||
if (debounceTimerRef.current !== null) {
|
||||
window.clearTimeout(debounceTimerRef.current);
|
||||
@@ -302,14 +310,20 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
}, 150);
|
||||
};
|
||||
|
||||
const handleFocus = (e: React.FocusEvent<HTMLTextAreaElement>) => {
|
||||
const handleFocus = () => {
|
||||
isFocusedRef.current = true;
|
||||
onFocusBlockRef.current(block.id);
|
||||
sendCursorMovedRef.current?.(block.id, e.target.selectionStart, e.target.selectionEnd);
|
||||
setIsLocallyFocused(true);
|
||||
onFocusBlock?.(block.id);
|
||||
setTimeout(adjustHeight, 0);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
isFocusedRef.current = false;
|
||||
setIsLocallyFocused(false);
|
||||
if (isPastingRef.current) {
|
||||
isPastingRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (debounceTimerRef.current !== null) {
|
||||
window.clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = null;
|
||||
@@ -317,23 +331,30 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
} else if (localContentRef.current !== latestBlockRef.current.content) {
|
||||
flushDebounce(localContentRef.current);
|
||||
}
|
||||
sendCursorMovedRef.current?.(null);
|
||||
};
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLTextAreaElement>) => {
|
||||
sendCursorMovedRef.current?.(block.id, e.currentTarget.selectionStart, e.currentTarget.selectionEnd);
|
||||
};
|
||||
const handlePaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
if (!canEdit || block.type === 'code') return;
|
||||
const pastedText = e.clipboardData.getData('text');
|
||||
if (!pastedText || !pastedText.includes('\n')) return;
|
||||
|
||||
const handleKeyUp = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
sendCursorMovedRef.current?.(block.id, e.currentTarget.selectionStart, e.currentTarget.selectionEnd);
|
||||
};
|
||||
e.preventDefault();
|
||||
const rawLines = pastedText.split(/\r?\n/);
|
||||
if (rawLines.length <= 1) return;
|
||||
|
||||
const handleSelect = (e: React.SyntheticEvent<HTMLTextAreaElement>) => {
|
||||
const target = e.currentTarget;
|
||||
sendCursorMovedRef.current?.(block.id, target.selectionStart, target.selectionEnd);
|
||||
};
|
||||
const start = e.currentTarget.selectionStart ?? localContent.length;
|
||||
const end = e.currentTarget.selectionEnd ?? start;
|
||||
const textBefore = localContent.slice(0, start);
|
||||
const textAfter = localContent.slice(end);
|
||||
|
||||
const primaryRemoteCursor = activeRemoteCursors.length > 0 ? activeRemoteCursors[0] : null;
|
||||
if (debounceTimerRef.current !== null) {
|
||||
window.clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = null;
|
||||
}
|
||||
|
||||
isPastingRef.current = true;
|
||||
onPasteMultiLinesRef.current?.(block.id, textBefore, textAfter, rawLines);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (!canEdit) return;
|
||||
@@ -374,13 +395,49 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Enter (without Shift) -> Create new block on same level
|
||||
// 2. Enter (without Shift) -> If block is completely empty:
|
||||
// - If level > minLevel, outdent (Shift+Tab behaviour)
|
||||
// - If already at minLevel and is the last block, do NOT create another empty block below
|
||||
// - Otherwise split paragraph at cursor and create new block below
|
||||
if (e.key === 'Enter' && !e.shiftKey && block.type !== 'code') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setSlashSearch(null);
|
||||
flushDebounce(localContent);
|
||||
onCreateBlockBelowRef.current(block.id, localContent);
|
||||
|
||||
const minLevel = levelOffset || 0;
|
||||
if (localContent === '') {
|
||||
if (Math.max(0, block.level || 0) > minLevel) {
|
||||
flushDebounce(localContent);
|
||||
onIndentBlockRef.current(block.id, -1);
|
||||
return;
|
||||
}
|
||||
if (isLast) {
|
||||
// Bereits auf oberster Ebene und der letzte Block: kein weiterer leerer Block
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const start = e.currentTarget.selectionStart ?? localContent.length;
|
||||
const end = e.currentTarget.selectionEnd ?? start;
|
||||
let beforeText = localContent.slice(0, start);
|
||||
let afterText = localContent.slice(end);
|
||||
|
||||
// Clean linebreaks at the split boundary if present
|
||||
if (beforeText.endsWith('\r\n')) {
|
||||
beforeText = beforeText.slice(0, -2);
|
||||
} else if (beforeText.endsWith('\n')) {
|
||||
beforeText = beforeText.slice(0, -1);
|
||||
}
|
||||
if (afterText.startsWith('\r\n')) {
|
||||
afterText = afterText.slice(2);
|
||||
} else if (afterText.startsWith('\n')) {
|
||||
afterText = afterText.slice(1);
|
||||
}
|
||||
|
||||
setLocalContent(beforeText);
|
||||
localContentRef.current = beforeText;
|
||||
flushDebounce(beforeText);
|
||||
onCreateBlockBelowRef.current(block.id, afterText);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -422,6 +479,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
|
||||
const level = Math.max(0, block.level || 0);
|
||||
const visualLevel = Math.max(0, level - (levelOffset || 0));
|
||||
const isEmpty = (!localContent || localContent.trim() === '') && !hasChildren;
|
||||
|
||||
// Parse rich tokens (WikiLinks [[Page]], Markdown Links [Title](url), raw URLs, and #Tags)
|
||||
const tokens = useMemo(() => {
|
||||
@@ -441,39 +499,29 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group relative flex items-start gap-1 py-1 rounded-card transition-all duration-300 ${
|
||||
className={`group relative flex items-start gap-1 py-1 px-1.5 rounded-card transition-all duration-300 ${
|
||||
isDragTarget
|
||||
? 'border-t-2 border-accent bg-accent/10'
|
||||
: isTargeted
|
||||
? 'bg-accent/15 ring-2 ring-accent'
|
||||
: isFocused
|
||||
: activeFocus
|
||||
? 'bg-panel-elevated'
|
||||
: 'hover:bg-panel'
|
||||
}`}
|
||||
style={{
|
||||
paddingLeft: `${visualLevel * 22 + 4}px`,
|
||||
...(primaryRemoteCursor
|
||||
? { borderLeft: `2px solid ${primaryRemoteCursor.userColor}` }
|
||||
: {}),
|
||||
}}
|
||||
onDragOver={(e) => onDragOverBlock?.(e, index)}
|
||||
onDrop={() => onDropBlock?.(index)}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest('button, a, input, select')) return;
|
||||
if (!activeFocus && canEdit && textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
const len = textareaRef.current.value.length;
|
||||
textareaRef.current.setSelectionRange(len, len);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Remote Cursors Floating Name Badges on Block */}
|
||||
{activeRemoteCursors.length > 0 && (
|
||||
<div className="absolute -top-2.5 right-2 z-20 flex items-center gap-1 select-none pointer-events-none">
|
||||
{activeRemoteCursors.map((c) => (
|
||||
<span
|
||||
key={c.clientId}
|
||||
className="text-[9px] font-bold px-1.5 py-0.5 rounded-btn text-white shadow-sm flex items-center gap-0.5"
|
||||
style={{ backgroundColor: c.userColor }}
|
||||
>
|
||||
{c.userName}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Indentation Guide Line */}
|
||||
{visualLevel > 0 &&
|
||||
Array.from({ length: visualLevel }).map((_, lvlIdx) => (
|
||||
@@ -484,20 +532,8 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Bullet Controls: Drag Handle, Collapse Toggle, Bullet / Checkbox */}
|
||||
{/* Bullet Controls: Collapse Toggle & Bullet (Drag-Handle & Zoom) / Checkbox */}
|
||||
<div className="flex items-center gap-0.5 pt-1 select-none shrink-0">
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
draggable
|
||||
onDragStart={() => onDragStartBlock?.(index)}
|
||||
title="Block ziehen oder umwandeln"
|
||||
className="opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-main cursor-grab active:cursor-grabbing transition-opacity"
|
||||
>
|
||||
<GripVertical className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -517,7 +553,38 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
<span className="w-3.5" />
|
||||
)}
|
||||
|
||||
{block.type === 'todo' ? (
|
||||
{/* Bullet handle for dragging and zoom focus */}
|
||||
<button
|
||||
type="button"
|
||||
draggable={canEdit}
|
||||
onDragStart={() => canEdit && onDragStartBlock?.(index)}
|
||||
onClick={() => onZoomBlock?.(block.id)}
|
||||
className={`w-4 h-4 flex items-center justify-center rounded-btn hover:bg-panel-hover group/bullet transition-transform ${
|
||||
canEdit ? 'cursor-grab active:cursor-grabbing' : 'cursor-pointer'
|
||||
}`}
|
||||
title={
|
||||
canEdit
|
||||
? 'Ziehen zum Verschieben • Klick: Ast hineinzoomen (Fokus-Modus)'
|
||||
: 'Klick: In diesen Ast hineinzoomen (Fokus-Modus)'
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full transition-all duration-150 ${
|
||||
block.collapsed && hasChildren
|
||||
? 'bg-accent ring-2 ring-accent/30'
|
||||
: activeFocus
|
||||
? 'bg-accent scale-125'
|
||||
: 'bg-faint group-hover/bullet:bg-accent group-hover/bullet:scale-125'
|
||||
} ${
|
||||
isEmpty
|
||||
? 'opacity-0 group-hover:opacity-100 hover:opacity-100'
|
||||
: 'opacity-100'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* If To-Do block: checkbox alongside bullet */}
|
||||
{block.type === 'todo' && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canEdit}
|
||||
@@ -533,28 +600,11 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
>
|
||||
<Check className="w-3 h-3 stroke-[3]" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onZoomBlock?.(block.id)}
|
||||
className="w-4 h-4 flex items-center justify-center rounded-btn hover:bg-panel-hover group/bullet transition-transform"
|
||||
title="Klick: In diesen Ast hineinzoomen (Fokus-Modus)"
|
||||
>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full transition-all ${
|
||||
block.collapsed && hasChildren
|
||||
? 'bg-accent ring-2 ring-accent/30'
|
||||
: isFocused
|
||||
? 'bg-accent scale-125'
|
||||
: 'bg-faint group-hover/bullet:bg-accent group-hover/bullet:scale-125'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main Block Content */}
|
||||
<div className="flex-1 min-w-0 pr-1 relative">
|
||||
<div className="flex-1 min-w-0 pr-1 relative min-h-[1.5rem]">
|
||||
{block.type === 'divider' ? (
|
||||
<div className="py-2">
|
||||
<hr className="border-t border-border" />
|
||||
@@ -581,9 +631,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
readOnly={!canEdit}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onClick={handleClick}
|
||||
onKeyUp={handleKeyUp}
|
||||
onSelect={handleSelect}
|
||||
onChange={handleContentChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-full resize-none font-mono text-xs bg-transparent text-status-ok border-0 focus:outline-none focus:ring-0 leading-relaxed"
|
||||
@@ -601,16 +648,133 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
readOnly={!canEdit}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onClick={handleClick}
|
||||
onKeyUp={handleKeyUp}
|
||||
onSelect={handleSelect}
|
||||
onChange={handleContentChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-full resize-none bg-transparent text-sm text-main border-0 focus:outline-none focus:ring-0 leading-relaxed italic"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative w-full">
|
||||
<div className="relative w-full min-h-[1.5rem]">
|
||||
{/* View Layer with Inline Markdown & Actions (3-dots or Erweitert Badge) directly behind text */}
|
||||
<div
|
||||
onClick={() => {
|
||||
if (!activeFocus && canEdit && textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
const len = textareaRef.current.value.length;
|
||||
textareaRef.current.setSelectionRange(len, len);
|
||||
}
|
||||
}}
|
||||
className={`w-full min-h-[1.5rem] leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] ${
|
||||
activeFocus ? 'absolute inset-0 pointer-events-none' : 'relative cursor-text'
|
||||
} ${
|
||||
block.type === 'h1'
|
||||
? 'text-lg font-bold text-main tracking-tight'
|
||||
: block.type === 'h2'
|
||||
? 'text-base font-semibold text-main'
|
||||
: block.type === 'h3'
|
||||
? 'text-sm font-semibold text-main'
|
||||
: block.type === 'todo' && block.checked
|
||||
? 'text-sm text-faint line-through'
|
||||
: 'text-sm text-main'
|
||||
}`}
|
||||
>
|
||||
{/* When actively focused, ghost text is invisible so textarea renders text/caret directly */}
|
||||
<span className={activeFocus ? 'invisible' : ''}>
|
||||
{localContent ? renderInlineMarkdown(localContent) : '\u200b'}
|
||||
</span>
|
||||
|
||||
{/* Inline Badge and 3-Dots directly behind the text */}
|
||||
{block.type === 'todo' && (
|
||||
<span className="inline-flex items-center align-middle ml-1.5 gap-1 shrink-0 pointer-events-auto z-10 relative select-none">
|
||||
{block.taskId && (
|
||||
<span
|
||||
className="badge-accent text-[9px] inline-flex items-center gap-0.5 px-1.5 py-0.2 rounded font-medium shrink-0"
|
||||
title="Mit erweiterter Aufgabe synchronisiert"
|
||||
>
|
||||
<CheckSquare className="w-2.5 h-2.5" />
|
||||
<span>Erweitert</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{canEdit && (
|
||||
<span className="relative inline-flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowTodoMenu((prev) => !prev);
|
||||
}}
|
||||
className="p-0.5 text-faint hover:text-accent rounded-btn transition-colors cursor-pointer"
|
||||
title="Aufgaben-Optionen"
|
||||
>
|
||||
<MoreHorizontal className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{showTodoMenu && (
|
||||
<div
|
||||
className="absolute left-0 top-full mt-1 bg-panel border border-border shadow-lg rounded-card p-1 z-30 w-52 text-xs"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{!block.taskId ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
setShowTodoMenu(false);
|
||||
try {
|
||||
const targetDocId = docId || block.documentId || block.docId || '';
|
||||
const thought = await api.createEnhancedTask(targetDocId, block.id, {
|
||||
content: localContent || 'Aufgabe',
|
||||
checked: Boolean(block.checked),
|
||||
});
|
||||
const updatedBlock: DocumentBlock = {
|
||||
...latestBlockRef.current,
|
||||
content: localContent,
|
||||
taskId: thought.id,
|
||||
type: 'todo',
|
||||
};
|
||||
onUpdateBlockRef.current(updatedBlock, true);
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Fehler beim Erstellen der erweiterten Aufgabe');
|
||||
}
|
||||
}}
|
||||
className="w-full text-left px-2 py-1.5 rounded-btn hover:bg-panel-elevated hover:text-accent flex items-center gap-1.5 text-main font-medium cursor-pointer"
|
||||
>
|
||||
<CheckSquare className="w-3.5 h-3.5 text-accent" />
|
||||
<span>In erweiterte Aufgabe umwandeln</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
setShowTodoMenu(false);
|
||||
try {
|
||||
if (block.taskId) {
|
||||
await api.unlinkEnhancedTask(block.taskId).catch(() => {});
|
||||
}
|
||||
const updatedBlock: DocumentBlock = {
|
||||
...latestBlockRef.current,
|
||||
content: localContent,
|
||||
taskId: '',
|
||||
type: 'todo',
|
||||
};
|
||||
onUpdateBlockRef.current(updatedBlock, true);
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Fehler beim Aufheben der Verknüpfung');
|
||||
}
|
||||
}}
|
||||
className="w-full text-left px-2 py-1.5 rounded-btn hover:bg-panel-elevated hover:text-status-warn flex items-center gap-1.5 text-main font-medium cursor-pointer"
|
||||
>
|
||||
<X className="w-3.5 h-3.5 text-status-warn" />
|
||||
<span>Verknüpfung aufheben</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
ref={(el) => {
|
||||
textareaRef.current = el;
|
||||
@@ -621,17 +785,12 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
readOnly={!canEdit}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onClick={handleClick}
|
||||
onKeyUp={handleKeyUp}
|
||||
onSelect={handleSelect}
|
||||
onChange={handleContentChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={
|
||||
index === 0 && level === 0
|
||||
? 'Schreibe einen Punkt… (Enter = Neu, Tab = Einrücken, Shift+Tab = Ausrücken, / für Befehle)'
|
||||
: ''
|
||||
}
|
||||
className={`w-full resize-none overflow-hidden bg-transparent border-0 focus:outline-none focus:ring-0 leading-relaxed whitespace-pre-wrap break-words ${
|
||||
onPaste={handlePaste}
|
||||
className={`w-full min-h-[1.5rem] resize-none overflow-hidden bg-transparent border-0 focus:outline-none focus:ring-0 leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] ${
|
||||
activeFocus ? 'relative z-0 text-main' : 'absolute inset-0 text-transparent selection:text-transparent pointer-events-none'
|
||||
} ${
|
||||
block.type === 'h1'
|
||||
? 'text-lg font-bold text-main tracking-tight'
|
||||
: block.type === 'h2'
|
||||
@@ -643,45 +802,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
: 'text-sm text-main'
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Remote Caret & Floating Name Tag Overlay */}
|
||||
{primaryRemoteCursor &&
|
||||
primaryRemoteCursor.selectionStart !== undefined &&
|
||||
primaryRemoteCursor.selectionStart !== null && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={`pointer-events-none absolute inset-0 overflow-hidden leading-relaxed whitespace-pre-wrap break-words select-none z-10 ${
|
||||
block.type === 'h1'
|
||||
? 'text-lg font-bold tracking-tight'
|
||||
: block.type === 'h2'
|
||||
? 'text-base font-semibold'
|
||||
: block.type === 'h3'
|
||||
? 'text-sm font-semibold'
|
||||
: block.type === 'todo' && block.checked
|
||||
? 'text-sm text-faint line-through'
|
||||
: 'text-sm'
|
||||
}`}
|
||||
>
|
||||
<span className="opacity-0">
|
||||
{localContent.slice(
|
||||
0,
|
||||
Math.min(primaryRemoteCursor.selectionStart, localContent.length)
|
||||
)}
|
||||
</span>
|
||||
<span className="relative inline-block align-baseline">
|
||||
<span
|
||||
className="inline-block w-[2px] h-[1.15em] -mb-0.5 animate-pulse"
|
||||
style={{ backgroundColor: primaryRemoteCursor.userColor }}
|
||||
/>
|
||||
<span
|
||||
className="absolute -top-3.5 left-0 text-[8px] font-bold px-1 py-0 rounded-btn text-white whitespace-nowrap shadow-sm"
|
||||
style={{ backgroundColor: primaryRemoteCursor.userColor }}
|
||||
>
|
||||
{primaryRemoteCursor.userName}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -804,15 +924,23 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
<Maximize2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
{/* Delete button on hover */}
|
||||
{/* Delete button on hover (Trash2 icon) */}
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeleteBlockRef.current(block.id)}
|
||||
className="opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-status-err rounded-btn transition-opacity shrink-0"
|
||||
onClick={() => {
|
||||
if (hasChildren) {
|
||||
if (window.confirm('Bist du sicher? Alle Unterpunkte dieses Asts werden ebenfalls gelöscht.')) {
|
||||
onDeleteBlockRef.current(block.id, true);
|
||||
}
|
||||
} else {
|
||||
onDeleteBlockRef.current(block.id, false);
|
||||
}
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-status-err rounded-btn transition-opacity shrink-0 cursor-pointer"
|
||||
title="Block löschen"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,42 +1,47 @@
|
||||
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { DocumentBlock } from '../../lib/types';
|
||||
import { RemoteCursor } from '../../lib/useDocumentSync';
|
||||
import React, { useState, useRef, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
|
||||
import { DocumentBlock, BlockType } from '../../lib/types';
|
||||
import { OutlinerBlock } from './OutlinerBlock';
|
||||
|
||||
export interface OutlinerTreeHandle {
|
||||
focusFirstBlock: () => void;
|
||||
focusBlock: (blockId: string, cursor?: 'start' | 'end') => void;
|
||||
}
|
||||
|
||||
export interface OutlinerTreeProps {
|
||||
docId: string;
|
||||
blocks: DocumentBlock[];
|
||||
canEdit: boolean;
|
||||
levelOffset?: number;
|
||||
onZoomBlock?: (blockId: string) => void;
|
||||
remoteCursors?: Record<string, RemoteCursor>;
|
||||
sendCursorMoved?: (blockId: string | null, selectionStart?: number, selectionEnd?: number) => void;
|
||||
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
|
||||
onUpdateBlocks?: (updated: DocumentBlock[]) => void;
|
||||
onCreateBlock: (newBlock: DocumentBlock, afterBlockId?: string) => void;
|
||||
onInsertBlocks?: (afterBlockId: string, updatedCurrent: DocumentBlock, newBlocks: DocumentBlock[]) => void;
|
||||
onDeleteBlock: (blockId: string) => void;
|
||||
onMoveBlock: (blockId: string, newIndex: number, level?: number) => void;
|
||||
onOpenTask?: (taskId: string) => void;
|
||||
onTagClick?: (tag: string) => void;
|
||||
onWikiLinkClick?: (pageTitle: string) => void;
|
||||
initialFocusedBlockId?: string | null;
|
||||
}
|
||||
|
||||
export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
||||
export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||
docId,
|
||||
blocks,
|
||||
canEdit,
|
||||
levelOffset = 0,
|
||||
onZoomBlock,
|
||||
remoteCursors,
|
||||
sendCursorMoved,
|
||||
onUpdateBlock,
|
||||
onUpdateBlocks,
|
||||
onCreateBlock,
|
||||
onInsertBlocks,
|
||||
onDeleteBlock,
|
||||
onMoveBlock,
|
||||
onOpenTask,
|
||||
onTagClick,
|
||||
onWikiLinkClick,
|
||||
initialFocusedBlockId,
|
||||
}) => {
|
||||
}, ref) => {
|
||||
const [focusedBlockId, setFocusedBlockId] = useState<string | null>(null);
|
||||
const [highlightedBlockId, setHighlightedBlockId] = useState<string | null>(
|
||||
initialFocusedBlockId || null
|
||||
@@ -73,6 +78,32 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
||||
[]
|
||||
);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
focusFirstBlock: () => {
|
||||
if (blocks.length > 0) {
|
||||
focusBlockInput(blocks[0].id, 'end');
|
||||
} else {
|
||||
const firstBlock: DocumentBlock = {
|
||||
id: `blk_${Date.now().toString(36)}_0`,
|
||||
documentId: docId,
|
||||
type: 'text',
|
||||
content: '',
|
||||
level: levelOffset || 0,
|
||||
position: 1000,
|
||||
};
|
||||
pendingFocusRef.current = { blockId: firstBlock.id, cursor: 'end' };
|
||||
onCreateBlock(firstBlock);
|
||||
}
|
||||
},
|
||||
focusBlock: (blockId: string, cursor: 'start' | 'end' = 'end') => {
|
||||
focusBlockInput(blockId, cursor);
|
||||
},
|
||||
}),
|
||||
[blocks, docId, levelOffset, onCreateBlock, focusBlockInput]
|
||||
);
|
||||
|
||||
const registerTextareaRef = useCallback((blockId: string, el: HTMLTextAreaElement | null) => {
|
||||
if (el) {
|
||||
textareaMapRef.current.set(blockId, el);
|
||||
@@ -164,22 +195,21 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [blocks]);
|
||||
}, [blocks, levelOffset]);
|
||||
|
||||
const handleCreateBlockBelow = useCallback(
|
||||
(afterBlockId: string, _currentContent: string) => {
|
||||
(afterBlockId: string, newContent: string = '') => {
|
||||
if (!canEdit) return;
|
||||
const idx = blocks.findIndex((b) => b.id === afterBlockId);
|
||||
const currentBlock = idx >= 0 ? blocks[idx] : null;
|
||||
const level = currentBlock ? Math.max(0, currentBlock.level || 0) : 0;
|
||||
const inheritedType =
|
||||
currentBlock && currentBlock.type === 'todo' ? 'todo' : 'text';
|
||||
const inheritedType: BlockType = 'text';
|
||||
|
||||
const newBlock: DocumentBlock = {
|
||||
id: `blk_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
documentId: docId,
|
||||
type: inheritedType,
|
||||
content: '',
|
||||
content: newContent,
|
||||
level,
|
||||
checked: false,
|
||||
collapsed: false,
|
||||
@@ -193,8 +223,252 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
||||
[blocks, canEdit, docId, onCreateBlock]
|
||||
);
|
||||
|
||||
const handlePasteMultiLines = useCallback(
|
||||
(blockId: string, textBefore: string, textAfter: string, rawLines: string[]) => {
|
||||
if (!canEdit || rawLines.length === 0) return;
|
||||
const idx = blocks.findIndex((b) => b.id === blockId);
|
||||
if (idx === -1) return;
|
||||
|
||||
const currentBlock = blocks[idx];
|
||||
const baseLevel = Math.max(0, currentBlock.level || 0);
|
||||
|
||||
// Detect indentation style (2-space vs 4-space tabs)
|
||||
let detectedSpaceStep = 2;
|
||||
const leadingSpacesList: number[] = [];
|
||||
for (const line of rawLines) {
|
||||
if (!line.trim()) continue;
|
||||
let sp = 0;
|
||||
while (sp < line.length && line[sp] === ' ') sp++;
|
||||
if (sp > 0) leadingSpacesList.push(sp);
|
||||
}
|
||||
if (leadingSpacesList.length > 0 && leadingSpacesList.every((s) => s % 4 === 0)) {
|
||||
detectedSpaceStep = 4;
|
||||
}
|
||||
|
||||
// Parse lines into structured blocks
|
||||
interface ParsedPasteItem {
|
||||
type: BlockType;
|
||||
checked?: boolean;
|
||||
content: string;
|
||||
indent: number;
|
||||
}
|
||||
|
||||
const parsed: ParsedPasteItem[] = [];
|
||||
let currentItem: ParsedPasteItem | null = null;
|
||||
let inCodeBlock = false;
|
||||
let afterHeading = false;
|
||||
|
||||
const pushCurrentItem = () => {
|
||||
if (currentItem) {
|
||||
currentItem.content = currentItem.content.trimEnd();
|
||||
parsed.push(currentItem);
|
||||
currentItem = null;
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < rawLines.length; i++) {
|
||||
const rawLine = rawLines[i];
|
||||
|
||||
// 1. Inside a fenced code block
|
||||
if (inCodeBlock) {
|
||||
if (currentItem) {
|
||||
currentItem.content += '\n' + rawLine;
|
||||
}
|
||||
if (rawLine.trim().startsWith('```')) {
|
||||
inCodeBlock = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. Starts a code fence
|
||||
if (rawLine.trim().startsWith('```')) {
|
||||
inCodeBlock = true;
|
||||
if (afterHeading) {
|
||||
afterHeading = false;
|
||||
pushCurrentItem();
|
||||
currentItem = {
|
||||
type: 'text',
|
||||
content: rawLine,
|
||||
indent: 0,
|
||||
};
|
||||
} else if (currentItem) {
|
||||
currentItem.content += (currentItem.content.endsWith('\n') ? '' : '\n') + rawLine;
|
||||
} else {
|
||||
currentItem = {
|
||||
type: 'text',
|
||||
content: rawLine,
|
||||
indent: 0,
|
||||
};
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Measure leading indent
|
||||
let tabs = 0;
|
||||
let spaces = 0;
|
||||
let charIdx = 0;
|
||||
while (charIdx < rawLine.length) {
|
||||
if (rawLine[charIdx] === '\t') {
|
||||
tabs++;
|
||||
charIdx++;
|
||||
} else if (rawLine[charIdx] === ' ') {
|
||||
spaces++;
|
||||
charIdx++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const strippedLine = rawLine.slice(charIdx);
|
||||
const lineIndent = tabs + Math.floor(spaces / detectedSpaceStep);
|
||||
|
||||
// Blank lines
|
||||
if (strippedLine.trim() === '') {
|
||||
if (currentItem) {
|
||||
currentItem.content += '\n';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Headings: #, ##, ###
|
||||
const h3Match = strippedLine.match(/^###\s+(.*)$/);
|
||||
const h2Match = strippedLine.match(/^##\s+(.*)$/);
|
||||
const h1Match = strippedLine.match(/^#\s+(.*)$/);
|
||||
if (h1Match || h2Match || h3Match) {
|
||||
pushCurrentItem();
|
||||
afterHeading = true;
|
||||
const type: BlockType = h1Match ? 'h1' : h2Match ? 'h2' : 'h3';
|
||||
const content = (h1Match || h2Match || h3Match)![1];
|
||||
parsed.push({ type, content, indent: lineIndent });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Dividers: ---, ***, ___
|
||||
if (/^(?:---|---|\*\*\*|___)\s*$/.test(strippedLine.trim())) {
|
||||
pushCurrentItem();
|
||||
afterHeading = false;
|
||||
parsed.push({ type: 'divider', content: '---', indent: lineIndent });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Todo items: - [ ], - [x], * [ ], * [x]
|
||||
const todoMatch = strippedLine.match(/^[-*]\s*\[([ xX])\]\s*(.*)$/);
|
||||
if (todoMatch) {
|
||||
pushCurrentItem();
|
||||
afterHeading = false;
|
||||
parsed.push({
|
||||
type: 'todo',
|
||||
checked: todoMatch[1].toLowerCase() === 'x',
|
||||
content: todoMatch[2],
|
||||
indent: lineIndent,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Explicit bullet or numbered list: - item, * item, • item, 1. item, 1) item
|
||||
const bulletMatch = strippedLine.match(/^[-*•]\s+(.*)$/);
|
||||
const numMatch = strippedLine.match(/^\d+[\.\)]\s+(.*)$/);
|
||||
if (bulletMatch || numMatch) {
|
||||
pushCurrentItem();
|
||||
afterHeading = false;
|
||||
parsed.push({
|
||||
type: 'text',
|
||||
content: (bulletMatch ? bulletMatch[1] : numMatch![1]),
|
||||
indent: lineIndent,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular text line
|
||||
if (afterHeading) {
|
||||
// This starts the single bullet point after the heading
|
||||
afterHeading = false;
|
||||
pushCurrentItem();
|
||||
currentItem = {
|
||||
type: 'text',
|
||||
content: strippedLine,
|
||||
indent: lineIndent,
|
||||
};
|
||||
} else if (currentItem) {
|
||||
if (currentItem.content.endsWith('\n')) {
|
||||
currentItem.content += strippedLine;
|
||||
} else {
|
||||
currentItem.content += '\n' + strippedLine;
|
||||
}
|
||||
} else {
|
||||
currentItem = {
|
||||
type: 'text',
|
||||
content: strippedLine,
|
||||
indent: lineIndent,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pushCurrentItem();
|
||||
|
||||
if (parsed.length === 0) return;
|
||||
|
||||
const line0 = parsed[0];
|
||||
const baseIndent = line0.indent;
|
||||
const isSingle = parsed.length === 1;
|
||||
const line0Content = textBefore + line0.content + (isSingle ? textAfter : '');
|
||||
|
||||
const isCurrentEmpty = (currentBlock.content || '').trim() === '';
|
||||
const adoptedType = line0.type !== 'text' ? line0.type : (isCurrentEmpty ? 'text' : currentBlock.type);
|
||||
const adoptedChecked = line0.type === 'todo' ? (line0.checked ?? false) : currentBlock.checked;
|
||||
|
||||
const updatedCurrentBlock: DocumentBlock = {
|
||||
...currentBlock,
|
||||
content: line0Content,
|
||||
type: adoptedType,
|
||||
checked: adoptedChecked,
|
||||
};
|
||||
|
||||
const newBlocks: DocumentBlock[] = [];
|
||||
const nowBase = Date.now().toString(36);
|
||||
|
||||
for (let j = 1; j < parsed.length; j++) {
|
||||
const p = parsed[j];
|
||||
const isLastLine = j === parsed.length - 1;
|
||||
const lineContent = isLastLine ? (p.content + textAfter) : p.content;
|
||||
const lineLevel = Math.max(0, baseLevel + (p.indent - baseIndent));
|
||||
|
||||
newBlocks.push({
|
||||
id: `blk_${nowBase}_${j}_${Math.random().toString(36).slice(2, 6)}`,
|
||||
documentId: docId,
|
||||
type: p.type,
|
||||
checked: p.checked || false,
|
||||
content: lineContent,
|
||||
level: lineLevel,
|
||||
collapsed: false,
|
||||
position: (currentBlock.position || 1000) + j * 10,
|
||||
});
|
||||
}
|
||||
|
||||
const lastTargetId = newBlocks.length > 0 ? newBlocks[newBlocks.length - 1].id : currentBlock.id;
|
||||
const existingEl = textareaMapRef.current.get(lastTargetId);
|
||||
if (existingEl) {
|
||||
pendingFocusRef.current = null;
|
||||
focusBlockInput(lastTargetId, 'end');
|
||||
} else {
|
||||
pendingFocusRef.current = { blockId: lastTargetId, cursor: 'end' };
|
||||
}
|
||||
setFocusedBlockId(lastTargetId);
|
||||
|
||||
if (onInsertBlocks) {
|
||||
onInsertBlocks(currentBlock.id, updatedCurrentBlock, newBlocks);
|
||||
} else {
|
||||
onUpdateBlock(updatedCurrentBlock, true);
|
||||
for (let k = 0; k < newBlocks.length; k++) {
|
||||
const afterId = k === 0 ? currentBlock.id : newBlocks[k - 1].id;
|
||||
onCreateBlock(newBlocks[k], afterId);
|
||||
}
|
||||
}
|
||||
},
|
||||
[blocks, canEdit, docId, focusBlockInput, onCreateBlock, onInsertBlocks, onUpdateBlock]
|
||||
);
|
||||
|
||||
const handleDeleteBlock = useCallback(
|
||||
(blockId: string) => {
|
||||
(blockId: string, deleteSubtree = false) => {
|
||||
if (!canEdit) return;
|
||||
const idx = blocks.findIndex((b) => b.id === blockId);
|
||||
if (idx === -1) return;
|
||||
@@ -219,9 +493,25 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
||||
if (targetFocusBlock) {
|
||||
focusBlockInput(targetFocusBlock.id, 'end');
|
||||
}
|
||||
onDeleteBlock(blockId);
|
||||
|
||||
if (deleteSubtree) {
|
||||
const rootLevel = blocks[idx].level || 0;
|
||||
const idsToDelete = [blockId];
|
||||
for (let j = idx + 1; j < blocks.length; j++) {
|
||||
if ((blocks[j].level || 0) > rootLevel) {
|
||||
idsToDelete.push(blocks[j].id);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (const id of idsToDelete) {
|
||||
onDeleteBlock(id);
|
||||
}
|
||||
} else {
|
||||
onDeleteBlock(blockId);
|
||||
}
|
||||
},
|
||||
[blocks, canEdit, focusBlockInput, onDeleteBlock, onUpdateBlock]
|
||||
[blocks, canEdit, focusBlockInput, levelOffset, onDeleteBlock, onUpdateBlock]
|
||||
);
|
||||
|
||||
const handleIndentBlock = useCallback(
|
||||
@@ -233,9 +523,9 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
||||
const current = blocks[idx];
|
||||
const currentLevel = Math.max(0, current.level || 0);
|
||||
const prevLevel = idx > 0 ? Math.max(0, blocks[idx - 1].level || 0) : 0;
|
||||
const maxLevel = idx === 0 ? 0 : prevLevel + 1;
|
||||
|
||||
const minLevel = levelOffset || 0;
|
||||
const maxLevel = idx === 0 ? minLevel : prevLevel + 1;
|
||||
|
||||
const nextLevel =
|
||||
delta === 1
|
||||
? Math.min(maxLevel, currentLevel + 1)
|
||||
@@ -243,15 +533,38 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
||||
|
||||
if (nextLevel === currentLevel) return;
|
||||
|
||||
onUpdateBlock(
|
||||
{
|
||||
...current,
|
||||
level: nextLevel,
|
||||
},
|
||||
true
|
||||
);
|
||||
const actualDelta = nextLevel - currentLevel;
|
||||
|
||||
// Find all contiguous children / descendants:
|
||||
// contiguous blocks with level > currentLevel
|
||||
let count = 1;
|
||||
for (let i = idx + 1; i < blocks.length; i++) {
|
||||
if (Math.max(0, blocks[i].level || 0) > currentLevel) {
|
||||
count++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const updatedBlocks: DocumentBlock[] = [];
|
||||
for (let i = idx; i < idx + count; i++) {
|
||||
const b = blocks[i];
|
||||
const newLvl = Math.max(minLevel, (b.level || 0) + actualDelta);
|
||||
updatedBlocks.push({
|
||||
...b,
|
||||
level: newLvl,
|
||||
});
|
||||
}
|
||||
|
||||
if (onUpdateBlocks) {
|
||||
onUpdateBlocks(updatedBlocks);
|
||||
} else {
|
||||
for (const b of updatedBlocks) {
|
||||
onUpdateBlock(b, true);
|
||||
}
|
||||
}
|
||||
},
|
||||
[blocks, canEdit, onUpdateBlock]
|
||||
[blocks, canEdit, levelOffset, onUpdateBlock, onUpdateBlocks]
|
||||
);
|
||||
|
||||
const handleNavigateFocus = useCallback(
|
||||
@@ -274,10 +587,29 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
||||
setDraggedIndex(index);
|
||||
}, []);
|
||||
|
||||
const handleDragOverBlock = useCallback((e: React.DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
setDragOverIndex(index);
|
||||
}, []);
|
||||
const handleDragOverBlock = useCallback(
|
||||
(e: React.DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
if (draggedIndex !== null) {
|
||||
const parentLevel = Math.max(0, blocks[draggedIndex]?.level || 0);
|
||||
let count = 1;
|
||||
for (let i = draggedIndex + 1; i < blocks.length; i++) {
|
||||
if (Math.max(0, blocks[i].level || 0) > parentLevel) {
|
||||
count++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Do not allow dropping inside its own subtree (self or descendants)
|
||||
if (index >= draggedIndex && index < draggedIndex + count) {
|
||||
setDragOverIndex(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setDragOverIndex(index);
|
||||
},
|
||||
[blocks, draggedIndex]
|
||||
);
|
||||
|
||||
const handleDropBlock = useCallback(
|
||||
(targetIndex: number) => {
|
||||
@@ -286,6 +618,20 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
||||
setDragOverIndex(null);
|
||||
return;
|
||||
}
|
||||
const parentLevel = Math.max(0, blocks[draggedIndex]?.level || 0);
|
||||
let count = 1;
|
||||
for (let i = draggedIndex + 1; i < blocks.length; i++) {
|
||||
if (Math.max(0, blocks[i].level || 0) > parentLevel) {
|
||||
count++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (targetIndex >= draggedIndex && targetIndex < draggedIndex + count) {
|
||||
setDraggedIndex(null);
|
||||
setDragOverIndex(null);
|
||||
return;
|
||||
}
|
||||
const movingBlock = blocks[draggedIndex];
|
||||
if (movingBlock) {
|
||||
onMoveBlock(movingBlock.id, targetIndex, movingBlock.level);
|
||||
@@ -305,78 +651,81 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
||||
return () => window.removeEventListener('dragend', clearDrag);
|
||||
}, []);
|
||||
|
||||
const cursorsByBlock = useMemo(() => {
|
||||
const map: Record<string, RemoteCursor[]> = {};
|
||||
if (!remoteCursors) return map;
|
||||
for (const c of Object.values(remoteCursors)) {
|
||||
if (c.blockId) {
|
||||
if (!map[c.blockId]) map[c.blockId] = [];
|
||||
map[c.blockId].push(c);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [remoteCursors]);
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{visibleEntries.map(({ block, index, hasChildren, maxAllowedLevel }) => (
|
||||
<OutlinerBlock
|
||||
key={block.id}
|
||||
block={block}
|
||||
index={index}
|
||||
maxAllowedLevel={maxAllowedLevel}
|
||||
hasChildren={hasChildren}
|
||||
isFocused={focusedBlockId === block.id}
|
||||
canEdit={canEdit}
|
||||
levelOffset={levelOffset}
|
||||
onZoomBlock={onZoomBlock}
|
||||
activeRemoteCursors={cursorsByBlock[block.id] || []}
|
||||
sendCursorMoved={sendCursorMoved}
|
||||
onFocusBlock={setFocusedBlockId}
|
||||
onUpdateBlock={onUpdateBlock}
|
||||
onCreateBlockBelow={handleCreateBlockBelow}
|
||||
onDeleteBlock={handleDeleteBlock}
|
||||
onIndentBlock={handleIndentBlock}
|
||||
onNavigateFocus={handleNavigateFocus}
|
||||
onTagClick={onTagClick}
|
||||
onWikiLinkClick={onWikiLinkClick}
|
||||
onDragStartBlock={handleDragStartBlock}
|
||||
onDragOverBlock={handleDragOverBlock}
|
||||
onDropBlock={handleDropBlock}
|
||||
isDragTarget={dragOverIndex === index && draggedIndex !== index}
|
||||
isTargeted={highlightedBlockId === block.id}
|
||||
registerTextareaRef={registerTextareaRef}
|
||||
/>
|
||||
))}
|
||||
{visibleEntries.map(({ block, index, hasChildren, maxAllowedLevel }, entryIdx) => {
|
||||
const isLast = entryIdx === visibleEntries.length - 1;
|
||||
return (
|
||||
<OutlinerBlock
|
||||
key={block.id}
|
||||
docId={docId}
|
||||
block={block}
|
||||
index={index}
|
||||
isLast={isLast}
|
||||
maxAllowedLevel={maxAllowedLevel}
|
||||
hasChildren={hasChildren}
|
||||
isFocused={focusedBlockId === block.id}
|
||||
canEdit={canEdit}
|
||||
levelOffset={levelOffset}
|
||||
onZoomBlock={onZoomBlock}
|
||||
onUpdateBlock={onUpdateBlock}
|
||||
onCreateBlockBelow={handleCreateBlockBelow}
|
||||
onPasteMultiLines={handlePasteMultiLines}
|
||||
onDeleteBlock={handleDeleteBlock}
|
||||
onIndentBlock={handleIndentBlock}
|
||||
onNavigateFocus={handleNavigateFocus}
|
||||
onFocusBlock={setFocusedBlockId}
|
||||
onOpenTask={onOpenTask}
|
||||
onTagClick={onTagClick}
|
||||
onWikiLinkClick={onWikiLinkClick}
|
||||
onDragStartBlock={handleDragStartBlock}
|
||||
onDragOverBlock={handleDragOverBlock}
|
||||
onDropBlock={handleDropBlock}
|
||||
isDragTarget={dragOverIndex === index && draggedIndex !== index}
|
||||
isTargeted={highlightedBlockId === block.id}
|
||||
registerTextareaRef={registerTextareaRef}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{canEdit && (
|
||||
<div className="pt-2 pl-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
<div
|
||||
className={`min-h-[14rem] pt-2 pl-2 transition-colors cursor-text ${
|
||||
dragOverIndex === blocks.length ? 'border-t-2 border-accent bg-accent/10 rounded' : ''
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
if (e.target !== e.currentTarget && (e.target as HTMLElement).closest('button, a, input, textarea, select')) {
|
||||
return;
|
||||
}
|
||||
if (blocks.length === 0) {
|
||||
const firstBlock: DocumentBlock = {
|
||||
id: `blk_${Date.now().toString(36)}_0`,
|
||||
documentId: docId,
|
||||
type: 'text',
|
||||
content: '',
|
||||
level: levelOffset || 0,
|
||||
position: 1000,
|
||||
};
|
||||
pendingFocusRef.current = { blockId: firstBlock.id, cursor: 'start' };
|
||||
onCreateBlock(firstBlock);
|
||||
} else {
|
||||
const lastBlock = blocks[blocks.length - 1];
|
||||
if (lastBlock) {
|
||||
handleCreateBlockBelow(lastBlock.id, lastBlock.content);
|
||||
if (lastBlock.content && lastBlock.content.trim() !== '') {
|
||||
handleCreateBlockBelow(lastBlock.id, '');
|
||||
} else {
|
||||
const firstBlock: DocumentBlock = {
|
||||
id: `blk_${Date.now().toString(36)}_0`,
|
||||
documentId: docId,
|
||||
type: 'text',
|
||||
content: '',
|
||||
level: levelOffset || 0,
|
||||
position: 1000,
|
||||
};
|
||||
pendingFocusRef.current = { blockId: firstBlock.id, cursor: 'start' };
|
||||
onCreateBlock(firstBlock);
|
||||
focusBlockInput(lastBlock.id, 'end');
|
||||
}
|
||||
}}
|
||||
className="btn-action inline-flex items-center gap-1.5 px-2.5 py-1 text-xs text-muted hover:text-main"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Neuer Punkt</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
if (draggedIndex !== null) {
|
||||
setDragOverIndex(blocks.length);
|
||||
}
|
||||
}}
|
||||
onDrop={() => handleDropBlock(blocks.length)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
+93
-34
@@ -5,7 +5,6 @@ import {
|
||||
Document,
|
||||
DocumentBlock,
|
||||
DocShare,
|
||||
DocLockInfo,
|
||||
KanbanColumn,
|
||||
ListItem,
|
||||
ListShare,
|
||||
@@ -37,6 +36,19 @@ export function getToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_STORAGE_KEY);
|
||||
}
|
||||
|
||||
export function getCurrentUserId(): string | null {
|
||||
const token = getToken();
|
||||
if (!token) return null;
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length >= 2) {
|
||||
const payload = JSON.parse(atob(parts[1]));
|
||||
return payload.userId || payload.sub || payload.id || null;
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function setToken(token: string): void {
|
||||
localStorage.setItem(TOKEN_STORAGE_KEY, token);
|
||||
}
|
||||
@@ -117,6 +129,18 @@ export const api = {
|
||||
return request<AuthStatus>('/api/v1/auth/status');
|
||||
},
|
||||
|
||||
async logout(): Promise<void> {
|
||||
try {
|
||||
await request<{ success: boolean }>('/api/v1/auth/logout', { method: 'POST' });
|
||||
} finally {
|
||||
clearToken();
|
||||
}
|
||||
},
|
||||
|
||||
async revokeAllSessions(): Promise<void> {
|
||||
await request<{ success: boolean }>('/api/v1/auth/revoke-all', { method: 'POST' });
|
||||
},
|
||||
|
||||
async completeSetup(payload: {
|
||||
email: string;
|
||||
password: string;
|
||||
@@ -285,6 +309,10 @@ export const api = {
|
||||
return ensureArray(res);
|
||||
},
|
||||
|
||||
async getThought(thoughtId: string): Promise<Thought> {
|
||||
return request<Thought>(`/api/v1/thoughts/${encodeURIComponent(thoughtId)}`);
|
||||
},
|
||||
|
||||
async triageAction(
|
||||
thoughtId: string,
|
||||
action: 'keep' | 'discard' | 'restore',
|
||||
@@ -625,6 +653,23 @@ export const api = {
|
||||
};
|
||||
},
|
||||
|
||||
async createEnhancedTask(
|
||||
docId: string,
|
||||
blockId: string,
|
||||
payload: { content: string; checked: boolean }
|
||||
): Promise<Thought> {
|
||||
return request<Thought>(`/api/v1/documents/${docId}/blocks/${blockId}/enhanced-task`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
|
||||
async unlinkEnhancedTask(taskId: string): Promise<{ unlinked: boolean }> {
|
||||
return request<{ unlinked: boolean }>(`/api/v1/tasks/${encodeURIComponent(taskId)}/unlink`, {
|
||||
method: 'POST',
|
||||
});
|
||||
},
|
||||
|
||||
// --- DOCUMENT SHARING ---
|
||||
async getDocShares(docId: string): Promise<DocShare[]> {
|
||||
const res = await request<DocShare[]>(`/api/v1/documents/${docId}/shares`);
|
||||
@@ -772,6 +817,17 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async updateListItem(
|
||||
listId: string,
|
||||
itemId: string,
|
||||
payload: { content?: string; category?: string; quantity?: string }
|
||||
): Promise<ListItem> {
|
||||
return request<ListItem>(`/api/v1/lists/${listId}/items/${itemId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
|
||||
async deleteListItem(listId: string, itemId: string): Promise<void> {
|
||||
await request(`/api/v1/lists/${listId}/items/${itemId}`, { method: 'DELETE' });
|
||||
},
|
||||
@@ -1077,7 +1133,7 @@ export function onRealtimeStatus(callback: (connected: boolean, transport: Realt
|
||||
};
|
||||
}
|
||||
|
||||
let isWsBlockedByProxy = false;
|
||||
let isWsBlockedByProxy = typeof window !== 'undefined' && sessionStorage.getItem('mindsift_ws_blocked') === 'true';
|
||||
let sseReconnectTimer: any = null;
|
||||
let sseReconnectDelay = 2000;
|
||||
const globalSubscribedRooms = new Set<string>();
|
||||
@@ -1095,6 +1151,25 @@ function notifyRealtimeStatusChange(connected: boolean, transport: RealtimeTrans
|
||||
window.dispatchEvent(new CustomEvent('mindsift:ws:status', { detail: { connected, transport } }));
|
||||
}
|
||||
|
||||
function switchToSSE(reason: string) {
|
||||
console.warn(`[MindSift Realtime] ${reason} -> Schalte sofort und dauerhaft auf Server-Sent Events (SSE) um.`);
|
||||
isWsBlockedByProxy = true;
|
||||
try {
|
||||
sessionStorage.setItem('mindsift_ws_blocked', 'true');
|
||||
} catch {}
|
||||
clearTimeout(wsConnectionWatchdog);
|
||||
clearTimeout(wsReconnectTimer);
|
||||
if (activeGlobalWS) {
|
||||
try {
|
||||
activeGlobalWS.onclose = null;
|
||||
activeGlobalWS.onerror = null;
|
||||
activeGlobalWS.close();
|
||||
} catch {}
|
||||
activeGlobalWS = null;
|
||||
}
|
||||
connectSSE();
|
||||
}
|
||||
|
||||
export function connectSSE(rooms?: string[]): void {
|
||||
if (rooms) {
|
||||
rooms.forEach((r) => globalSubscribedRooms.add(r));
|
||||
@@ -1114,7 +1189,6 @@ export function connectSSE(rooms?: string[]): void {
|
||||
activeGlobalSSE = es;
|
||||
|
||||
es.onopen = () => {
|
||||
console.info('[MindSift Realtime] Server-Sent Events (SSE) verbunden (Proxy-Fallback aktiv).');
|
||||
sseReconnectDelay = 2000;
|
||||
notifyRealtimeStatusChange(true, 'sse');
|
||||
if (activeSSEId && globalSubscribedRooms.size > 0) {
|
||||
@@ -1149,6 +1223,7 @@ export function connectSSE(rooms?: string[]): void {
|
||||
};
|
||||
|
||||
es.onerror = (e) => {
|
||||
console.warn('[MindSift Realtime] SSE Verbindungsfehler:', e);
|
||||
if (activeTransport === 'sse') {
|
||||
notifyRealtimeStatusChange(false, 'none');
|
||||
}
|
||||
@@ -1160,11 +1235,7 @@ export function connectSSE(rooms?: string[]): void {
|
||||
sseReconnectDelay = Math.min(30000, Math.round(sseReconnectDelay * 1.5));
|
||||
sseReconnectTimer = setTimeout(() => {
|
||||
if (!manualDisconnect && getToken()) {
|
||||
if (isWsBlockedByProxy) {
|
||||
connectSSE();
|
||||
} else {
|
||||
connectWebSocket();
|
||||
}
|
||||
connectSSE();
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
@@ -1199,16 +1270,12 @@ export function connectWebSocket(): void {
|
||||
const ws = new WebSocket(url);
|
||||
activeGlobalWS = ws;
|
||||
|
||||
// 3.5s watchdog for corporate proxies silently stripping Upgrade header
|
||||
// 2.5s watchdog for corporate proxies silently stripping Upgrade header or hanging
|
||||
wsConnectionWatchdog = setTimeout(() => {
|
||||
if (ws && ws.readyState !== WebSocket.OPEN) {
|
||||
console.warn('[MindSift Realtime] WebSocket connection timeout (Proxy Upgrade Block). Wechsle automatisch zu SSE...');
|
||||
isWsBlockedByProxy = true;
|
||||
try { ws.close(); } catch {}
|
||||
activeGlobalWS = null;
|
||||
connectSSE();
|
||||
switchToSSE('WebSocket Timeout nach 2.5s (Proxy Upgrade Header blockiert)');
|
||||
}
|
||||
}, 3500);
|
||||
}, 2500);
|
||||
|
||||
ws.onopen = () => {
|
||||
clearTimeout(wsConnectionWatchdog);
|
||||
@@ -1247,11 +1314,9 @@ export function connectWebSocket(): void {
|
||||
clearTimeout(wsConnectionWatchdog);
|
||||
activeGlobalWS = null;
|
||||
|
||||
const isProxyIssue = evt?.code === 1006 || evt?.code === 1002 || evt?.code === 1008;
|
||||
if (isProxyIssue && !manualDisconnect && getToken()) {
|
||||
console.warn('[MindSift Realtime] Proxy/Firewall Inkompatibilität erkannt (Code ' + evt.code + '). Wechsle dauerhaft zu SSE...');
|
||||
isWsBlockedByProxy = true;
|
||||
connectSSE();
|
||||
// In corporate environments, blocked WebSockets close abruptly without clean handshake
|
||||
if (!evt.wasClean || evt.code === 1006 || evt.code === 1002 || evt.code === 1008 || evt.code === 1005) {
|
||||
switchToSSE(`WebSocket unsauber beendet (Code ${evt.code}, wasClean=${evt.wasClean})`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1273,14 +1338,10 @@ export function connectWebSocket(): void {
|
||||
|
||||
ws.onerror = (e) => {
|
||||
clearTimeout(wsConnectionWatchdog);
|
||||
try {
|
||||
ws.close();
|
||||
} catch {}
|
||||
switchToSSE('WebSocket Fehler (Handshake/Verbindung verweigert)');
|
||||
};
|
||||
} catch (e) {
|
||||
clearTimeout(wsConnectionWatchdog);
|
||||
isWsBlockedByProxy = true;
|
||||
connectSSE();
|
||||
switchToSSE('WebSocket Exception beim Initialisieren');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1394,10 +1455,9 @@ export function createRealtimeConnection(options: {
|
||||
// Join rooms on the active connection
|
||||
const joinAll = () => {
|
||||
joinedRooms.forEach((r) => {
|
||||
if (r.startsWith('doc:')) {
|
||||
sendWebSocketMessage({ event: 'CRDT_JOIN', docId: r.slice(4) });
|
||||
} else {
|
||||
sendWebSocketMessage({ event: 'JOIN_ROOM', room: r });
|
||||
sendWebSocketMessage({ event: 'JOIN_ROOM', room: r });
|
||||
if (activeSSEId) {
|
||||
api.joinSSERoom(activeSSEId, r, 'join').catch(() => {});
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1427,10 +1487,9 @@ export function createRealtimeConnection(options: {
|
||||
if (unsubMsg) unsubMsg();
|
||||
joinedRooms.forEach((r) => {
|
||||
globalSubscribedRooms.delete(r);
|
||||
if (r.startsWith('doc:')) {
|
||||
sendWebSocketMessage({ event: 'CRDT_LEAVE', docId: r.slice(4) });
|
||||
} else {
|
||||
sendWebSocketMessage({ event: 'LEAVE_ROOM', room: r });
|
||||
sendWebSocketMessage({ event: 'LEAVE_ROOM', room: r });
|
||||
if (activeSSEId) {
|
||||
api.joinSSERoom(activeSSEId, r, 'leave').catch(() => {});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* Parses inline markdown:
|
||||
* - **bold** or __bold__
|
||||
* - *italic* or _italic_
|
||||
* - ~~strikethrough~~
|
||||
* - `code`
|
||||
* - [label](url)
|
||||
*/
|
||||
function renderInlineTokens(text: string): React.ReactNode[] {
|
||||
if (!text) return [];
|
||||
|
||||
const regex = /(`[^`]+`|\*\*[^*]+\*\*|__[^_]+__|~~[^~]+~~|\*[^*]+\*|_[^_]+_|\[([^\]]+)\]\(([^)]+)\))/g;
|
||||
|
||||
const elements: React.ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
elements.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
const token = match[0];
|
||||
const key = `${match.index}-${token}`;
|
||||
|
||||
if (token.startsWith('`') && token.endsWith('`')) {
|
||||
elements.push(
|
||||
<code key={key} className="px-1.5 py-0.5 rounded bg-panel-elevated text-accent font-mono text-[0.9em] border border-border/50">
|
||||
{token.slice(1, -1)}
|
||||
</code>
|
||||
);
|
||||
} else if (
|
||||
(token.startsWith('**') && token.endsWith('**')) ||
|
||||
(token.startsWith('__') && token.endsWith('__'))
|
||||
) {
|
||||
elements.push(
|
||||
<strong key={key} className="font-semibold text-main">
|
||||
{token.slice(2, -2)}
|
||||
</strong>
|
||||
);
|
||||
} else if (
|
||||
(token.startsWith('*') && token.endsWith('*')) ||
|
||||
(token.startsWith('_') && token.endsWith('_'))
|
||||
) {
|
||||
elements.push(
|
||||
<em key={key} className="italic text-main/90">
|
||||
{token.slice(1, -1)}
|
||||
</em>
|
||||
);
|
||||
} else if (token.startsWith('~~') && token.endsWith('~~')) {
|
||||
elements.push(
|
||||
<del key={key} className="line-through text-muted/80">
|
||||
{token.slice(2, -2)}
|
||||
</del>
|
||||
);
|
||||
} else if (token.startsWith('[') && match[2] && match[3]) {
|
||||
const label = match[2];
|
||||
const url = match[3];
|
||||
elements.push(
|
||||
<a
|
||||
key={key}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent underline hover:opacity-80 transition-opacity"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
} else {
|
||||
elements.push(token);
|
||||
}
|
||||
|
||||
lastIndex = regex.lastIndex;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
elements.push(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses inline markdown and fenced code blocks:
|
||||
* - ```lang\ncode\n```
|
||||
* - **bold** or __bold__
|
||||
* - *italic* or _italic_
|
||||
* - ~~strikethrough~~
|
||||
* - `code`
|
||||
* - [label](url)
|
||||
*/
|
||||
export function renderInlineMarkdown(text: string): React.ReactNode[] {
|
||||
if (!text) return [];
|
||||
|
||||
if (text.includes('```')) {
|
||||
const codeBlockRegex = /(```[a-zA-Z0-9_-]*\r?\n[\s\S]*?```|```[\s\S]*?```)/g;
|
||||
const parts = text.split(codeBlockRegex);
|
||||
const nodes: React.ReactNode[] = [];
|
||||
|
||||
parts.forEach((part, partIdx) => {
|
||||
if (part.startsWith('```') && part.endsWith('```')) {
|
||||
const lines = part.split(/\r?\n/);
|
||||
const firstLine = lines[0].slice(3).trim();
|
||||
const codeLines = lines.slice(1, lines.length - 1);
|
||||
const code = codeLines.join('\n');
|
||||
nodes.push(
|
||||
<div
|
||||
key={`fenced-${partIdx}`}
|
||||
className="p-2.5 my-2 rounded bg-code text-accent font-mono text-xs overflow-x-auto border border-border/60 select-text"
|
||||
>
|
||||
{firstLine && (
|
||||
<div className="text-[10px] text-faint uppercase font-sans pb-1 select-none">
|
||||
{firstLine}
|
||||
</div>
|
||||
)}
|
||||
<pre className="whitespace-pre overflow-x-auto font-mono text-status-ok leading-relaxed">
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
} else if (part) {
|
||||
nodes.push(...renderInlineTokens(part));
|
||||
}
|
||||
});
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
return renderInlineTokens(text);
|
||||
}
|
||||
|
||||
export function MarkdownViewer({ content, className = '' }: { content: string; className?: string }) {
|
||||
if (!content) return null;
|
||||
|
||||
const lines = content.split('\n');
|
||||
const renderedNodes: React.ReactNode[] = [];
|
||||
let inCodeBlock = false;
|
||||
let codeBlockLines: string[] = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
if (line.trim().startsWith('```')) {
|
||||
if (inCodeBlock) {
|
||||
renderedNodes.push(
|
||||
<pre key={`code-${i}`} className="p-2.5 my-2 rounded bg-panel-elevated text-accent font-mono text-xs overflow-x-auto border border-border/60">
|
||||
<code>{codeBlockLines.join('\n')}</code>
|
||||
</pre>
|
||||
);
|
||||
codeBlockLines = [];
|
||||
inCodeBlock = false;
|
||||
} else {
|
||||
inCodeBlock = true;
|
||||
codeBlockLines = [];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inCodeBlock) {
|
||||
codeBlockLines.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (trimmed.startsWith('### ')) {
|
||||
renderedNodes.push(
|
||||
<h3 key={i} className="text-sm font-bold text-main mt-2 mb-1">
|
||||
{renderInlineMarkdown(trimmed.slice(4))}
|
||||
</h3>
|
||||
);
|
||||
} else if (trimmed.startsWith('## ')) {
|
||||
renderedNodes.push(
|
||||
<h2 key={i} className="text-base font-bold text-main mt-3 mb-1">
|
||||
{renderInlineMarkdown(trimmed.slice(3))}
|
||||
</h2>
|
||||
);
|
||||
} else if (trimmed.startsWith('# ')) {
|
||||
renderedNodes.push(
|
||||
<h1 key={i} className="text-lg font-bold text-main mt-3 mb-1.5">
|
||||
{renderInlineMarkdown(trimmed.slice(2))}
|
||||
</h1>
|
||||
);
|
||||
} else if (trimmed.startsWith('> ')) {
|
||||
renderedNodes.push(
|
||||
<blockquote key={i} className="border-l-2 border-accent pl-2.5 my-1 text-muted italic">
|
||||
{renderInlineMarkdown(trimmed.slice(2))}
|
||||
</blockquote>
|
||||
);
|
||||
} else if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) {
|
||||
renderedNodes.push(
|
||||
<div key={i} className="flex items-start gap-1.5 my-0.5">
|
||||
<span className="text-accent mt-0.5">•</span>
|
||||
<span className="flex-1 text-main">{renderInlineMarkdown(trimmed.slice(2))}</span>
|
||||
</div>
|
||||
);
|
||||
} else if (/^\d+\.\s/.test(trimmed)) {
|
||||
const matchNum = trimmed.match(/^(\d+)\.\s(.*)$/);
|
||||
renderedNodes.push(
|
||||
<div key={i} className="flex items-start gap-1.5 my-0.5">
|
||||
<span className="text-muted text-[11px] font-mono mt-0.5">{matchNum ? matchNum[1] + '.' : '•'}</span>
|
||||
<span className="flex-1 text-main">{renderInlineMarkdown(matchNum ? matchNum[2] : trimmed)}</span>
|
||||
</div>
|
||||
);
|
||||
} else if (trimmed === '') {
|
||||
renderedNodes.push(<div key={i} className="h-2" />);
|
||||
} else {
|
||||
renderedNodes.push(
|
||||
<div key={i} className="my-0.5 text-main leading-relaxed">
|
||||
{renderInlineMarkdown(line)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (inCodeBlock && codeBlockLines.length > 0) {
|
||||
renderedNodes.push(
|
||||
<pre key="code-end" className="p-2.5 my-2 rounded bg-panel-elevated text-accent font-mono text-xs overflow-x-auto border border-border/60">
|
||||
<code>{codeBlockLines.join('\n')}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={`markdown-body text-xs ${className}`}>{renderedNodes}</div>;
|
||||
}
|
||||
@@ -53,8 +53,10 @@ export interface Thought {
|
||||
source: 'manual' | 'voice' | 'webhook';
|
||||
status: ThoughtStatus;
|
||||
isCompleted: boolean;
|
||||
convertedType?: 'task' | 'document' | 'list_item';
|
||||
convertedType?: 'task' | 'document' | 'list_item' | 'document_block';
|
||||
convertedRefId?: string;
|
||||
docId?: string;
|
||||
blockId?: string;
|
||||
dueDate?: string;
|
||||
taskListId?: string;
|
||||
kanbanStatus?: string;
|
||||
@@ -159,6 +161,7 @@ export interface DocumentBlock {
|
||||
id: string;
|
||||
docId?: string;
|
||||
documentId?: string;
|
||||
taskId?: string;
|
||||
type: BlockType;
|
||||
content: string;
|
||||
checked?: boolean;
|
||||
@@ -200,13 +203,6 @@ export interface DocShare {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface DocLockInfo {
|
||||
docId: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface Document {
|
||||
id: string;
|
||||
userId: string;
|
||||
|
||||
@@ -1,553 +0,0 @@
|
||||
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
||||
import { DocumentBlock } from './types';
|
||||
import { getToken, api } from './api';
|
||||
|
||||
export type DocSyncStatus = 'connecting' | 'connected' | 'reconnecting' | 'offline';
|
||||
|
||||
export interface RemoteCursor {
|
||||
clientId: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
userColor: string;
|
||||
blockId: string | null;
|
||||
selectionStart?: number;
|
||||
selectionEnd?: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export const CURSOR_COLORS = [
|
||||
'#3b82f6', // blue
|
||||
'#10b981', // emerald
|
||||
'#f59e0b', // amber
|
||||
'#ec4899', // pink
|
||||
'#8b5cf6', // purple
|
||||
'#06b6d4', // cyan
|
||||
'#f97316', // orange
|
||||
'#14b8a6', // teal
|
||||
'#6366f1', // indigo
|
||||
];
|
||||
|
||||
export function getUserColor(idOrName: string): string {
|
||||
if (!idOrName) return CURSOR_COLORS[0];
|
||||
let hash = 0;
|
||||
for (let i = 0; i < idOrName.length; i++) {
|
||||
hash = (hash << 5) - hash + idOrName.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
return CURSOR_COLORS[Math.abs(hash) % CURSOR_COLORS.length];
|
||||
}
|
||||
|
||||
export interface DocSyncEvent {
|
||||
type:
|
||||
| 'doc_state'
|
||||
| 'block_created'
|
||||
| 'block_updated'
|
||||
| 'block_deleted'
|
||||
| 'block_moved'
|
||||
| 'title_updated'
|
||||
| 'blocks_replaced'
|
||||
| 'cursor_moved'
|
||||
| 'client_left';
|
||||
docId?: string;
|
||||
clientId?: string;
|
||||
userId?: string;
|
||||
userName?: string;
|
||||
userColor?: string;
|
||||
selectionStart?: number;
|
||||
selectionEnd?: number;
|
||||
activeCursors?: RemoteCursor[];
|
||||
title?: string;
|
||||
block?: DocumentBlock;
|
||||
blockId?: string;
|
||||
afterBlockId?: string;
|
||||
newIndex?: number;
|
||||
content?: string;
|
||||
level?: number;
|
||||
checked?: boolean;
|
||||
collapsed?: boolean;
|
||||
blockType?: string;
|
||||
blocks?: DocumentBlock[];
|
||||
}
|
||||
|
||||
export function useDocumentSync(
|
||||
docId: string | null,
|
||||
onRemoteEvent: (evt: DocSyncEvent) => void,
|
||||
canEdit: boolean = true,
|
||||
currentUser?: { id: string; email?: string; displayName?: string } | null
|
||||
) {
|
||||
const [status, setStatus] = useState<DocSyncStatus>('connecting');
|
||||
const [remoteCursors, setRemoteCursors] = useState<Record<string, RemoteCursor>>({});
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const clientIdRef = useRef<string>(
|
||||
`cli_${Math.random().toString(36).slice(2, 10)}_${Date.now().toString(36)}`
|
||||
);
|
||||
const onRemoteEventRef = useRef(onRemoteEvent);
|
||||
onRemoteEventRef.current = onRemoteEvent;
|
||||
|
||||
const docIdRef = useRef(docId);
|
||||
docIdRef.current = docId;
|
||||
const canEditRef = useRef(canEdit);
|
||||
canEditRef.current = canEdit;
|
||||
const currentUserRef = useRef(currentUser);
|
||||
currentUserRef.current = currentUser;
|
||||
|
||||
const reconnectTimerRef = useRef<number | null>(null);
|
||||
const fallbackTimerRef = useRef<number | null>(null);
|
||||
|
||||
const localUserName =
|
||||
currentUser?.displayName || currentUser?.email?.split('@')[0] || 'Anonym';
|
||||
const localUserColor = useMemo(
|
||||
() => getUserColor(currentUser?.id || clientIdRef.current),
|
||||
[currentUser?.id]
|
||||
);
|
||||
const localUserNameRef = useRef(localUserName);
|
||||
localUserNameRef.current = localUserName;
|
||||
const localUserColorRef = useRef(localUserColor);
|
||||
localUserColorRef.current = localUserColor;
|
||||
|
||||
const pendingMessagesQueueRef = useRef<string[]>([]);
|
||||
|
||||
const lastCursorSendRef = useRef<number>(0);
|
||||
const pendingCursorTimerRef = useRef<number | null>(null);
|
||||
const pendingCursorPayloadRef = useRef<{
|
||||
blockId: string | null;
|
||||
selectionStart?: number;
|
||||
selectionEnd?: number;
|
||||
} | null>(null);
|
||||
|
||||
// Clean up stale remote cursors (> 30s)
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
setRemoteCursors((prev) => {
|
||||
let hasStale = false;
|
||||
const next: Record<string, RemoteCursor> = {};
|
||||
for (const [key, cursor] of Object.entries(prev)) {
|
||||
if (now - cursor.updatedAt < 30000) {
|
||||
next[key] = cursor;
|
||||
} else {
|
||||
hasStale = true;
|
||||
}
|
||||
}
|
||||
return hasStale ? next : prev;
|
||||
});
|
||||
}, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!docId) {
|
||||
setStatus('offline');
|
||||
setRemoteCursors({});
|
||||
pendingMessagesQueueRef.current = [];
|
||||
return;
|
||||
}
|
||||
|
||||
pendingMessagesQueueRef.current = [];
|
||||
let isDisposed = false;
|
||||
let retryCount = 0;
|
||||
const currentClientId = `cli_${Math.random().toString(36).slice(2, 10)}_${Date.now().toString(36)}`;
|
||||
clientIdRef.current = currentClientId;
|
||||
|
||||
const connect = () => {
|
||||
if (isDisposed) return;
|
||||
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
setStatus('offline');
|
||||
return;
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/v1/ws/docs/${encodeURIComponent(
|
||||
docId
|
||||
)}?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(
|
||||
currentClientId
|
||||
)}&userName=${encodeURIComponent(localUserName)}&userColor=${encodeURIComponent(
|
||||
localUserColor
|
||||
)}`;
|
||||
|
||||
setStatus(retryCount === 0 ? 'connecting' : 'reconnecting');
|
||||
|
||||
let ws: WebSocket;
|
||||
try {
|
||||
ws = new WebSocket(wsUrl);
|
||||
} catch {
|
||||
setStatus('offline');
|
||||
return;
|
||||
}
|
||||
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
if (isDisposed) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
retryCount = 0;
|
||||
setStatus('connected');
|
||||
|
||||
// Flush any queued messages buffered while connecting or reconnecting
|
||||
if (pendingMessagesQueueRef.current.length > 0) {
|
||||
const queue = [...pendingMessagesQueueRef.current];
|
||||
pendingMessagesQueueRef.current = [];
|
||||
for (const msg of queue) {
|
||||
try {
|
||||
ws.send(msg);
|
||||
} catch (err) {
|
||||
console.warn('[DocSync] Failed to flush queued message:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (isDisposed) return;
|
||||
try {
|
||||
const parsed: DocSyncEvent = JSON.parse(event.data);
|
||||
if (!parsed || !parsed.type) return;
|
||||
|
||||
// Echo protection: ignore events originating from our own clientId (except initial doc_state)
|
||||
if (parsed.type !== 'doc_state' && parsed.clientId && parsed.clientId === currentClientId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.type === 'doc_state') {
|
||||
if (Array.isArray(parsed.activeCursors)) {
|
||||
const initialCursors: Record<string, RemoteCursor> = {};
|
||||
for (const c of parsed.activeCursors) {
|
||||
if (c.clientId && c.clientId !== currentClientId) {
|
||||
initialCursors[c.clientId] = {
|
||||
...c,
|
||||
updatedAt: c.updatedAt || Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
setRemoteCursors(initialCursors);
|
||||
}
|
||||
} else if (parsed.type === 'cursor_moved') {
|
||||
if (parsed.clientId && parsed.clientId !== currentClientId) {
|
||||
setRemoteCursors((prev) => ({
|
||||
...prev,
|
||||
[parsed.clientId!]: {
|
||||
clientId: parsed.clientId!,
|
||||
userId: parsed.userId || '',
|
||||
userName: parsed.userName || 'Anonym',
|
||||
userColor: parsed.userColor || '#3b82f6',
|
||||
blockId:
|
||||
parsed.blockId !== undefined
|
||||
? parsed.blockId
|
||||
: prev[parsed.clientId!]?.blockId ?? null,
|
||||
selectionStart: parsed.selectionStart,
|
||||
selectionEnd: parsed.selectionEnd,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
}));
|
||||
}
|
||||
} else if (parsed.type === 'client_left') {
|
||||
if (parsed.clientId) {
|
||||
setRemoteCursors((prev) => {
|
||||
if (!prev[parsed.clientId!]) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[parsed.clientId!];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onRemoteEventRef.current(parsed);
|
||||
} catch {
|
||||
// Ignore malformed frames
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
// Handled in onclose
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (isDisposed) return;
|
||||
wsRef.current = null;
|
||||
retryCount += 1;
|
||||
setStatus(retryCount > 2 ? 'offline' : 'reconnecting');
|
||||
const delay = Math.min(1000 * Math.pow(1.6, retryCount), 8000);
|
||||
reconnectTimerRef.current = window.setTimeout(connect, delay);
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
isDisposed = true;
|
||||
if (reconnectTimerRef.current !== null) {
|
||||
window.clearTimeout(reconnectTimerRef.current);
|
||||
reconnectTimerRef.current = null;
|
||||
}
|
||||
if (fallbackTimerRef.current !== null) {
|
||||
window.clearTimeout(fallbackTimerRef.current);
|
||||
fallbackTimerRef.current = null;
|
||||
}
|
||||
if (pendingCursorTimerRef.current !== null) {
|
||||
window.clearTimeout(pendingCursorTimerRef.current);
|
||||
pendingCursorTimerRef.current = null;
|
||||
}
|
||||
if (wsRef.current) {
|
||||
try {
|
||||
wsRef.current.onopen = null;
|
||||
wsRef.current.onmessage = null;
|
||||
wsRef.current.onerror = null;
|
||||
wsRef.current.onclose = null;
|
||||
wsRef.current.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
wsRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [docId, localUserName, localUserColor]);
|
||||
|
||||
const executeSendCursor = useCallback(
|
||||
(blockId: string | null, selectionStart?: number, selectionEnd?: number) => {
|
||||
const ws = wsRef.current;
|
||||
const activeDocId = docIdRef.current;
|
||||
if (!activeDocId || !ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
const payload: DocSyncEvent = {
|
||||
type: 'cursor_moved',
|
||||
docId: activeDocId,
|
||||
clientId: clientIdRef.current,
|
||||
userId: currentUserRef.current?.id,
|
||||
userName: localUserNameRef.current,
|
||||
userColor: localUserColorRef.current,
|
||||
blockId: blockId || undefined,
|
||||
selectionStart,
|
||||
selectionEnd,
|
||||
};
|
||||
try {
|
||||
ws.send(JSON.stringify(payload));
|
||||
} catch {
|
||||
// Ephemeral cursor, ignore send failure
|
||||
}
|
||||
lastCursorSendRef.current = Date.now();
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const sendCursorMoved = useCallback(
|
||||
(blockId: string | null, selectionStart?: number, selectionEnd?: number) => {
|
||||
if (!docIdRef.current) return;
|
||||
const now = Date.now();
|
||||
const elapsed = now - lastCursorSendRef.current;
|
||||
|
||||
// When leaving focus / blur (blockId === null), send immediately
|
||||
if (blockId === null) {
|
||||
if (pendingCursorTimerRef.current !== null) {
|
||||
window.clearTimeout(pendingCursorTimerRef.current);
|
||||
pendingCursorTimerRef.current = null;
|
||||
}
|
||||
pendingCursorPayloadRef.current = null;
|
||||
executeSendCursor(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Max once per 100ms
|
||||
if (elapsed >= 100) {
|
||||
if (pendingCursorTimerRef.current !== null) {
|
||||
window.clearTimeout(pendingCursorTimerRef.current);
|
||||
pendingCursorTimerRef.current = null;
|
||||
}
|
||||
executeSendCursor(blockId, selectionStart, selectionEnd);
|
||||
} else {
|
||||
pendingCursorPayloadRef.current = { blockId, selectionStart, selectionEnd };
|
||||
if (pendingCursorTimerRef.current === null) {
|
||||
pendingCursorTimerRef.current = window.setTimeout(() => {
|
||||
pendingCursorTimerRef.current = null;
|
||||
if (pendingCursorPayloadRef.current) {
|
||||
executeSendCursor(
|
||||
pendingCursorPayloadRef.current.blockId,
|
||||
pendingCursorPayloadRef.current.selectionStart,
|
||||
pendingCursorPayloadRef.current.selectionEnd
|
||||
);
|
||||
pendingCursorPayloadRef.current = null;
|
||||
}
|
||||
}, 100 - elapsed);
|
||||
}
|
||||
}
|
||||
},
|
||||
[executeSendCursor]
|
||||
);
|
||||
|
||||
const sendRawEvent = useCallback(
|
||||
(evt: DocSyncEvent, fallbackFn?: () => void) => {
|
||||
const activeDocId = docIdRef.current;
|
||||
const activeCanEdit = canEditRef.current;
|
||||
if (!activeDocId || !activeCanEdit) return;
|
||||
|
||||
const ws = wsRef.current;
|
||||
const payload: DocSyncEvent = {
|
||||
...evt,
|
||||
docId: activeDocId,
|
||||
clientId: clientIdRef.current,
|
||||
};
|
||||
const jsonStr = JSON.stringify(payload);
|
||||
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
ws.send(jsonStr);
|
||||
} catch (err) {
|
||||
console.warn('[DocSync] Failed to send WebSocket message, buffering:', err);
|
||||
pendingMessagesQueueRef.current.push(jsonStr);
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
`[DocSync] WebSocket not OPEN (state=${ws ? ws.readyState : 'null'}). Buffering event:`,
|
||||
evt.type
|
||||
);
|
||||
pendingMessagesQueueRef.current.push(jsonStr);
|
||||
|
||||
if (fallbackFn) {
|
||||
if (fallbackTimerRef.current !== null) {
|
||||
window.clearTimeout(fallbackTimerRef.current);
|
||||
}
|
||||
fallbackTimerRef.current = window.setTimeout(() => {
|
||||
fallbackFn();
|
||||
}, 600);
|
||||
}
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const sendBlockCreated = useCallback(
|
||||
(block: DocumentBlock, afterBlockId?: string, allBlocks?: DocumentBlock[]) => {
|
||||
sendRawEvent(
|
||||
{
|
||||
type: 'block_created',
|
||||
block,
|
||||
afterBlockId,
|
||||
},
|
||||
() => {
|
||||
if (docId && allBlocks) {
|
||||
api
|
||||
.patchDocumentBlocks(docId, {
|
||||
upserted: [block],
|
||||
blockOrder: allBlocks.map((b) => b.id),
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
[docId, sendRawEvent]
|
||||
);
|
||||
|
||||
const sendBlockUpdated = useCallback(
|
||||
(block: DocumentBlock) => {
|
||||
sendRawEvent(
|
||||
{
|
||||
type: 'block_updated',
|
||||
blockId: block.id,
|
||||
block,
|
||||
},
|
||||
() => {
|
||||
if (docId) {
|
||||
api.patchDocumentBlocks(docId, { upserted: [block] }).catch(() => {});
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
[docId, sendRawEvent]
|
||||
);
|
||||
|
||||
const sendBlockDeleted = useCallback(
|
||||
(blockId: string, allBlocks?: DocumentBlock[]) => {
|
||||
sendRawEvent(
|
||||
{
|
||||
type: 'block_deleted',
|
||||
blockId,
|
||||
},
|
||||
() => {
|
||||
if (docId) {
|
||||
api
|
||||
.patchDocumentBlocks(docId, {
|
||||
deletedIds: [blockId],
|
||||
blockOrder: allBlocks ? allBlocks.map((b) => b.id) : undefined,
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
[docId, sendRawEvent]
|
||||
);
|
||||
|
||||
const sendBlockMoved = useCallback(
|
||||
(blockId: string, newIndex: number, level?: number, allBlocks?: DocumentBlock[]) => {
|
||||
sendRawEvent(
|
||||
{
|
||||
type: 'block_moved',
|
||||
blockId,
|
||||
newIndex,
|
||||
level,
|
||||
},
|
||||
() => {
|
||||
if (docId && allBlocks) {
|
||||
api
|
||||
.patchDocumentBlocks(docId, {
|
||||
upserted: allBlocks,
|
||||
blockOrder: allBlocks.map((b) => b.id),
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
[docId, sendRawEvent]
|
||||
);
|
||||
|
||||
const sendTitleUpdated = useCallback(
|
||||
(title: string) => {
|
||||
sendRawEvent(
|
||||
{
|
||||
type: 'title_updated',
|
||||
title,
|
||||
},
|
||||
() => {
|
||||
if (docId) {
|
||||
api.updateDocument(docId, { title }).catch(() => {});
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
[docId, sendRawEvent]
|
||||
);
|
||||
|
||||
const sendBlocksReplaced = useCallback(
|
||||
(blocks: DocumentBlock[]) => {
|
||||
sendRawEvent(
|
||||
{
|
||||
type: 'blocks_replaced',
|
||||
blocks,
|
||||
},
|
||||
() => {
|
||||
if (docId) {
|
||||
api.updateDocument(docId, { blocks }).catch(() => {});
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
[docId, sendRawEvent]
|
||||
);
|
||||
|
||||
return {
|
||||
status,
|
||||
clientId: clientIdRef.current,
|
||||
remoteCursors,
|
||||
sendCursorMoved,
|
||||
sendBlockCreated,
|
||||
sendBlockUpdated,
|
||||
sendBlockDeleted,
|
||||
sendBlockMoved,
|
||||
sendTitleUpdated,
|
||||
sendBlocksReplaced,
|
||||
};
|
||||
}
|
||||
+149
-100
@@ -31,9 +31,6 @@ type Server struct {
|
||||
}
|
||||
|
||||
func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS fs.FS) *Server {
|
||||
if wsHub != nil && store != nil {
|
||||
wsHub.SetDocStore(store)
|
||||
}
|
||||
srv := &Server{
|
||||
cfg: cfg,
|
||||
store: store,
|
||||
@@ -70,7 +67,6 @@ func (s *Server) Router() http.Handler {
|
||||
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)
|
||||
r.Get("/api/v1/ws/docs/{id}", s.handleDocWebSocket)
|
||||
r.Get("/api/v1/events", s.handleSSE)
|
||||
r.Post("/api/v1/events/rooms", s.handleSSERooms)
|
||||
|
||||
@@ -79,6 +75,8 @@ func (s *Server) Router() http.Handler {
|
||||
r.Use(s.authMiddleware)
|
||||
|
||||
r.Get("/api/v1/auth/me", s.handleMe)
|
||||
r.Post("/api/v1/auth/logout", s.handleLogout)
|
||||
r.Post("/api/v1/auth/revoke-all", s.handleRevokeAllSessions)
|
||||
r.Patch("/api/v1/auth/settings", s.handleUpdateUserSettings)
|
||||
r.Post("/api/v1/auth/alarms/test", s.handleTestUserAlarm)
|
||||
r.Get("/api/v1/users/directory", s.handleGetUsersDirectory)
|
||||
@@ -90,6 +88,7 @@ func (s *Server) Router() http.Handler {
|
||||
// Instant Capture & Thoughts
|
||||
r.Post("/api/v1/inbox", s.handleCaptureInbox)
|
||||
r.Get("/api/v1/thoughts", s.handleGetThoughts)
|
||||
r.Get("/api/v1/thoughts/{id}", s.handleGetThought)
|
||||
r.Patch("/api/v1/thoughts/{id}", s.handleUpdateThought)
|
||||
r.Delete("/api/v1/thoughts/{id}", s.handleDeleteThought)
|
||||
r.Delete("/api/v1/thoughts/discarded/purge", s.handlePurgeDiscarded)
|
||||
@@ -98,6 +97,7 @@ 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/{id}/unlink", s.handleUnlinkEnhancedTask)
|
||||
r.Post("/api/v1/tasks/reorder", s.handleReorderTasks)
|
||||
r.Post("/api/v1/task-lists/{id}/tasks/reorder", s.handleReorderTasks)
|
||||
|
||||
@@ -137,6 +137,7 @@ func (s *Server) Router() http.Handler {
|
||||
r.Patch("/api/v1/documents/{id}/blocks", s.handlePatchDocumentBlocks)
|
||||
r.Put("/api/v1/documents/{id}/move", s.handleMoveDocument)
|
||||
r.Delete("/api/v1/documents/{id}", s.handleDeleteDocument)
|
||||
r.Post("/api/v1/documents/{id}/blocks/{blockId}/enhanced-task", s.handleCreateEnhancedTask)
|
||||
|
||||
// Document Sharing & Collaboration
|
||||
r.Get("/api/v1/documents/{id}/shares", s.handleGetDocShares)
|
||||
@@ -153,6 +154,7 @@ func (s *Server) Router() http.Handler {
|
||||
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.Put("/api/v1/lists/{id}/items/{itemId}", s.handleUpdateListItem)
|
||||
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)
|
||||
@@ -304,10 +306,14 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
||||
}
|
||||
claims, err := auth.ValidateToken(s.cfg.JWTSecret, tokenStr)
|
||||
if err == nil && claims != nil {
|
||||
user, userErr := s.store.GetUserByID(claims.UserID)
|
||||
if userErr == nil && user != nil && user.IsActive {
|
||||
next.ServeHTTP(w, r.WithContext(auth.WithUserID(r.Context(), user.ID)))
|
||||
return
|
||||
if !s.store.IsTokenRevoked(tokenStr) {
|
||||
user, userErr := s.store.GetUserByID(claims.UserID)
|
||||
if userErr == nil && user != nil && user.IsActive {
|
||||
if claims.TokenVersion == 0 || claims.TokenVersion == user.TokenVersion {
|
||||
next.ServeHTTP(w, r.WithContext(auth.WithUserID(r.Context(), user.ID)))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -443,7 +449,7 @@ func (s *Server) handleFirstBootSetup(w http.ResponseWriter, r *http.Request) {
|
||||
_ = s.store.SeedStarterContent(user)
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName)
|
||||
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
|
||||
return
|
||||
@@ -519,7 +525,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName)
|
||||
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
|
||||
return
|
||||
@@ -557,21 +563,31 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
user, err := s.store.GetUserByLogin(identifier)
|
||||
if err != nil || !auth.CheckPassword(req.Password, user.PasswordHash) {
|
||||
if err != nil {
|
||||
slog.Warn("Login fehlgeschlagen: Benutzer existiert nicht", "login", identifier, "remote_addr", r.RemoteAddr)
|
||||
writeError(w, http.StatusUnauthorized, "Benutzername/E-Mail oder Passwort ungültig")
|
||||
return
|
||||
}
|
||||
if !auth.CheckPassword(req.Password, user.PasswordHash) {
|
||||
slog.Warn("Login fehlgeschlagen: Falsches Passwort", "login", identifier, "user_id", user.ID, "display_name", user.DisplayName, "remote_addr", r.RemoteAddr)
|
||||
writeError(w, http.StatusUnauthorized, "Benutzername/E-Mail oder Passwort ungültig")
|
||||
return
|
||||
}
|
||||
if !user.IsActive {
|
||||
slog.Warn("Login verweigert: Benutzerkonto ist deaktiviert", "login", identifier, "user_id", user.ID, "display_name", user.DisplayName, "remote_addr", r.RemoteAddr)
|
||||
writeError(w, http.StatusForbidden, "Dieses Benutzerkonto ist deaktiviert")
|
||||
return
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName)
|
||||
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
|
||||
if err != nil {
|
||||
slog.Error("Login-Token konnte nicht erstellt werden", "login", identifier, "user_id", user.ID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("Login erfolgreich", "login", identifier, "user_id", user.ID, "display_name", user.DisplayName, "role", user.Role, "remote_addr", r.RemoteAddr)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"token": token,
|
||||
"user": user,
|
||||
@@ -688,6 +704,17 @@ func (s *Server) handleGetThoughts(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, thoughts)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetThought(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
thoughtID := chi.URLParam(r, "id")
|
||||
thought, err := s.store.GetThoughtByID(userID, thoughtID)
|
||||
if err != nil || thought == nil {
|
||||
writeError(w, http.StatusNotFound, "Aufgabe nicht gefunden")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, thought)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetTriageDeck(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
thoughts, err := s.store.GetThoughts(userID, "inbox", 100)
|
||||
@@ -816,13 +843,22 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.store.SyncDocBlockAndTask(userID, thoughtID, updated.IsCompleted)
|
||||
s.wsHub.BroadcastToAll("TASK_UPDATED", updated)
|
||||
writeJSON(w, http.StatusOK, updated)
|
||||
}
|
||||
|
||||
func (s *Server) handleUnlinkEnhancedTask(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
thoughtID := chi.URLParam(r, "id")
|
||||
s.store.UnlinkTaskFromDocBlock(userID, thoughtID)
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"unlinked": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteThought(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
thoughtID := chi.URLParam(r, "id")
|
||||
s.store.UnlinkTaskFromDocBlock(userID, thoughtID)
|
||||
if err := s.store.DeleteThought(userID, thoughtID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -848,11 +884,18 @@ func (s *Server) broadcastDocToAuthorizedUsers(docID, event string) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var firstDoc *domain.Document
|
||||
for _, uid := range users {
|
||||
if userDoc, err := s.store.GetDocumentByID(uid, docID); err == nil && userDoc != nil {
|
||||
if firstDoc == nil {
|
||||
firstDoc = userDoc
|
||||
}
|
||||
s.wsHub.BroadcastToUser(uid, event, userDoc)
|
||||
}
|
||||
}
|
||||
if s.wsHub != nil && firstDoc != nil {
|
||||
s.wsHub.BroadcastRoom("doc:"+docID, event, firstDoc, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleGetDocuments(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -898,18 +941,6 @@ func (s *Server) handleGetDocument(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusNotFound, "Dokument nicht gefunden")
|
||||
return
|
||||
}
|
||||
if s.wsHub != nil {
|
||||
if liveTitle, liveBlocks, ok := s.wsHub.GetActiveDocState(docID); ok {
|
||||
if liveTitle != "" {
|
||||
doc.Title = liveTitle
|
||||
}
|
||||
if len(liveBlocks) > 0 {
|
||||
doc.Blocks = liveBlocks
|
||||
doc.MarkdownCache = domain.CompileBlocksToMarkdown(liveBlocks)
|
||||
doc.Content = doc.MarkdownCache
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, doc)
|
||||
}
|
||||
|
||||
@@ -935,9 +966,6 @@ func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if s.wsHub != nil && (req.Title != nil || req.Blocks != nil) {
|
||||
s.wsHub.SyncActiveDocRoom(docID, doc.Title, doc.Blocks)
|
||||
}
|
||||
if req.Title != nil || req.Icon != nil || req.FolderPath != nil || req.Blocks != nil || req.Tags != nil {
|
||||
s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
|
||||
}
|
||||
@@ -959,9 +987,6 @@ func (s *Server) handlePatchDocumentBlocks(w http.ResponseWriter, r *http.Reques
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if s.wsHub != nil {
|
||||
s.wsHub.SyncActiveDocRoom(docID, doc.Title, doc.Blocks)
|
||||
}
|
||||
|
||||
patchEvent := domain.DocBlocksPatchEvent{
|
||||
DocID: docID,
|
||||
@@ -986,9 +1011,6 @@ func (s *Server) handleDeleteDocument(w http.ResponseWriter, r *http.Request) {
|
||||
docID := chi.URLParam(r, "id")
|
||||
authorizedUsers, _ := s.store.GetAuthorizedDocUsers(docID)
|
||||
|
||||
if s.wsHub != nil {
|
||||
s.wsHub.CloseDocRoom(docID)
|
||||
}
|
||||
if err := s.store.DeleteDocument(userID, docID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -999,6 +1021,30 @@ func (s *Server) handleDeleteDocument(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateEnhancedTask(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
docID := chi.URLParam(r, "id")
|
||||
blockID := chi.URLParam(r, "blockId")
|
||||
|
||||
var req struct {
|
||||
Content string `json:"content"`
|
||||
Checked bool `json:"checked"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
|
||||
thought, err := s.store.CreateTaskFromDocBlock(userID, docID, blockID, req.Content, req.Checked)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.wsHub.BroadcastToUser(userID, "TASK_CREATED", thought)
|
||||
writeJSON(w, http.StatusCreated, thought)
|
||||
}
|
||||
|
||||
// --- WORKSPACE FOLDERS ---
|
||||
|
||||
func (s *Server) handleGetFolders(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -1044,7 +1090,6 @@ func (s *Server) handleDeleteFolder(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
for _, docID := range deletedDocIDs {
|
||||
s.wsHub.ReleaseDocLock(docID, userID, true)
|
||||
s.wsHub.BroadcastDocEvent(docID, "DOC_DELETED", map[string]string{"id": docID})
|
||||
}
|
||||
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", map[string]string{"path": folderPath})
|
||||
@@ -1375,8 +1420,6 @@ func (s *Server) handleGetCollaborators(w http.ResponseWriter, r *http.Request)
|
||||
writeJSON(w, http.StatusOK, users)
|
||||
}
|
||||
|
||||
// --- DOCUMENT REALTIME WEBSOCKET ROOM ---
|
||||
|
||||
func (s *Server) authenticateUserFromReq(r *http.Request) string {
|
||||
token := r.URL.Query().Get("token")
|
||||
if token == "" {
|
||||
@@ -1385,53 +1428,24 @@ func (s *Server) authenticateUserFromReq(r *http.Request) string {
|
||||
}
|
||||
}
|
||||
if token != "" {
|
||||
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil {
|
||||
return claims.UserID
|
||||
if strings.HasPrefix(token, "mindsift_sec_") {
|
||||
if user, err := s.store.ValidateAPIKeyAndGetUser(token); err == nil && user != nil && user.IsActive {
|
||||
return user.ID
|
||||
}
|
||||
}
|
||||
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil && claims != nil {
|
||||
if !s.store.IsTokenRevoked(token) {
|
||||
if user, err := s.store.GetUserByID(claims.UserID); err == nil && user != nil && user.IsActive {
|
||||
if claims.TokenVersion == 0 || claims.TokenVersion == user.TokenVersion {
|
||||
return claims.UserID
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GET /api/v1/ws/docs/{id}
|
||||
func (s *Server) handleDocWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Sec-Websocket-Key") != "" {
|
||||
if !strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade") {
|
||||
r.Header.Set("Connection", "Upgrade")
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(r.Header.Get("Upgrade")), "websocket") {
|
||||
r.Header.Set("Upgrade", "websocket")
|
||||
}
|
||||
}
|
||||
|
||||
docID := chi.URLParam(r, "id")
|
||||
if docID == "" {
|
||||
writeError(w, http.StatusBadRequest, "docId erforderlich")
|
||||
return
|
||||
}
|
||||
|
||||
userID := s.authenticateUserFromReq(r)
|
||||
if userID == "" {
|
||||
userID = auth.GetUserID(r.Context())
|
||||
}
|
||||
if userID == "" {
|
||||
userID = "anonymous"
|
||||
}
|
||||
|
||||
canWrite := false
|
||||
if userID != "anonymous" {
|
||||
doc, err := s.store.GetDocumentByID(userID, docID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusForbidden, "Kein Zugriff auf dieses Dokument")
|
||||
return
|
||||
}
|
||||
canWrite = doc.IsOwner || doc.Permission == "owner" || doc.Permission == "write"
|
||||
}
|
||||
|
||||
clientID := r.URL.Query().Get("clientId")
|
||||
room := s.wsHub.GetOrCreateDocRoom(docID)
|
||||
room.ServeDocWS(w, r, userID, clientID, canWrite)
|
||||
}
|
||||
|
||||
// --- REALTIME LISTS & SHARING ---
|
||||
|
||||
func (s *Server) handleGetAllLists(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -1608,6 +1622,30 @@ func (s *Server) handleToggleListItem(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateListItem(w http.ResponseWriter, r *http.Request) {
|
||||
listID := chi.URLParam(r, "id")
|
||||
itemID := chi.URLParam(r, "itemId")
|
||||
|
||||
var req struct {
|
||||
Content *string `json:"content"`
|
||||
Category *string `json:"category"`
|
||||
Quantity *string `json:"quantity"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
|
||||
item, err := s.store.UpdateListItem(itemID, req.Content, req.Category, req.Quantity)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.wsHub.BroadcastListEvent(listID, "ITEM_UPDATED", item)
|
||||
writeJSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteListItem(w http.ResponseWriter, r *http.Request) {
|
||||
listID := chi.URLParam(r, "id")
|
||||
itemID := chi.URLParam(r, "itemId")
|
||||
@@ -1686,17 +1724,9 @@ func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
roomID = r.URL.Query().Get("spaceId")
|
||||
}
|
||||
|
||||
token := r.URL.Query().Get("token")
|
||||
if token == "" {
|
||||
if authHdr := r.Header.Get("Authorization"); strings.HasPrefix(authHdr, "Bearer ") {
|
||||
token = strings.TrimPrefix(authHdr, "Bearer ")
|
||||
}
|
||||
}
|
||||
userID := "anonymous"
|
||||
if token != "" {
|
||||
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil {
|
||||
userID = claims.UserID
|
||||
}
|
||||
userID := s.authenticateUserFromReq(r)
|
||||
if userID == "" {
|
||||
userID = "anonymous"
|
||||
}
|
||||
s.wsHub.ServeWS(w, r, roomID, userID)
|
||||
}
|
||||
@@ -1707,17 +1737,9 @@ func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) {
|
||||
roomID = r.URL.Query().Get("spaceId")
|
||||
}
|
||||
|
||||
token := r.URL.Query().Get("token")
|
||||
if token == "" {
|
||||
if authHdr := r.Header.Get("Authorization"); strings.HasPrefix(authHdr, "Bearer ") {
|
||||
token = strings.TrimPrefix(authHdr, "Bearer ")
|
||||
}
|
||||
}
|
||||
userID := "anonymous"
|
||||
if token != "" {
|
||||
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil {
|
||||
userID = claims.UserID
|
||||
}
|
||||
userID := s.authenticateUserFromReq(r)
|
||||
if userID == "" {
|
||||
userID = "anonymous"
|
||||
}
|
||||
|
||||
var initialRooms []string
|
||||
@@ -1933,6 +1955,33 @@ func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// --- USER SETTINGS & ALARMS ---
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(authHeader, "Bearer ") {
|
||||
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, tokenStr); err == nil && claims != nil {
|
||||
var exp time.Time
|
||||
if claims.ExpiresAt != nil {
|
||||
exp = claims.ExpiresAt.Time
|
||||
} else {
|
||||
exp = time.Now().Add(30 * 24 * time.Hour)
|
||||
}
|
||||
_ = s.store.RevokeToken(tokenStr, userID, exp)
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleRevokeAllSessions(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
if err := s.store.RevokeAllUserTokens(userID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Sitzungen konnten nicht beendet werden")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
var req struct {
|
||||
|
||||
+250
-103
@@ -45,7 +45,7 @@ func setupTestServer(t *testing.T) (*Server, *storage.Store, string) {
|
||||
hub := ws.NewHub()
|
||||
srv := NewServer(cfg, store, hub, nil)
|
||||
|
||||
token, err := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.DisplayName)
|
||||
token, err := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate token: %v", err)
|
||||
}
|
||||
@@ -219,7 +219,7 @@ func TestQuickDumpToTriageAndDirectTargets(t *testing.T) {
|
||||
router := srv.Router()
|
||||
|
||||
// Verify stale token from non-existent user is rejected (401)
|
||||
staleToken, _ := auth.GenerateToken("test-jwt-secret-key", "usr_non_existent", "old@mindsift.local", "Old")
|
||||
staleToken, _ := auth.GenerateToken("test-jwt-secret-key", "usr_non_existent", "old@mindsift.local", "Old", 1)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+staleToken)
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -548,11 +548,11 @@ func TestSpaceMembersDirectInvitationAPI(t *testing.T) {
|
||||
t.Fatalf("failed to get normal user: %v", err)
|
||||
}
|
||||
|
||||
adminToken, err := auth.GenerateToken(cfg.JWTSecret, adminUser.ID, adminUser.Email, adminUser.DisplayName)
|
||||
adminToken, err := auth.GenerateToken(cfg.JWTSecret, adminUser.ID, adminUser.Email, adminUser.DisplayName, adminUser.TokenVersion)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate token: %v", err)
|
||||
}
|
||||
userToken, err := auth.GenerateToken(cfg.JWTSecret, normalUser.ID, normalUser.Email, normalUser.DisplayName)
|
||||
userToken, err := auth.GenerateToken(cfg.JWTSecret, normalUser.ID, normalUser.Email, normalUser.DisplayName, normalUser.TokenVersion)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate token: %v", err)
|
||||
}
|
||||
@@ -663,7 +663,7 @@ func TestWorkspaceTagsAPI(t *testing.T) {
|
||||
router := srv.Router()
|
||||
|
||||
user, _ := store.CreateUser("api_tag_user@example.com", "hash", "API Tag User", "member")
|
||||
token, _ := auth.GenerateToken(srv.cfg.JWTSecret, user.ID, user.Email, user.DisplayName)
|
||||
token, _ := auth.GenerateToken(srv.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
|
||||
|
||||
// Create document with hashtags
|
||||
blocks := []domain.DocumentBlock{
|
||||
@@ -841,93 +841,6 @@ func TestWebhookTriageBadgeAndIngestion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocRoomLiveStateAndDebouncedFlush(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "docroom_test.db")
|
||||
|
||||
store, err := storage.NewStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
cfg := &config.Config{
|
||||
JWTSecret: "test-jwt-secret-key",
|
||||
AllowRegistration: true,
|
||||
}
|
||||
hub := ws.NewHub()
|
||||
srv := NewServer(cfg, store, hub, nil)
|
||||
router := srv.Router()
|
||||
|
||||
user, err := store.CreateUser("outliner_ws@example.com", "hash", "WS Outliner User", "member")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser failed: %v", err)
|
||||
}
|
||||
token, _ := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.Role)
|
||||
|
||||
doc, err := store.CreateDocument(user.ID, "", "Initial Title", "📄", "", nil, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDocument failed: %v", err)
|
||||
}
|
||||
|
||||
// 1. Mutate in-memory DocRoom before 1.5s flush fires
|
||||
room := hub.GetOrCreateDocRoom(doc.ID)
|
||||
newContent := "Live RAM Block #ramtag"
|
||||
newLevel := 1
|
||||
room.HandleEvent(ws.DocSyncEvent{
|
||||
Type: "title_updated",
|
||||
ClientID: "cli_1",
|
||||
Title: "RAM Updated Title",
|
||||
}, nil)
|
||||
room.HandleEvent(ws.DocSyncEvent{
|
||||
Type: "block_created",
|
||||
ClientID: "cli_1",
|
||||
AfterBlockID: doc.Blocks[0].ID,
|
||||
Block: &domain.DocumentBlock{
|
||||
ID: "blk_ram_2",
|
||||
Type: "text",
|
||||
Content: newContent,
|
||||
Level: newLevel,
|
||||
},
|
||||
}, nil)
|
||||
|
||||
// 2. Verify GET /api/v1/documents/{id} immediately returns the un-flushed in-memory state
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+doc.ID, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK from GET document, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var liveDoc domain.Document
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &liveDoc)
|
||||
if liveDoc.Title != "RAM Updated Title" {
|
||||
t.Fatalf("expected live RAM title 'RAM Updated Title', got '%s'", liveDoc.Title)
|
||||
}
|
||||
if len(liveDoc.Blocks) != 2 || liveDoc.Blocks[1].Content != newContent {
|
||||
t.Fatalf("expected 2 live blocks with RAM content, got %+v", liveDoc.Blocks)
|
||||
}
|
||||
|
||||
// 3. Flush room to SQLite and verify persistence + tag extraction + markdown_cache
|
||||
room.Flush()
|
||||
|
||||
persistedDoc, err := store.GetDocumentByID(user.ID, doc.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDocumentByID failed: %v", err)
|
||||
}
|
||||
if persistedDoc.Title != "RAM Updated Title" {
|
||||
t.Fatalf("expected persisted title 'RAM Updated Title', got '%s'", persistedDoc.Title)
|
||||
}
|
||||
if !strings.Contains(persistedDoc.MarkdownCache, " - Live RAM Block #ramtag") {
|
||||
t.Fatalf("expected compiled markdown_cache to contain indented bullet, got:\n%s", persistedDoc.MarkdownCache)
|
||||
}
|
||||
tags, _ := store.GetWorkspaceTags(user.ID)
|
||||
if len(tags) != 1 || tags[0].Tag != "ramtag" {
|
||||
t.Fatalf("expected extracted #ramtag in block_tags, got %+v", tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSEGlobalEndpoint(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "test_sse.db")
|
||||
store, err := storage.NewStore(dbPath)
|
||||
@@ -997,7 +910,7 @@ func TestDecoupledFolderSharingAPI(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create user B: %v", err)
|
||||
}
|
||||
tokenB, err := auth.GenerateToken(srv.cfg.JWTSecret, userB.ID, userB.Email, userB.DisplayName)
|
||||
tokenB, err := auth.GenerateToken(srv.cfg.JWTSecret, userB.ID, userB.Email, userB.DisplayName, userB.TokenVersion)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate token B: %v", err)
|
||||
}
|
||||
@@ -1121,7 +1034,7 @@ func TestDecoupledFolderSharingAPI(t *testing.T) {
|
||||
}
|
||||
|
||||
// 7. Synchronous contents check:
|
||||
// User B moves the document into a subfolder: "MeineOrdner/TeamProjekt/Archiv"
|
||||
// User B (read-only recipient) cannot move documents within the shared folder:
|
||||
moveDocPayload := map[string]string{
|
||||
"folderPath": "MeineOrdner/TeamProjekt/Archiv",
|
||||
}
|
||||
@@ -1131,25 +1044,259 @@ func TestDecoupledFolderSharingAPI(t *testing.T) {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("User B move doc into subfolder failed: %s", rec.Body.String())
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("Expected User B move doc in read-only shared folder to fail, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// User A must now see the document synchronized at "TeamProjekt/Archiv"!
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+createdDoc.ID, nil)
|
||||
// User A (the owner) moves the document into a subfolder: "TeamProjekt/Archiv"
|
||||
moveDocPayload = map[string]string{
|
||||
"folderPath": "TeamProjekt/Archiv",
|
||||
}
|
||||
b, _ = json.Marshal(moveDocPayload)
|
||||
req = httptest.NewRequest(http.MethodPut, "/api/v1/documents/"+createdDoc.ID+"/move", bytes.NewReader(b))
|
||||
req.Header.Set("Authorization", "Bearer "+tokenA)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("User A get doc failed: %s", rec.Body.String())
|
||||
t.Fatalf("User A move doc into subfolder failed: %s", rec.Body.String())
|
||||
}
|
||||
var docASynced domain.Document
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &docASynced)
|
||||
if docASynced.FolderPath != "TeamProjekt/Archiv" {
|
||||
t.Fatalf("expected User A to see subfolder sync at 'TeamProjekt/Archiv', got %q", docASynced.FolderPath)
|
||||
|
||||
// User B must now see the document synchronized at "MeineOrdner/TeamProjekt/Archiv"!
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+createdDoc.ID, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+tokenB)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("User B get doc failed: %s", rec.Body.String())
|
||||
}
|
||||
var docBSynced domain.Document
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &docBSynced)
|
||||
if docBSynced.FolderPath != "MeineOrdner/TeamProjekt/Archiv" {
|
||||
t.Fatalf("expected User B to see subfolder sync at 'MeineOrdner/TeamProjekt/Archiv', got %q", docBSynced.FolderPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenBlacklistingAndTokenVersionRevocation(t *testing.T) {
|
||||
srv, store, _ := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
|
||||
// 1. Create a fresh user with known password
|
||||
password := "initialPassword123"
|
||||
hash, _ := auth.HashPassword(password)
|
||||
user, err := store.CreateUser("revocation_test@example.com", hash, "Revocation Tester", "member")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create user: %v", err)
|
||||
}
|
||||
|
||||
// 2. Perform login to get initial JWT
|
||||
loginBody, _ := json.Marshal(map[string]string{
|
||||
"login": user.Email,
|
||||
"password": password,
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("login failed: %s", rec.Body.String())
|
||||
}
|
||||
var loginResp struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &loginResp)
|
||||
token1 := loginResp.Token
|
||||
if token1 == "" {
|
||||
t.Fatalf("expected token in login response")
|
||||
}
|
||||
|
||||
// 3. Verify token1 works
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token1)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("token1 should be valid, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// 4. Test explicit logout (blacklists token1)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/logout", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token1)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("logout failed: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
// Token1 must now be blacklisted and rejected (401)
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token1)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 Unauthorized for blacklisted token1, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// Also check authenticateUserFromReq rejects token1
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/ws?token="+token1, nil)
|
||||
if uid := srv.authenticateUserFromReq(req); uid != "" {
|
||||
t.Fatalf("authenticateUserFromReq should reject blacklisted token1, got uid %q", uid)
|
||||
}
|
||||
|
||||
// 5. Login again to get token2
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
var loginResp2 struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &loginResp2)
|
||||
token2 := loginResp2.Token
|
||||
|
||||
// Token2 works
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token2)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("token2 should be valid, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// 6. Change password via UpdateUserPassword -> increments token_version
|
||||
newPassword := "newSecretPassword456"
|
||||
newHash, _ := auth.HashPassword(newPassword)
|
||||
if err := store.UpdateUserPassword(user.ID, newHash); err != nil {
|
||||
t.Fatalf("failed to update password: %v", err)
|
||||
}
|
||||
|
||||
// Token2 must now be REJECTED (401) because its token_version is outdated!
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token2)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 Unauthorized for token2 after password change, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// 7. Login with new password to get token3
|
||||
loginNewBody, _ := json.Marshal(map[string]string{
|
||||
"login": user.Email,
|
||||
"password": newPassword,
|
||||
})
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginNewBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
var loginResp3 struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &loginResp3)
|
||||
token3 := loginResp3.Token
|
||||
|
||||
// Token3 works
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token3)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("token3 should be valid, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// 8. Test revoke-all endpoint -> increments token_version and invalidates all existing sessions
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/revoke-all", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token3)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("revoke-all failed: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
// Token3 must now be REJECTED (401)
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token3)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 Unauthorized for token3 after revoke-all, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentSharingStrictlyReadOnly(t *testing.T) {
|
||||
srv, store, _ := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
|
||||
userA, _ := store.CreateUser("user_a_ro@example.com", "hash", "User A", "member")
|
||||
tokenA, _ := auth.GenerateToken(srv.cfg.JWTSecret, userA.ID, userA.Email, userA.DisplayName, userA.TokenVersion)
|
||||
|
||||
userB, _ := store.CreateUser("user_b_ro@example.com", "hash", "User B", "member")
|
||||
tokenB, _ := auth.GenerateToken(srv.cfg.JWTSecret, userB.ID, userB.Email, userB.DisplayName, userB.TokenVersion)
|
||||
|
||||
// Create document owned by User A
|
||||
blocks := []domain.DocumentBlock{
|
||||
{ID: "b1", Type: "bullet", Content: "Row 1", Level: 0},
|
||||
{ID: "b2", Type: "bullet", Content: "Row 2", Level: 0},
|
||||
}
|
||||
doc, err := store.CreateDocument(userA.ID, "", "Read Only Test Doc", "📝", "", blocks, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDocument failed: %v", err)
|
||||
}
|
||||
|
||||
// Share with User B (forced read-only)
|
||||
_, err = store.CreateDocShare(userA.ID, doc.ID, userB.ID, "read")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDocShare failed: %v", err)
|
||||
}
|
||||
|
||||
// 1. User B can read document
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+doc.ID, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+tokenB)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("User B should be able to read doc, got status: %d", rec.Code)
|
||||
}
|
||||
|
||||
// 2. User B tries to update document via PUT -> must fail (not owner)
|
||||
newTitle := "Hacked Title"
|
||||
updateBody, _ := json.Marshal(map[string]interface{}{"title": newTitle})
|
||||
req = httptest.NewRequest(http.MethodPut, "/api/v1/documents/"+doc.ID, bytes.NewReader(updateBody))
|
||||
req.Header.Set("Authorization", "Bearer "+tokenB)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Fatalf("expected error for User B updating document, got 200 OK")
|
||||
}
|
||||
|
||||
// 3. User B tries to patch document blocks -> must fail (not owner)
|
||||
patchBody, _ := json.Marshal(domain.DocBlocksPatchRequest{
|
||||
Upserted: []domain.DocumentBlock{{ID: "b2", Content: "Hacked by User B"}},
|
||||
})
|
||||
req = httptest.NewRequest(http.MethodPatch, "/api/v1/documents/"+doc.ID+"/blocks", bytes.NewReader(patchBody))
|
||||
req.Header.Set("Authorization", "Bearer "+tokenB)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Fatalf("expected error for User B patching document blocks, got 200 OK")
|
||||
}
|
||||
|
||||
// 4. User A (owner) can successfully update document
|
||||
ownerTitle := "Owner Updated Title"
|
||||
ownerBody, _ := json.Marshal(map[string]interface{}{"title": ownerTitle})
|
||||
req = httptest.NewRequest(http.MethodPut, "/api/v1/documents/"+doc.ID, bytes.NewReader(ownerBody))
|
||||
req.Header.Set("Authorization", "Bearer "+tokenA)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("owner should be able to update document, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+20
-9
@@ -18,9 +18,10 @@ type contextKey string
|
||||
const UserIDContextKey contextKey = "mindsift_user_id"
|
||||
|
||||
type Claims struct {
|
||||
UserID string `json:"userId"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
UserID string `json:"userId"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
TokenVersion int `json:"tokenVersion"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
@@ -34,14 +35,24 @@ func CheckPassword(password, hash string) bool {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func GenerateToken(secret, userID, email, displayName string) (string, error) {
|
||||
func GenerateToken(secret, userID, email, displayName string, tokenVersion int) (string, error) {
|
||||
if tokenVersion <= 0 {
|
||||
tokenVersion = 1
|
||||
}
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
jti := hex.EncodeToString(b)
|
||||
|
||||
now := time.Now()
|
||||
claims := &Claims{
|
||||
UserID: userID,
|
||||
Email: email,
|
||||
DisplayName: displayName,
|
||||
UserID: userID,
|
||||
Email: email,
|
||||
DisplayName: displayName,
|
||||
TokenVersion: tokenVersion,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * 24 * time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ID: jti,
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(30 * 24 * time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
Issuer: "mindsift",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@ type User struct {
|
||||
AvatarURL string `json:"avatarUrl,omitempty"`
|
||||
Role string `json:"role"` // admin | member
|
||||
IsActive bool `json:"isActive"`
|
||||
TokenVersion int `json:"tokenVersion"`
|
||||
AlarmChannels []string `json:"alarmChannels"` // email | discord | webhook
|
||||
AlarmEmail string `json:"alarmEmail"`
|
||||
DiscordWebhookURL string `json:"discordWebhookUrl"`
|
||||
@@ -147,8 +148,10 @@ type Thought struct {
|
||||
Source string `json:"source"` // manual | voice | webhook
|
||||
Status ThoughtStatus `json:"status"`
|
||||
IsCompleted bool `json:"isCompleted"`
|
||||
ConvertedType string `json:"convertedType,omitempty"` // task | document | list_item
|
||||
ConvertedType string `json:"convertedType,omitempty"` // task | document | list_item | document_block
|
||||
ConvertedRefID string `json:"convertedRefId,omitempty"` // referenced entity ID
|
||||
DocID string `json:"docId,omitempty"`
|
||||
BlockID string `json:"blockId,omitempty"`
|
||||
DueDate string `json:"dueDate,omitempty"`
|
||||
TaskListID string `json:"taskListId,omitempty"`
|
||||
KanbanStatus string `json:"kanbanStatus,omitempty"` // todo | in_progress | done
|
||||
@@ -269,6 +272,7 @@ type DocumentBlock struct {
|
||||
Level int `json:"level"`
|
||||
Position float64 `json:"position"`
|
||||
Collapsed bool `json:"collapsed,omitempty"`
|
||||
TaskID string `json:"taskId,omitempty"`
|
||||
}
|
||||
|
||||
func CompileBlocksToMarkdown(blocks []DocumentBlock) string {
|
||||
@@ -336,13 +340,6 @@ type DocShare struct {
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type DocLockInfo struct {
|
||||
DocID string `json:"docId"`
|
||||
UserID string `json:"userId"`
|
||||
UserName string `json:"userName"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
}
|
||||
|
||||
type Document struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
|
||||
+302
-38
@@ -72,6 +72,7 @@ func (s *Store) migrate() error {
|
||||
display_name TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'member',
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
token_version INTEGER NOT NULL DEFAULT 1,
|
||||
alarm_channels TEXT NOT NULL DEFAULT 'discord',
|
||||
alarm_email TEXT NOT NULL DEFAULT '',
|
||||
discord_webhook_url TEXT NOT NULL DEFAULT '',
|
||||
@@ -307,6 +308,16 @@ func (s *Store) migrate() error {
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS revoked_tokens (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
revoked_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_revoked_tokens_expires ON revoked_tokens(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_revoked_tokens_user ON revoked_tokens(user_id);
|
||||
`
|
||||
if _, err := s.db.Exec(schema); err != nil {
|
||||
return err
|
||||
@@ -318,6 +329,7 @@ func (s *Store) migrate() error {
|
||||
"CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);",
|
||||
"ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'member';",
|
||||
"ALTER TABLE users ADD COLUMN is_active INTEGER NOT NULL DEFAULT 1;",
|
||||
"ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 1;",
|
||||
"ALTER TABLE users ADD COLUMN alarm_channels TEXT NOT NULL DEFAULT 'discord';",
|
||||
"ALTER TABLE users ADD COLUMN alarm_email TEXT NOT NULL DEFAULT '';",
|
||||
"ALTER TABLE users ADD COLUMN discord_webhook_url TEXT NOT NULL DEFAULT '';",
|
||||
@@ -402,6 +414,11 @@ func (s *Store) migrate() error {
|
||||
// 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`)
|
||||
|
||||
// Ensure all existing document & folder shares are strictly read-only
|
||||
_, _ = s.db.Exec(`UPDATE doc_shares SET permission = 'read' WHERE permission != 'read'`)
|
||||
_, _ = s.db.Exec(`UPDATE doc_team_shares SET permission = 'read' WHERE permission != 'read'`)
|
||||
_, _ = s.db.Exec(`UPDATE workspace_folder_shares SET permission = 'read' WHERE permission != 'read'`)
|
||||
|
||||
// Default system settings (public registration disabled by default; invite or admin creation required)
|
||||
_, _ = s.db.Exec(`INSERT OR IGNORE INTO system_settings (key, value) VALUES
|
||||
('sharing_mode', 'teams_only'),
|
||||
@@ -536,7 +553,7 @@ func (s *Store) EnsureDefaultUser() (*domain.User, error) {
|
||||
|
||||
// --- USERS ---
|
||||
|
||||
const userSelectCols = `id, email, username, password_hash, display_name, role, is_active, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, avatar_url, created_at`
|
||||
const userSelectCols = `id, email, username, password_hash, display_name, role, is_active, token_version, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, avatar_url, created_at`
|
||||
|
||||
func parseAlarmChannels(raw string) []string {
|
||||
parts := strings.Split(raw, ",")
|
||||
@@ -558,13 +575,16 @@ func scanUser(row interface{ Scan(dest ...any) error }) (*domain.User, error) {
|
||||
var channelsRaw string
|
||||
err := row.Scan(
|
||||
&u.ID, &u.Email, &u.Username, &u.PasswordHash, &u.DisplayName,
|
||||
&u.Role, &isActiveInt, &channelsRaw, &u.AlarmEmail,
|
||||
&u.Role, &isActiveInt, &u.TokenVersion, &channelsRaw, &u.AlarmEmail,
|
||||
&u.DiscordWebhookURL, &u.CustomWebhookURL, &u.NotifyBeforeMinutes, &u.AvatarURL, &u.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.IsActive = isActiveInt != 0
|
||||
if u.TokenVersion <= 0 {
|
||||
u.TokenVersion = 1
|
||||
}
|
||||
if u.Role == "" {
|
||||
u.Role = "member"
|
||||
}
|
||||
@@ -604,6 +624,7 @@ func (s *Store) CreateUserWithUsername(email, username, passwordHash, displayNam
|
||||
DisplayName: strings.TrimSpace(displayName),
|
||||
Role: role,
|
||||
IsActive: true,
|
||||
TokenVersion: 1,
|
||||
AlarmChannels: []string{},
|
||||
AlarmEmail: cleanEmail,
|
||||
DiscordWebhookURL: "",
|
||||
@@ -613,8 +634,8 @@ func (s *Store) CreateUserWithUsername(email, username, passwordHash, displayNam
|
||||
CreatedAt: domain.NowISO(),
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO users (id, email, username, password_hash, display_name, role, is_active, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, avatar_url, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 1, '', ?, '', '', 30, '', ?)`,
|
||||
`INSERT INTO users (id, email, username, password_hash, display_name, role, is_active, token_version, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, avatar_url, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 1, 1, '', ?, '', '', 30, '', ?)`,
|
||||
u.ID, u.Email, u.Username, u.PasswordHash, u.DisplayName, u.Role, u.AlarmEmail, u.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -811,14 +832,17 @@ func (s *Store) UpdateUserFull(
|
||||
if avatarURL != nil {
|
||||
u.AvatarURL = strings.TrimSpace(*avatarURL)
|
||||
}
|
||||
if (role != nil && *role != u.Role) || (isActive != nil && !*isActive && u.IsActive) {
|
||||
u.TokenVersion++
|
||||
}
|
||||
activeInt := 0
|
||||
if u.IsActive {
|
||||
activeInt = 1
|
||||
}
|
||||
channelsStr := strings.Join(u.AlarmChannels, ",")
|
||||
_, err = s.db.Exec(
|
||||
`UPDATE users SET display_name = ?, email = ?, role = ?, is_active = ?, alarm_channels = ?, alarm_email = ?, discord_webhook_url = ?, custom_webhook_url = ?, notify_before_minutes = ?, avatar_url = ? WHERE id = ?`,
|
||||
u.DisplayName, u.Email, u.Role, activeInt, channelsStr, u.AlarmEmail, u.DiscordWebhookURL, u.CustomWebhookURL, u.NotifyBeforeMinutes, u.AvatarURL, userID,
|
||||
`UPDATE users SET display_name = ?, email = ?, role = ?, is_active = ?, token_version = ?, alarm_channels = ?, alarm_email = ?, discord_webhook_url = ?, custom_webhook_url = ?, notify_before_minutes = ?, avatar_url = ? WHERE id = ?`,
|
||||
u.DisplayName, u.Email, u.Role, activeInt, u.TokenVersion, channelsStr, u.AlarmEmail, u.DiscordWebhookURL, u.CustomWebhookURL, u.NotifyBeforeMinutes, u.AvatarURL, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -827,15 +851,45 @@ func (s *Store) UpdateUserFull(
|
||||
}
|
||||
|
||||
func (s *Store) UpdateUserPassword(userID, passwordHash string) error {
|
||||
_, err := s.db.Exec(`UPDATE users SET password_hash = ? WHERE id = ?`, passwordHash, userID)
|
||||
_, err := s.db.Exec(`UPDATE users SET password_hash = ?, token_version = token_version + 1 WHERE id = ?`, passwordHash, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteUser(userID string) error {
|
||||
_, _ = s.db.Exec(`DELETE FROM revoked_tokens WHERE user_id = ?`, userID)
|
||||
_, err := s.db.Exec(`DELETE FROM users WHERE id = ?`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// RevokeToken adds an individual JWT to the blacklist until its expiration date
|
||||
func (s *Store) RevokeToken(tokenStr, userID string, expiresAt time.Time) error {
|
||||
hash := auth.HashAPIKey(tokenStr)
|
||||
_, err := s.db.Exec(
|
||||
`INSERT OR REPLACE INTO revoked_tokens (token_hash, user_id, expires_at, revoked_at) VALUES (?, ?, ?, ?)`,
|
||||
hash, userID, expiresAt.UTC().Format(time.RFC3339), time.Now().UTC().Format(time.RFC3339),
|
||||
)
|
||||
// Idempotently prune old expired tokens
|
||||
_, _ = s.db.Exec(`DELETE FROM revoked_tokens WHERE expires_at < ?`, time.Now().UTC().Format(time.RFC3339))
|
||||
return err
|
||||
}
|
||||
|
||||
// IsTokenRevoked checks if a specific JWT token has been explicitly blacklisted
|
||||
func (s *Store) IsTokenRevoked(tokenStr string) bool {
|
||||
hash := auth.HashAPIKey(tokenStr)
|
||||
var count int
|
||||
err := s.db.QueryRow(
|
||||
`SELECT COUNT(1) FROM revoked_tokens WHERE token_hash = ? AND expires_at >= ?`,
|
||||
hash, time.Now().UTC().Format(time.RFC3339),
|
||||
).Scan(&count)
|
||||
return err == nil && count > 0
|
||||
}
|
||||
|
||||
// RevokeAllUserTokens increments the user's token_version, instantly invalidating all existing JWTs for that user
|
||||
func (s *Store) RevokeAllUserTokens(userID string) error {
|
||||
_, err := s.db.Exec(`UPDATE users SET token_version = token_version + 1 WHERE id = ?`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- THOUGHTS & TRIAGE ---
|
||||
|
||||
func (s *Store) CreateThought(userID, content, priority, source, dueDate string) (*domain.Thought, error) {
|
||||
@@ -858,6 +912,30 @@ func (s *Store) CreateThoughtWithDetails(userID, content, priority, source, dueD
|
||||
}
|
||||
|
||||
tags := domain.ExtractTags(content)
|
||||
if strings.EqualFold(source, "voice") {
|
||||
hasDiktat := false
|
||||
for _, tag := range tags {
|
||||
if strings.EqualFold(tag, "diktat") {
|
||||
hasDiktat = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasDiktat {
|
||||
tags = append(tags, "diktat")
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(source, "webhook") {
|
||||
hasWebhook := false
|
||||
for _, tag := range tags {
|
||||
if strings.EqualFold(tag, "webhook") {
|
||||
hasWebhook = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasWebhook {
|
||||
tags = append(tags, "webhook")
|
||||
}
|
||||
}
|
||||
tagsJSON, _ := json.Marshal(tags)
|
||||
|
||||
t := &domain.Thought{
|
||||
@@ -915,6 +993,11 @@ func scanThought(rows interface {
|
||||
}
|
||||
t.Tags = []string{}
|
||||
_ = json.Unmarshal([]byte(tagsStr), &t.Tags)
|
||||
if t.ConvertedType == "document_block" && strings.Contains(t.ConvertedRefID, ":") {
|
||||
parts := strings.SplitN(t.ConvertedRefID, ":", 2)
|
||||
t.DocID = parts[0]
|
||||
t.BlockID = parts[1]
|
||||
}
|
||||
t.Subtasks = []domain.SubTask{}
|
||||
if subtasksStr != "" && subtasksStr != "null" {
|
||||
_ = json.Unmarshal([]byte(subtasksStr), &t.Subtasks)
|
||||
@@ -1084,23 +1167,23 @@ func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, target
|
||||
title = title[:72] + "…"
|
||||
}
|
||||
blocks := make([]domain.DocumentBlock, 0)
|
||||
formattedDate := domain.FormatGermanDateTime(thought.CreatedAt)
|
||||
calloutText := "Ursprünglicher Gedanke aus deiner Inbox"
|
||||
if formattedDate != "" {
|
||||
calloutText += " vom " + formattedDate
|
||||
}
|
||||
|
||||
// 17. Wenn ich eine Aufgabe zu einer Notiz mache, muss die erste Zeile das "To-Do" noch mal als "To-Do" enthalten, auch wenn es schon in der Überschrift steht. Wenn es vorher schon abgehakt war, dann den Zustand natürlich übernehmen.
|
||||
blocks = append(blocks, domain.DocumentBlock{
|
||||
ID: domain.NewID("blk"),
|
||||
Type: "callout",
|
||||
Content: calloutText,
|
||||
Type: "todo",
|
||||
Content: thought.Content,
|
||||
Checked: thought.IsCompleted,
|
||||
Level: 0,
|
||||
})
|
||||
|
||||
// 17a. Die anderen Details aus der Aufgabe sollen natürlich ebenfalls übernommen werden (Notizen, Subtasks, Fälligkeit/Priorität).
|
||||
if strings.TrimSpace(thought.Notes) != "" {
|
||||
blocks = append(blocks, domain.DocumentBlock{
|
||||
ID: domain.NewID("blk"),
|
||||
Type: "bullet",
|
||||
Content: strings.TrimSpace(thought.Notes),
|
||||
Level: 0,
|
||||
Level: 1,
|
||||
})
|
||||
}
|
||||
if len(thought.Subtasks) > 0 {
|
||||
@@ -1110,10 +1193,25 @@ func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, target
|
||||
Type: "todo",
|
||||
Content: st.Title,
|
||||
Checked: st.IsCompleted,
|
||||
Level: 0,
|
||||
Level: 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
var metaItems []string
|
||||
if thought.DueDate != "" {
|
||||
metaItems = append(metaItems, "Fällig: "+thought.DueDate)
|
||||
}
|
||||
if thought.Priority != "" && thought.Priority != "normal" {
|
||||
metaItems = append(metaItems, "Priorität: "+thought.Priority)
|
||||
}
|
||||
if len(metaItems) > 0 {
|
||||
blocks = append(blocks, domain.DocumentBlock{
|
||||
ID: domain.NewID("blk"),
|
||||
Type: "bullet",
|
||||
Content: strings.Join(metaItems, " • "),
|
||||
Level: 1,
|
||||
})
|
||||
}
|
||||
if len(blocks) == 1 {
|
||||
blocks = append(blocks, domain.DocumentBlock{
|
||||
ID: domain.NewID("blk"),
|
||||
@@ -1477,7 +1575,7 @@ func (s *Store) PatchDocumentBlocks(userID, docID string, req domain.DocBlocksPa
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if !doc.IsOwner && doc.Permission != "write" {
|
||||
if !doc.IsOwner {
|
||||
return nil, nil, fmt.Errorf("keine Schreibberechtigung für dieses Dokument (nur Lesezugriff)")
|
||||
}
|
||||
|
||||
@@ -2225,7 +2323,7 @@ func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *st
|
||||
isOnlyPinChange := isPinned != nil && title == nil && icon == nil && folderPath == nil && blocks == nil && tags == nil
|
||||
isOnlyFolderMove := folderPath != nil && title == nil && icon == nil && blocks == nil && tags == nil && isPinned == nil
|
||||
|
||||
if !doc.IsOwner && doc.Permission != "write" && !isOnlyPinChange && !isOnlyFolderMove {
|
||||
if !doc.IsOwner && !isOnlyPinChange && !isOnlyFolderMove {
|
||||
return nil, fmt.Errorf("keine Schreibberechtigung für dieses Dokument (nur Lesezugriff)")
|
||||
}
|
||||
|
||||
@@ -2266,7 +2364,7 @@ func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *st
|
||||
if title != nil {
|
||||
doc.Title = strings.TrimSpace(*title)
|
||||
}
|
||||
if icon != nil && *icon != "" {
|
||||
if icon != nil {
|
||||
doc.Icon = *icon
|
||||
}
|
||||
if tags != nil {
|
||||
@@ -2301,6 +2399,34 @@ func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *st
|
||||
}
|
||||
if blocks != nil {
|
||||
_ = s.indexDocBlockTags(doc.ID, doc.UserID, doc.Blocks)
|
||||
for _, b := range doc.Blocks {
|
||||
if b.TaskID != "" {
|
||||
s.SyncTaskFromBlockCheck(doc.UserID, b.TaskID, b.Checked)
|
||||
}
|
||||
}
|
||||
|
||||
// When a block is deleted from the note, unlink the corresponding task in thoughts without deleting the task
|
||||
remainingBlockIDs := make(map[string]bool)
|
||||
for _, b := range doc.Blocks {
|
||||
remainingBlockIDs[b.ID] = true
|
||||
}
|
||||
rows, err := s.db.Query(`SELECT id, converted_ref_id, block_id FROM thoughts WHERE (doc_id = ? OR converted_ref_id LIKE ?) AND converted_type = 'document_block'`, docID, docID+":%")
|
||||
if err == nil {
|
||||
for rows.Next() {
|
||||
var tid, refID, bID string
|
||||
if err := rows.Scan(&tid, &refID, &bID); err == nil {
|
||||
bid := bID
|
||||
if bid == "" && strings.Contains(refID, ":") {
|
||||
parts := strings.SplitN(refID, ":", 2)
|
||||
bid = parts[1]
|
||||
}
|
||||
if !remainingBlockIDs[bid] {
|
||||
_, _ = s.db.Exec(`UPDATE thoughts SET converted_type = '', converted_ref_id = '', doc_id = '', block_id = '' WHERE id = ?`, tid)
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
}
|
||||
}
|
||||
return doc, nil
|
||||
}
|
||||
@@ -2835,8 +2961,8 @@ func (s *Store) MoveDocument(userID, docID, targetFolderPath string) (*domain.Do
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !shFolder.IsOwner && shFolder.Permission != "write" {
|
||||
return nil, fmt.Errorf("keine Schreibberechtigung für diesen geteilten Ordner")
|
||||
if !shFolder.IsOwner {
|
||||
return nil, fmt.Errorf("nur der Eigentümer dieses geteilten Ordners kann Dateien dorthin verschieben")
|
||||
}
|
||||
|
||||
// Update document to belong to targetSharedFolderID
|
||||
@@ -2859,7 +2985,7 @@ func (s *Store) MoveDocument(userID, docID, targetFolderPath string) (*domain.Do
|
||||
// Target is NOT inside a shared folder (personal / standalone)
|
||||
if doc.SharedFolderID != "" {
|
||||
// Moving OUT of a shared folder into a personal folder
|
||||
if !doc.IsOwner && doc.Permission != "write" {
|
||||
if !doc.IsOwner {
|
||||
return nil, fmt.Errorf("keine Berechtigung, die Notiz aus dem geteilten Ordner zu verschieben")
|
||||
}
|
||||
_, err = s.db.Exec(`UPDATE documents SET shared_folder_id = '', sub_folder_path = '', folder_path = ?, updated_at = ? WHERE id = ?`,
|
||||
@@ -3285,8 +3411,8 @@ func (s *Store) CreateFolderShare(currentUserID, folderID, targetType, targetIde
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !f.IsOwner && f.Permission != "write" {
|
||||
return nil, fmt.Errorf("nur der Eigentümer oder Bearbeiter kann Ordner-Freigaben verwalten")
|
||||
if !f.IsOwner {
|
||||
return nil, fmt.Errorf("nur der Eigentümer kann Ordner-Freigaben verwalten")
|
||||
}
|
||||
|
||||
cleanType := strings.TrimSpace(targetType)
|
||||
@@ -3299,9 +3425,8 @@ func (s *Store) CreateFolderShare(currentUserID, folderID, targetType, targetIde
|
||||
}
|
||||
}
|
||||
|
||||
if permission != "read" && permission != "write" {
|
||||
permission = "read"
|
||||
}
|
||||
// Geteilte Ordner können ausschließlich mit Leserberechtigung geteilt werden (wie Dokumente)
|
||||
permission = "read"
|
||||
|
||||
var targetID, targetName, email, avatarURL string
|
||||
if cleanType == "team" {
|
||||
@@ -3515,8 +3640,8 @@ func (s *Store) CreateDocTeamShare(currentUserID, docID, teamID, permission stri
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !doc.IsOwner && doc.Permission != "write" {
|
||||
return nil, fmt.Errorf("nur der Eigentümer oder Bearbeiter kann Freigaben verwalten")
|
||||
if !doc.IsOwner {
|
||||
return nil, fmt.Errorf("nur der Eigentümer kann Freigaben verwalten")
|
||||
}
|
||||
|
||||
cleanTeamID := strings.TrimSpace(teamID)
|
||||
@@ -3526,9 +3651,8 @@ func (s *Store) CreateDocTeamShare(currentUserID, docID, teamID, permission stri
|
||||
return nil, fmt.Errorf("team nicht gefunden")
|
||||
}
|
||||
|
||||
if permission != "read" && permission != "write" {
|
||||
permission = "read"
|
||||
}
|
||||
// Document sharing is strictly read-only
|
||||
permission = "read"
|
||||
|
||||
now := domain.NowISO()
|
||||
shareID := domain.NewID("share")
|
||||
@@ -3560,20 +3684,19 @@ func (s *Store) CreateDocShare(currentUserID, docID, targetUserIdentifier, permi
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !doc.IsOwner && doc.Permission != "write" {
|
||||
return nil, fmt.Errorf("nur der Eigentümer oder Bearbeiter kann Freigaben verwalten")
|
||||
if !doc.IsOwner {
|
||||
return nil, fmt.Errorf("nur der Eigentümer kann Freigaben verwalten")
|
||||
}
|
||||
|
||||
cleanTarget := strings.TrimSpace(targetUserIdentifier)
|
||||
|
||||
// If identifier looks like a team or is prefixed with team_, route to CreateDocTeamShare
|
||||
if strings.HasPrefix(cleanTarget, "team_") {
|
||||
return s.CreateDocTeamShare(currentUserID, docID, cleanTarget, permission)
|
||||
return s.CreateDocTeamShare(currentUserID, docID, cleanTarget, "read")
|
||||
}
|
||||
|
||||
if permission != "read" && permission != "write" {
|
||||
permission = "read"
|
||||
}
|
||||
// Document sharing is strictly read-only
|
||||
permission = "read"
|
||||
|
||||
// Resolve target user by ID, username, or Email
|
||||
var targetUser *domain.User
|
||||
@@ -4039,6 +4162,147 @@ func (s *Store) ToggleListItem(itemID string, isCompleted bool, userID, userName
|
||||
return &it, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateListItem(itemID string, content *string, category *string, quantity *string) (*domain.ListItem, error) {
|
||||
now := domain.NowISO()
|
||||
var it domain.ListItem
|
||||
var dInt int
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, list_id, content, category, quantity, is_completed, completed_by, completed_by_name, sort_order, created_at, completed_at
|
||||
FROM list_items WHERE id = ?`,
|
||||
itemID,
|
||||
).Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &dInt, &it.CompletedBy, &it.CompletedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
it.IsCompleted = dInt != 0
|
||||
|
||||
if content != nil {
|
||||
it.Content = *content
|
||||
}
|
||||
if category != nil {
|
||||
it.Category = *category
|
||||
}
|
||||
if quantity != nil {
|
||||
it.Quantity = *quantity
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(`UPDATE list_items SET content = ?, category = ?, quantity = ? WHERE id = ?`, it.Content, it.Category, it.Quantity, itemID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, _ = s.db.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, now, it.ListID)
|
||||
return &it, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateTaskFromDocBlock(userID, docID, blockID, content string, checked bool) (*domain.Thought, error) {
|
||||
doc, err := s.GetDocumentByID(userID, docID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t, err := s.CreateThoughtWithDetails(userID, content, "normal", "manual", "", "", "todo", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.ConvertedType = "document_block"
|
||||
t.ConvertedRefID = docID + ":" + blockID
|
||||
t.DocID = docID
|
||||
t.BlockID = blockID
|
||||
t.IsCompleted = checked
|
||||
t.Status = domain.StatusKept
|
||||
doneInt := 0
|
||||
if checked {
|
||||
doneInt = 1
|
||||
t.KanbanStatus = "done"
|
||||
}
|
||||
_, _ = s.db.Exec(`UPDATE thoughts SET converted_type = 'document_block', converted_ref_id = ?, is_completed = ?, kanban_status = ?, status = 'kept' WHERE id = ?`,
|
||||
t.ConvertedRefID, doneInt, t.KanbanStatus, t.ID)
|
||||
|
||||
var updatedBlocks []domain.DocumentBlock
|
||||
found := false
|
||||
for _, b := range doc.Blocks {
|
||||
if b.ID == blockID {
|
||||
b.TaskID = t.ID
|
||||
b.Checked = checked
|
||||
found = true
|
||||
}
|
||||
updatedBlocks = append(updatedBlocks, b)
|
||||
}
|
||||
if found {
|
||||
_, _ = s.UpdateDocument(userID, docID, nil, nil, nil, &updatedBlocks, nil, nil)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (s *Store) SyncDocBlockAndTask(userID, taskID string, isCompleted bool) {
|
||||
t, err := s.GetThoughtByID(userID, taskID)
|
||||
if err != nil || t == nil || t.ConvertedType != "document_block" || !strings.Contains(t.ConvertedRefID, ":") {
|
||||
return
|
||||
}
|
||||
parts := strings.SplitN(t.ConvertedRefID, ":", 2)
|
||||
docID, blockID := parts[0], parts[1]
|
||||
doc, err := s.GetDocumentByID(userID, docID)
|
||||
if err != nil || doc == nil {
|
||||
return
|
||||
}
|
||||
var updatedBlocks []domain.DocumentBlock
|
||||
found := false
|
||||
for _, b := range doc.Blocks {
|
||||
if b.ID == blockID {
|
||||
if b.Checked != isCompleted {
|
||||
b.Checked = isCompleted
|
||||
found = true
|
||||
}
|
||||
}
|
||||
updatedBlocks = append(updatedBlocks, b)
|
||||
}
|
||||
if found {
|
||||
_, _ = s.UpdateDocument(userID, docID, nil, nil, nil, &updatedBlocks, nil, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) UnlinkTaskFromDocBlock(userID, taskID string) {
|
||||
t, err := s.GetThoughtByID(userID, taskID)
|
||||
if err != nil || t == nil {
|
||||
return
|
||||
}
|
||||
docID := t.DocID
|
||||
blockID := t.BlockID
|
||||
if (docID == "" || blockID == "") && strings.Contains(t.ConvertedRefID, ":") {
|
||||
parts := strings.SplitN(t.ConvertedRefID, ":", 2)
|
||||
docID, blockID = parts[0], parts[1]
|
||||
}
|
||||
if docID != "" {
|
||||
if doc, err := s.GetDocumentByID(userID, docID); err == nil && doc != nil {
|
||||
var updatedBlocks []domain.DocumentBlock
|
||||
found := false
|
||||
for _, b := range doc.Blocks {
|
||||
if b.ID == blockID || b.TaskID == taskID {
|
||||
b.TaskID = ""
|
||||
found = true
|
||||
}
|
||||
updatedBlocks = append(updatedBlocks, b)
|
||||
}
|
||||
if found {
|
||||
_, _ = s.UpdateDocument(userID, docID, nil, nil, nil, &updatedBlocks, nil, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
_, _ = s.db.Exec(`UPDATE thoughts SET converted_type = '', converted_ref_id = '', doc_id = '', block_id = '' WHERE id = ?`, taskID)
|
||||
}
|
||||
|
||||
func (s *Store) SyncTaskFromBlockCheck(userID, taskID string, checked bool) {
|
||||
if taskID == "" {
|
||||
return
|
||||
}
|
||||
doneInt := 0
|
||||
kanban := "todo"
|
||||
if checked {
|
||||
doneInt = 1
|
||||
kanban = "done"
|
||||
}
|
||||
_, _ = s.db.Exec(`UPDATE thoughts SET is_completed = ?, kanban_status = ? WHERE id = ? AND user_id = ?`, doneInt, kanban, taskID, userID)
|
||||
}
|
||||
|
||||
func (s *Store) DeleteListItem(itemID string) (string, error) {
|
||||
var listID string
|
||||
_ = s.db.QueryRow(`SELECT list_id FROM list_items WHERE id = ?`, itemID).Scan(&listID)
|
||||
|
||||
@@ -173,16 +173,10 @@ func TestStoreLifecycle(t *testing.T) {
|
||||
t.Fatalf("expected UpdateDocument to fail for read-only user, but succeeded")
|
||||
}
|
||||
|
||||
// Upgrade to write permission
|
||||
_, err = store.CreateDocShare(user.ID, newDoc.ID, user2.ID, "write")
|
||||
if err != nil {
|
||||
t.Fatalf("Upgrade CreateDocShare failed: %v", err)
|
||||
}
|
||||
|
||||
// user2 edits writeable doc -> must succeed
|
||||
// user2 is a read-only collaborator -> editing must fail
|
||||
_, err = store.UpdateDocument(user2.ID, newDoc.ID, &updatedTitle, nil, nil, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateDocument failed for write collaborator: %v", err)
|
||||
if err == nil {
|
||||
t.Fatalf("expected UpdateDocument to fail for read-only collaborator, but succeeded")
|
||||
}
|
||||
|
||||
// Remove share
|
||||
@@ -551,8 +545,7 @@ func TestDatabaseFirstWorkspaceArchitecture(t *testing.T) {
|
||||
t.Fatalf("MoveDocument by userB failed: %v", err)
|
||||
}
|
||||
|
||||
// User B edits the document via PatchDocumentBlocks (modifying b2 and adding b3)
|
||||
// Simultaneously, User A edits b1 and adds b4 using a slightly stale order
|
||||
// User B is a read-only collaborator -> PatchDocumentBlocks must fail
|
||||
_, _, err = store.PatchDocumentBlocks(userB.ID, doc.ID, domain.DocBlocksPatchRequest{
|
||||
Upserted: []domain.DocumentBlock{
|
||||
{ID: "b2", Type: "bullet", Content: "Zeile 2 von B bearbeitet", Level: 0},
|
||||
@@ -560,8 +553,8 @@ func TestDatabaseFirstWorkspaceArchitecture(t *testing.T) {
|
||||
},
|
||||
BlockOrder: []string{"b1", "b2", "b3"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PatchDocumentBlocks by userB failed: %v", err)
|
||||
if err == nil {
|
||||
t.Fatalf("expected PatchDocumentBlocks by read-only userB to fail, but succeeded")
|
||||
}
|
||||
|
||||
// User A's client only knew about b1, b2 when sending its patch (adds b4)
|
||||
@@ -576,9 +569,9 @@ func TestDatabaseFirstWorkspaceArchitecture(t *testing.T) {
|
||||
t.Fatalf("PatchDocumentBlocks by userA failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify NO blocks were lost! All 4 blocks (b1, b2, b3, b4) must exist with both users' edits preserved
|
||||
if len(patchedDocA.Blocks) != 4 {
|
||||
t.Fatalf("expected 4 blocks after concurrent patches, got %d: %+v", len(patchedDocA.Blocks), patchedDocA.Blocks)
|
||||
// Verify User A's blocks are preserved: b1, b2, b4 (3 blocks)
|
||||
if len(patchedDocA.Blocks) != 3 {
|
||||
t.Fatalf("expected 3 blocks after patch, got %d: %+v", len(patchedDocA.Blocks), patchedDocA.Blocks)
|
||||
}
|
||||
blockContent := make(map[string]string)
|
||||
for _, b := range patchedDocA.Blocks {
|
||||
@@ -587,12 +580,6 @@ func TestDatabaseFirstWorkspaceArchitecture(t *testing.T) {
|
||||
if blockContent["b1"] != "Zeile 1 von A bearbeitet" {
|
||||
t.Fatalf("expected b1 updated by A, got %q", blockContent["b1"])
|
||||
}
|
||||
if blockContent["b2"] != "Zeile 2 von B bearbeitet" {
|
||||
t.Fatalf("expected b2 updated by B to be preserved, got %q", blockContent["b2"])
|
||||
}
|
||||
if blockContent["b3"] != "Zeile 3 neu von B" {
|
||||
t.Fatalf("expected b3 added by B to be preserved even with stale order from A, got %q", blockContent["b3"])
|
||||
}
|
||||
if blockContent["b4"] != "Zeile 4 neu von A" {
|
||||
t.Fatalf("expected b4 added by A, got %q", blockContent["b4"])
|
||||
}
|
||||
|
||||
@@ -1,736 +0,0 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
)
|
||||
|
||||
// CursorPresence represents an active user's ephemeral cursor position in a document room.
|
||||
type CursorPresence struct {
|
||||
ClientID string `json:"clientId"`
|
||||
UserID string `json:"userId"`
|
||||
UserName string `json:"userName"`
|
||||
UserColor string `json:"userColor"`
|
||||
BlockID string `json:"blockId,omitempty"`
|
||||
SelectionStart *int `json:"selectionStart,omitempty"`
|
||||
SelectionEnd *int `json:"selectionEnd,omitempty"`
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// DocWSClient represents an active WebSocket connection to a specific document room.
|
||||
type DocWSClient struct {
|
||||
hub *Hub
|
||||
room *DocRoom
|
||||
conn *websocket.Conn
|
||||
userID string
|
||||
clientID string
|
||||
canWrite bool
|
||||
send chan []byte
|
||||
}
|
||||
|
||||
// DocSyncEvent is the lightweight JSON payload exchanged over /api/v1/ws/docs/{id}.
|
||||
type DocSyncEvent struct {
|
||||
Type string `json:"type"`
|
||||
DocID string `json:"docId,omitempty"`
|
||||
ClientID string `json:"clientId,omitempty"`
|
||||
UserID string `json:"userId,omitempty"`
|
||||
UserName string `json:"userName,omitempty"`
|
||||
UserColor string `json:"userColor,omitempty"`
|
||||
SelectionStart *int `json:"selectionStart,omitempty"`
|
||||
SelectionEnd *int `json:"selectionEnd,omitempty"`
|
||||
ActiveCursors []CursorPresence `json:"activeCursors,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Block *domain.DocumentBlock `json:"block,omitempty"`
|
||||
BlockID string `json:"blockId,omitempty"`
|
||||
AfterBlockID string `json:"afterBlockId,omitempty"`
|
||||
NewIndex *int `json:"newIndex,omitempty"`
|
||||
Content *string `json:"content,omitempty"`
|
||||
Level *int `json:"level,omitempty"`
|
||||
Checked *bool `json:"checked,omitempty"`
|
||||
Collapsed *bool `json:"collapsed,omitempty"`
|
||||
BlockType *string `json:"blockType,omitempty"`
|
||||
Blocks []domain.DocumentBlock `json:"blocks,omitempty"`
|
||||
}
|
||||
|
||||
// DocRoom coordinates in-memory real-time block state and 1.5s debounced SQLite persistence for one document.
|
||||
type DocRoom struct {
|
||||
mu sync.Mutex
|
||||
docID string
|
||||
hub *Hub
|
||||
title string
|
||||
blocks []domain.DocumentBlock
|
||||
loaded bool
|
||||
wsClients map[*DocWSClient]bool
|
||||
activeCursors map[string]*CursorPresence
|
||||
debounceTimer *time.Timer
|
||||
dirty bool
|
||||
}
|
||||
|
||||
// GetOrCreateDocRoom returns the active in-memory room for docID or initializes one from SQLite.
|
||||
func (h *Hub) GetOrCreateDocRoom(docID string) *DocRoom {
|
||||
h.mu.Lock()
|
||||
if h.docRooms == nil {
|
||||
h.docRooms = make(map[string]*DocRoom)
|
||||
}
|
||||
room, exists := h.docRooms[docID]
|
||||
if !exists {
|
||||
room = &DocRoom{
|
||||
docID: docID,
|
||||
hub: h,
|
||||
wsClients: make(map[*DocWSClient]bool),
|
||||
activeCursors: make(map[string]*CursorPresence),
|
||||
}
|
||||
h.docRooms[docID] = room
|
||||
}
|
||||
store := h.docStore
|
||||
h.mu.Unlock()
|
||||
|
||||
room.mu.Lock()
|
||||
if !room.loaded && store != nil {
|
||||
title, blocks, err := store.LoadDocumentState(docID)
|
||||
if err == nil {
|
||||
room.title = title
|
||||
room.blocks = normalizeRoomBlocks(docID, blocks)
|
||||
room.loaded = true
|
||||
}
|
||||
}
|
||||
room.mu.Unlock()
|
||||
|
||||
return room
|
||||
}
|
||||
|
||||
// GetActiveDocState returns the live in-memory title and blocks if an active DocRoom exists in RAM.
|
||||
func (h *Hub) GetActiveDocState(docID string) (string, []domain.DocumentBlock, bool) {
|
||||
h.mu.RLock()
|
||||
room, exists := h.docRooms[docID]
|
||||
h.mu.RUnlock()
|
||||
if !exists || room == nil {
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
room.mu.Lock()
|
||||
defer room.mu.Unlock()
|
||||
if !room.loaded {
|
||||
return "", nil, false
|
||||
}
|
||||
blocksCopy := make([]domain.DocumentBlock, len(room.blocks))
|
||||
copy(blocksCopy, room.blocks)
|
||||
return room.title, blocksCopy, true
|
||||
}
|
||||
|
||||
// SyncActiveDocRoom updates an in-memory room if one is currently loaded (e.g. after REST PUT/PATCH).
|
||||
func (h *Hub) SyncActiveDocRoom(docID string, title string, blocks []domain.DocumentBlock) {
|
||||
h.mu.RLock()
|
||||
room, exists := h.docRooms[docID]
|
||||
h.mu.RUnlock()
|
||||
if !exists || room == nil {
|
||||
return
|
||||
}
|
||||
|
||||
room.mu.Lock()
|
||||
if title != "" {
|
||||
room.title = title
|
||||
}
|
||||
if blocks != nil {
|
||||
room.blocks = normalizeRoomBlocks(docID, blocks)
|
||||
}
|
||||
room.loaded = true
|
||||
room.mu.Unlock()
|
||||
}
|
||||
|
||||
// CloseDocRoom removes a document room from memory when a document is deleted.
|
||||
func (h *Hub) CloseDocRoom(docID string) {
|
||||
h.mu.Lock()
|
||||
room, exists := h.docRooms[docID]
|
||||
if exists {
|
||||
delete(h.docRooms, docID)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
if exists && room != nil {
|
||||
room.mu.Lock()
|
||||
if room.debounceTimer != nil {
|
||||
room.debounceTimer.Stop()
|
||||
}
|
||||
room.dirty = false
|
||||
for c := range room.wsClients {
|
||||
_ = c.conn.Close()
|
||||
}
|
||||
room.wsClients = make(map[*DocWSClient]bool)
|
||||
room.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeRoomBlocks(docID string, blocks []domain.DocumentBlock) []domain.DocumentBlock {
|
||||
if len(blocks) == 0 {
|
||||
return []domain.DocumentBlock{
|
||||
{
|
||||
ID: docID + "-b0",
|
||||
DocumentID: docID,
|
||||
Type: "text",
|
||||
Content: "",
|
||||
Level: 0,
|
||||
Position: 1000,
|
||||
},
|
||||
}
|
||||
}
|
||||
out := make([]domain.DocumentBlock, len(blocks))
|
||||
for i, b := range blocks {
|
||||
if b.ID == "" {
|
||||
b.ID = domain.NewID("blk")
|
||||
}
|
||||
b.DocumentID = docID
|
||||
if b.Type == "" {
|
||||
b.Type = "text"
|
||||
}
|
||||
if b.Level < 0 {
|
||||
b.Level = 0
|
||||
}
|
||||
b.Position = float64((i + 1) * 1000)
|
||||
out[i] = b
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetStateSnapshot returns a copy of the room's current live in-memory title and blocks.
|
||||
func (r *DocRoom) GetStateSnapshot() (string, []domain.DocumentBlock) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
blocksCopy := make([]domain.DocumentBlock, len(r.blocks))
|
||||
copy(blocksCopy, r.blocks)
|
||||
return r.title, blocksCopy
|
||||
}
|
||||
|
||||
// HandleEvent applies a granular block or title mutation to the in-memory room,
|
||||
// broadcasts it to all OTHER clients in the room (excluding senderClientID for echo protection),
|
||||
// and resets the 1.5-second inactivity debounce timer for SQLite persistence.
|
||||
func (r *DocRoom) HandleEvent(evt DocSyncEvent, sender *DocWSClient) {
|
||||
r.mu.Lock()
|
||||
|
||||
evt.DocID = r.docID
|
||||
if sender != nil {
|
||||
if evt.ClientID == "" {
|
||||
evt.ClientID = sender.clientID
|
||||
}
|
||||
evt.UserID = sender.userID
|
||||
}
|
||||
|
||||
mutated := false
|
||||
|
||||
switch evt.Type {
|
||||
case "cursor_moved":
|
||||
if evt.ClientID != "" {
|
||||
cp := &CursorPresence{
|
||||
ClientID: evt.ClientID,
|
||||
UserID: evt.UserID,
|
||||
UserName: evt.UserName,
|
||||
UserColor: evt.UserColor,
|
||||
BlockID: evt.BlockID,
|
||||
SelectionStart: evt.SelectionStart,
|
||||
SelectionEnd: evt.SelectionEnd,
|
||||
UpdatedAt: time.Now().UnixMilli(),
|
||||
}
|
||||
if r.activeCursors == nil {
|
||||
r.activeCursors = make(map[string]*CursorPresence)
|
||||
}
|
||||
r.activeCursors[evt.ClientID] = cp
|
||||
|
||||
msgBytes, err := json.Marshal(evt)
|
||||
if err == nil {
|
||||
for client := range r.wsClients {
|
||||
if sender != nil && (client == sender || (sender.clientID != "" && client.clientID == sender.clientID)) {
|
||||
continue
|
||||
}
|
||||
if evt.ClientID != "" && client.clientID == evt.ClientID {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case client.send <- msgBytes:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
r.mu.Unlock()
|
||||
return
|
||||
|
||||
case "title_updated":
|
||||
if evt.Title != "" || r.title != evt.Title {
|
||||
r.title = evt.Title
|
||||
mutated = true
|
||||
}
|
||||
|
||||
case "block_created":
|
||||
if evt.Block != nil {
|
||||
newBlock := *evt.Block
|
||||
if newBlock.ID == "" {
|
||||
newBlock.ID = domain.NewID("blk")
|
||||
}
|
||||
newBlock.DocumentID = r.docID
|
||||
if newBlock.Type == "" {
|
||||
newBlock.Type = "text"
|
||||
}
|
||||
if newBlock.Level < 0 {
|
||||
newBlock.Level = 0
|
||||
}
|
||||
|
||||
// Avoid duplicate ID if already created
|
||||
existsIdx := -1
|
||||
for i, b := range r.blocks {
|
||||
if b.ID == newBlock.ID {
|
||||
existsIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if existsIdx >= 0 {
|
||||
r.blocks[existsIdx] = newBlock
|
||||
} else {
|
||||
insertIdx := len(r.blocks)
|
||||
if evt.AfterBlockID != "" {
|
||||
for i, b := range r.blocks {
|
||||
if b.ID == evt.AfterBlockID {
|
||||
insertIdx = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if evt.NewIndex != nil && *evt.NewIndex >= 0 && *evt.NewIndex <= len(r.blocks) {
|
||||
insertIdx = *evt.NewIndex
|
||||
}
|
||||
|
||||
updated := make([]domain.DocumentBlock, 0, len(r.blocks)+1)
|
||||
updated = append(updated, r.blocks[:insertIdx]...)
|
||||
updated = append(updated, newBlock)
|
||||
updated = append(updated, r.blocks[insertIdx:]...)
|
||||
r.blocks = normalizeRoomBlocks(r.docID, updated)
|
||||
}
|
||||
evt.Block = &newBlock
|
||||
mutated = true
|
||||
}
|
||||
|
||||
case "block_updated":
|
||||
targetID := evt.BlockID
|
||||
if targetID == "" && evt.Block != nil {
|
||||
targetID = evt.Block.ID
|
||||
}
|
||||
if targetID != "" {
|
||||
found := false
|
||||
for i := range r.blocks {
|
||||
if r.blocks[i].ID == targetID {
|
||||
found = true
|
||||
if evt.Block != nil {
|
||||
r.blocks[i].Content = evt.Block.Content
|
||||
r.blocks[i].Level = evt.Block.Level
|
||||
if evt.Block.Type != "" {
|
||||
r.blocks[i].Type = evt.Block.Type
|
||||
}
|
||||
r.blocks[i].Checked = evt.Block.Checked
|
||||
r.blocks[i].Collapsed = evt.Block.Collapsed
|
||||
r.blocks[i].Lang = evt.Block.Lang
|
||||
} else {
|
||||
if evt.Content != nil {
|
||||
r.blocks[i].Content = *evt.Content
|
||||
}
|
||||
if evt.Level != nil {
|
||||
lvl := *evt.Level
|
||||
if lvl < 0 {
|
||||
lvl = 0
|
||||
}
|
||||
r.blocks[i].Level = lvl
|
||||
}
|
||||
if evt.Checked != nil {
|
||||
r.blocks[i].Checked = *evt.Checked
|
||||
}
|
||||
if evt.Collapsed != nil {
|
||||
r.blocks[i].Collapsed = *evt.Collapsed
|
||||
}
|
||||
if evt.BlockType != nil && *evt.BlockType != "" {
|
||||
r.blocks[i].Type = *evt.BlockType
|
||||
}
|
||||
}
|
||||
bCopy := r.blocks[i]
|
||||
evt.Block = &bCopy
|
||||
evt.BlockID = targetID
|
||||
mutated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found && evt.Block != nil {
|
||||
newBlock := *evt.Block
|
||||
newBlock.DocumentID = r.docID
|
||||
if newBlock.Type == "" {
|
||||
newBlock.Type = "text"
|
||||
}
|
||||
if newBlock.Level < 0 {
|
||||
newBlock.Level = 0
|
||||
}
|
||||
r.blocks = append(r.blocks, newBlock)
|
||||
r.blocks = normalizeRoomBlocks(r.docID, r.blocks)
|
||||
bCopy := newBlock
|
||||
evt.Block = &bCopy
|
||||
evt.BlockID = targetID
|
||||
mutated = true
|
||||
}
|
||||
}
|
||||
|
||||
case "block_deleted":
|
||||
if evt.BlockID != "" {
|
||||
filtered := make([]domain.DocumentBlock, 0, len(r.blocks))
|
||||
for _, b := range r.blocks {
|
||||
if b.ID != evt.BlockID {
|
||||
filtered = append(filtered, b)
|
||||
}
|
||||
}
|
||||
r.blocks = normalizeRoomBlocks(r.docID, filtered)
|
||||
mutated = true
|
||||
}
|
||||
|
||||
case "block_moved":
|
||||
if evt.BlockID != "" && evt.NewIndex != nil {
|
||||
fromIdx := -1
|
||||
for i, b := range r.blocks {
|
||||
if b.ID == evt.BlockID {
|
||||
fromIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if fromIdx >= 0 {
|
||||
moving := r.blocks[fromIdx]
|
||||
if evt.Level != nil {
|
||||
lvl := *evt.Level
|
||||
if lvl < 0 {
|
||||
lvl = 0
|
||||
}
|
||||
moving.Level = lvl
|
||||
}
|
||||
without := make([]domain.DocumentBlock, 0, len(r.blocks)-1)
|
||||
without = append(without, r.blocks[:fromIdx]...)
|
||||
without = append(without, r.blocks[fromIdx+1:]...)
|
||||
|
||||
toIdx := *evt.NewIndex
|
||||
if toIdx < 0 {
|
||||
toIdx = 0
|
||||
}
|
||||
if toIdx > len(without) {
|
||||
toIdx = len(without)
|
||||
}
|
||||
|
||||
reordered := make([]domain.DocumentBlock, 0, len(r.blocks))
|
||||
reordered = append(reordered, without[:toIdx]...)
|
||||
reordered = append(reordered, moving)
|
||||
reordered = append(reordered, without[toIdx:]...)
|
||||
r.blocks = normalizeRoomBlocks(r.docID, reordered)
|
||||
mutated = true
|
||||
}
|
||||
}
|
||||
|
||||
case "blocks_replaced":
|
||||
if evt.Blocks != nil {
|
||||
r.blocks = normalizeRoomBlocks(r.docID, evt.Blocks)
|
||||
evt.Blocks = r.blocks
|
||||
mutated = true
|
||||
}
|
||||
}
|
||||
|
||||
if !mutated {
|
||||
r.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
r.dirty = true
|
||||
r.loaded = true
|
||||
|
||||
msgBytes, err := json.Marshal(evt)
|
||||
if err == nil {
|
||||
for client := range r.wsClients {
|
||||
// Strict Echo Protection: never send back to the originating connection or clientId
|
||||
if sender != nil && (client == sender || (sender.clientID != "" && client.clientID == sender.clientID)) {
|
||||
continue
|
||||
}
|
||||
if evt.ClientID != "" && client.clientID == evt.ClientID {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case client.send <- msgBytes:
|
||||
default:
|
||||
slog.Warn("Doc room WS client send buffer full, dropping message",
|
||||
"doc_id", r.docID,
|
||||
"client_id", client.clientID,
|
||||
"user_id", client.userID,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
r.scheduleDebouncedFlushLocked()
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *DocRoom) scheduleDebouncedFlushLocked() {
|
||||
if r.debounceTimer != nil {
|
||||
r.debounceTimer.Stop()
|
||||
}
|
||||
r.debounceTimer = time.AfterFunc(1500*time.Millisecond, func() {
|
||||
r.Flush()
|
||||
})
|
||||
}
|
||||
|
||||
// Flush persists the current in-memory document state (blocks, #tags in block_tags, and markdown_cache) to SQLite.
|
||||
func (r *DocRoom) Flush() {
|
||||
r.mu.Lock()
|
||||
if !r.dirty {
|
||||
r.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if r.debounceTimer != nil {
|
||||
r.debounceTimer.Stop()
|
||||
r.debounceTimer = nil
|
||||
}
|
||||
|
||||
docID := r.docID
|
||||
title := r.title
|
||||
blocksCopy := make([]domain.DocumentBlock, len(r.blocks))
|
||||
copy(blocksCopy, r.blocks)
|
||||
r.dirty = false
|
||||
r.mu.Unlock()
|
||||
|
||||
tagsCount := 0
|
||||
for _, b := range blocksCopy {
|
||||
tagsCount += len(domain.ExtractTags(b.Content))
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
r.hub.mu.RLock()
|
||||
store := r.hub.docStore
|
||||
r.hub.mu.RUnlock()
|
||||
|
||||
if store != nil {
|
||||
if err := store.FlushDocumentState(docID, title, blocksCopy); err != nil {
|
||||
slog.Error("SQLite document flush failed",
|
||||
"doc_id", docID,
|
||||
"error", err,
|
||||
)
|
||||
} else {
|
||||
durationMs := float64(time.Since(start).Microseconds()) / 1000.0
|
||||
slog.Debug("SQLite document flushed",
|
||||
"doc_id", docID,
|
||||
"blocks_count", len(blocksCopy),
|
||||
"tags_count", tagsCount,
|
||||
"duration_ms", durationMs,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ServeDocWS handles WebSocket connections for GET /api/v1/ws/docs/{id}
|
||||
func (r *DocRoom) ServeDocWS(w http.ResponseWriter, req *http.Request, userID string, clientID string, canWrite bool) {
|
||||
conn, err := upgrader.Upgrade(w, req, nil)
|
||||
if err != nil {
|
||||
slog.Error("WebSocket upgrade error", "doc_id", r.docID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if clientID == "" {
|
||||
clientID = req.URL.Query().Get("clientId")
|
||||
}
|
||||
if clientID == "" {
|
||||
clientID = domain.NewID("cli")
|
||||
}
|
||||
|
||||
userName := req.URL.Query().Get("userName")
|
||||
userColor := req.URL.Query().Get("userColor")
|
||||
|
||||
client := &DocWSClient{
|
||||
hub: r.hub,
|
||||
room: r,
|
||||
conn: conn,
|
||||
userID: userID,
|
||||
clientID: clientID,
|
||||
canWrite: canWrite,
|
||||
send: make(chan []byte, 256),
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
r.wsClients[client] = true
|
||||
activeClients := len(r.wsClients)
|
||||
if r.activeCursors == nil {
|
||||
r.activeCursors = make(map[string]*CursorPresence)
|
||||
}
|
||||
if userName != "" {
|
||||
r.activeCursors[clientID] = &CursorPresence{
|
||||
ClientID: clientID,
|
||||
UserID: userID,
|
||||
UserName: userName,
|
||||
UserColor: userColor,
|
||||
UpdatedAt: time.Now().UnixMilli(),
|
||||
}
|
||||
}
|
||||
title := r.title
|
||||
blocksCopy := make([]domain.DocumentBlock, len(r.blocks))
|
||||
copy(blocksCopy, r.blocks)
|
||||
|
||||
activeList := make([]CursorPresence, 0, len(r.activeCursors))
|
||||
for _, cp := range r.activeCursors {
|
||||
if cp != nil {
|
||||
activeList = append(activeList, *cp)
|
||||
}
|
||||
}
|
||||
r.mu.Unlock()
|
||||
|
||||
slog.Debug("WS client joined doc room",
|
||||
"doc_id", r.docID,
|
||||
"client_id", clientID,
|
||||
"user_id", userID,
|
||||
"active_clients", activeClients,
|
||||
)
|
||||
|
||||
// Immediately send live in-memory state and current cursors to the newly connected client
|
||||
initEvt := DocSyncEvent{
|
||||
Type: "doc_state",
|
||||
DocID: r.docID,
|
||||
ClientID: clientID,
|
||||
Title: title,
|
||||
Blocks: blocksCopy,
|
||||
ActiveCursors: activeList,
|
||||
}
|
||||
if initBytes, err := json.Marshal(initEvt); err == nil {
|
||||
client.send <- initBytes
|
||||
}
|
||||
|
||||
// If userName was provided, broadcast initial presence to existing clients in room
|
||||
if userName != "" {
|
||||
joinEvt := DocSyncEvent{
|
||||
Type: "cursor_moved",
|
||||
DocID: r.docID,
|
||||
ClientID: clientID,
|
||||
UserID: userID,
|
||||
UserName: userName,
|
||||
UserColor: userColor,
|
||||
}
|
||||
if joinBytes, err := json.Marshal(joinEvt); err == nil {
|
||||
r.mu.Lock()
|
||||
for c := range r.wsClients {
|
||||
if c != client && c.clientID != clientID {
|
||||
select {
|
||||
case c.send <- joinBytes:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
r.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
go client.writePump()
|
||||
go client.readPump()
|
||||
}
|
||||
|
||||
func (c *DocWSClient) readPump() {
|
||||
defer func() {
|
||||
c.room.mu.Lock()
|
||||
delete(c.room.wsClients, c)
|
||||
delete(c.room.activeCursors, c.clientID)
|
||||
remaining := len(c.room.wsClients)
|
||||
|
||||
// Broadcast client_left to all remaining clients in room
|
||||
leftEvt := DocSyncEvent{
|
||||
Type: "client_left",
|
||||
DocID: c.room.docID,
|
||||
ClientID: c.clientID,
|
||||
UserID: c.userID,
|
||||
}
|
||||
if leftBytes, err := json.Marshal(leftEvt); err == nil {
|
||||
for client := range c.room.wsClients {
|
||||
if client != c && client.clientID != c.clientID {
|
||||
select {
|
||||
case client.send <- leftBytes:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
c.room.mu.Unlock()
|
||||
|
||||
slog.Debug("WS client left doc room",
|
||||
"doc_id", c.room.docID,
|
||||
"client_id", c.clientID,
|
||||
"user_id", c.userID,
|
||||
"active_clients", remaining,
|
||||
)
|
||||
|
||||
_ = c.conn.Close()
|
||||
|
||||
// Flush immediately when the last client leaves the document room
|
||||
if remaining == 0 {
|
||||
c.room.Flush()
|
||||
}
|
||||
}()
|
||||
|
||||
c.conn.SetReadLimit(4 * 1024 * 1024)
|
||||
_ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
c.conn.SetPongHandler(func(string) error {
|
||||
_ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
return nil
|
||||
})
|
||||
|
||||
for {
|
||||
_, message, err := c.conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
var evt DocSyncEvent
|
||||
if err := json.Unmarshal(message, &evt); err == nil && evt.Type != "" {
|
||||
targetBlockID := evt.BlockID
|
||||
if targetBlockID == "" && evt.Block != nil {
|
||||
targetBlockID = evt.Block.ID
|
||||
}
|
||||
|
||||
slog.Debug("WS doc event received",
|
||||
"type", evt.Type,
|
||||
"doc_id", c.room.docID,
|
||||
"block_id", targetBlockID,
|
||||
"sender_client_id", c.clientID,
|
||||
)
|
||||
|
||||
// Read-only clients can still move their cursor and send presence updates
|
||||
if !c.canWrite && evt.Type != "cursor_moved" {
|
||||
continue
|
||||
}
|
||||
c.room.HandleEvent(evt, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *DocWSClient) writePump() {
|
||||
ticker := time.NewTicker(25 * time.Second)
|
||||
defer func() {
|
||||
ticker.Stop()
|
||||
_ = c.conn.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case message, ok := <-c.send:
|
||||
_ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
if !ok {
|
||||
_ = c.conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||
return
|
||||
}
|
||||
if err := c.conn.WriteMessage(websocket.TextMessage, message); err != nil {
|
||||
return
|
||||
}
|
||||
case <-ticker.C:
|
||||
_ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDocRoomCursorPresenceAndEchoProtection(t *testing.T) {
|
||||
hub := NewHub()
|
||||
room := hub.GetOrCreateDocRoom("doc_test_presence")
|
||||
|
||||
c1 := &DocWSClient{
|
||||
hub: hub,
|
||||
room: room,
|
||||
userID: "user_1",
|
||||
clientID: "cli_1",
|
||||
canWrite: true,
|
||||
send: make(chan []byte, 10),
|
||||
}
|
||||
c2 := &DocWSClient{
|
||||
hub: hub,
|
||||
room: room,
|
||||
userID: "user_2",
|
||||
clientID: "cli_2",
|
||||
canWrite: false, // read-only viewer
|
||||
send: make(chan []byte, 10),
|
||||
}
|
||||
|
||||
room.mu.Lock()
|
||||
room.wsClients[c1] = true
|
||||
room.wsClients[c2] = true
|
||||
room.mu.Unlock()
|
||||
|
||||
// 1. Send cursor_moved from c1
|
||||
selStart := 5
|
||||
selEnd := 5
|
||||
room.HandleEvent(DocSyncEvent{
|
||||
Type: "cursor_moved",
|
||||
DocID: "doc_test_presence",
|
||||
ClientID: "cli_1",
|
||||
UserID: "user_1",
|
||||
UserName: "Alice",
|
||||
UserColor: "#3b82f6",
|
||||
BlockID: "blk_123",
|
||||
SelectionStart: &selStart,
|
||||
SelectionEnd: &selEnd,
|
||||
}, c1)
|
||||
|
||||
// c1 should NOT have received its own echo
|
||||
select {
|
||||
case <-c1.send:
|
||||
t.Fatal("c1 received echo of its own cursor_moved event!")
|
||||
default:
|
||||
}
|
||||
|
||||
// c2 SHOULD receive the event
|
||||
select {
|
||||
case msg := <-c2.send:
|
||||
if len(msg) == 0 {
|
||||
t.Fatal("empty message received by c2")
|
||||
}
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatal("timed out waiting for cursor_moved event on c2")
|
||||
}
|
||||
|
||||
// Room should have registered c1's presence
|
||||
room.mu.Lock()
|
||||
cp, exists := room.activeCursors["cli_1"]
|
||||
room.mu.Unlock()
|
||||
|
||||
if !exists || cp == nil {
|
||||
t.Fatal("expected active cursor for cli_1 in room")
|
||||
}
|
||||
if cp.UserName != "Alice" || cp.BlockID != "blk_123" {
|
||||
t.Fatalf("unexpected cursor data: %+v", cp)
|
||||
}
|
||||
|
||||
// Ephemeral cursor events must NOT mark the room dirty for disk flush
|
||||
room.mu.Lock()
|
||||
dirty := room.dirty
|
||||
room.mu.Unlock()
|
||||
if dirty {
|
||||
t.Fatal("cursor_moved should not mark room dirty")
|
||||
}
|
||||
}
|
||||
+96
-252
@@ -28,6 +28,29 @@ type Client struct {
|
||||
userID string
|
||||
send chan []byte
|
||||
subscribedRooms map[string]bool
|
||||
sendMu sync.RWMutex
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (c *Client) closeSend() {
|
||||
c.sendMu.Lock()
|
||||
defer c.sendMu.Unlock()
|
||||
if !c.closed {
|
||||
c.closed = true
|
||||
close(c.send)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) trySend(data []byte) {
|
||||
c.sendMu.RLock()
|
||||
defer c.sendMu.RUnlock()
|
||||
if c.closed {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case c.send <- data:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
type SSEClient struct {
|
||||
@@ -36,19 +59,29 @@ type SSEClient struct {
|
||||
roomID string
|
||||
send chan []byte
|
||||
subscribedRooms map[string]bool
|
||||
sendMu sync.RWMutex
|
||||
closed bool
|
||||
}
|
||||
|
||||
type docLockItem struct {
|
||||
docID string
|
||||
userID string
|
||||
userName string
|
||||
lockedAt time.Time
|
||||
expiresAt time.Time
|
||||
func (c *SSEClient) closeSend() {
|
||||
c.sendMu.Lock()
|
||||
defer c.sendMu.Unlock()
|
||||
if !c.closed {
|
||||
c.closed = true
|
||||
close(c.send)
|
||||
}
|
||||
}
|
||||
|
||||
type DocStore interface {
|
||||
LoadDocumentState(docID string) (string, []domain.DocumentBlock, error)
|
||||
FlushDocumentState(docID string, title string, blocks []domain.DocumentBlock) error
|
||||
func (c *SSEClient) trySend(data []byte) {
|
||||
c.sendMu.RLock()
|
||||
defer c.sendMu.RUnlock()
|
||||
if c.closed {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case c.send <- data:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
type Hub struct {
|
||||
@@ -59,30 +92,18 @@ type Hub struct {
|
||||
sseClients map[*SSEClient]bool
|
||||
sseUserRooms map[string]map[*SSEClient]bool
|
||||
sseRooms map[string]map[*SSEClient]bool
|
||||
locks map[string]*docLockItem
|
||||
docRooms map[string]*DocRoom
|
||||
docStore DocStore
|
||||
closed bool
|
||||
}
|
||||
|
||||
func NewHub() *Hub {
|
||||
h := &Hub{
|
||||
return &Hub{
|
||||
rooms: make(map[string]map[*Client]bool),
|
||||
userRooms: make(map[string]map[*Client]bool),
|
||||
allClients: make(map[*Client]bool),
|
||||
sseClients: make(map[*SSEClient]bool),
|
||||
sseUserRooms: make(map[string]map[*SSEClient]bool),
|
||||
sseRooms: make(map[string]map[*SSEClient]bool),
|
||||
locks: make(map[string]*docLockItem),
|
||||
docRooms: make(map[string]*DocRoom),
|
||||
}
|
||||
go h.cleanupLocksLoop()
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *Hub) SetDocStore(store DocStore) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.docStore = store
|
||||
}
|
||||
|
||||
func (h *Hub) Register(c *Client) {
|
||||
@@ -122,7 +143,6 @@ func (h *Hub) Unregister(c *Client) {
|
||||
}
|
||||
}
|
||||
|
||||
var docRoomsLeft []string
|
||||
if c.subscribedRooms != nil {
|
||||
for rID := range c.subscribedRooms {
|
||||
if room, ok := h.rooms[rID]; ok {
|
||||
@@ -131,52 +151,19 @@ func (h *Hub) Unregister(c *Client) {
|
||||
delete(h.rooms, rID)
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(rID, "doc:") {
|
||||
docRoomsLeft = append(docRoomsLeft, strings.TrimPrefix(rID, "doc:"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
userRemaining := 0
|
||||
if c.userID != "" && c.userID != "anonymous" {
|
||||
if uRoom, ok := h.userRooms[c.userID]; ok {
|
||||
delete(uRoom, c)
|
||||
userRemaining += len(uRoom)
|
||||
if len(uRoom) == 0 {
|
||||
delete(h.userRooms, c.userID)
|
||||
}
|
||||
}
|
||||
if sseRoom, ok := h.sseUserRooms[c.userID]; ok {
|
||||
userRemaining += len(sseRoom)
|
||||
}
|
||||
}
|
||||
close(c.send)
|
||||
|
||||
// If this user has no other active client connections, release their document locks
|
||||
var releasedDocIDs []string
|
||||
if userRemaining == 0 && c.userID != "" {
|
||||
now := time.Now()
|
||||
for docID, l := range h.locks {
|
||||
if l.userID == c.userID || l.expiresAt.Before(now) {
|
||||
releasedDocIDs = append(releasedDocIDs, docID)
|
||||
delete(h.locks, docID)
|
||||
}
|
||||
}
|
||||
}
|
||||
c.closeSend()
|
||||
h.mu.Unlock()
|
||||
|
||||
// Broadcast unlock events for released locks
|
||||
for _, docID := range releasedDocIDs {
|
||||
h.BroadcastToAll("DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID})
|
||||
}
|
||||
|
||||
// Broadcast awareness leave for doc rooms
|
||||
for _, docID := range docRoomsLeft {
|
||||
h.BroadcastRoom("doc:"+docID, "CRDT_AWARENESS_LEAVE", map[string]interface{}{
|
||||
"docId": docID,
|
||||
"userId": c.userID,
|
||||
}, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) RegisterSSE(c *SSEClient) {
|
||||
@@ -223,7 +210,6 @@ func (h *Hub) UnregisterSSE(c *SSEClient) {
|
||||
}
|
||||
}
|
||||
}
|
||||
var docRoomsLeft []string
|
||||
if c.subscribedRooms != nil {
|
||||
for rID := range c.subscribedRooms {
|
||||
if room, ok := h.sseRooms[rID]; ok {
|
||||
@@ -232,48 +218,18 @@ func (h *Hub) UnregisterSSE(c *SSEClient) {
|
||||
delete(h.sseRooms, rID)
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(rID, "doc:") {
|
||||
docRoomsLeft = append(docRoomsLeft, strings.TrimPrefix(rID, "doc:"))
|
||||
}
|
||||
}
|
||||
}
|
||||
userRemaining := 0
|
||||
if c.userID != "" && c.userID != "anonymous" {
|
||||
if uRoom, ok := h.sseUserRooms[c.userID]; ok {
|
||||
delete(uRoom, c)
|
||||
userRemaining += len(uRoom)
|
||||
if len(uRoom) == 0 {
|
||||
delete(h.sseUserRooms, c.userID)
|
||||
}
|
||||
}
|
||||
if wsRoom, ok := h.userRooms[c.userID]; ok {
|
||||
userRemaining += len(wsRoom)
|
||||
}
|
||||
}
|
||||
close(c.send)
|
||||
|
||||
var releasedDocIDs []string
|
||||
if userRemaining == 0 && c.userID != "" {
|
||||
now := time.Now()
|
||||
for docID, l := range h.locks {
|
||||
if l.userID == c.userID || l.expiresAt.Before(now) {
|
||||
releasedDocIDs = append(releasedDocIDs, docID)
|
||||
delete(h.locks, docID)
|
||||
}
|
||||
}
|
||||
}
|
||||
c.closeSend()
|
||||
h.mu.Unlock()
|
||||
|
||||
for _, docID := range releasedDocIDs {
|
||||
h.BroadcastToAll("DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID})
|
||||
}
|
||||
|
||||
for _, docID := range docRoomsLeft {
|
||||
h.BroadcastRoom("doc:"+docID, "CRDT_AWARENESS_LEAVE", map[string]interface{}{
|
||||
"docId": docID,
|
||||
"userId": c.userID,
|
||||
}, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) JoinSSERoomByID(sseID, roomID string) bool {
|
||||
@@ -315,120 +271,35 @@ func (h *Hub) LeaveSSERoomByID(sseID, roomID string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Document Locking Methods
|
||||
|
||||
func (h *Hub) AcquireDocLock(docID, userID, userName string, ttl time.Duration) (*domain.DocLockInfo, bool, *domain.DocLockInfo) {
|
||||
|
||||
// Close gracefully disconnects all WebSocket and SSE connections so HTTP shutdown completes immediately.
|
||||
func (h *Hub) Close() {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
existing, exists := h.locks[docID]
|
||||
if exists && existing.expiresAt.After(now) {
|
||||
if existing.userID != userID {
|
||||
// Locked by another user
|
||||
info := &domain.DocLockInfo{
|
||||
DocID: existing.docID,
|
||||
UserID: existing.userID,
|
||||
UserName: existing.userName,
|
||||
ExpiresAt: existing.expiresAt.Format(time.RFC3339),
|
||||
}
|
||||
return nil, false, info
|
||||
}
|
||||
// Same user extending lock
|
||||
existing.expiresAt = now.Add(ttl)
|
||||
if userName != "" {
|
||||
existing.userName = userName
|
||||
}
|
||||
info := &domain.DocLockInfo{
|
||||
DocID: existing.docID,
|
||||
UserID: existing.userID,
|
||||
UserName: existing.userName,
|
||||
ExpiresAt: existing.expiresAt.Format(time.RFC3339),
|
||||
}
|
||||
return info, true, nil
|
||||
}
|
||||
|
||||
// Create fresh lock
|
||||
item := &docLockItem{
|
||||
docID: docID,
|
||||
userID: userID,
|
||||
userName: userName,
|
||||
lockedAt: now,
|
||||
expiresAt: now.Add(ttl),
|
||||
}
|
||||
h.locks[docID] = item
|
||||
info := &domain.DocLockInfo{
|
||||
DocID: item.docID,
|
||||
UserID: item.userID,
|
||||
UserName: item.userName,
|
||||
ExpiresAt: item.expiresAt.Format(time.RFC3339),
|
||||
}
|
||||
return info, true, nil
|
||||
}
|
||||
|
||||
func (h *Hub) HeartbeatDocLock(docID, userID string, ttl time.Duration) bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
item, ok := h.locks[docID]
|
||||
if !ok || item.userID != userID || item.expiresAt.Before(now) {
|
||||
return false
|
||||
}
|
||||
item.expiresAt = now.Add(ttl)
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *Hub) ReleaseDocLock(docID, userID string, isAdmin bool) bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
item, ok := h.locks[docID]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if item.userID == userID || isAdmin || userID == "admin" {
|
||||
delete(h.locks, docID)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *Hub) GetDocLock(docID string) *domain.DocLockInfo {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
item, ok := h.locks[docID]
|
||||
if !ok || item.expiresAt.Before(time.Now()) {
|
||||
return nil
|
||||
}
|
||||
return &domain.DocLockInfo{
|
||||
DocID: item.docID,
|
||||
UserID: item.userID,
|
||||
UserName: item.userName,
|
||||
ExpiresAt: item.expiresAt.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) cleanupLocksLoop() {
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
h.mu.Lock()
|
||||
now := time.Now()
|
||||
var expiredDocIDs []string
|
||||
for docID, l := range h.locks {
|
||||
if l.expiresAt.Before(now) {
|
||||
expiredDocIDs = append(expiredDocIDs, docID)
|
||||
delete(h.locks, docID)
|
||||
}
|
||||
}
|
||||
if h.closed {
|
||||
h.mu.Unlock()
|
||||
return
|
||||
}
|
||||
h.closed = true
|
||||
|
||||
for _, docID := range expiredDocIDs {
|
||||
h.BroadcastToAll("DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID})
|
||||
}
|
||||
allWsClients := make([]*Client, 0, len(h.allClients))
|
||||
for c := range h.allClients {
|
||||
allWsClients = append(allWsClients, c)
|
||||
}
|
||||
|
||||
allSSEClients := make([]*SSEClient, 0, len(h.sseClients))
|
||||
for c := range h.sseClients {
|
||||
allSSEClients = append(allSSEClients, c)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
for _, c := range allWsClients {
|
||||
_ = c.conn.Close()
|
||||
c.closeSend()
|
||||
}
|
||||
|
||||
for _, c := range allSSEClients {
|
||||
c.closeSend()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,19 +317,17 @@ func (h *Hub) BroadcastToAll(event string, payload interface{}) {
|
||||
}
|
||||
|
||||
h.mu.RLock()
|
||||
if h.closed {
|
||||
h.mu.RUnlock()
|
||||
return
|
||||
}
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
for client := range h.allClients {
|
||||
select {
|
||||
case client.send <- data:
|
||||
default:
|
||||
}
|
||||
client.trySend(data)
|
||||
}
|
||||
for sseClient := range h.sseClients {
|
||||
select {
|
||||
case sseClient.send <- data:
|
||||
default:
|
||||
}
|
||||
sseClient.trySend(data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -474,22 +343,20 @@ func (h *Hub) BroadcastToUser(userID, event string, payload interface{}) {
|
||||
}
|
||||
|
||||
h.mu.RLock()
|
||||
if h.closed {
|
||||
h.mu.RUnlock()
|
||||
return
|
||||
}
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
if room, ok := h.userRooms[userID]; ok {
|
||||
for client := range room {
|
||||
select {
|
||||
case client.send <- data:
|
||||
default:
|
||||
}
|
||||
client.trySend(data)
|
||||
}
|
||||
}
|
||||
if sseRoom, ok := h.sseUserRooms[userID]; ok {
|
||||
for sseClient := range sseRoom {
|
||||
select {
|
||||
case sseClient.send <- data:
|
||||
default:
|
||||
}
|
||||
sseClient.trySend(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -519,6 +386,10 @@ func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) {
|
||||
}
|
||||
|
||||
h.mu.RLock()
|
||||
if h.closed {
|
||||
h.mu.RUnlock()
|
||||
return
|
||||
}
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
targetRoom := listID
|
||||
@@ -528,19 +399,12 @@ func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) {
|
||||
|
||||
if room, ok := h.rooms[targetRoom]; ok {
|
||||
for client := range room {
|
||||
select {
|
||||
case client.send <- data:
|
||||
default:
|
||||
// Slow client buffer full
|
||||
}
|
||||
client.trySend(data)
|
||||
}
|
||||
}
|
||||
if sseRoom, ok := h.sseRooms[targetRoom]; ok {
|
||||
for sseClient := range sseRoom {
|
||||
select {
|
||||
case sseClient.send <- data:
|
||||
default:
|
||||
}
|
||||
sseClient.trySend(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -588,6 +452,10 @@ func (h *Hub) BroadcastRoom(roomID, event string, payload interface{}, excludeCl
|
||||
}
|
||||
|
||||
h.mu.RLock()
|
||||
if h.closed {
|
||||
h.mu.RUnlock()
|
||||
return
|
||||
}
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
if room, ok := h.rooms[roomID]; ok {
|
||||
@@ -595,18 +463,12 @@ func (h *Hub) BroadcastRoom(roomID, event string, payload interface{}, excludeCl
|
||||
if excludeClient != nil && client == excludeClient {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case client.send <- data:
|
||||
default:
|
||||
}
|
||||
client.trySend(data)
|
||||
}
|
||||
}
|
||||
if sseRoom, ok := h.sseRooms[roomID]; ok {
|
||||
for sseClient := range sseRoom {
|
||||
select {
|
||||
case sseClient.send <- data:
|
||||
default:
|
||||
}
|
||||
sseClient.trySend(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -619,10 +481,7 @@ func (c *Client) SendEvent(event string, payload interface{}) {
|
||||
}
|
||||
data, err := json.Marshal(msg)
|
||||
if err == nil {
|
||||
select {
|
||||
case c.send <- data:
|
||||
default:
|
||||
}
|
||||
c.trySend(data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -767,21 +626,6 @@ func (c *Client) readPump() {
|
||||
c.hub.LeaveRoom(c, room)
|
||||
}
|
||||
}
|
||||
|
||||
if docID != "" {
|
||||
switch incoming.Event {
|
||||
case "LOCK_DOC":
|
||||
if info, ok, _ := c.hub.AcquireDocLock(docID, c.userID, "", 30*time.Second); ok && info != nil {
|
||||
c.hub.BroadcastDocEvent(docID, "DOC_LOCKED", info)
|
||||
}
|
||||
case "HEARTBEAT_DOC":
|
||||
c.hub.HeartbeatDocLock(docID, c.userID, 30*time.Second)
|
||||
case "UNLOCK_DOC":
|
||||
if c.hub.ReleaseDocLock(docID, c.userID, false) {
|
||||
c.hub.BroadcastDocEvent(docID, "DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHubCloseAndGracefulShutdown(t *testing.T) {
|
||||
hub := NewHub()
|
||||
|
||||
// Register multiple dummy SSE clients
|
||||
var sseClients []*SSEClient
|
||||
for i := 0; i < 5; i++ {
|
||||
client := &SSEClient{
|
||||
id: "sse-test",
|
||||
userID: "user-1",
|
||||
send: make(chan []byte, 10),
|
||||
}
|
||||
hub.RegisterSSE(client)
|
||||
sseClients = append(sseClients, client)
|
||||
}
|
||||
|
||||
// Verify broadcast works
|
||||
hub.BroadcastToAll("TEST_EVENT", map[string]string{"msg": "hello"})
|
||||
|
||||
// Concurrently invoke Close() and Broadcast() to verify no panics or deadlocks
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 50; i++ {
|
||||
hub.BroadcastToAll("SPAM", map[string]int{"i": i})
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
hub.Close()
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Verify that all SSE client channels are closed
|
||||
for _, c := range sseClients {
|
||||
select {
|
||||
case _, ok := <-c.send:
|
||||
if ok {
|
||||
// Drained a message, check next
|
||||
_, ok = <-c.send
|
||||
}
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user