Dev #8
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
|
avatar_url TEXT NOT NULL DEFAULT '', -- Profilbild URL oder leer
|
||||||
role TEXT NOT NULL DEFAULT 'member', -- 'admin' | 'member'
|
role TEXT NOT NULL DEFAULT 'member', -- 'admin' | 'member'
|
||||||
is_active INTEGER NOT NULL DEFAULT 1, -- 1 = aktiv, 0 = gesperrt
|
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_channels TEXT NOT NULL DEFAULT 'discord', -- Kommagetrennt: 'email', 'discord', 'webhook'
|
||||||
alarm_email TEXT NOT NULL DEFAULT '', -- Ziel-E-Mail für Alarme
|
alarm_email TEXT NOT NULL DEFAULT '', -- Ziel-E-Mail für Alarme
|
||||||
discord_webhook_url TEXT NOT NULL DEFAULT '', -- Discord Webhook URL
|
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
|
### 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-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-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-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
|
### FLAW-08: [GELÖST / IMPLEMENTIERT] JWT-Sicherheit, Token-Blacklisting & Token-Versionierung
|
||||||
- **Betroffene Komponenten:** [`architecture.md`](./architecture.md), `internal/api/auth.go`
|
- **Status:** **Gelöst via `token_version`-Spalte in `users`, `revoked_tokens`-Blacklist und serverseitige Invalidierungs-Endpunkte**
|
||||||
- **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.
|
- **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`
|
||||||
- **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.
|
- **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.
|
||||||
- **Empfohlene Lösung:**
|
- **Implementierte Lösung:**
|
||||||
1. Einführung einer `revoked_tokens`-Tabelle oder einer `token_version`-Spalte in der `users`-Tabelle.
|
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. Implementierung von kurzlebigen Access-Tokens (15 Min.) in Verbindung mit rotierenden Refresh-Tokens in `httpOnly`-Cookies.
|
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"
|
"embed"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
@@ -58,9 +59,15 @@ func main() {
|
|||||||
|
|
||||||
srv := api.NewServer(cfg, store, wsHub, staticFS)
|
srv := api.NewServer(cfg, store, wsHub, staticFS)
|
||||||
|
|
||||||
|
serverCtx, serverCancel := context.WithCancel(context.Background())
|
||||||
|
defer serverCancel()
|
||||||
|
|
||||||
httpServer := &http.Server{
|
httpServer := &http.Server{
|
||||||
Addr: ":" + cfg.Port,
|
Addr: ":" + cfg.Port,
|
||||||
Handler: srv.Router(),
|
Handler: srv.Router(),
|
||||||
|
BaseContext: func(l net.Listener) context.Context {
|
||||||
|
return serverCtx
|
||||||
|
},
|
||||||
ReadTimeout: 15 * time.Second,
|
ReadTimeout: 15 * time.Second,
|
||||||
WriteTimeout: 30 * time.Second,
|
WriteTimeout: 30 * time.Second,
|
||||||
IdleTimeout: 60 * time.Second,
|
IdleTimeout: 60 * time.Second,
|
||||||
@@ -79,7 +86,12 @@ func main() {
|
|||||||
<-stop
|
<-stop
|
||||||
|
|
||||||
slog.Info("Shutting down MindSift server gracefully...")
|
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()
|
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">
|
<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"/>
|
<rect width="512" height="512" rx="128" fill="#8ab4f8"/>
|
||||||
<circle cx="256" cy="256" r="164" fill="none" stroke="#8ab4f8" stroke-width="36"/>
|
<g transform="translate(80, 80) scale(14.666)" fill="none" stroke="#0b0f19" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M184 266 L236 318 L338 194" fill="none" stroke="#c58af9" stroke-width="40" 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>
|
</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 [activeTab, setActiveTab] = useState<ActiveTab>('triage');
|
||||||
const [previousTab, setPreviousTab] = useState<ActiveTab>('triage');
|
const [previousTab, setPreviousTab] = useState<ActiveTab>('triage');
|
||||||
const [selectedDocId, setSelectedDocId] = useState<string | null>(null);
|
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 [captureFocusCount, setCaptureFocusCount] = useState(0);
|
||||||
const [refreshRevision, setRefreshRevision] = useState(0);
|
const [refreshRevision, setRefreshRevision] = useState(0);
|
||||||
|
|
||||||
@@ -143,6 +145,7 @@ export const App: React.FC = () => {
|
|||||||
const [mobileVoiceActive, setMobileVoiceActive] = useState(false);
|
const [mobileVoiceActive, setMobileVoiceActive] = useState(false);
|
||||||
const [isVoiceCaptured, setIsVoiceCaptured] = useState(false);
|
const [isVoiceCaptured, setIsVoiceCaptured] = useState(false);
|
||||||
const mobileRecognitionRef = useRef<any>(null);
|
const mobileRecognitionRef = useRef<any>(null);
|
||||||
|
const mobileBaseVoiceRef = useRef('');
|
||||||
|
|
||||||
const toggleMobileVoice = () => {
|
const toggleMobileVoice = () => {
|
||||||
const SpeechRecognition =
|
const SpeechRecognition =
|
||||||
@@ -157,6 +160,7 @@ export const App: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
mobileBaseVoiceRef.current = mobileTriageContent.replace(/\s*\[…\]$/, '').trim();
|
||||||
const recognition = new SpeechRecognition();
|
const recognition = new SpeechRecognition();
|
||||||
recognition.lang = 'de-DE';
|
recognition.lang = 'de-DE';
|
||||||
recognition.continuous = false;
|
recognition.continuous = false;
|
||||||
@@ -170,10 +174,8 @@ export const App: React.FC = () => {
|
|||||||
for (let i = 0; i < event.results.length; i++) {
|
for (let i = 0; i < event.results.length; i++) {
|
||||||
transcript += event.results[i][0].transcript;
|
transcript += event.results[i][0].transcript;
|
||||||
}
|
}
|
||||||
setMobileTriageContent((prev) => {
|
const base = mobileBaseVoiceRef.current;
|
||||||
const base = prev.replace(/\s*\[…\]$/, '').trim();
|
setMobileTriageContent(base ? `${base} ${transcript}` : transcript);
|
||||||
return base ? `${base} ${transcript}` : transcript;
|
|
||||||
});
|
|
||||||
setIsVoiceCaptured(true);
|
setIsVoiceCaptured(true);
|
||||||
};
|
};
|
||||||
recognition.onerror = () => setMobileVoiceActive(false);
|
recognition.onerror = () => setMobileVoiceActive(false);
|
||||||
@@ -249,13 +251,6 @@ export const App: React.FC = () => {
|
|||||||
id: 'triage-nav',
|
id: 'triage-nav',
|
||||||
title: 'Bereiche wechseln',
|
title: 'Bereiche wechseln',
|
||||||
items: [
|
items: [
|
||||||
{
|
|
||||||
id: 'goto-tasks',
|
|
||||||
label: 'Aufgaben & Kanban',
|
|
||||||
icon: <CheckSquare className="w-4 h-4" />,
|
|
||||||
badge: stats.tasksOpenCount,
|
|
||||||
onClick: () => setActiveTab('tasks'),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'goto-workspace',
|
id: 'goto-workspace',
|
||||||
label: 'Notizen & Workspace',
|
label: 'Notizen & Workspace',
|
||||||
@@ -263,6 +258,13 @@ export const App: React.FC = () => {
|
|||||||
badge: stats.docsCount,
|
badge: stats.docsCount,
|
||||||
onClick: () => setActiveTab('workspace'),
|
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',
|
id: 'goto-lists',
|
||||||
label: 'Einkaufslisten',
|
label: 'Einkaufslisten',
|
||||||
@@ -297,18 +299,18 @@ export const App: React.FC = () => {
|
|||||||
variant: 'accent',
|
variant: 'accent',
|
||||||
onClick: () => setActiveTab('triage'),
|
onClick: () => setActiveTab('triage'),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'goto-tasks',
|
|
||||||
label: 'Zu den Aufgaben',
|
|
||||||
icon: <CheckSquare className="w-4 h-4" />,
|
|
||||||
onClick: () => setActiveTab('tasks'),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'goto-workspace',
|
id: 'goto-workspace',
|
||||||
label: 'Zum Workspace',
|
label: 'Zum Workspace',
|
||||||
icon: <FileText className="w-4 h-4" />,
|
icon: <FileText className="w-4 h-4" />,
|
||||||
onClick: () => setActiveTab('workspace'),
|
onClick: () => setActiveTab('workspace'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'goto-tasks',
|
||||||
|
label: 'Zu den Aufgaben',
|
||||||
|
icon: <CheckSquare className="w-4 h-4" />,
|
||||||
|
onClick: () => setActiveTab('tasks'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'goto-lists',
|
id: 'goto-lists',
|
||||||
label: 'Zu den Listen',
|
label: 'Zu den Listen',
|
||||||
@@ -549,11 +551,17 @@ export const App: React.FC = () => {
|
|||||||
refreshAll();
|
refreshAll();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenDocument = (docId: string) => {
|
const handleOpenDocument = (docId: string, blockId?: string) => {
|
||||||
setSelectedDocId(docId);
|
setSelectedDocId(docId);
|
||||||
|
setSelectedBlockId(blockId || null);
|
||||||
setActiveTab('workspace');
|
setActiveTab('workspace');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOpenTask = useCallback((taskId: string) => {
|
||||||
|
setSelectedTaskId(taskId);
|
||||||
|
setActiveTab('tasks');
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleTriggerAddButton = () => {
|
const handleTriggerAddButton = () => {
|
||||||
triggerHaptic(20);
|
triggerHaptic(20);
|
||||||
if (window.innerWidth < 640) {
|
if (window.innerWidth < 640) {
|
||||||
@@ -567,7 +575,11 @@ export const App: React.FC = () => {
|
|||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
triggerHaptic(20);
|
triggerHaptic(20);
|
||||||
disconnectWebSocket();
|
disconnectWebSocket();
|
||||||
clearToken();
|
try {
|
||||||
|
await api.logout();
|
||||||
|
} catch {
|
||||||
|
clearToken();
|
||||||
|
}
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setShowProfileMenu(false);
|
setShowProfileMenu(false);
|
||||||
setActiveTab('triage');
|
setActiveTab('triage');
|
||||||
@@ -632,9 +644,12 @@ export const App: React.FC = () => {
|
|||||||
<Brain className="w-4 h-4" />
|
<Brain className="w-4 h-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="font-bold text-base text-main tracking-tight">
|
<div className="flex flex-col">
|
||||||
{authStatus.instanceName || 'MindSift'}
|
<span className="font-bold text-base text-main tracking-tight leading-none">
|
||||||
</span>
|
{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">
|
<span className="text-[11px] font-semibold text-accent bg-accent/15 px-1.5 py-0.5 rounded-md">
|
||||||
{activeTabLabelMap[activeTab]}
|
{activeTabLabelMap[activeTab]}
|
||||||
</span>
|
</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">
|
<div className="w-7 h-7 bg-accent text-on-accent rounded-btn flex items-center justify-center">
|
||||||
<Brain className="w-4 h-4" />
|
<Brain className="w-4 h-4" />
|
||||||
</div>
|
</div>
|
||||||
<span className="font-bold text-sm sm:text-base text-main tracking-tight leading-none">
|
<div className="flex flex-col">
|
||||||
{authStatus.instanceName || 'MindSift'}
|
<span className="font-bold text-sm sm:text-base text-main tracking-tight leading-none">
|
||||||
</span>
|
{authStatus.instanceName || 'MindSift'}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-muted font-mono leading-tight mt-0.5">v0.1</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick-Capture Input Bubble directly in Header (above Tabs) */}
|
{/* Quick-Capture Input Bubble directly in Header (above Tabs) */}
|
||||||
@@ -862,24 +880,6 @@ export const App: React.FC = () => {
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
triggerHaptic(10);
|
triggerHaptic(10);
|
||||||
@@ -898,6 +898,24 @@ export const App: React.FC = () => {
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
triggerHaptic(10);
|
triggerHaptic(10);
|
||||||
@@ -950,6 +968,7 @@ export const App: React.FC = () => {
|
|||||||
onRefreshStats={refreshAll}
|
onRefreshStats={refreshAll}
|
||||||
onOpenDocument={handleOpenDocument}
|
onOpenDocument={handleOpenDocument}
|
||||||
refreshTrigger={refreshRevision}
|
refreshTrigger={refreshRevision}
|
||||||
|
initialSelectedTaskId={selectedTaskId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -957,7 +976,9 @@ export const App: React.FC = () => {
|
|||||||
<WorkspaceView
|
<WorkspaceView
|
||||||
currentUser={user}
|
currentUser={user}
|
||||||
initialDocId={selectedDocId}
|
initialDocId={selectedDocId}
|
||||||
|
initialBlockId={selectedBlockId}
|
||||||
onStatsChange={refreshAll}
|
onStatsChange={refreshAll}
|
||||||
|
onOpenTask={handleOpenTask}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1024,27 +1045,27 @@ export const App: React.FC = () => {
|
|||||||
<span className="text-[10px] leading-tight">Triage</span>
|
<span className="text-[10px] leading-tight">Triage</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Col 2: Aufgaben */}
|
{/* Col 2: Workspace */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
triggerHaptic(10);
|
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 ${
|
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">
|
<div className="relative">
|
||||||
<CheckSquare className="w-5 h-5" />
|
<FileText className="w-5 h-5" />
|
||||||
{stats.tasksOpenCount > 0 && (
|
{(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">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] leading-tight">Aufgaben</span>
|
<span className="text-[10px] leading-tight">Workspace</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Col 3 (EXACT CENTER): + Quick-Add FAB */}
|
{/* Col 3 (EXACT CENTER): + Quick-Add FAB */}
|
||||||
@@ -1066,27 +1087,27 @@ export const App: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Col 4: Workspace */}
|
{/* Col 4: Aufgaben */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
triggerHaptic(10);
|
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 ${
|
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">
|
<div className="relative">
|
||||||
<FileText className="w-5 h-5" />
|
<CheckSquare className="w-5 h-5" />
|
||||||
{(stats.docsTodosOpenCount ?? 0) > 0 && (
|
{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">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] leading-tight">Workspace</span>
|
<span className="text-[10px] leading-tight">Aufgaben</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Col 5: Listen */}
|
{/* Col 5: Listen */}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export interface UnifiedShareModalProps {
|
|||||||
teams: Team[];
|
teams: Team[];
|
||||||
collaborators: User[];
|
collaborators: User[];
|
||||||
allowRoleSelection?: boolean; // Default true (editor vs viewer)
|
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>;
|
onAddShare: (targetType: 'team' | 'user', targetId: string, permission: 'editor' | 'viewer') => Promise<void>;
|
||||||
onRemoveShare: (shareId: string) => Promise<void>;
|
onRemoveShare: (shareId: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -40,13 +41,17 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
|
|||||||
teams,
|
teams,
|
||||||
collaborators,
|
collaborators,
|
||||||
allowRoleSelection = true,
|
allowRoleSelection = true,
|
||||||
|
fixedPermission,
|
||||||
onAddShare,
|
onAddShare,
|
||||||
onRemoveShare,
|
onRemoveShare,
|
||||||
}) => {
|
}) => {
|
||||||
|
const effectiveFixedPerm = fixedPermission || 'viewer';
|
||||||
const [shareType, setShareType] = useState<'team' | 'user'>('team');
|
const [shareType, setShareType] = useState<'team' | 'user'>('team');
|
||||||
const [selectedTeamId, setSelectedTeamId] = useState('');
|
const [selectedTeamId, setSelectedTeamId] = useState('');
|
||||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
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 [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -63,7 +68,8 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
|
|||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await onAddShare(shareType, targetId, selectedPermission);
|
const permToUse = allowRoleSelection ? selectedPermission : effectiveFixedPerm;
|
||||||
|
await onAddShare(shareType, targetId, permToUse);
|
||||||
setSelectedTeamId('');
|
setSelectedTeamId('');
|
||||||
setSelectedUser(null);
|
setSelectedUser(null);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -196,7 +202,7 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{allowRoleSelection && (
|
{allowRoleSelection ? (
|
||||||
<select
|
<select
|
||||||
value={selectedPermission}
|
value={selectedPermission}
|
||||||
onChange={(e) => setSelectedPermission(e.target.value as 'editor' | 'viewer')}
|
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="editor" className="bg-panel text-main">Bearbeiten</option>
|
||||||
<option value="viewer" className="bg-panel text-main">Nur Lesen</option>
|
<option value="viewer" className="bg-panel text-main">Nur Lesen</option>
|
||||||
</select>
|
</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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -220,7 +240,7 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
|
|||||||
placeholder="Benutzer suchen (Name oder @Benutzername)…"
|
placeholder="Benutzer suchen (Name oder @Benutzername)…"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{allowRoleSelection && (
|
{allowRoleSelection ? (
|
||||||
<select
|
<select
|
||||||
value={selectedPermission}
|
value={selectedPermission}
|
||||||
onChange={(e) => setSelectedPermission(e.target.value as 'editor' | 'viewer')}
|
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="editor" className="bg-panel text-main">Bearbeiten</option>
|
||||||
<option value="viewer" className="bg-panel text-main">Nur Lesen</option>
|
<option value="viewer" className="bg-panel text-main">Nur Lesen</option>
|
||||||
</select>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const containerRef = useRef<HTMLFormElement>(null);
|
const containerRef = useRef<HTMLFormElement>(null);
|
||||||
const recognitionRef = useRef<any>(null);
|
const recognitionRef = useRef<any>(null);
|
||||||
|
const baseVoiceTextRef = useRef('');
|
||||||
|
const hasVoiceInputRef = useRef(false);
|
||||||
|
|
||||||
const adjustHeight = (el?: HTMLTextAreaElement | null) => {
|
const adjustHeight = (el?: HTMLTextAreaElement | null) => {
|
||||||
const targetEl = el || inputRef.current;
|
const targetEl = el || inputRef.current;
|
||||||
@@ -131,6 +133,9 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
baseVoiceTextRef.current = content.replace(/\s*\[…\]$/, '').trim();
|
||||||
|
hasVoiceInputRef.current = true;
|
||||||
|
|
||||||
const recognition = new SpeechRecognition();
|
const recognition = new SpeechRecognition();
|
||||||
recognition.lang = 'de-DE';
|
recognition.lang = 'de-DE';
|
||||||
recognition.continuous = false;
|
recognition.continuous = false;
|
||||||
@@ -146,10 +151,8 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
for (let i = 0; i < event.results.length; i++) {
|
for (let i = 0; i < event.results.length; i++) {
|
||||||
transcript += event.results[i][0].transcript;
|
transcript += event.results[i][0].transcript;
|
||||||
}
|
}
|
||||||
setContent((prev) => {
|
const base = baseVoiceTextRef.current;
|
||||||
const base = prev.replace(/\s*\[…\]$/, '').trim();
|
setContent(base ? `${base} ${transcript}` : transcript);
|
||||||
return base ? `${base} ${transcript}` : transcript;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
recognition.onerror = () => {
|
recognition.onerror = () => {
|
||||||
@@ -180,7 +183,8 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
overrideListId || targetListId || (lists.length > 0 ? lists[0].id : '');
|
overrideListId || targetListId || (lists.length > 0 ? lists[0].id : '');
|
||||||
const capturedPriority = priority;
|
const capturedPriority = priority;
|
||||||
const capturedDueDate = dueDate;
|
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
|
// Optimistically clear input immediately so the UI feels instant (<10ms) in all browsers
|
||||||
setContent('');
|
setContent('');
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
X,
|
X,
|
||||||
Hash,
|
Hash,
|
||||||
|
ShoppingCart,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { ListItem, SharedList, ListShare, Team, User, WSMessage } from '../../lib/types';
|
import { ListItem, SharedList, ListShare, Team, User, WSMessage } from '../../lib/types';
|
||||||
import { api, buildWebSocketURL, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api';
|
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 [newItemText, setNewItemText] = useState('');
|
||||||
const [newItemQty, setNewItemQty] = useState('');
|
const [newItemQty, setNewItemQty] = useState('');
|
||||||
const [isSubmittingItem, setIsSubmittingItem] = useState(false);
|
const [isSubmittingItem, setIsSubmittingItem] = useState(false);
|
||||||
|
const [editingQtyItemId, setEditingQtyItemId] = useState<string | null>(null);
|
||||||
|
const [editingQtyValue, setEditingQtyValue] = useState('');
|
||||||
|
|
||||||
const [wsConnected, setWsConnected] = useState(false);
|
const [wsConnected, setWsConnected] = useState(false);
|
||||||
const [realtimeTransport, setRealtimeTransport] = useState<RealtimeTransport>('none');
|
const [realtimeTransport, setRealtimeTransport] = useState<RealtimeTransport>('none');
|
||||||
@@ -173,7 +176,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
return { ...l, items: deduplicateItems([newItem, ...l.items]) };
|
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;
|
const updatedItem: ListItem = msg.payload;
|
||||||
setLists((prev) =>
|
setLists((prev) =>
|
||||||
prev.map((l) => {
|
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) => {
|
const handleToggleItem = async (item: ListItem) => {
|
||||||
if (!activeList) return;
|
if (!activeList) return;
|
||||||
triggerHaptic(10);
|
triggerHaptic(10);
|
||||||
@@ -953,41 +976,61 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showNewListModal && (
|
{showNewListModal && (
|
||||||
<form
|
<div className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center p-4">
|
||||||
onSubmit={handleCreateList}
|
<div className="w-full max-w-sm panel-surface shadow-panel p-4 space-y-3">
|
||||||
className="p-2 panel-surface flex flex-wrap items-center gap-2"
|
<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">
|
||||||
<div className="flex items-center gap-1.5">
|
<ShoppingCart className="w-4 h-4 text-accent" />
|
||||||
{['🛒', '🏔️', '🍕', '🎉', '🧰', '🎁'].map((ic) => (
|
<span>Neue Einkaufsliste erstellen</span>
|
||||||
|
</h3>
|
||||||
<button
|
<button
|
||||||
key={ic}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setNewListIcon(ic)}
|
onClick={() => setShowNewListModal(false)}
|
||||||
className={`w-9 h-9 flex items-center justify-center rounded-btn transition-colors ${
|
className="text-muted hover:text-main"
|
||||||
newListIcon === ic
|
|
||||||
? 'bg-accent/20 border border-accent text-accent'
|
|
||||||
: 'bg-canvas hover:bg-panel-elevated border border-border'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{ic}
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</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>
|
</div>
|
||||||
<input
|
</div>
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Active Shared List View */}
|
{/* Active Shared List View */}
|
||||||
@@ -1064,8 +1107,8 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={newItemQty}
|
value={newItemQty}
|
||||||
onChange={(e) => setNewItemQty(e.target.value)}
|
onChange={(e) => setNewItemQty(e.target.value.replace(/[^0-9.,xX]/g, ''))}
|
||||||
placeholder="Menge (z.B. 2x)"
|
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"
|
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
|
<button
|
||||||
@@ -1175,10 +1218,45 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
{displayContent}
|
{displayContent}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{item.quantity && (
|
{editingQtyItemId === item.id ? (
|
||||||
<span className="badge-accent">
|
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||||
{item.quantity}
|
<input
|
||||||
</span>
|
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 && (
|
{cat && !group.label && (
|
||||||
@@ -1253,8 +1331,45 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
{displayContent}
|
{displayContent}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{item.quantity && (
|
{editingQtyItemId === item.id ? (
|
||||||
<span className="text-xs text-faint">({item.quantity})</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
const [newPassword, setNewPassword] = useState('');
|
const [newPassword, setNewPassword] = useState('');
|
||||||
const [profileMsg, setProfileMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
const [profileMsg, setProfileMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
||||||
const [savingProfile, setSavingProfile] = useState(false);
|
const [savingProfile, setSavingProfile] = useState(false);
|
||||||
|
const [revokingSessions, setRevokingSessions] = useState(false);
|
||||||
const [isProcessingAvatar, setIsProcessingAvatar] = useState(false);
|
const [isProcessingAvatar, setIsProcessingAvatar] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
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 ---
|
// --- Handlers: Alarms ---
|
||||||
const handleSaveAlarms = async (e: React.FormEvent) => {
|
const handleSaveAlarms = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -530,6 +546,32 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Thought, SubTask, TaskList, KanbanColumn } from '../../lib/types';
|
import { Thought, SubTask, TaskList, KanbanColumn } from '../../lib/types';
|
||||||
import { triggerHaptic } from '../../lib/api';
|
import { triggerHaptic } from '../../lib/api';
|
||||||
|
import { MarkdownViewer } from '../../lib/markdown';
|
||||||
|
|
||||||
interface TaskDetailModalProps {
|
interface TaskDetailModalProps {
|
||||||
task: Thought | null;
|
task: Thought | null;
|
||||||
@@ -36,6 +37,7 @@ interface TaskDetailModalProps {
|
|||||||
onAddSubtask: (task: Thought, title: string) => Promise<void>;
|
onAddSubtask: (task: Thought, title: string) => Promise<void>;
|
||||||
onDeleteSubtask: (task: Thought, subtaskId: string) => Promise<void>;
|
onDeleteSubtask: (task: Thought, subtaskId: string) => Promise<void>;
|
||||||
onConvertToDoc: (task: Thought) => void;
|
onConvertToDoc: (task: Thought) => void;
|
||||||
|
onOpenDocument?: (docId: string, blockId?: string) => void;
|
||||||
onDeleteTask: (taskId: string) => void;
|
onDeleteTask: (taskId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +58,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
|||||||
onAddSubtask,
|
onAddSubtask,
|
||||||
onDeleteSubtask,
|
onDeleteSubtask,
|
||||||
onConvertToDoc,
|
onConvertToDoc,
|
||||||
|
onOpenDocument,
|
||||||
onDeleteTask,
|
onDeleteTask,
|
||||||
}) => {
|
}) => {
|
||||||
if (!isOpen || !task) return null;
|
if (!isOpen || !task) return null;
|
||||||
@@ -202,18 +205,35 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<button
|
{(task.convertedType === 'document_block' || task.docId) ? (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => {
|
type="button"
|
||||||
onClose();
|
onClick={() => {
|
||||||
onConvertToDoc(task);
|
onClose();
|
||||||
}}
|
const docId = task.docId || (task.convertedRefId ? task.convertedRefId.split(':')[0] : '');
|
||||||
title="Als Dokument / Notiz vertiefen"
|
const blockId = task.blockId || (task.convertedRefId ? task.convertedRefId.split(':')[1] : '');
|
||||||
className="btn-action min-h-[38px] sm:min-h-0 h-9 sm:h-7 px-2.5 sm:px-2 text-xs flex items-center gap-1.5"
|
if (docId && onOpenDocument) onOpenDocument(docId, blockId);
|
||||||
>
|
}}
|
||||||
<FileText className="w-4 h-4 sm:w-3.5 sm:h-3.5 text-accent" />
|
title="Zur verlinkten Notiz springen"
|
||||||
<span className="hidden sm:inline">Als Notiz vertiefen</span>
|
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"
|
||||||
</button>
|
>
|
||||||
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -656,10 +676,10 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
|||||||
setNotesDraft(task.notes || '');
|
setNotesDraft(task.notes || '');
|
||||||
setIsEditingNotes(true);
|
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"
|
title="Klicken zum Bearbeiten"
|
||||||
>
|
>
|
||||||
{task.notes}
|
<MarkdownViewer content={task.notes} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -46,8 +46,9 @@ const getInitials = (name?: string): string => {
|
|||||||
interface TasksViewProps {
|
interface TasksViewProps {
|
||||||
currentUser?: User | null;
|
currentUser?: User | null;
|
||||||
onRefreshStats: () => void;
|
onRefreshStats: () => void;
|
||||||
onOpenDocument: (docId: string) => void;
|
onOpenDocument: (docId: string, blockId?: string) => void;
|
||||||
refreshTrigger?: number;
|
refreshTrigger?: number;
|
||||||
|
initialSelectedTaskId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TasksView: React.FC<TasksViewProps> = ({
|
export const TasksView: React.FC<TasksViewProps> = ({
|
||||||
@@ -55,6 +56,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
onRefreshStats,
|
onRefreshStats,
|
||||||
onOpenDocument,
|
onOpenDocument,
|
||||||
refreshTrigger = 0,
|
refreshTrigger = 0,
|
||||||
|
initialSelectedTaskId,
|
||||||
}) => {
|
}) => {
|
||||||
const [viewMode, setViewMode] = useState<'list' | 'kanban'>('list');
|
const [viewMode, setViewMode] = useState<'list' | 'kanban'>('list');
|
||||||
const [filter, setFilter] = useState<'open' | 'done' | 'discarded'>('open');
|
const [filter, setFilter] = useState<'open' | 'done' | 'discarded'>('open');
|
||||||
@@ -101,6 +103,18 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
// Detail modal state
|
// Detail modal state
|
||||||
const [detailTaskId, setDetailTaskId] = useState<string | null>(null);
|
const [detailTaskId, setDetailTaskId] = useState<string | null>(null);
|
||||||
const [showDetailModal, setShowDetailModal] = useState(false);
|
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
|
// Manage columns modal state
|
||||||
const [showManageColumnsModal, setShowManageColumnsModal] = useState(false);
|
const [showManageColumnsModal, setShowManageColumnsModal] = useState(false);
|
||||||
@@ -243,8 +257,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
|
|
||||||
const detailTask = useMemo(() => {
|
const detailTask = useMemo(() => {
|
||||||
if (!detailTaskId) return null;
|
if (!detailTaskId) return null;
|
||||||
return tasks.find((t) => t.id === detailTaskId) || null;
|
return tasks.find((t) => t.id === detailTaskId) || (extraTask?.id === detailTaskId ? extraTask : null);
|
||||||
}, [detailTaskId, tasks]);
|
}, [detailTaskId, tasks, extraTask]);
|
||||||
|
|
||||||
const safeTeams = Array.isArray(teams) ? teams : [];
|
const safeTeams = Array.isArray(teams) ? teams : [];
|
||||||
const safeCollaborators = Array.isArray(collaborators) ? collaborators : [];
|
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"
|
className="flex flex-wrap items-center gap-1.5 pt-0.5 empty:hidden"
|
||||||
onClick={(e) => e.stopPropagation()}
|
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) */}
|
{/* Kanban status selector pill (Desktop only - on mobile done via TaskDetailModal) */}
|
||||||
<select
|
<select
|
||||||
value={task.kanbanStatus || (isCompleted ? 'done' : 'todo')}
|
value={task.kanbanStatus || (isCompleted ? 'done' : 'todo')}
|
||||||
@@ -1978,6 +2009,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
teams={safeTeams}
|
teams={safeTeams}
|
||||||
collaborators={safeCollaborators}
|
collaborators={safeCollaborators}
|
||||||
allowRoleSelection={false}
|
allowRoleSelection={false}
|
||||||
|
fixedPermission="editor"
|
||||||
onAddShare={async (targetType, targetId) => {
|
onAddShare={async (targetType, targetId) => {
|
||||||
await api.addTaskListShare(sharingTaskList.id, {
|
await api.addTaskListShare(sharingTaskList.id, {
|
||||||
targetType,
|
targetType,
|
||||||
@@ -2029,6 +2061,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
}}
|
}}
|
||||||
activeColumns={activeColumns}
|
activeColumns={activeColumns}
|
||||||
taskLists={safeTaskLists}
|
taskLists={safeTaskLists}
|
||||||
|
onOpenDocument={onOpenDocument}
|
||||||
onUpdateTitle={handleUpdateTaskTitle}
|
onUpdateTitle={handleUpdateTaskTitle}
|
||||||
onUpdateStatus={handleSetKanbanStatus}
|
onUpdateStatus={handleSetKanbanStatus}
|
||||||
onUpdatePriority={handleSetPriority}
|
onUpdatePriority={handleSetPriority}
|
||||||
@@ -2163,8 +2196,24 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
</div>
|
</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()}>
|
<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' && (
|
{task.priority === 'urgent' && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -7,18 +7,19 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
Loader2,
|
Loader2,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Hash,
|
|
||||||
Lock,
|
Lock,
|
||||||
Home,
|
Home,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Minimize2,
|
Minimize2,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Plus,
|
Plus,
|
||||||
|
Search,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Document, DocumentBlock, User } from '../../lib/types';
|
import { Document, DocumentBlock, User } from '../../lib/types';
|
||||||
import { api } from '../../lib/api';
|
import { api, getToken } from '../../lib/api';
|
||||||
import { useDocumentSync, DocSyncEvent, RemoteCursor, DocSyncStatus } from '../../lib/useDocumentSync';
|
import { OutlinerTree, OutlinerTreeHandle } from './OutlinerTree';
|
||||||
import { OutlinerTree } from './OutlinerTree';
|
|
||||||
|
export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error';
|
||||||
|
|
||||||
export interface DocumentEditorProps {
|
export interface DocumentEditorProps {
|
||||||
docId: string;
|
docId: string;
|
||||||
@@ -33,10 +34,67 @@ export interface DocumentEditorProps {
|
|||||||
onSelectTag: (tag: string) => void;
|
onSelectTag: (tag: string) => void;
|
||||||
onWikiLinkClick?: (pageTitle: string) => void;
|
onWikiLinkClick?: (pageTitle: string) => void;
|
||||||
onBackMobile?: () => 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[] {
|
function ensureNonEmptyBlocks(docId: string, blocks?: DocumentBlock[]): DocumentBlock[] {
|
||||||
if (!blocks || blocks.length === 0) {
|
if (!blocks || blocks.length === 0) {
|
||||||
@@ -144,7 +202,6 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
docId,
|
docId,
|
||||||
currentUser,
|
currentUser,
|
||||||
initialDoc,
|
initialDoc,
|
||||||
availableFolders,
|
|
||||||
autoFocusTitle,
|
autoFocusTitle,
|
||||||
initialFocusedBlockId,
|
initialFocusedBlockId,
|
||||||
onDocMetadataChange,
|
onDocMetadataChange,
|
||||||
@@ -153,7 +210,8 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
onSelectTag,
|
onSelectTag,
|
||||||
onWikiLinkClick,
|
onWikiLinkClick,
|
||||||
onBackMobile,
|
onBackMobile,
|
||||||
onSyncStatusChange,
|
onOpenTask,
|
||||||
|
onSaveStatusChange,
|
||||||
}) => {
|
}) => {
|
||||||
const [doc, setDoc] = useState<Document | null>(initialDoc || null);
|
const [doc, setDoc] = useState<Document | null>(initialDoc || null);
|
||||||
const [title, setTitle] = useState<string>(initialDoc?.title || '');
|
const [title, setTitle] = useState<string>(initialDoc?.title || '');
|
||||||
@@ -163,13 +221,36 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
const [loading, setLoading] = useState<boolean>(!initialDoc);
|
const [loading, setLoading] = useState<boolean>(!initialDoc);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false);
|
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false);
|
||||||
|
const [emojiSearch, setEmojiSearch] = useState<string>('');
|
||||||
const [zoomedBlockId, setZoomedBlockId] = useState<string | null>(null);
|
const [zoomedBlockId, setZoomedBlockId] = useState<string | null>(null);
|
||||||
|
|
||||||
const titleInputRef = useRef<HTMLInputElement | null>(null);
|
const filteredEmojis = useMemo(() => {
|
||||||
const titleDebounceRef = useRef<number | null>(null);
|
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);
|
const docRef = useRef<Document | null>(doc);
|
||||||
docRef.current = 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(() => {
|
useEffect(() => {
|
||||||
if (!loading && (autoFocusTitle || (doc?.title === 'Neue Notiz' && (!doc?.blocks || doc?.blocks.length <= 1)))) {
|
if (!loading && (autoFocusTitle || (doc?.title === 'Neue Notiz' && (!doc?.blocks || doc?.blocks.length <= 1)))) {
|
||||||
const focusTitle = () => {
|
const focusTitle = () => {
|
||||||
@@ -202,186 +283,12 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
return computeBreadcrumbs(title || doc?.title || 'Dokument', blocks, zoomedBlockId);
|
return computeBreadcrumbs(title || doc?.title || 'Dokument', blocks, zoomedBlockId);
|
||||||
}, [title, doc?.title, 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(() => {
|
const canEdit = useMemo(() => {
|
||||||
if (!doc) return true;
|
if (!doc) return true;
|
||||||
if (doc.isOwner) return true;
|
return Boolean(doc.isOwner || doc.permission === 'owner');
|
||||||
return doc.permission === 'owner' || doc.permission === 'write';
|
|
||||||
}, [doc]);
|
}, [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
|
// Extract all #tags present across the current blocks
|
||||||
const extractedTags = useMemo(() => {
|
const extractedTags = useMemo(() => {
|
||||||
const tagSet = new Set<string>();
|
const tagSet = new Set<string>();
|
||||||
@@ -396,6 +303,174 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
return Array.from(tagSet);
|
return Array.from(tagSet);
|
||||||
}, [blocks]);
|
}, [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
|
// Keep parent sidebar metadata in sync when blocks/tags change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!docRef.current) return;
|
if (!docRef.current) return;
|
||||||
@@ -410,118 +485,154 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
onDocMetadataChange(updatedDoc);
|
onDocMetadataChange(updatedDoc);
|
||||||
}, [blocks, extractedTags, title]);
|
}, [blocks, extractedTags, title]);
|
||||||
|
|
||||||
// Active remote users in this room (excluding self)
|
const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
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>) => {
|
|
||||||
if (!canEdit) return;
|
if (!canEdit) return;
|
||||||
const nextTitle = e.target.value;
|
setTitle(e.target.value);
|
||||||
setTitle(nextTitle);
|
|
||||||
|
|
||||||
if (titleDebounceRef.current !== null) {
|
|
||||||
window.clearTimeout(titleDebounceRef.current);
|
|
||||||
}
|
|
||||||
titleDebounceRef.current = window.setTimeout(() => {
|
|
||||||
titleDebounceRef.current = null;
|
|
||||||
sendTitleUpdated(nextTitle);
|
|
||||||
}, 250);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTitleBlur = () => {
|
const handleTitleBlur = () => {
|
||||||
if (titleDebounceRef.current !== null) {
|
executeAutoSave();
|
||||||
window.clearTimeout(titleDebounceRef.current);
|
|
||||||
titleDebounceRef.current = null;
|
|
||||||
sendTitleUpdated(title);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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(
|
const handleUpdateBlock = useCallback(
|
||||||
(updatedBlock: DocumentBlock) => {
|
(updatedBlock: DocumentBlock, immediate: boolean = false) => {
|
||||||
setBlocks((prev) =>
|
if (!canEdit) return;
|
||||||
prev.map((b) => (b.id === updatedBlock.id ? updatedBlock : b))
|
setBlocks((prev) => {
|
||||||
);
|
const next = prev.map((b) => (b.id === updatedBlock.id ? updatedBlock : b));
|
||||||
sendBlockUpdated(updatedBlock);
|
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(
|
const handleCreateBlock = useCallback(
|
||||||
(newBlock: DocumentBlock, afterBlockId?: string) => {
|
(newBlock: DocumentBlock, afterBlockId?: string) => {
|
||||||
|
if (!canEdit) return;
|
||||||
setBlocks((prev) => {
|
setBlocks((prev) => {
|
||||||
const next = [...prev];
|
const next = [...prev];
|
||||||
if (afterBlockId) {
|
if (afterBlockId) {
|
||||||
const idx = next.findIndex((b) => b.id === afterBlockId);
|
const idx = next.findIndex((b) => b.id === afterBlockId);
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
next.splice(idx + 1, 0, newBlock);
|
next.splice(idx + 1, 0, newBlock);
|
||||||
const normalized = ensureNonEmptyBlocks(docId, next);
|
return ensureNonEmptyBlocks(docId, next);
|
||||||
sendBlockCreated(newBlock, afterBlockId, normalized);
|
|
||||||
return normalized;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
next.push(newBlock);
|
next.push(newBlock);
|
||||||
const normalized = ensureNonEmptyBlocks(docId, next);
|
return ensureNonEmptyBlocks(docId, next);
|
||||||
sendBlockCreated(newBlock, afterBlockId, normalized);
|
|
||||||
return normalized;
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[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(
|
const handleDeleteBlock = useCallback(
|
||||||
(blockId: string) => {
|
(blockId: string) => {
|
||||||
|
if (!canEdit) return;
|
||||||
setBlocks((prev) => {
|
setBlocks((prev) => {
|
||||||
const filtered = prev.filter((b) => b.id !== blockId);
|
const filtered = prev.filter((b) => b.id !== blockId);
|
||||||
const normalized = ensureNonEmptyBlocks(docId, filtered);
|
return ensureNonEmptyBlocks(docId, filtered);
|
||||||
sendBlockDeleted(blockId, normalized);
|
|
||||||
return normalized;
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[docId, sendBlockDeleted]
|
[docId, canEdit]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleMoveBlock = useCallback(
|
const handleMoveBlock = useCallback(
|
||||||
(blockId: string, newIndex: number, level?: number) => {
|
(blockId: string, targetIndex: number, newLevel?: number) => {
|
||||||
|
if (!canEdit) return;
|
||||||
setBlocks((prev) => {
|
setBlocks((prev) => {
|
||||||
const fromIdx = prev.findIndex((b) => b.id === blockId);
|
const fromIdx = prev.findIndex((b) => b.id === blockId);
|
||||||
if (fromIdx === -1) return prev;
|
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 copy = [...prev];
|
||||||
const [moving] = copy.splice(fromIdx, 1);
|
const subtree = copy.splice(fromIdx, count);
|
||||||
const updatedMoving: DocumentBlock = {
|
|
||||||
...moving,
|
// Adjust insertion index because removing elements from before shifts target index
|
||||||
level: level !== undefined ? level : moving.level,
|
let insertIndex = targetIndex;
|
||||||
};
|
if (fromIdx < targetIndex) {
|
||||||
const boundedIdx = Math.max(0, Math.min(newIndex, copy.length));
|
insertIndex = targetIndex - count;
|
||||||
copy.splice(boundedIdx, 0, updatedMoving);
|
}
|
||||||
const normalized = ensureNonEmptyBlocks(docId, copy);
|
insertIndex = Math.max(0, Math.min(insertIndex, copy.length));
|
||||||
sendBlockMoved(blockId, boundedIdx, updatedMoving.level, normalized);
|
|
||||||
return normalized;
|
// 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 () => {
|
const handleTogglePin = async () => {
|
||||||
@@ -680,37 +791,9 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Status & Action Buttons */}
|
{/* Right Action Buttons */}
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{/* Active Presence Avatars (Immer rund: rounded-full mit Überlappung) */}
|
{/* Sharing Section */}
|
||||||
{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) */}
|
|
||||||
{doc && (() => {
|
{doc && (() => {
|
||||||
const isOwner = doc.isOwner !== false && (doc.userId === currentUser?.id || !doc.userId || currentUser?.role === 'admin');
|
const isOwner = doc.isOwner !== false && (doc.userId === currentUser?.id || !doc.userId || currentUser?.role === 'admin');
|
||||||
const sharesCount = doc.sharedWith?.length || 0;
|
const sharesCount = doc.sharedWith?.length || 0;
|
||||||
@@ -790,14 +873,21 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* 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 ? (
|
{zoomedBlock ? (
|
||||||
/* =========================================================================
|
/* =========================================================================
|
||||||
FOKUS-MODUS & ZOOM-IN (HOISTED SUBTREE VIEW)
|
FOKUS-MODUS & ZOOM-IN (HOISTED SUBTREE VIEW)
|
||||||
========================================================================= */
|
========================================================================= */
|
||||||
<div className="space-y-3">
|
<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 items-start justify-between gap-3 pb-4 mb-2 border-b border-border">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1.5">
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
@@ -846,7 +936,6 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Subtree Children or Empty State */}
|
|
||||||
{childBlocks.length === 0 ? (
|
{childBlocks.length === 0 ? (
|
||||||
<div className="py-8 text-center text-xs text-faint border border-dashed border-border rounded-card my-2">
|
<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>
|
<p className="mb-2">Dieser Ast hat noch keine Unterpunkte.</p>
|
||||||
@@ -878,12 +967,13 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
levelOffset={levelOffset}
|
levelOffset={levelOffset}
|
||||||
onZoomBlock={setZoomedBlockId}
|
onZoomBlock={setZoomedBlockId}
|
||||||
remoteCursors={remoteCursors}
|
|
||||||
sendCursorMoved={sendCursorMoved}
|
|
||||||
onUpdateBlock={handleUpdateBlock}
|
onUpdateBlock={handleUpdateBlock}
|
||||||
|
onUpdateBlocks={handleUpdateBlocks}
|
||||||
onCreateBlock={handleCreateBlock}
|
onCreateBlock={handleCreateBlock}
|
||||||
|
onInsertBlocks={handleInsertBlocks}
|
||||||
onDeleteBlock={handleDeleteBlock}
|
onDeleteBlock={handleDeleteBlock}
|
||||||
onMoveBlock={handleMoveBlock}
|
onMoveBlock={handleMoveBlock}
|
||||||
|
onOpenTask={onOpenTask}
|
||||||
onTagClick={onSelectTag}
|
onTagClick={onSelectTag}
|
||||||
onWikiLinkClick={onWikiLinkClick}
|
onWikiLinkClick={onWikiLinkClick}
|
||||||
initialFocusedBlockId={initialFocusedBlockId}
|
initialFocusedBlockId={initialFocusedBlockId}
|
||||||
@@ -907,47 +997,119 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
{doc?.icon || '📝'}
|
{doc?.icon || '📝'}
|
||||||
</button>
|
</button>
|
||||||
{showEmojiPicker && canEdit && (
|
{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">
|
<div
|
||||||
{EMOJI_PALETTE.map((emoji) => (
|
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
|
<button
|
||||||
key={emoji}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleSelectIcon(emoji)}
|
onClick={() => {
|
||||||
className="p-1.5 rounded-btn hover:bg-panel-elevated text-base flex items-center justify-center cursor-pointer"
|
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>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
<textarea
|
||||||
ref={titleInputRef}
|
ref={(el) => {
|
||||||
type="text"
|
titleInputRef.current = el;
|
||||||
|
if (el) {
|
||||||
|
el.style.height = 'auto';
|
||||||
|
el.style.height = `${el.scrollHeight}px`;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
rows={1}
|
||||||
value={title}
|
value={title}
|
||||||
readOnly={!canEdit}
|
readOnly={!canEdit}
|
||||||
autoFocus={autoFocusTitle}
|
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}
|
onBlur={handleTitleBlur}
|
||||||
placeholder="Titel der Notiz…"
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Logseq Outliner Tree */}
|
{/* Logseq Outliner Tree */}
|
||||||
<OutlinerTree
|
<OutlinerTree
|
||||||
|
ref={outlinerTreeRef}
|
||||||
docId={docId}
|
docId={docId}
|
||||||
blocks={blocks}
|
blocks={blocks}
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
levelOffset={0}
|
levelOffset={0}
|
||||||
onZoomBlock={setZoomedBlockId}
|
onZoomBlock={setZoomedBlockId}
|
||||||
remoteCursors={remoteCursors}
|
|
||||||
sendCursorMoved={sendCursorMoved}
|
|
||||||
onUpdateBlock={handleUpdateBlock}
|
onUpdateBlock={handleUpdateBlock}
|
||||||
|
onUpdateBlocks={handleUpdateBlocks}
|
||||||
onCreateBlock={handleCreateBlock}
|
onCreateBlock={handleCreateBlock}
|
||||||
|
onInsertBlocks={handleInsertBlocks}
|
||||||
onDeleteBlock={handleDeleteBlock}
|
onDeleteBlock={handleDeleteBlock}
|
||||||
onMoveBlock={handleMoveBlock}
|
onMoveBlock={handleMoveBlock}
|
||||||
|
onOpenTask={onOpenTask}
|
||||||
onTagClick={onSelectTag}
|
onTagClick={onSelectTag}
|
||||||
onWikiLinkClick={onWikiLinkClick}
|
onWikiLinkClick={onWikiLinkClick}
|
||||||
initialFocusedBlockId={initialFocusedBlockId}
|
initialFocusedBlockId={initialFocusedBlockId}
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Check,
|
Check,
|
||||||
|
CheckSquare,
|
||||||
X,
|
X,
|
||||||
GripVertical,
|
Trash2,
|
||||||
|
MoreHorizontal,
|
||||||
Code,
|
Code,
|
||||||
Heading1,
|
Heading1,
|
||||||
Heading2,
|
Heading2,
|
||||||
@@ -19,8 +21,9 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { DocumentBlock, BlockType } from '../../lib/types';
|
import { DocumentBlock, BlockType } from '../../lib/types';
|
||||||
import { RemoteCursor } from '../../lib/useDocumentSync';
|
|
||||||
import { LinkModal } from './LinkModal';
|
import { LinkModal } from './LinkModal';
|
||||||
|
import { renderInlineMarkdown } from '../../lib/markdown';
|
||||||
|
import { api } from '../../lib/api';
|
||||||
|
|
||||||
export interface SlashCommand {
|
export interface SlashCommand {
|
||||||
type: BlockType | 'link';
|
type: BlockType | 'link';
|
||||||
@@ -41,22 +44,23 @@ const SLASH_COMMANDS: SlashCommand[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export interface OutlinerBlockProps {
|
export interface OutlinerBlockProps {
|
||||||
|
docId: string;
|
||||||
block: DocumentBlock;
|
block: DocumentBlock;
|
||||||
index: number;
|
index: number;
|
||||||
|
isLast: boolean;
|
||||||
maxAllowedLevel: number;
|
maxAllowedLevel: number;
|
||||||
hasChildren: boolean;
|
hasChildren: boolean;
|
||||||
isFocused: boolean;
|
isFocused: boolean;
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
activeRemoteCursors?: RemoteCursor[];
|
|
||||||
sendCursorMoved?: (blockId: string | null, selectionStart?: number, selectionEnd?: number) => void;
|
|
||||||
levelOffset?: number;
|
levelOffset?: number;
|
||||||
onZoomBlock?: (blockId: string) => void;
|
onZoomBlock?: (blockId: string) => void;
|
||||||
onFocusBlock: (blockId: string) => void;
|
|
||||||
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
|
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
|
||||||
onCreateBlockBelow: (afterBlockId: string, currentContent: string) => void;
|
onCreateBlockBelow: (afterBlockId: string, newContent?: string) => void;
|
||||||
onDeleteBlock: (blockId: string) => void;
|
onDeleteBlock: (blockId: string, deleteSubtree?: boolean) => void;
|
||||||
onIndentBlock: (blockId: string, delta: 1 | -1) => void;
|
onIndentBlock: (blockId: string, delta: 1 | -1) => void;
|
||||||
onNavigateFocus: (blockId: string, direction: 'up' | 'down') => void;
|
onNavigateFocus: (blockId: string, direction: 'up' | 'down') => void;
|
||||||
|
onFocusBlock?: (blockId: string) => void;
|
||||||
|
onOpenTask?: (taskId: string) => void;
|
||||||
onTagClick?: (tag: string) => void;
|
onTagClick?: (tag: string) => void;
|
||||||
onWikiLinkClick?: (pageTitle: string) => void;
|
onWikiLinkClick?: (pageTitle: string) => void;
|
||||||
onDragStartBlock?: (index: number) => void;
|
onDragStartBlock?: (index: number) => void;
|
||||||
@@ -65,24 +69,27 @@ export interface OutlinerBlockProps {
|
|||||||
isDragTarget?: boolean;
|
isDragTarget?: boolean;
|
||||||
isTargeted?: boolean;
|
isTargeted?: boolean;
|
||||||
registerTextareaRef: (blockId: string, el: HTMLTextAreaElement | null) => void;
|
registerTextareaRef: (blockId: string, el: HTMLTextAreaElement | null) => void;
|
||||||
|
onPasteMultiLines?: (blockId: string, textBefore: string, textAfter: string, rawLines: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||||
|
docId,
|
||||||
block,
|
block,
|
||||||
index,
|
index,
|
||||||
|
isLast,
|
||||||
|
maxAllowedLevel,
|
||||||
hasChildren,
|
hasChildren,
|
||||||
isFocused,
|
isFocused,
|
||||||
canEdit,
|
canEdit,
|
||||||
activeRemoteCursors = [],
|
|
||||||
sendCursorMoved,
|
|
||||||
levelOffset = 0,
|
levelOffset = 0,
|
||||||
onZoomBlock,
|
onZoomBlock,
|
||||||
onFocusBlock,
|
|
||||||
onUpdateBlock,
|
onUpdateBlock,
|
||||||
onCreateBlockBelow,
|
onCreateBlockBelow,
|
||||||
onDeleteBlock,
|
onDeleteBlock,
|
||||||
onIndentBlock,
|
onIndentBlock,
|
||||||
onNavigateFocus,
|
onNavigateFocus,
|
||||||
|
onFocusBlock,
|
||||||
|
onOpenTask,
|
||||||
onTagClick,
|
onTagClick,
|
||||||
onWikiLinkClick,
|
onWikiLinkClick,
|
||||||
onDragStartBlock,
|
onDragStartBlock,
|
||||||
@@ -91,11 +98,15 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
isDragTarget,
|
isDragTarget,
|
||||||
isTargeted,
|
isTargeted,
|
||||||
registerTextareaRef,
|
registerTextareaRef,
|
||||||
|
onPasteMultiLines,
|
||||||
}) => {
|
}) => {
|
||||||
const [localContent, setLocalContent] = useState<string>(block.content || '');
|
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 [slashSearch, setSlashSearch] = useState<string | null>(null);
|
||||||
const [slashIndex, setSlashIndex] = useState(0);
|
const [slashIndex, setSlashIndex] = useState(0);
|
||||||
const [showLinkModal, setShowLinkModal] = useState<boolean>(false);
|
const [showLinkModal, setShowLinkModal] = useState<boolean>(false);
|
||||||
|
const [showTodoMenu, setShowTodoMenu] = useState<boolean>(false);
|
||||||
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
const debounceTimerRef = useRef<number | 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
|
// Callback refs to avoid stale closures in setTimeout, debounce flush, and event listeners
|
||||||
const onUpdateBlockRef = useRef(onUpdateBlock);
|
const onUpdateBlockRef = useRef(onUpdateBlock);
|
||||||
onUpdateBlockRef.current = onUpdateBlock;
|
onUpdateBlockRef.current = onUpdateBlock;
|
||||||
const onFocusBlockRef = useRef(onFocusBlock);
|
|
||||||
onFocusBlockRef.current = onFocusBlock;
|
|
||||||
const onCreateBlockBelowRef = useRef(onCreateBlockBelow);
|
const onCreateBlockBelowRef = useRef(onCreateBlockBelow);
|
||||||
onCreateBlockBelowRef.current = onCreateBlockBelow;
|
onCreateBlockBelowRef.current = onCreateBlockBelow;
|
||||||
|
const onPasteMultiLinesRef = useRef(onPasteMultiLines);
|
||||||
|
onPasteMultiLinesRef.current = onPasteMultiLines;
|
||||||
const onDeleteBlockRef = useRef(onDeleteBlock);
|
const onDeleteBlockRef = useRef(onDeleteBlock);
|
||||||
onDeleteBlockRef.current = onDeleteBlock;
|
onDeleteBlockRef.current = onDeleteBlock;
|
||||||
const onIndentBlockRef = useRef(onIndentBlock);
|
const onIndentBlockRef = useRef(onIndentBlock);
|
||||||
onIndentBlockRef.current = onIndentBlock;
|
onIndentBlockRef.current = onIndentBlock;
|
||||||
const onNavigateFocusRef = useRef(onNavigateFocus);
|
const onNavigateFocusRef = useRef(onNavigateFocus);
|
||||||
onNavigateFocusRef.current = onNavigateFocus;
|
onNavigateFocusRef.current = onNavigateFocus;
|
||||||
const sendCursorMovedRef = useRef(sendCursorMoved);
|
const isPastingRef = useRef<boolean>(false);
|
||||||
sendCursorMovedRef.current = sendCursorMoved;
|
|
||||||
|
|
||||||
// Track if this input currently has actual focus in an active browser window
|
// Track if this input currently has actual focus in an active browser window
|
||||||
const isFocusedRef = useRef<boolean>(false);
|
const isFocusedRef = useRef<boolean>(false);
|
||||||
@@ -166,7 +176,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
flushDebounce(localContentRef.current);
|
flushDebounce(localContentRef.current);
|
||||||
}
|
}
|
||||||
isFocusedRef.current = false;
|
isFocusedRef.current = false;
|
||||||
sendCursorMovedRef.current?.(null);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleWindowFocus = () => {
|
const handleWindowFocus = () => {
|
||||||
@@ -203,7 +212,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
adjustHeight();
|
adjustHeight();
|
||||||
}, [localContent, block.type, block.level, adjustHeight]);
|
}, [localContent, block.type, block.level, activeFocus, adjustHeight]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.addEventListener('resize', adjustHeight);
|
window.addEventListener('resize', adjustHeight);
|
||||||
@@ -279,7 +288,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setLocalContent(val);
|
setLocalContent(val);
|
||||||
sendCursorMoved?.(block.id, e.target.selectionStart, e.target.selectionEnd);
|
|
||||||
|
|
||||||
if (debounceTimerRef.current !== null) {
|
if (debounceTimerRef.current !== null) {
|
||||||
window.clearTimeout(debounceTimerRef.current);
|
window.clearTimeout(debounceTimerRef.current);
|
||||||
@@ -302,14 +310,20 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
}, 150);
|
}, 150);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFocus = (e: React.FocusEvent<HTMLTextAreaElement>) => {
|
const handleFocus = () => {
|
||||||
isFocusedRef.current = true;
|
isFocusedRef.current = true;
|
||||||
onFocusBlockRef.current(block.id);
|
setIsLocallyFocused(true);
|
||||||
sendCursorMovedRef.current?.(block.id, e.target.selectionStart, e.target.selectionEnd);
|
onFocusBlock?.(block.id);
|
||||||
|
setTimeout(adjustHeight, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBlur = () => {
|
const handleBlur = () => {
|
||||||
isFocusedRef.current = false;
|
isFocusedRef.current = false;
|
||||||
|
setIsLocallyFocused(false);
|
||||||
|
if (isPastingRef.current) {
|
||||||
|
isPastingRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (debounceTimerRef.current !== null) {
|
if (debounceTimerRef.current !== null) {
|
||||||
window.clearTimeout(debounceTimerRef.current);
|
window.clearTimeout(debounceTimerRef.current);
|
||||||
debounceTimerRef.current = null;
|
debounceTimerRef.current = null;
|
||||||
@@ -317,23 +331,30 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
} else if (localContentRef.current !== latestBlockRef.current.content) {
|
} else if (localContentRef.current !== latestBlockRef.current.content) {
|
||||||
flushDebounce(localContentRef.current);
|
flushDebounce(localContentRef.current);
|
||||||
}
|
}
|
||||||
sendCursorMovedRef.current?.(null);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClick = (e: React.MouseEvent<HTMLTextAreaElement>) => {
|
const handlePaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||||
sendCursorMovedRef.current?.(block.id, e.currentTarget.selectionStart, e.currentTarget.selectionEnd);
|
if (!canEdit || block.type === 'code') return;
|
||||||
};
|
const pastedText = e.clipboardData.getData('text');
|
||||||
|
if (!pastedText || !pastedText.includes('\n')) return;
|
||||||
|
|
||||||
const handleKeyUp = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
e.preventDefault();
|
||||||
sendCursorMovedRef.current?.(block.id, e.currentTarget.selectionStart, e.currentTarget.selectionEnd);
|
const rawLines = pastedText.split(/\r?\n/);
|
||||||
};
|
if (rawLines.length <= 1) return;
|
||||||
|
|
||||||
const handleSelect = (e: React.SyntheticEvent<HTMLTextAreaElement>) => {
|
const start = e.currentTarget.selectionStart ?? localContent.length;
|
||||||
const target = e.currentTarget;
|
const end = e.currentTarget.selectionEnd ?? start;
|
||||||
sendCursorMovedRef.current?.(block.id, target.selectionStart, target.selectionEnd);
|
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>) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
if (!canEdit) return;
|
if (!canEdit) return;
|
||||||
@@ -374,13 +395,49 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
return;
|
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') {
|
if (e.key === 'Enter' && !e.shiftKey && block.type !== 'code') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setSlashSearch(null);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,6 +479,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
|
|
||||||
const level = Math.max(0, block.level || 0);
|
const level = Math.max(0, block.level || 0);
|
||||||
const visualLevel = Math.max(0, level - (levelOffset || 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)
|
// Parse rich tokens (WikiLinks [[Page]], Markdown Links [Title](url), raw URLs, and #Tags)
|
||||||
const tokens = useMemo(() => {
|
const tokens = useMemo(() => {
|
||||||
@@ -441,39 +499,29 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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
|
isDragTarget
|
||||||
? 'border-t-2 border-accent bg-accent/10'
|
? 'border-t-2 border-accent bg-accent/10'
|
||||||
: isTargeted
|
: isTargeted
|
||||||
? 'bg-accent/15 ring-2 ring-accent'
|
? 'bg-accent/15 ring-2 ring-accent'
|
||||||
: isFocused
|
: activeFocus
|
||||||
? 'bg-panel-elevated'
|
? 'bg-panel-elevated'
|
||||||
: 'hover:bg-panel'
|
: 'hover:bg-panel'
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
paddingLeft: `${visualLevel * 22 + 4}px`,
|
paddingLeft: `${visualLevel * 22 + 4}px`,
|
||||||
...(primaryRemoteCursor
|
|
||||||
? { borderLeft: `2px solid ${primaryRemoteCursor.userColor}` }
|
|
||||||
: {}),
|
|
||||||
}}
|
}}
|
||||||
onDragOver={(e) => onDragOverBlock?.(e, index)}
|
onDragOver={(e) => onDragOverBlock?.(e, index)}
|
||||||
onDrop={() => onDropBlock?.(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 */}
|
{/* Indentation Guide Line */}
|
||||||
{visualLevel > 0 &&
|
{visualLevel > 0 &&
|
||||||
Array.from({ length: visualLevel }).map((_, lvlIdx) => (
|
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">
|
<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 ? (
|
{hasChildren ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -517,7 +553,38 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
<span className="w-3.5" />
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
@@ -533,28 +600,11 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
>
|
>
|
||||||
<Check className="w-3 h-3 stroke-[3]" />
|
<Check className="w-3 h-3 stroke-[3]" />
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Main Block Content */}
|
{/* 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' ? (
|
{block.type === 'divider' ? (
|
||||||
<div className="py-2">
|
<div className="py-2">
|
||||||
<hr className="border-t border-border" />
|
<hr className="border-t border-border" />
|
||||||
@@ -581,9 +631,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
readOnly={!canEdit}
|
readOnly={!canEdit}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
onClick={handleClick}
|
|
||||||
onKeyUp={handleKeyUp}
|
|
||||||
onSelect={handleSelect}
|
|
||||||
onChange={handleContentChange}
|
onChange={handleContentChange}
|
||||||
onKeyDown={handleKeyDown}
|
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"
|
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}
|
readOnly={!canEdit}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
onClick={handleClick}
|
|
||||||
onKeyUp={handleKeyUp}
|
|
||||||
onSelect={handleSelect}
|
|
||||||
onChange={handleContentChange}
|
onChange={handleContentChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
className="w-full resize-none bg-transparent text-sm text-main border-0 focus:outline-none focus:ring-0 leading-relaxed italic"
|
className="w-full resize-none bg-transparent text-sm text-main border-0 focus:outline-none focus:ring-0 leading-relaxed italic"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<textarea
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
textareaRef.current = el;
|
textareaRef.current = el;
|
||||||
@@ -621,17 +785,12 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
readOnly={!canEdit}
|
readOnly={!canEdit}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
onClick={handleClick}
|
|
||||||
onKeyUp={handleKeyUp}
|
|
||||||
onSelect={handleSelect}
|
|
||||||
onChange={handleContentChange}
|
onChange={handleContentChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={
|
onPaste={handlePaste}
|
||||||
index === 0 && level === 0
|
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] ${
|
||||||
? 'Schreibe einen Punkt… (Enter = Neu, Tab = Einrücken, Shift+Tab = Ausrücken, / für Befehle)'
|
activeFocus ? 'relative z-0 text-main' : 'absolute inset-0 text-transparent selection:text-transparent pointer-events-none'
|
||||||
: ''
|
} ${
|
||||||
}
|
|
||||||
className={`w-full resize-none overflow-hidden bg-transparent border-0 focus:outline-none focus:ring-0 leading-relaxed whitespace-pre-wrap break-words ${
|
|
||||||
block.type === 'h1'
|
block.type === 'h1'
|
||||||
? 'text-lg font-bold text-main tracking-tight'
|
? 'text-lg font-bold text-main tracking-tight'
|
||||||
: block.type === 'h2'
|
: block.type === 'h2'
|
||||||
@@ -643,45 +802,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
: 'text-sm text-main'
|
: '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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -804,15 +924,23 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
<Maximize2 className="w-3.5 h-3.5" />
|
<Maximize2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Delete button on hover */}
|
{/* Delete button on hover (Trash2 icon) */}
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onDeleteBlockRef.current(block.id)}
|
onClick={() => {
|
||||||
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"
|
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"
|
title="Block löschen"
|
||||||
>
|
>
|
||||||
<X className="w-3.5 h-3.5" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,42 +1,47 @@
|
|||||||
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
import React, { useState, useRef, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
|
||||||
import { Plus } from 'lucide-react';
|
import { DocumentBlock, BlockType } from '../../lib/types';
|
||||||
import { DocumentBlock } from '../../lib/types';
|
|
||||||
import { RemoteCursor } from '../../lib/useDocumentSync';
|
|
||||||
import { OutlinerBlock } from './OutlinerBlock';
|
import { OutlinerBlock } from './OutlinerBlock';
|
||||||
|
|
||||||
|
export interface OutlinerTreeHandle {
|
||||||
|
focusFirstBlock: () => void;
|
||||||
|
focusBlock: (blockId: string, cursor?: 'start' | 'end') => void;
|
||||||
|
}
|
||||||
|
|
||||||
export interface OutlinerTreeProps {
|
export interface OutlinerTreeProps {
|
||||||
docId: string;
|
docId: string;
|
||||||
blocks: DocumentBlock[];
|
blocks: DocumentBlock[];
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
levelOffset?: number;
|
levelOffset?: number;
|
||||||
onZoomBlock?: (blockId: string) => void;
|
onZoomBlock?: (blockId: string) => void;
|
||||||
remoteCursors?: Record<string, RemoteCursor>;
|
|
||||||
sendCursorMoved?: (blockId: string | null, selectionStart?: number, selectionEnd?: number) => void;
|
|
||||||
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
|
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
|
||||||
|
onUpdateBlocks?: (updated: DocumentBlock[]) => void;
|
||||||
onCreateBlock: (newBlock: DocumentBlock, afterBlockId?: string) => void;
|
onCreateBlock: (newBlock: DocumentBlock, afterBlockId?: string) => void;
|
||||||
|
onInsertBlocks?: (afterBlockId: string, updatedCurrent: DocumentBlock, newBlocks: DocumentBlock[]) => void;
|
||||||
onDeleteBlock: (blockId: string) => void;
|
onDeleteBlock: (blockId: string) => void;
|
||||||
onMoveBlock: (blockId: string, newIndex: number, level?: number) => void;
|
onMoveBlock: (blockId: string, newIndex: number, level?: number) => void;
|
||||||
|
onOpenTask?: (taskId: string) => void;
|
||||||
onTagClick?: (tag: string) => void;
|
onTagClick?: (tag: string) => void;
|
||||||
onWikiLinkClick?: (pageTitle: string) => void;
|
onWikiLinkClick?: (pageTitle: string) => void;
|
||||||
initialFocusedBlockId?: string | null;
|
initialFocusedBlockId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||||
docId,
|
docId,
|
||||||
blocks,
|
blocks,
|
||||||
canEdit,
|
canEdit,
|
||||||
levelOffset = 0,
|
levelOffset = 0,
|
||||||
onZoomBlock,
|
onZoomBlock,
|
||||||
remoteCursors,
|
|
||||||
sendCursorMoved,
|
|
||||||
onUpdateBlock,
|
onUpdateBlock,
|
||||||
|
onUpdateBlocks,
|
||||||
onCreateBlock,
|
onCreateBlock,
|
||||||
|
onInsertBlocks,
|
||||||
onDeleteBlock,
|
onDeleteBlock,
|
||||||
onMoveBlock,
|
onMoveBlock,
|
||||||
|
onOpenTask,
|
||||||
onTagClick,
|
onTagClick,
|
||||||
onWikiLinkClick,
|
onWikiLinkClick,
|
||||||
initialFocusedBlockId,
|
initialFocusedBlockId,
|
||||||
}) => {
|
}, ref) => {
|
||||||
const [focusedBlockId, setFocusedBlockId] = useState<string | null>(null);
|
const [focusedBlockId, setFocusedBlockId] = useState<string | null>(null);
|
||||||
const [highlightedBlockId, setHighlightedBlockId] = useState<string | null>(
|
const [highlightedBlockId, setHighlightedBlockId] = useState<string | null>(
|
||||||
initialFocusedBlockId || 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) => {
|
const registerTextareaRef = useCallback((blockId: string, el: HTMLTextAreaElement | null) => {
|
||||||
if (el) {
|
if (el) {
|
||||||
textareaMapRef.current.set(blockId, el);
|
textareaMapRef.current.set(blockId, el);
|
||||||
@@ -164,22 +195,21 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}, [blocks]);
|
}, [blocks, levelOffset]);
|
||||||
|
|
||||||
const handleCreateBlockBelow = useCallback(
|
const handleCreateBlockBelow = useCallback(
|
||||||
(afterBlockId: string, _currentContent: string) => {
|
(afterBlockId: string, newContent: string = '') => {
|
||||||
if (!canEdit) return;
|
if (!canEdit) return;
|
||||||
const idx = blocks.findIndex((b) => b.id === afterBlockId);
|
const idx = blocks.findIndex((b) => b.id === afterBlockId);
|
||||||
const currentBlock = idx >= 0 ? blocks[idx] : null;
|
const currentBlock = idx >= 0 ? blocks[idx] : null;
|
||||||
const level = currentBlock ? Math.max(0, currentBlock.level || 0) : 0;
|
const level = currentBlock ? Math.max(0, currentBlock.level || 0) : 0;
|
||||||
const inheritedType =
|
const inheritedType: BlockType = 'text';
|
||||||
currentBlock && currentBlock.type === 'todo' ? 'todo' : 'text';
|
|
||||||
|
|
||||||
const newBlock: DocumentBlock = {
|
const newBlock: DocumentBlock = {
|
||||||
id: `blk_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
|
id: `blk_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
|
||||||
documentId: docId,
|
documentId: docId,
|
||||||
type: inheritedType,
|
type: inheritedType,
|
||||||
content: '',
|
content: newContent,
|
||||||
level,
|
level,
|
||||||
checked: false,
|
checked: false,
|
||||||
collapsed: false,
|
collapsed: false,
|
||||||
@@ -193,8 +223,252 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
|||||||
[blocks, canEdit, docId, onCreateBlock]
|
[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(
|
const handleDeleteBlock = useCallback(
|
||||||
(blockId: string) => {
|
(blockId: string, deleteSubtree = false) => {
|
||||||
if (!canEdit) return;
|
if (!canEdit) return;
|
||||||
const idx = blocks.findIndex((b) => b.id === blockId);
|
const idx = blocks.findIndex((b) => b.id === blockId);
|
||||||
if (idx === -1) return;
|
if (idx === -1) return;
|
||||||
@@ -219,9 +493,25 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
|||||||
if (targetFocusBlock) {
|
if (targetFocusBlock) {
|
||||||
focusBlockInput(targetFocusBlock.id, 'end');
|
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(
|
const handleIndentBlock = useCallback(
|
||||||
@@ -233,9 +523,9 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
|||||||
const current = blocks[idx];
|
const current = blocks[idx];
|
||||||
const currentLevel = Math.max(0, current.level || 0);
|
const currentLevel = Math.max(0, current.level || 0);
|
||||||
const prevLevel = idx > 0 ? Math.max(0, blocks[idx - 1].level || 0) : 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 minLevel = levelOffset || 0;
|
||||||
|
const maxLevel = idx === 0 ? minLevel : prevLevel + 1;
|
||||||
|
|
||||||
const nextLevel =
|
const nextLevel =
|
||||||
delta === 1
|
delta === 1
|
||||||
? Math.min(maxLevel, currentLevel + 1)
|
? Math.min(maxLevel, currentLevel + 1)
|
||||||
@@ -243,15 +533,38 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
|||||||
|
|
||||||
if (nextLevel === currentLevel) return;
|
if (nextLevel === currentLevel) return;
|
||||||
|
|
||||||
onUpdateBlock(
|
const actualDelta = nextLevel - currentLevel;
|
||||||
{
|
|
||||||
...current,
|
// Find all contiguous children / descendants:
|
||||||
level: nextLevel,
|
// contiguous blocks with level > currentLevel
|
||||||
},
|
let count = 1;
|
||||||
true
|
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(
|
const handleNavigateFocus = useCallback(
|
||||||
@@ -274,10 +587,29 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
|||||||
setDraggedIndex(index);
|
setDraggedIndex(index);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDragOverBlock = useCallback((e: React.DragEvent, index: number) => {
|
const handleDragOverBlock = useCallback(
|
||||||
e.preventDefault();
|
(e: React.DragEvent, index: number) => {
|
||||||
setDragOverIndex(index);
|
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(
|
const handleDropBlock = useCallback(
|
||||||
(targetIndex: number) => {
|
(targetIndex: number) => {
|
||||||
@@ -286,6 +618,20 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
|||||||
setDragOverIndex(null);
|
setDragOverIndex(null);
|
||||||
return;
|
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];
|
const movingBlock = blocks[draggedIndex];
|
||||||
if (movingBlock) {
|
if (movingBlock) {
|
||||||
onMoveBlock(movingBlock.id, targetIndex, movingBlock.level);
|
onMoveBlock(movingBlock.id, targetIndex, movingBlock.level);
|
||||||
@@ -305,78 +651,81 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
|||||||
return () => window.removeEventListener('dragend', clearDrag);
|
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 (
|
return (
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{visibleEntries.map(({ block, index, hasChildren, maxAllowedLevel }) => (
|
{visibleEntries.map(({ block, index, hasChildren, maxAllowedLevel }, entryIdx) => {
|
||||||
<OutlinerBlock
|
const isLast = entryIdx === visibleEntries.length - 1;
|
||||||
key={block.id}
|
return (
|
||||||
block={block}
|
<OutlinerBlock
|
||||||
index={index}
|
key={block.id}
|
||||||
maxAllowedLevel={maxAllowedLevel}
|
docId={docId}
|
||||||
hasChildren={hasChildren}
|
block={block}
|
||||||
isFocused={focusedBlockId === block.id}
|
index={index}
|
||||||
canEdit={canEdit}
|
isLast={isLast}
|
||||||
levelOffset={levelOffset}
|
maxAllowedLevel={maxAllowedLevel}
|
||||||
onZoomBlock={onZoomBlock}
|
hasChildren={hasChildren}
|
||||||
activeRemoteCursors={cursorsByBlock[block.id] || []}
|
isFocused={focusedBlockId === block.id}
|
||||||
sendCursorMoved={sendCursorMoved}
|
canEdit={canEdit}
|
||||||
onFocusBlock={setFocusedBlockId}
|
levelOffset={levelOffset}
|
||||||
onUpdateBlock={onUpdateBlock}
|
onZoomBlock={onZoomBlock}
|
||||||
onCreateBlockBelow={handleCreateBlockBelow}
|
onUpdateBlock={onUpdateBlock}
|
||||||
onDeleteBlock={handleDeleteBlock}
|
onCreateBlockBelow={handleCreateBlockBelow}
|
||||||
onIndentBlock={handleIndentBlock}
|
onPasteMultiLines={handlePasteMultiLines}
|
||||||
onNavigateFocus={handleNavigateFocus}
|
onDeleteBlock={handleDeleteBlock}
|
||||||
onTagClick={onTagClick}
|
onIndentBlock={handleIndentBlock}
|
||||||
onWikiLinkClick={onWikiLinkClick}
|
onNavigateFocus={handleNavigateFocus}
|
||||||
onDragStartBlock={handleDragStartBlock}
|
onFocusBlock={setFocusedBlockId}
|
||||||
onDragOverBlock={handleDragOverBlock}
|
onOpenTask={onOpenTask}
|
||||||
onDropBlock={handleDropBlock}
|
onTagClick={onTagClick}
|
||||||
isDragTarget={dragOverIndex === index && draggedIndex !== index}
|
onWikiLinkClick={onWikiLinkClick}
|
||||||
isTargeted={highlightedBlockId === block.id}
|
onDragStartBlock={handleDragStartBlock}
|
||||||
registerTextareaRef={registerTextareaRef}
|
onDragOverBlock={handleDragOverBlock}
|
||||||
/>
|
onDropBlock={handleDropBlock}
|
||||||
))}
|
isDragTarget={dragOverIndex === index && draggedIndex !== index}
|
||||||
|
isTargeted={highlightedBlockId === block.id}
|
||||||
|
registerTextareaRef={registerTextareaRef}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<div className="pt-2 pl-2">
|
<div
|
||||||
<button
|
className={`min-h-[14rem] pt-2 pl-2 transition-colors cursor-text ${
|
||||||
type="button"
|
dragOverIndex === blocks.length ? 'border-t-2 border-accent bg-accent/10 rounded' : ''
|
||||||
onClick={() => {
|
}`}
|
||||||
|
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];
|
const lastBlock = blocks[blocks.length - 1];
|
||||||
if (lastBlock) {
|
if (lastBlock.content && lastBlock.content.trim() !== '') {
|
||||||
handleCreateBlockBelow(lastBlock.id, lastBlock.content);
|
handleCreateBlockBelow(lastBlock.id, '');
|
||||||
} else {
|
} else {
|
||||||
const firstBlock: DocumentBlock = {
|
focusBlockInput(lastBlock.id, 'end');
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}}
|
}
|
||||||
className="btn-action inline-flex items-center gap-1.5 px-2.5 py-1 text-xs text-muted hover:text-main"
|
}}
|
||||||
>
|
onDragOver={(e) => {
|
||||||
<Plus className="w-3.5 h-3.5" />
|
e.preventDefault();
|
||||||
<span>Neuer Punkt</span>
|
if (draggedIndex !== null) {
|
||||||
</button>
|
setDragOverIndex(blocks.length);
|
||||||
</div>
|
}
|
||||||
|
}}
|
||||||
|
onDrop={() => handleDropBlock(blocks.length)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
File diff suppressed because it is too large.
Load diff
+93
-34
@@ -5,7 +5,6 @@ import {
|
|||||||
Document,
|
Document,
|
||||||
DocumentBlock,
|
DocumentBlock,
|
||||||
DocShare,
|
DocShare,
|
||||||
DocLockInfo,
|
|
||||||
KanbanColumn,
|
KanbanColumn,
|
||||||
ListItem,
|
ListItem,
|
||||||
ListShare,
|
ListShare,
|
||||||
@@ -37,6 +36,19 @@ export function getToken(): string | null {
|
|||||||
return localStorage.getItem(TOKEN_STORAGE_KEY);
|
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 {
|
export function setToken(token: string): void {
|
||||||
localStorage.setItem(TOKEN_STORAGE_KEY, token);
|
localStorage.setItem(TOKEN_STORAGE_KEY, token);
|
||||||
}
|
}
|
||||||
@@ -117,6 +129,18 @@ export const api = {
|
|||||||
return request<AuthStatus>('/api/v1/auth/status');
|
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: {
|
async completeSetup(payload: {
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
@@ -285,6 +309,10 @@ export const api = {
|
|||||||
return ensureArray(res);
|
return ensureArray(res);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getThought(thoughtId: string): Promise<Thought> {
|
||||||
|
return request<Thought>(`/api/v1/thoughts/${encodeURIComponent(thoughtId)}`);
|
||||||
|
},
|
||||||
|
|
||||||
async triageAction(
|
async triageAction(
|
||||||
thoughtId: string,
|
thoughtId: string,
|
||||||
action: 'keep' | 'discard' | 'restore',
|
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 ---
|
// --- DOCUMENT SHARING ---
|
||||||
async getDocShares(docId: string): Promise<DocShare[]> {
|
async getDocShares(docId: string): Promise<DocShare[]> {
|
||||||
const res = await request<DocShare[]>(`/api/v1/documents/${docId}/shares`);
|
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> {
|
async deleteListItem(listId: string, itemId: string): Promise<void> {
|
||||||
await request(`/api/v1/lists/${listId}/items/${itemId}`, { method: 'DELETE' });
|
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 sseReconnectTimer: any = null;
|
||||||
let sseReconnectDelay = 2000;
|
let sseReconnectDelay = 2000;
|
||||||
const globalSubscribedRooms = new Set<string>();
|
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 } }));
|
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 {
|
export function connectSSE(rooms?: string[]): void {
|
||||||
if (rooms) {
|
if (rooms) {
|
||||||
rooms.forEach((r) => globalSubscribedRooms.add(r));
|
rooms.forEach((r) => globalSubscribedRooms.add(r));
|
||||||
@@ -1114,7 +1189,6 @@ export function connectSSE(rooms?: string[]): void {
|
|||||||
activeGlobalSSE = es;
|
activeGlobalSSE = es;
|
||||||
|
|
||||||
es.onopen = () => {
|
es.onopen = () => {
|
||||||
console.info('[MindSift Realtime] Server-Sent Events (SSE) verbunden (Proxy-Fallback aktiv).');
|
|
||||||
sseReconnectDelay = 2000;
|
sseReconnectDelay = 2000;
|
||||||
notifyRealtimeStatusChange(true, 'sse');
|
notifyRealtimeStatusChange(true, 'sse');
|
||||||
if (activeSSEId && globalSubscribedRooms.size > 0) {
|
if (activeSSEId && globalSubscribedRooms.size > 0) {
|
||||||
@@ -1149,6 +1223,7 @@ export function connectSSE(rooms?: string[]): void {
|
|||||||
};
|
};
|
||||||
|
|
||||||
es.onerror = (e) => {
|
es.onerror = (e) => {
|
||||||
|
console.warn('[MindSift Realtime] SSE Verbindungsfehler:', e);
|
||||||
if (activeTransport === 'sse') {
|
if (activeTransport === 'sse') {
|
||||||
notifyRealtimeStatusChange(false, 'none');
|
notifyRealtimeStatusChange(false, 'none');
|
||||||
}
|
}
|
||||||
@@ -1160,11 +1235,7 @@ export function connectSSE(rooms?: string[]): void {
|
|||||||
sseReconnectDelay = Math.min(30000, Math.round(sseReconnectDelay * 1.5));
|
sseReconnectDelay = Math.min(30000, Math.round(sseReconnectDelay * 1.5));
|
||||||
sseReconnectTimer = setTimeout(() => {
|
sseReconnectTimer = setTimeout(() => {
|
||||||
if (!manualDisconnect && getToken()) {
|
if (!manualDisconnect && getToken()) {
|
||||||
if (isWsBlockedByProxy) {
|
connectSSE();
|
||||||
connectSSE();
|
|
||||||
} else {
|
|
||||||
connectWebSocket();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, delay);
|
}, delay);
|
||||||
}
|
}
|
||||||
@@ -1199,16 +1270,12 @@ export function connectWebSocket(): void {
|
|||||||
const ws = new WebSocket(url);
|
const ws = new WebSocket(url);
|
||||||
activeGlobalWS = ws;
|
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(() => {
|
wsConnectionWatchdog = setTimeout(() => {
|
||||||
if (ws && ws.readyState !== WebSocket.OPEN) {
|
if (ws && ws.readyState !== WebSocket.OPEN) {
|
||||||
console.warn('[MindSift Realtime] WebSocket connection timeout (Proxy Upgrade Block). Wechsle automatisch zu SSE...');
|
switchToSSE('WebSocket Timeout nach 2.5s (Proxy Upgrade Header blockiert)');
|
||||||
isWsBlockedByProxy = true;
|
|
||||||
try { ws.close(); } catch {}
|
|
||||||
activeGlobalWS = null;
|
|
||||||
connectSSE();
|
|
||||||
}
|
}
|
||||||
}, 3500);
|
}, 2500);
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
clearTimeout(wsConnectionWatchdog);
|
clearTimeout(wsConnectionWatchdog);
|
||||||
@@ -1247,11 +1314,9 @@ export function connectWebSocket(): void {
|
|||||||
clearTimeout(wsConnectionWatchdog);
|
clearTimeout(wsConnectionWatchdog);
|
||||||
activeGlobalWS = null;
|
activeGlobalWS = null;
|
||||||
|
|
||||||
const isProxyIssue = evt?.code === 1006 || evt?.code === 1002 || evt?.code === 1008;
|
// In corporate environments, blocked WebSockets close abruptly without clean handshake
|
||||||
if (isProxyIssue && !manualDisconnect && getToken()) {
|
if (!evt.wasClean || evt.code === 1006 || evt.code === 1002 || evt.code === 1008 || evt.code === 1005) {
|
||||||
console.warn('[MindSift Realtime] Proxy/Firewall Inkompatibilität erkannt (Code ' + evt.code + '). Wechsle dauerhaft zu SSE...');
|
switchToSSE(`WebSocket unsauber beendet (Code ${evt.code}, wasClean=${evt.wasClean})`);
|
||||||
isWsBlockedByProxy = true;
|
|
||||||
connectSSE();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1273,14 +1338,10 @@ export function connectWebSocket(): void {
|
|||||||
|
|
||||||
ws.onerror = (e) => {
|
ws.onerror = (e) => {
|
||||||
clearTimeout(wsConnectionWatchdog);
|
clearTimeout(wsConnectionWatchdog);
|
||||||
try {
|
switchToSSE('WebSocket Fehler (Handshake/Verbindung verweigert)');
|
||||||
ws.close();
|
|
||||||
} catch {}
|
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
clearTimeout(wsConnectionWatchdog);
|
switchToSSE('WebSocket Exception beim Initialisieren');
|
||||||
isWsBlockedByProxy = true;
|
|
||||||
connectSSE();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1394,10 +1455,9 @@ export function createRealtimeConnection(options: {
|
|||||||
// Join rooms on the active connection
|
// Join rooms on the active connection
|
||||||
const joinAll = () => {
|
const joinAll = () => {
|
||||||
joinedRooms.forEach((r) => {
|
joinedRooms.forEach((r) => {
|
||||||
if (r.startsWith('doc:')) {
|
sendWebSocketMessage({ event: 'JOIN_ROOM', room: r });
|
||||||
sendWebSocketMessage({ event: 'CRDT_JOIN', docId: r.slice(4) });
|
if (activeSSEId) {
|
||||||
} else {
|
api.joinSSERoom(activeSSEId, r, 'join').catch(() => {});
|
||||||
sendWebSocketMessage({ event: 'JOIN_ROOM', room: r });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -1427,10 +1487,9 @@ export function createRealtimeConnection(options: {
|
|||||||
if (unsubMsg) unsubMsg();
|
if (unsubMsg) unsubMsg();
|
||||||
joinedRooms.forEach((r) => {
|
joinedRooms.forEach((r) => {
|
||||||
globalSubscribedRooms.delete(r);
|
globalSubscribedRooms.delete(r);
|
||||||
if (r.startsWith('doc:')) {
|
sendWebSocketMessage({ event: 'LEAVE_ROOM', room: r });
|
||||||
sendWebSocketMessage({ event: 'CRDT_LEAVE', docId: r.slice(4) });
|
if (activeSSEId) {
|
||||||
} else {
|
api.joinSSERoom(activeSSEId, r, 'leave').catch(() => {});
|
||||||
sendWebSocketMessage({ event: 'LEAVE_ROOM', room: r });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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';
|
source: 'manual' | 'voice' | 'webhook';
|
||||||
status: ThoughtStatus;
|
status: ThoughtStatus;
|
||||||
isCompleted: boolean;
|
isCompleted: boolean;
|
||||||
convertedType?: 'task' | 'document' | 'list_item';
|
convertedType?: 'task' | 'document' | 'list_item' | 'document_block';
|
||||||
convertedRefId?: string;
|
convertedRefId?: string;
|
||||||
|
docId?: string;
|
||||||
|
blockId?: string;
|
||||||
dueDate?: string;
|
dueDate?: string;
|
||||||
taskListId?: string;
|
taskListId?: string;
|
||||||
kanbanStatus?: string;
|
kanbanStatus?: string;
|
||||||
@@ -159,6 +161,7 @@ export interface DocumentBlock {
|
|||||||
id: string;
|
id: string;
|
||||||
docId?: string;
|
docId?: string;
|
||||||
documentId?: string;
|
documentId?: string;
|
||||||
|
taskId?: string;
|
||||||
type: BlockType;
|
type: BlockType;
|
||||||
content: string;
|
content: string;
|
||||||
checked?: boolean;
|
checked?: boolean;
|
||||||
@@ -200,13 +203,6 @@ export interface DocShare {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DocLockInfo {
|
|
||||||
docId: string;
|
|
||||||
userId: string;
|
|
||||||
userName: string;
|
|
||||||
expiresAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Document {
|
export interface Document {
|
||||||
id: string;
|
id: string;
|
||||||
userId: 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 {
|
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{
|
srv := &Server{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
store: store,
|
store: store,
|
||||||
@@ -70,7 +67,6 @@ func (s *Server) Router() http.Handler {
|
|||||||
r.Get("/api/v1/ws", s.handleWebSocket)
|
r.Get("/api/v1/ws", s.handleWebSocket)
|
||||||
r.Get("/api/v1/ws/spaces/{spaceId}", s.handleWebSocket)
|
r.Get("/api/v1/ws/spaces/{spaceId}", s.handleWebSocket)
|
||||||
r.Get("/api/v1/ws/lists/{listId}", s.handleWebSocket)
|
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.Get("/api/v1/events", s.handleSSE)
|
||||||
r.Post("/api/v1/events/rooms", s.handleSSERooms)
|
r.Post("/api/v1/events/rooms", s.handleSSERooms)
|
||||||
|
|
||||||
@@ -79,6 +75,8 @@ func (s *Server) Router() http.Handler {
|
|||||||
r.Use(s.authMiddleware)
|
r.Use(s.authMiddleware)
|
||||||
|
|
||||||
r.Get("/api/v1/auth/me", s.handleMe)
|
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.Patch("/api/v1/auth/settings", s.handleUpdateUserSettings)
|
||||||
r.Post("/api/v1/auth/alarms/test", s.handleTestUserAlarm)
|
r.Post("/api/v1/auth/alarms/test", s.handleTestUserAlarm)
|
||||||
r.Get("/api/v1/users/directory", s.handleGetUsersDirectory)
|
r.Get("/api/v1/users/directory", s.handleGetUsersDirectory)
|
||||||
@@ -90,6 +88,7 @@ func (s *Server) Router() http.Handler {
|
|||||||
// Instant Capture & Thoughts
|
// Instant Capture & Thoughts
|
||||||
r.Post("/api/v1/inbox", s.handleCaptureInbox)
|
r.Post("/api/v1/inbox", s.handleCaptureInbox)
|
||||||
r.Get("/api/v1/thoughts", s.handleGetThoughts)
|
r.Get("/api/v1/thoughts", s.handleGetThoughts)
|
||||||
|
r.Get("/api/v1/thoughts/{id}", s.handleGetThought)
|
||||||
r.Patch("/api/v1/thoughts/{id}", s.handleUpdateThought)
|
r.Patch("/api/v1/thoughts/{id}", s.handleUpdateThought)
|
||||||
r.Delete("/api/v1/thoughts/{id}", s.handleDeleteThought)
|
r.Delete("/api/v1/thoughts/{id}", s.handleDeleteThought)
|
||||||
r.Delete("/api/v1/thoughts/discarded/purge", s.handlePurgeDiscarded)
|
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.Get("/api/v1/triage/deck", s.handleGetTriageDeck)
|
||||||
r.Patch("/api/v1/triage/{id}", s.handleTriageAction)
|
r.Patch("/api/v1/triage/{id}", s.handleTriageAction)
|
||||||
r.Get("/api/v1/tasks", s.handleGetTasks)
|
r.Get("/api/v1/tasks", s.handleGetTasks)
|
||||||
|
r.Post("/api/v1/tasks/{id}/unlink", s.handleUnlinkEnhancedTask)
|
||||||
r.Post("/api/v1/tasks/reorder", s.handleReorderTasks)
|
r.Post("/api/v1/tasks/reorder", s.handleReorderTasks)
|
||||||
r.Post("/api/v1/task-lists/{id}/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.Patch("/api/v1/documents/{id}/blocks", s.handlePatchDocumentBlocks)
|
||||||
r.Put("/api/v1/documents/{id}/move", s.handleMoveDocument)
|
r.Put("/api/v1/documents/{id}/move", s.handleMoveDocument)
|
||||||
r.Delete("/api/v1/documents/{id}", s.handleDeleteDocument)
|
r.Delete("/api/v1/documents/{id}", s.handleDeleteDocument)
|
||||||
|
r.Post("/api/v1/documents/{id}/blocks/{blockId}/enhanced-task", s.handleCreateEnhancedTask)
|
||||||
|
|
||||||
// Document Sharing & Collaboration
|
// Document Sharing & Collaboration
|
||||||
r.Get("/api/v1/documents/{id}/shares", s.handleGetDocShares)
|
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.Delete("/api/v1/lists/{id}/shares/{shareId}", s.handleDeleteListShare)
|
||||||
r.Post("/api/v1/lists/{id}/items", s.handleCreateListItem)
|
r.Post("/api/v1/lists/{id}/items", s.handleCreateListItem)
|
||||||
r.Patch("/api/v1/lists/{id}/items/{itemId}", s.handleToggleListItem)
|
r.Patch("/api/v1/lists/{id}/items/{itemId}", s.handleToggleListItem)
|
||||||
|
r.Put("/api/v1/lists/{id}/items/{itemId}", s.handleUpdateListItem)
|
||||||
r.Delete("/api/v1/lists/{id}/items/{itemId}", s.handleDeleteListItem)
|
r.Delete("/api/v1/lists/{id}/items/{itemId}", s.handleDeleteListItem)
|
||||||
r.Post("/api/v1/lists/{id}/items/reorder", s.handleReorderListItems)
|
r.Post("/api/v1/lists/{id}/items/reorder", s.handleReorderListItems)
|
||||||
r.Put("/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)
|
claims, err := auth.ValidateToken(s.cfg.JWTSecret, tokenStr)
|
||||||
if err == nil && claims != nil {
|
if err == nil && claims != nil {
|
||||||
user, userErr := s.store.GetUserByID(claims.UserID)
|
if !s.store.IsTokenRevoked(tokenStr) {
|
||||||
if userErr == nil && user != nil && user.IsActive {
|
user, userErr := s.store.GetUserByID(claims.UserID)
|
||||||
next.ServeHTTP(w, r.WithContext(auth.WithUserID(r.Context(), user.ID)))
|
if userErr == nil && user != nil && user.IsActive {
|
||||||
return
|
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)
|
_ = 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 {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
|
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
|
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
|
||||||
return
|
return
|
||||||
@@ -557,21 +563,31 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
user, err := s.store.GetUserByLogin(identifier)
|
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")
|
writeError(w, http.StatusUnauthorized, "Benutzername/E-Mail oder Passwort ungültig")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !user.IsActive {
|
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")
|
writeError(w, http.StatusForbidden, "Dieses Benutzerkonto ist deaktiviert")
|
||||||
return
|
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 {
|
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")
|
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
|
||||||
return
|
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{}{
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
"token": token,
|
"token": token,
|
||||||
"user": user,
|
"user": user,
|
||||||
@@ -688,6 +704,17 @@ func (s *Server) handleGetThoughts(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, thoughts)
|
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) {
|
func (s *Server) handleGetTriageDeck(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := auth.GetUserID(r.Context())
|
userID := auth.GetUserID(r.Context())
|
||||||
thoughts, err := s.store.GetThoughts(userID, "inbox", 100)
|
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())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.store.SyncDocBlockAndTask(userID, thoughtID, updated.IsCompleted)
|
||||||
s.wsHub.BroadcastToAll("TASK_UPDATED", updated)
|
s.wsHub.BroadcastToAll("TASK_UPDATED", updated)
|
||||||
writeJSON(w, http.StatusOK, 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) {
|
func (s *Server) handleDeleteThought(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := auth.GetUserID(r.Context())
|
userID := auth.GetUserID(r.Context())
|
||||||
thoughtID := chi.URLParam(r, "id")
|
thoughtID := chi.URLParam(r, "id")
|
||||||
|
s.store.UnlinkTaskFromDocBlock(userID, thoughtID)
|
||||||
if err := s.store.DeleteThought(userID, thoughtID); err != nil {
|
if err := s.store.DeleteThought(userID, thoughtID); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
@@ -848,11 +884,18 @@ func (s *Server) broadcastDocToAuthorizedUsers(docID, event string) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
var firstDoc *domain.Document
|
||||||
for _, uid := range users {
|
for _, uid := range users {
|
||||||
if userDoc, err := s.store.GetDocumentByID(uid, docID); err == nil && userDoc != nil {
|
if userDoc, err := s.store.GetDocumentByID(uid, docID); err == nil && userDoc != nil {
|
||||||
|
if firstDoc == nil {
|
||||||
|
firstDoc = userDoc
|
||||||
|
}
|
||||||
s.wsHub.BroadcastToUser(uid, event, 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) {
|
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")
|
writeError(w, http.StatusNotFound, "Dokument nicht gefunden")
|
||||||
return
|
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)
|
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())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
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 {
|
if req.Title != nil || req.Icon != nil || req.FolderPath != nil || req.Blocks != nil || req.Tags != nil {
|
||||||
s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
|
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())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if s.wsHub != nil {
|
|
||||||
s.wsHub.SyncActiveDocRoom(docID, doc.Title, doc.Blocks)
|
|
||||||
}
|
|
||||||
|
|
||||||
patchEvent := domain.DocBlocksPatchEvent{
|
patchEvent := domain.DocBlocksPatchEvent{
|
||||||
DocID: docID,
|
DocID: docID,
|
||||||
@@ -986,9 +1011,6 @@ func (s *Server) handleDeleteDocument(w http.ResponseWriter, r *http.Request) {
|
|||||||
docID := chi.URLParam(r, "id")
|
docID := chi.URLParam(r, "id")
|
||||||
authorizedUsers, _ := s.store.GetAuthorizedDocUsers(docID)
|
authorizedUsers, _ := s.store.GetAuthorizedDocUsers(docID)
|
||||||
|
|
||||||
if s.wsHub != nil {
|
|
||||||
s.wsHub.CloseDocRoom(docID)
|
|
||||||
}
|
|
||||||
if err := s.store.DeleteDocument(userID, docID); err != nil {
|
if err := s.store.DeleteDocument(userID, docID); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
@@ -999,6 +1021,30 @@ func (s *Server) handleDeleteDocument(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
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 ---
|
// --- WORKSPACE FOLDERS ---
|
||||||
|
|
||||||
func (s *Server) handleGetFolders(w http.ResponseWriter, r *http.Request) {
|
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 {
|
for _, docID := range deletedDocIDs {
|
||||||
s.wsHub.ReleaseDocLock(docID, userID, true)
|
|
||||||
s.wsHub.BroadcastDocEvent(docID, "DOC_DELETED", map[string]string{"id": docID})
|
s.wsHub.BroadcastDocEvent(docID, "DOC_DELETED", map[string]string{"id": docID})
|
||||||
}
|
}
|
||||||
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", map[string]string{"path": folderPath})
|
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)
|
writeJSON(w, http.StatusOK, users)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- DOCUMENT REALTIME WEBSOCKET ROOM ---
|
|
||||||
|
|
||||||
func (s *Server) authenticateUserFromReq(r *http.Request) string {
|
func (s *Server) authenticateUserFromReq(r *http.Request) string {
|
||||||
token := r.URL.Query().Get("token")
|
token := r.URL.Query().Get("token")
|
||||||
if token == "" {
|
if token == "" {
|
||||||
@@ -1385,53 +1428,24 @@ func (s *Server) authenticateUserFromReq(r *http.Request) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if token != "" {
|
if token != "" {
|
||||||
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil {
|
if strings.HasPrefix(token, "mindsift_sec_") {
|
||||||
return claims.UserID
|
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 ""
|
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 ---
|
// --- REALTIME LISTS & SHARING ---
|
||||||
|
|
||||||
func (s *Server) handleGetAllLists(w http.ResponseWriter, r *http.Request) {
|
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)
|
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) {
|
func (s *Server) handleDeleteListItem(w http.ResponseWriter, r *http.Request) {
|
||||||
listID := chi.URLParam(r, "id")
|
listID := chi.URLParam(r, "id")
|
||||||
itemID := chi.URLParam(r, "itemId")
|
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")
|
roomID = r.URL.Query().Get("spaceId")
|
||||||
}
|
}
|
||||||
|
|
||||||
token := r.URL.Query().Get("token")
|
userID := s.authenticateUserFromReq(r)
|
||||||
if token == "" {
|
if userID == "" {
|
||||||
if authHdr := r.Header.Get("Authorization"); strings.HasPrefix(authHdr, "Bearer ") {
|
userID = "anonymous"
|
||||||
token = strings.TrimPrefix(authHdr, "Bearer ")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
userID := "anonymous"
|
|
||||||
if token != "" {
|
|
||||||
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil {
|
|
||||||
userID = claims.UserID
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
s.wsHub.ServeWS(w, r, roomID, userID)
|
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")
|
roomID = r.URL.Query().Get("spaceId")
|
||||||
}
|
}
|
||||||
|
|
||||||
token := r.URL.Query().Get("token")
|
userID := s.authenticateUserFromReq(r)
|
||||||
if token == "" {
|
if userID == "" {
|
||||||
if authHdr := r.Header.Get("Authorization"); strings.HasPrefix(authHdr, "Bearer ") {
|
userID = "anonymous"
|
||||||
token = strings.TrimPrefix(authHdr, "Bearer ")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
userID := "anonymous"
|
|
||||||
if token != "" {
|
|
||||||
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil {
|
|
||||||
userID = claims.UserID
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var initialRooms []string
|
var initialRooms []string
|
||||||
@@ -1933,6 +1955,33 @@ func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// --- USER SETTINGS & ALARMS ---
|
// --- 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) {
|
func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := auth.GetUserID(r.Context())
|
userID := auth.GetUserID(r.Context())
|
||||||
var req struct {
|
var req struct {
|
||||||
|
|||||||
+250
-103
@@ -45,7 +45,7 @@ func setupTestServer(t *testing.T) (*Server, *storage.Store, string) {
|
|||||||
hub := ws.NewHub()
|
hub := ws.NewHub()
|
||||||
srv := NewServer(cfg, store, hub, nil)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("failed to generate token: %v", err)
|
t.Fatalf("failed to generate token: %v", err)
|
||||||
}
|
}
|
||||||
@@ -219,7 +219,7 @@ func TestQuickDumpToTriageAndDirectTargets(t *testing.T) {
|
|||||||
router := srv.Router()
|
router := srv.Router()
|
||||||
|
|
||||||
// Verify stale token from non-existent user is rejected (401)
|
// 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 := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
|
||||||
req.Header.Set("Authorization", "Bearer "+staleToken)
|
req.Header.Set("Authorization", "Bearer "+staleToken)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
@@ -548,11 +548,11 @@ func TestSpaceMembersDirectInvitationAPI(t *testing.T) {
|
|||||||
t.Fatalf("failed to get normal user: %v", err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("failed to generate token: %v", err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("failed to generate token: %v", err)
|
t.Fatalf("failed to generate token: %v", err)
|
||||||
}
|
}
|
||||||
@@ -663,7 +663,7 @@ func TestWorkspaceTagsAPI(t *testing.T) {
|
|||||||
router := srv.Router()
|
router := srv.Router()
|
||||||
|
|
||||||
user, _ := store.CreateUser("api_tag_user@example.com", "hash", "API Tag User", "member")
|
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
|
// Create document with hashtags
|
||||||
blocks := []domain.DocumentBlock{
|
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) {
|
func TestSSEGlobalEndpoint(t *testing.T) {
|
||||||
dbPath := filepath.Join(t.TempDir(), "test_sse.db")
|
dbPath := filepath.Join(t.TempDir(), "test_sse.db")
|
||||||
store, err := storage.NewStore(dbPath)
|
store, err := storage.NewStore(dbPath)
|
||||||
@@ -997,7 +910,7 @@ func TestDecoupledFolderSharingAPI(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create user B: %v", err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("failed to generate token B: %v", err)
|
t.Fatalf("failed to generate token B: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1121,7 +1034,7 @@ func TestDecoupledFolderSharingAPI(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 7. Synchronous contents check:
|
// 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{
|
moveDocPayload := map[string]string{
|
||||||
"folderPath": "MeineOrdner/TeamProjekt/Archiv",
|
"folderPath": "MeineOrdner/TeamProjekt/Archiv",
|
||||||
}
|
}
|
||||||
@@ -1131,25 +1044,259 @@ func TestDecoupledFolderSharingAPI(t *testing.T) {
|
|||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
router.ServeHTTP(rec, req)
|
router.ServeHTTP(rec, req)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusBadRequest {
|
||||||
t.Fatalf("User B move doc into subfolder failed: %s", rec.Body.String())
|
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"!
|
// User A (the owner) moves the document into a subfolder: "TeamProjekt/Archiv"
|
||||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+createdDoc.ID, nil)
|
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("Authorization", "Bearer "+tokenA)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
router.ServeHTTP(rec, req)
|
router.ServeHTTP(rec, req)
|
||||||
if rec.Code != http.StatusOK {
|
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)
|
// User B must now see the document synchronized at "MeineOrdner/TeamProjekt/Archiv"!
|
||||||
if docASynced.FolderPath != "TeamProjekt/Archiv" {
|
req = httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+createdDoc.ID, nil)
|
||||||
t.Fatalf("expected User A to see subfolder sync at 'TeamProjekt/Archiv', got %q", docASynced.FolderPath)
|
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"
|
const UserIDContextKey contextKey = "mindsift_user_id"
|
||||||
|
|
||||||
type Claims struct {
|
type Claims struct {
|
||||||
UserID string `json:"userId"`
|
UserID string `json:"userId"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
|
TokenVersion int `json:"tokenVersion"`
|
||||||
jwt.RegisteredClaims
|
jwt.RegisteredClaims
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,14 +35,24 @@ func CheckPassword(password, hash string) bool {
|
|||||||
return err == nil
|
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{
|
claims := &Claims{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Email: email,
|
Email: email,
|
||||||
DisplayName: displayName,
|
DisplayName: displayName,
|
||||||
|
TokenVersion: tokenVersion,
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * 24 * time.Hour)),
|
ID: jti,
|
||||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
ExpiresAt: jwt.NewNumericDate(now.Add(30 * 24 * time.Hour)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
Issuer: "mindsift",
|
Issuer: "mindsift",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ type User struct {
|
|||||||
AvatarURL string `json:"avatarUrl,omitempty"`
|
AvatarURL string `json:"avatarUrl,omitempty"`
|
||||||
Role string `json:"role"` // admin | member
|
Role string `json:"role"` // admin | member
|
||||||
IsActive bool `json:"isActive"`
|
IsActive bool `json:"isActive"`
|
||||||
|
TokenVersion int `json:"tokenVersion"`
|
||||||
AlarmChannels []string `json:"alarmChannels"` // email | discord | webhook
|
AlarmChannels []string `json:"alarmChannels"` // email | discord | webhook
|
||||||
AlarmEmail string `json:"alarmEmail"`
|
AlarmEmail string `json:"alarmEmail"`
|
||||||
DiscordWebhookURL string `json:"discordWebhookUrl"`
|
DiscordWebhookURL string `json:"discordWebhookUrl"`
|
||||||
@@ -147,8 +148,10 @@ type Thought struct {
|
|||||||
Source string `json:"source"` // manual | voice | webhook
|
Source string `json:"source"` // manual | voice | webhook
|
||||||
Status ThoughtStatus `json:"status"`
|
Status ThoughtStatus `json:"status"`
|
||||||
IsCompleted bool `json:"isCompleted"`
|
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
|
ConvertedRefID string `json:"convertedRefId,omitempty"` // referenced entity ID
|
||||||
|
DocID string `json:"docId,omitempty"`
|
||||||
|
BlockID string `json:"blockId,omitempty"`
|
||||||
DueDate string `json:"dueDate,omitempty"`
|
DueDate string `json:"dueDate,omitempty"`
|
||||||
TaskListID string `json:"taskListId,omitempty"`
|
TaskListID string `json:"taskListId,omitempty"`
|
||||||
KanbanStatus string `json:"kanbanStatus,omitempty"` // todo | in_progress | done
|
KanbanStatus string `json:"kanbanStatus,omitempty"` // todo | in_progress | done
|
||||||
@@ -269,6 +272,7 @@ type DocumentBlock struct {
|
|||||||
Level int `json:"level"`
|
Level int `json:"level"`
|
||||||
Position float64 `json:"position"`
|
Position float64 `json:"position"`
|
||||||
Collapsed bool `json:"collapsed,omitempty"`
|
Collapsed bool `json:"collapsed,omitempty"`
|
||||||
|
TaskID string `json:"taskId,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func CompileBlocksToMarkdown(blocks []DocumentBlock) string {
|
func CompileBlocksToMarkdown(blocks []DocumentBlock) string {
|
||||||
@@ -336,13 +340,6 @@ type DocShare struct {
|
|||||||
CreatedAt string `json:"createdAt"`
|
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 {
|
type Document struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
UserID string `json:"userId"`
|
UserID string `json:"userId"`
|
||||||
|
|||||||
+302
-38
@@ -72,6 +72,7 @@ func (s *Store) migrate() error {
|
|||||||
display_name TEXT NOT NULL,
|
display_name TEXT NOT NULL,
|
||||||
role TEXT NOT NULL DEFAULT 'member',
|
role TEXT NOT NULL DEFAULT 'member',
|
||||||
is_active INTEGER NOT NULL DEFAULT 1,
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
token_version INTEGER NOT NULL DEFAULT 1,
|
||||||
alarm_channels TEXT NOT NULL DEFAULT 'discord',
|
alarm_channels TEXT NOT NULL DEFAULT 'discord',
|
||||||
alarm_email TEXT NOT NULL DEFAULT '',
|
alarm_email TEXT NOT NULL DEFAULT '',
|
||||||
discord_webhook_url TEXT NOT NULL DEFAULT '',
|
discord_webhook_url TEXT NOT NULL DEFAULT '',
|
||||||
@@ -307,6 +308,16 @@ func (s *Store) migrate() error {
|
|||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
value TEXT NOT NULL
|
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 {
|
if _, err := s.db.Exec(schema); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -318,6 +329,7 @@ func (s *Store) migrate() error {
|
|||||||
"CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);",
|
"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 role TEXT NOT NULL DEFAULT 'member';",
|
||||||
"ALTER TABLE users ADD COLUMN is_active INTEGER NOT NULL DEFAULT 1;",
|
"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_channels TEXT NOT NULL DEFAULT 'discord';",
|
||||||
"ALTER TABLE users ADD COLUMN alarm_email TEXT NOT NULL DEFAULT '';",
|
"ALTER TABLE users ADD COLUMN alarm_email TEXT NOT NULL DEFAULT '';",
|
||||||
"ALTER TABLE users ADD COLUMN discord_webhook_url 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
|
// Backfill username for existing accounts where username is empty
|
||||||
_, _ = s.db.Exec(`UPDATE users SET username = LOWER(SUBSTR(email, 1, INSTR(email, '@') - 1)) WHERE (username IS NULL OR username = '') AND INSTR(email, '@') > 0`)
|
_, _ = s.db.Exec(`UPDATE users SET username = LOWER(SUBSTR(email, 1, INSTR(email, '@') - 1)) WHERE (username IS NULL OR username = '') AND INSTR(email, '@') > 0`)
|
||||||
|
|
||||||
|
// 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)
|
// 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
|
_, _ = s.db.Exec(`INSERT OR IGNORE INTO system_settings (key, value) VALUES
|
||||||
('sharing_mode', 'teams_only'),
|
('sharing_mode', 'teams_only'),
|
||||||
@@ -536,7 +553,7 @@ func (s *Store) EnsureDefaultUser() (*domain.User, error) {
|
|||||||
|
|
||||||
// --- USERS ---
|
// --- 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 {
|
func parseAlarmChannels(raw string) []string {
|
||||||
parts := strings.Split(raw, ",")
|
parts := strings.Split(raw, ",")
|
||||||
@@ -558,13 +575,16 @@ func scanUser(row interface{ Scan(dest ...any) error }) (*domain.User, error) {
|
|||||||
var channelsRaw string
|
var channelsRaw string
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&u.ID, &u.Email, &u.Username, &u.PasswordHash, &u.DisplayName,
|
&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,
|
&u.DiscordWebhookURL, &u.CustomWebhookURL, &u.NotifyBeforeMinutes, &u.AvatarURL, &u.CreatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
u.IsActive = isActiveInt != 0
|
u.IsActive = isActiveInt != 0
|
||||||
|
if u.TokenVersion <= 0 {
|
||||||
|
u.TokenVersion = 1
|
||||||
|
}
|
||||||
if u.Role == "" {
|
if u.Role == "" {
|
||||||
u.Role = "member"
|
u.Role = "member"
|
||||||
}
|
}
|
||||||
@@ -604,6 +624,7 @@ func (s *Store) CreateUserWithUsername(email, username, passwordHash, displayNam
|
|||||||
DisplayName: strings.TrimSpace(displayName),
|
DisplayName: strings.TrimSpace(displayName),
|
||||||
Role: role,
|
Role: role,
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
|
TokenVersion: 1,
|
||||||
AlarmChannels: []string{},
|
AlarmChannels: []string{},
|
||||||
AlarmEmail: cleanEmail,
|
AlarmEmail: cleanEmail,
|
||||||
DiscordWebhookURL: "",
|
DiscordWebhookURL: "",
|
||||||
@@ -613,8 +634,8 @@ func (s *Store) CreateUserWithUsername(email, username, passwordHash, displayNam
|
|||||||
CreatedAt: domain.NowISO(),
|
CreatedAt: domain.NowISO(),
|
||||||
}
|
}
|
||||||
_, err := s.db.Exec(
|
_, 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)
|
`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, '', ?, '', '', 30, '', ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, 1, 1, '', ?, '', '', 30, '', ?)`,
|
||||||
u.ID, u.Email, u.Username, u.PasswordHash, u.DisplayName, u.Role, u.AlarmEmail, u.CreatedAt,
|
u.ID, u.Email, u.Username, u.PasswordHash, u.DisplayName, u.Role, u.AlarmEmail, u.CreatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -811,14 +832,17 @@ func (s *Store) UpdateUserFull(
|
|||||||
if avatarURL != nil {
|
if avatarURL != nil {
|
||||||
u.AvatarURL = strings.TrimSpace(*avatarURL)
|
u.AvatarURL = strings.TrimSpace(*avatarURL)
|
||||||
}
|
}
|
||||||
|
if (role != nil && *role != u.Role) || (isActive != nil && !*isActive && u.IsActive) {
|
||||||
|
u.TokenVersion++
|
||||||
|
}
|
||||||
activeInt := 0
|
activeInt := 0
|
||||||
if u.IsActive {
|
if u.IsActive {
|
||||||
activeInt = 1
|
activeInt = 1
|
||||||
}
|
}
|
||||||
channelsStr := strings.Join(u.AlarmChannels, ",")
|
channelsStr := strings.Join(u.AlarmChannels, ",")
|
||||||
_, err = s.db.Exec(
|
_, 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 = ?`,
|
`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, channelsStr, u.AlarmEmail, u.DiscordWebhookURL, u.CustomWebhookURL, u.NotifyBeforeMinutes, u.AvatarURL, userID,
|
u.DisplayName, u.Email, u.Role, activeInt, u.TokenVersion, channelsStr, u.AlarmEmail, u.DiscordWebhookURL, u.CustomWebhookURL, u.NotifyBeforeMinutes, u.AvatarURL, userID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -827,15 +851,45 @@ func (s *Store) UpdateUserFull(
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) UpdateUserPassword(userID, passwordHash string) error {
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) DeleteUser(userID string) error {
|
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)
|
_, err := s.db.Exec(`DELETE FROM users WHERE id = ?`, userID)
|
||||||
return err
|
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 ---
|
// --- THOUGHTS & TRIAGE ---
|
||||||
|
|
||||||
func (s *Store) CreateThought(userID, content, priority, source, dueDate string) (*domain.Thought, error) {
|
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)
|
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)
|
tagsJSON, _ := json.Marshal(tags)
|
||||||
|
|
||||||
t := &domain.Thought{
|
t := &domain.Thought{
|
||||||
@@ -915,6 +993,11 @@ func scanThought(rows interface {
|
|||||||
}
|
}
|
||||||
t.Tags = []string{}
|
t.Tags = []string{}
|
||||||
_ = json.Unmarshal([]byte(tagsStr), &t.Tags)
|
_ = 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{}
|
t.Subtasks = []domain.SubTask{}
|
||||||
if subtasksStr != "" && subtasksStr != "null" {
|
if subtasksStr != "" && subtasksStr != "null" {
|
||||||
_ = json.Unmarshal([]byte(subtasksStr), &t.Subtasks)
|
_ = json.Unmarshal([]byte(subtasksStr), &t.Subtasks)
|
||||||
@@ -1084,23 +1167,23 @@ func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, target
|
|||||||
title = title[:72] + "…"
|
title = title[:72] + "…"
|
||||||
}
|
}
|
||||||
blocks := make([]domain.DocumentBlock, 0)
|
blocks := make([]domain.DocumentBlock, 0)
|
||||||
formattedDate := domain.FormatGermanDateTime(thought.CreatedAt)
|
|
||||||
calloutText := "Ursprünglicher Gedanke aus deiner Inbox"
|
// 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.
|
||||||
if formattedDate != "" {
|
|
||||||
calloutText += " vom " + formattedDate
|
|
||||||
}
|
|
||||||
blocks = append(blocks, domain.DocumentBlock{
|
blocks = append(blocks, domain.DocumentBlock{
|
||||||
ID: domain.NewID("blk"),
|
ID: domain.NewID("blk"),
|
||||||
Type: "callout",
|
Type: "todo",
|
||||||
Content: calloutText,
|
Content: thought.Content,
|
||||||
|
Checked: thought.IsCompleted,
|
||||||
Level: 0,
|
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) != "" {
|
if strings.TrimSpace(thought.Notes) != "" {
|
||||||
blocks = append(blocks, domain.DocumentBlock{
|
blocks = append(blocks, domain.DocumentBlock{
|
||||||
ID: domain.NewID("blk"),
|
ID: domain.NewID("blk"),
|
||||||
Type: "bullet",
|
Type: "bullet",
|
||||||
Content: strings.TrimSpace(thought.Notes),
|
Content: strings.TrimSpace(thought.Notes),
|
||||||
Level: 0,
|
Level: 1,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if len(thought.Subtasks) > 0 {
|
if len(thought.Subtasks) > 0 {
|
||||||
@@ -1110,10 +1193,25 @@ func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, target
|
|||||||
Type: "todo",
|
Type: "todo",
|
||||||
Content: st.Title,
|
Content: st.Title,
|
||||||
Checked: st.IsCompleted,
|
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 {
|
if len(blocks) == 1 {
|
||||||
blocks = append(blocks, domain.DocumentBlock{
|
blocks = append(blocks, domain.DocumentBlock{
|
||||||
ID: domain.NewID("blk"),
|
ID: domain.NewID("blk"),
|
||||||
@@ -1477,7 +1575,7 @@ func (s *Store) PatchDocumentBlocks(userID, docID string, req domain.DocBlocksPa
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
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)")
|
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
|
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
|
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)")
|
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 {
|
if title != nil {
|
||||||
doc.Title = strings.TrimSpace(*title)
|
doc.Title = strings.TrimSpace(*title)
|
||||||
}
|
}
|
||||||
if icon != nil && *icon != "" {
|
if icon != nil {
|
||||||
doc.Icon = *icon
|
doc.Icon = *icon
|
||||||
}
|
}
|
||||||
if tags != nil {
|
if tags != nil {
|
||||||
@@ -2301,6 +2399,34 @@ func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *st
|
|||||||
}
|
}
|
||||||
if blocks != nil {
|
if blocks != nil {
|
||||||
_ = s.indexDocBlockTags(doc.ID, doc.UserID, doc.Blocks)
|
_ = 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
|
return doc, nil
|
||||||
}
|
}
|
||||||
@@ -2835,8 +2961,8 @@ func (s *Store) MoveDocument(userID, docID, targetFolderPath string) (*domain.Do
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if !shFolder.IsOwner && shFolder.Permission != "write" {
|
if !shFolder.IsOwner {
|
||||||
return nil, fmt.Errorf("keine Schreibberechtigung für diesen geteilten Ordner")
|
return nil, fmt.Errorf("nur der Eigentümer dieses geteilten Ordners kann Dateien dorthin verschieben")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update document to belong to targetSharedFolderID
|
// 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)
|
// Target is NOT inside a shared folder (personal / standalone)
|
||||||
if doc.SharedFolderID != "" {
|
if doc.SharedFolderID != "" {
|
||||||
// Moving OUT of a shared folder into a personal folder
|
// 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")
|
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 = ?`,
|
_, 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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if !f.IsOwner && f.Permission != "write" {
|
if !f.IsOwner {
|
||||||
return nil, fmt.Errorf("nur der Eigentümer oder Bearbeiter kann Ordner-Freigaben verwalten")
|
return nil, fmt.Errorf("nur der Eigentümer kann Ordner-Freigaben verwalten")
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanType := strings.TrimSpace(targetType)
|
cleanType := strings.TrimSpace(targetType)
|
||||||
@@ -3299,9 +3425,8 @@ func (s *Store) CreateFolderShare(currentUserID, folderID, targetType, targetIde
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if permission != "read" && permission != "write" {
|
// Geteilte Ordner können ausschließlich mit Leserberechtigung geteilt werden (wie Dokumente)
|
||||||
permission = "read"
|
permission = "read"
|
||||||
}
|
|
||||||
|
|
||||||
var targetID, targetName, email, avatarURL string
|
var targetID, targetName, email, avatarURL string
|
||||||
if cleanType == "team" {
|
if cleanType == "team" {
|
||||||
@@ -3515,8 +3640,8 @@ func (s *Store) CreateDocTeamShare(currentUserID, docID, teamID, permission stri
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if !doc.IsOwner && doc.Permission != "write" {
|
if !doc.IsOwner {
|
||||||
return nil, fmt.Errorf("nur der Eigentümer oder Bearbeiter kann Freigaben verwalten")
|
return nil, fmt.Errorf("nur der Eigentümer kann Freigaben verwalten")
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanTeamID := strings.TrimSpace(teamID)
|
cleanTeamID := strings.TrimSpace(teamID)
|
||||||
@@ -3526,9 +3651,8 @@ func (s *Store) CreateDocTeamShare(currentUserID, docID, teamID, permission stri
|
|||||||
return nil, fmt.Errorf("team nicht gefunden")
|
return nil, fmt.Errorf("team nicht gefunden")
|
||||||
}
|
}
|
||||||
|
|
||||||
if permission != "read" && permission != "write" {
|
// Document sharing is strictly read-only
|
||||||
permission = "read"
|
permission = "read"
|
||||||
}
|
|
||||||
|
|
||||||
now := domain.NowISO()
|
now := domain.NowISO()
|
||||||
shareID := domain.NewID("share")
|
shareID := domain.NewID("share")
|
||||||
@@ -3560,20 +3684,19 @@ func (s *Store) CreateDocShare(currentUserID, docID, targetUserIdentifier, permi
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if !doc.IsOwner && doc.Permission != "write" {
|
if !doc.IsOwner {
|
||||||
return nil, fmt.Errorf("nur der Eigentümer oder Bearbeiter kann Freigaben verwalten")
|
return nil, fmt.Errorf("nur der Eigentümer kann Freigaben verwalten")
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanTarget := strings.TrimSpace(targetUserIdentifier)
|
cleanTarget := strings.TrimSpace(targetUserIdentifier)
|
||||||
|
|
||||||
// If identifier looks like a team or is prefixed with team_, route to CreateDocTeamShare
|
// If identifier looks like a team or is prefixed with team_, route to CreateDocTeamShare
|
||||||
if strings.HasPrefix(cleanTarget, "team_") {
|
if strings.HasPrefix(cleanTarget, "team_") {
|
||||||
return s.CreateDocTeamShare(currentUserID, docID, cleanTarget, permission)
|
return s.CreateDocTeamShare(currentUserID, docID, cleanTarget, "read")
|
||||||
}
|
}
|
||||||
|
|
||||||
if permission != "read" && permission != "write" {
|
// Document sharing is strictly read-only
|
||||||
permission = "read"
|
permission = "read"
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve target user by ID, username, or Email
|
// Resolve target user by ID, username, or Email
|
||||||
var targetUser *domain.User
|
var targetUser *domain.User
|
||||||
@@ -4039,6 +4162,147 @@ func (s *Store) ToggleListItem(itemID string, isCompleted bool, userID, userName
|
|||||||
return &it, nil
|
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) {
|
func (s *Store) DeleteListItem(itemID string) (string, error) {
|
||||||
var listID string
|
var listID string
|
||||||
_ = s.db.QueryRow(`SELECT list_id FROM list_items WHERE id = ?`, itemID).Scan(&listID)
|
_ = 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")
|
t.Fatalf("expected UpdateDocument to fail for read-only user, but succeeded")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upgrade to write permission
|
// user2 is a read-only collaborator -> editing must fail
|
||||||
_, 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
|
|
||||||
_, err = store.UpdateDocument(user2.ID, newDoc.ID, &updatedTitle, nil, nil, nil, nil, nil)
|
_, err = store.UpdateDocument(user2.ID, newDoc.ID, &updatedTitle, nil, nil, nil, nil, nil)
|
||||||
if err != nil {
|
if err == nil {
|
||||||
t.Fatalf("UpdateDocument failed for write collaborator: %v", err)
|
t.Fatalf("expected UpdateDocument to fail for read-only collaborator, but succeeded")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove share
|
// Remove share
|
||||||
@@ -551,8 +545,7 @@ func TestDatabaseFirstWorkspaceArchitecture(t *testing.T) {
|
|||||||
t.Fatalf("MoveDocument by userB failed: %v", err)
|
t.Fatalf("MoveDocument by userB failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// User B edits the document via PatchDocumentBlocks (modifying b2 and adding b3)
|
// User B is a read-only collaborator -> PatchDocumentBlocks must fail
|
||||||
// Simultaneously, User A edits b1 and adds b4 using a slightly stale order
|
|
||||||
_, _, err = store.PatchDocumentBlocks(userB.ID, doc.ID, domain.DocBlocksPatchRequest{
|
_, _, err = store.PatchDocumentBlocks(userB.ID, doc.ID, domain.DocBlocksPatchRequest{
|
||||||
Upserted: []domain.DocumentBlock{
|
Upserted: []domain.DocumentBlock{
|
||||||
{ID: "b2", Type: "bullet", Content: "Zeile 2 von B bearbeitet", Level: 0},
|
{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"},
|
BlockOrder: []string{"b1", "b2", "b3"},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err == nil {
|
||||||
t.Fatalf("PatchDocumentBlocks by userB failed: %v", err)
|
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)
|
// 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)
|
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
|
// Verify User A's blocks are preserved: b1, b2, b4 (3 blocks)
|
||||||
if len(patchedDocA.Blocks) != 4 {
|
if len(patchedDocA.Blocks) != 3 {
|
||||||
t.Fatalf("expected 4 blocks after concurrent patches, got %d: %+v", len(patchedDocA.Blocks), patchedDocA.Blocks)
|
t.Fatalf("expected 3 blocks after patch, got %d: %+v", len(patchedDocA.Blocks), patchedDocA.Blocks)
|
||||||
}
|
}
|
||||||
blockContent := make(map[string]string)
|
blockContent := make(map[string]string)
|
||||||
for _, b := range patchedDocA.Blocks {
|
for _, b := range patchedDocA.Blocks {
|
||||||
@@ -587,12 +580,6 @@ func TestDatabaseFirstWorkspaceArchitecture(t *testing.T) {
|
|||||||
if blockContent["b1"] != "Zeile 1 von A bearbeitet" {
|
if blockContent["b1"] != "Zeile 1 von A bearbeitet" {
|
||||||
t.Fatalf("expected b1 updated by A, got %q", blockContent["b1"])
|
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" {
|
if blockContent["b4"] != "Zeile 4 neu von A" {
|
||||||
t.Fatalf("expected b4 added by A, got %q", blockContent["b4"])
|
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
|
userID string
|
||||||
send chan []byte
|
send chan []byte
|
||||||
subscribedRooms map[string]bool
|
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 {
|
type SSEClient struct {
|
||||||
@@ -36,19 +59,29 @@ type SSEClient struct {
|
|||||||
roomID string
|
roomID string
|
||||||
send chan []byte
|
send chan []byte
|
||||||
subscribedRooms map[string]bool
|
subscribedRooms map[string]bool
|
||||||
|
sendMu sync.RWMutex
|
||||||
|
closed bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type docLockItem struct {
|
func (c *SSEClient) closeSend() {
|
||||||
docID string
|
c.sendMu.Lock()
|
||||||
userID string
|
defer c.sendMu.Unlock()
|
||||||
userName string
|
if !c.closed {
|
||||||
lockedAt time.Time
|
c.closed = true
|
||||||
expiresAt time.Time
|
close(c.send)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type DocStore interface {
|
func (c *SSEClient) trySend(data []byte) {
|
||||||
LoadDocumentState(docID string) (string, []domain.DocumentBlock, error)
|
c.sendMu.RLock()
|
||||||
FlushDocumentState(docID string, title string, blocks []domain.DocumentBlock) error
|
defer c.sendMu.RUnlock()
|
||||||
|
if c.closed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case c.send <- data:
|
||||||
|
default:
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type Hub struct {
|
type Hub struct {
|
||||||
@@ -59,30 +92,18 @@ type Hub struct {
|
|||||||
sseClients map[*SSEClient]bool
|
sseClients map[*SSEClient]bool
|
||||||
sseUserRooms map[string]map[*SSEClient]bool
|
sseUserRooms map[string]map[*SSEClient]bool
|
||||||
sseRooms map[string]map[*SSEClient]bool
|
sseRooms map[string]map[*SSEClient]bool
|
||||||
locks map[string]*docLockItem
|
closed bool
|
||||||
docRooms map[string]*DocRoom
|
|
||||||
docStore DocStore
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHub() *Hub {
|
func NewHub() *Hub {
|
||||||
h := &Hub{
|
return &Hub{
|
||||||
rooms: make(map[string]map[*Client]bool),
|
rooms: make(map[string]map[*Client]bool),
|
||||||
userRooms: make(map[string]map[*Client]bool),
|
userRooms: make(map[string]map[*Client]bool),
|
||||||
allClients: make(map[*Client]bool),
|
allClients: make(map[*Client]bool),
|
||||||
sseClients: make(map[*SSEClient]bool),
|
sseClients: make(map[*SSEClient]bool),
|
||||||
sseUserRooms: make(map[string]map[*SSEClient]bool),
|
sseUserRooms: make(map[string]map[*SSEClient]bool),
|
||||||
sseRooms: 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) {
|
func (h *Hub) Register(c *Client) {
|
||||||
@@ -122,7 +143,6 @@ func (h *Hub) Unregister(c *Client) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var docRoomsLeft []string
|
|
||||||
if c.subscribedRooms != nil {
|
if c.subscribedRooms != nil {
|
||||||
for rID := range c.subscribedRooms {
|
for rID := range c.subscribedRooms {
|
||||||
if room, ok := h.rooms[rID]; ok {
|
if room, ok := h.rooms[rID]; ok {
|
||||||
@@ -131,52 +151,19 @@ func (h *Hub) Unregister(c *Client) {
|
|||||||
delete(h.rooms, rID)
|
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 c.userID != "" && c.userID != "anonymous" {
|
||||||
if uRoom, ok := h.userRooms[c.userID]; ok {
|
if uRoom, ok := h.userRooms[c.userID]; ok {
|
||||||
delete(uRoom, c)
|
delete(uRoom, c)
|
||||||
userRemaining += len(uRoom)
|
|
||||||
if len(uRoom) == 0 {
|
if len(uRoom) == 0 {
|
||||||
delete(h.userRooms, c.userID)
|
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()
|
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) {
|
func (h *Hub) RegisterSSE(c *SSEClient) {
|
||||||
@@ -223,7 +210,6 @@ func (h *Hub) UnregisterSSE(c *SSEClient) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var docRoomsLeft []string
|
|
||||||
if c.subscribedRooms != nil {
|
if c.subscribedRooms != nil {
|
||||||
for rID := range c.subscribedRooms {
|
for rID := range c.subscribedRooms {
|
||||||
if room, ok := h.sseRooms[rID]; ok {
|
if room, ok := h.sseRooms[rID]; ok {
|
||||||
@@ -232,48 +218,18 @@ func (h *Hub) UnregisterSSE(c *SSEClient) {
|
|||||||
delete(h.sseRooms, rID)
|
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 c.userID != "" && c.userID != "anonymous" {
|
||||||
if uRoom, ok := h.sseUserRooms[c.userID]; ok {
|
if uRoom, ok := h.sseUserRooms[c.userID]; ok {
|
||||||
delete(uRoom, c)
|
delete(uRoom, c)
|
||||||
userRemaining += len(uRoom)
|
|
||||||
if len(uRoom) == 0 {
|
if len(uRoom) == 0 {
|
||||||
delete(h.sseUserRooms, c.userID)
|
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()
|
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 {
|
func (h *Hub) JoinSSERoomByID(sseID, roomID string) bool {
|
||||||
@@ -315,120 +271,35 @@ func (h *Hub) LeaveSSERoomByID(sseID, roomID string) bool {
|
|||||||
return false
|
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()
|
h.mu.Lock()
|
||||||
defer h.mu.Unlock()
|
if h.closed {
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.closed = true
|
||||||
|
|
||||||
for _, docID := range expiredDocIDs {
|
allWsClients := make([]*Client, 0, len(h.allClients))
|
||||||
h.BroadcastToAll("DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID})
|
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()
|
h.mu.RLock()
|
||||||
|
if h.closed {
|
||||||
|
h.mu.RUnlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
defer h.mu.RUnlock()
|
defer h.mu.RUnlock()
|
||||||
|
|
||||||
for client := range h.allClients {
|
for client := range h.allClients {
|
||||||
select {
|
client.trySend(data)
|
||||||
case client.send <- data:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
for sseClient := range h.sseClients {
|
for sseClient := range h.sseClients {
|
||||||
select {
|
sseClient.trySend(data)
|
||||||
case sseClient.send <- data:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,22 +343,20 @@ func (h *Hub) BroadcastToUser(userID, event string, payload interface{}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h.mu.RLock()
|
h.mu.RLock()
|
||||||
|
if h.closed {
|
||||||
|
h.mu.RUnlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
defer h.mu.RUnlock()
|
defer h.mu.RUnlock()
|
||||||
|
|
||||||
if room, ok := h.userRooms[userID]; ok {
|
if room, ok := h.userRooms[userID]; ok {
|
||||||
for client := range room {
|
for client := range room {
|
||||||
select {
|
client.trySend(data)
|
||||||
case client.send <- data:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if sseRoom, ok := h.sseUserRooms[userID]; ok {
|
if sseRoom, ok := h.sseUserRooms[userID]; ok {
|
||||||
for sseClient := range sseRoom {
|
for sseClient := range sseRoom {
|
||||||
select {
|
sseClient.trySend(data)
|
||||||
case sseClient.send <- data:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -519,6 +386,10 @@ func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h.mu.RLock()
|
h.mu.RLock()
|
||||||
|
if h.closed {
|
||||||
|
h.mu.RUnlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
defer h.mu.RUnlock()
|
defer h.mu.RUnlock()
|
||||||
|
|
||||||
targetRoom := listID
|
targetRoom := listID
|
||||||
@@ -528,19 +399,12 @@ func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) {
|
|||||||
|
|
||||||
if room, ok := h.rooms[targetRoom]; ok {
|
if room, ok := h.rooms[targetRoom]; ok {
|
||||||
for client := range room {
|
for client := range room {
|
||||||
select {
|
client.trySend(data)
|
||||||
case client.send <- data:
|
|
||||||
default:
|
|
||||||
// Slow client buffer full
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if sseRoom, ok := h.sseRooms[targetRoom]; ok {
|
if sseRoom, ok := h.sseRooms[targetRoom]; ok {
|
||||||
for sseClient := range sseRoom {
|
for sseClient := range sseRoom {
|
||||||
select {
|
sseClient.trySend(data)
|
||||||
case sseClient.send <- data:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -588,6 +452,10 @@ func (h *Hub) BroadcastRoom(roomID, event string, payload interface{}, excludeCl
|
|||||||
}
|
}
|
||||||
|
|
||||||
h.mu.RLock()
|
h.mu.RLock()
|
||||||
|
if h.closed {
|
||||||
|
h.mu.RUnlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
defer h.mu.RUnlock()
|
defer h.mu.RUnlock()
|
||||||
|
|
||||||
if room, ok := h.rooms[roomID]; ok {
|
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 {
|
if excludeClient != nil && client == excludeClient {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
select {
|
client.trySend(data)
|
||||||
case client.send <- data:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if sseRoom, ok := h.sseRooms[roomID]; ok {
|
if sseRoom, ok := h.sseRooms[roomID]; ok {
|
||||||
for sseClient := range sseRoom {
|
for sseClient := range sseRoom {
|
||||||
select {
|
sseClient.trySend(data)
|
||||||
case sseClient.send <- data:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -619,10 +481,7 @@ func (c *Client) SendEvent(event string, payload interface{}) {
|
|||||||
}
|
}
|
||||||
data, err := json.Marshal(msg)
|
data, err := json.Marshal(msg)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
select {
|
c.trySend(data)
|
||||||
case c.send <- data:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -767,21 +626,6 @@ func (c *Client) readPump() {
|
|||||||
c.hub.LeaveRoom(c, room)
|
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