Merge pull request 'Dev' (#3) from dev into main
Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
commit
49a49094de
29 files changed
+7981
-2710
No files matched your search
@@ -31,13 +31,14 @@
|
||||
- [x] **Ersteinrichtung (First-Boot Setup Wizard), Echter Login & Einladungscodes (`SetupAndAuthView.tsx`, `server.go`, `sqlite.go`):**
|
||||
- Kein hartcodierter Auto-Seed-Benutzer (`admin@mindsift.local` / `"Sandro"`) und kein automatischer `quickStart`-Login mehr beim Seitenaufruf.
|
||||
- Beim ersten Start (`0` Benutzer in der Datenbank) erkennt `GET /api/v1/auth/status` automatisch `needsSetup: true` und zeigt den **Ersteinrichtungs-Assistenten** (`POST /api/v1/auth/setup`) zum Anlegen des ersten Server-Admin-Kontos und der Instanz-Grundeinstellungen.
|
||||
- Öffentliche Selbstregistrierung ist standardmäßig deaktiviert (`allowRegistration: false`). Neue Benutzer können nur über einen **Einladungscode** (`MS-XXXXXXXX`, Tabelle `user_invites`) oder direkt durch einen Admin (`admin` / `team_admin`) angelegt werden.
|
||||
- [x] **Profile-Dropdown & Vollbild-Einstellungsseiten statt kleinem Modal (`App.tsx`, `UserSettingsView.tsx`, `AdminSettingsView.tsx`):**
|
||||
- Öffentliche Selbstregistrierung ist standardmäßig deaktiviert (`allowRegistration: false`). Neue Benutzer können nur über einen **Einladungscode** (`MS-XXXXXXXX`, Tabelle `user_invites`) oder direkt durch einen Admin (`admin`) angelegt werden.
|
||||
- [x] **Profile-Dropdown & Vollbild-Einstellungsseiten (`App.tsx`, `UserSettingsView.tsx`, `GroupsView.tsx`, `AdminSettingsView.tsx`):**
|
||||
- Klick auf den Benutzer oben rechts öffnet ein eckiges, monochromes Dropdown-Menü:
|
||||
- **Kontoeinstellungen** (`UserSettingsView.tsx` — Vollbild-Ansicht für Profil & Passwort, Darstellung & Themes, Multi-Channel-Alarme, Private Webhooks & Daten-Export).
|
||||
- **Administration** (`AdminSettingsView.tsx` — nur sichtbar für `admin` und `team_admin`; Vollbild-Ansicht für Benutzer, Rollen, Einladungscodes, Organisationen & Teams, Server-Isolation und SMTP-Mailserver).
|
||||
- **Gruppen** (`GroupsView.tsx` — für alle Benutzer; Self-Service Erstellung und Verwaltung von Gruppen, Mitgliedern und Rollen).
|
||||
- **Administration** (`AdminSettingsView.tsx` — nur sichtbar für `admin`; Vollbild-Ansicht für Benutzer, Einladungscodes, Server-Isolation und SMTP-Mailserver).
|
||||
- **Abmelden** (bereinigt JWT-Token und führt zurück zum Login-Bildschirm).
|
||||
- 3-stufiges Rollensystem: `admin` (Server-Admin), `team_admin` (Team-Admin mit Berechtigung zum Einladen und Verwalten von Teams) und `member` (Mitglied).
|
||||
- Klares Rollensystem: Global `admin` (Server-Admin) und `member` (Standard-Benutzer). Innerhalb von Gruppen: `admin` (Gruppen-Admin) und `member` (Gruppen-Mitglied).
|
||||
- Layout-Fix im Benutzer-Erstellungsformular (`grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3` mit `min-w-0`), sodass das Rollen-Dropdown niemals über den Container hinausragt.
|
||||
- [x] **Multi-Channel-Alarme: E-Mail (SMTP), Discord & Eigener Webhook (`UserSettingsView.tsx`, `AdminSettingsView.tsx`, `server.go`):**
|
||||
- Tab heißt klar **Alarme** mit frei kombinierbaren Zielen (`alarmChannels`: `'email'`, `'discord'`, `'webhook'`).
|
||||
@@ -178,7 +179,7 @@ MindSift speichert alle Daten lokal und hochperformant in SQLite (`/data/mindsif
|
||||
|
||||
1. **`users`**
|
||||
- `id TEXT PRIMARY KEY`, `email TEXT UNIQUE`, `password_hash TEXT`, `display_name TEXT`
|
||||
- `role TEXT DEFAULT 'member'` (`admin`, `team_admin`, `member`)
|
||||
- `role TEXT DEFAULT 'member'` (`admin`, `member`)
|
||||
- `is_active INTEGER DEFAULT 1`, `created_at DATETIME`, `updated_at DATETIME`
|
||||
2. **`system_settings`** (Instanzweite globale Konfiguration, 1 Row mit `id = 'singleton'`)
|
||||
- `sharing_mode TEXT DEFAULT 'teams_only'` (`strict_private`, `teams_only`, `open`)
|
||||
@@ -187,7 +188,7 @@ MindSift speichert alle Daten lokal und hochperformant in SQLite (`/data/mindsif
|
||||
- `smtp_host TEXT`, `smtp_port INTEGER`, `smtp_user TEXT`, `smtp_pass TEXT`, `smtp_from TEXT`, `smtp_tls INTEGER`
|
||||
3. **`user_invites`** (Einladungscodes für neue Registrierungen)
|
||||
- `id TEXT PRIMARY KEY`, `code TEXT UNIQUE` (`MS-XXXXXXXX`), `email TEXT` (optional gebunden)
|
||||
- `role TEXT DEFAULT 'member'`, `team_id TEXT` (optional direkte Team-Zuweisung)
|
||||
- `role TEXT DEFAULT 'member'` (`admin`, `member`)
|
||||
- `created_by TEXT`, `used_by TEXT`, `expires_at DATETIME`, `created_at DATETIME`
|
||||
4. **`organizations` & `organization_teams` & `team_members`**
|
||||
- Strukturierung von Benutzern in Abteilungen, Familien oder Projekt-Teams.
|
||||
|
||||
+16
-5
@@ -4,12 +4,23 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="theme-color" content="#0b0f19" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-title" content="MindSift" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="description" content="MindSift — Der Kopf frei in drei Sekunden. Instant Capture, Tinder-Style Triage, Workspace & Echtzeit-Listen." />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='26' fill='%23131314'/><circle cx='50' cy='50' r='32' fill='none' stroke='%238ab4f8' stroke-width='7'/><path d='M36 52 L46 62 L66 38' fill='none' stroke='%23c58af9' stroke-width='8' stroke-linecap='round' stroke-linejoin='round'/></svg>" />
|
||||
<title>MindSift — Der Kopf frei in drei Sekunden</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||
<link rel="apple-touch-icon" href="/icon.svg" />
|
||||
<title>MindSift</title>
|
||||
<script>
|
||||
window.__deferredPwaPrompt = null;
|
||||
window.addEventListener('beforeinstallprompt', function (e) {
|
||||
e.preventDefault();
|
||||
window.__deferredPwaPrompt = e;
|
||||
window.dispatchEvent(new CustomEvent('mindsift:pwa-ready'));
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-[#0b0f19] text-[#e3e3e3] antialiased selection:bg-[#8ab4f8]/30 selection:text-[#8ab4f8]">
|
||||
<div id="root"></div>
|
||||
@@ -19,9 +30,9 @@
|
||||
caches.keys().then((keys) => keys.forEach((k) => caches.delete(k))).catch(() => {});
|
||||
}
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.getRegistrations().then((regs) => {
|
||||
regs.forEach((r) => r.unregister());
|
||||
}).catch(() => {});
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Generated
+17
@@ -8,8 +8,10 @@
|
||||
"name": "mindsift-frontend",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@types/prismjs": "^1.26.6",
|
||||
"framer-motion": "^11.15.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
@@ -1224,6 +1226,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/prismjs": {
|
||||
"version": "1.26.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz",
|
||||
"integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
@@ -2242,6 +2250,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prismjs": {
|
||||
"version": "1.30.0",
|
||||
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
|
||||
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
|
||||
@@ -11,8 +11,10 @@
|
||||
"test:run": "echo 'Frontend checks passed'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/prismjs": "^1.26.6",
|
||||
"framer-motion": "^11.15.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<rect width="512" height="512" rx="128" fill="#0b0f19"/>
|
||||
<circle cx="256" cy="256" r="164" fill="none" stroke="#8ab4f8" stroke-width="36"/>
|
||||
<path d="M184 266 L236 318 L338 194" fill="none" stroke="#c58af9" stroke-width="40" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 374 B |
@@ -1,18 +1,26 @@
|
||||
{
|
||||
"id": "/",
|
||||
"name": "MindSift — Der Kopf frei in drei Sekunden",
|
||||
"short_name": "MindSift",
|
||||
"description": "Instant Brain-Dump, Tinder-Style Triage, Workspace & Echtzeit-Familienlisten.",
|
||||
"start_url": "/?mode=capture",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0b0f19",
|
||||
"theme_color": "#0b0f19",
|
||||
"orientation": "portrait-primary",
|
||||
"icons": [
|
||||
{
|
||||
"src": "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='26' fill='%23131314'/><circle cx='50' cy='50' r='32' fill='none' stroke='%238ab4f8' stroke-width='7'/><path d='M36 52 L46 62 L66 38' fill='none' stroke='%23c58af9' stroke-width='8' stroke-linecap='round' stroke-linejoin='round'/></svg>",
|
||||
"src": "/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon.svg",
|
||||
"sizes": "192x192 512x512",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"shortcuts": [
|
||||
|
||||
+576
-20
@@ -12,6 +12,12 @@ import {
|
||||
RotateCcw,
|
||||
Palette,
|
||||
HelpCircle,
|
||||
Plus,
|
||||
X,
|
||||
Download,
|
||||
Smartphone,
|
||||
Share,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { AuthStatus, DashboardStats, SharedList, Thought, User } from './lib/types';
|
||||
import {
|
||||
@@ -37,12 +43,14 @@ import { SetupAndAuthView } from './features/auth/SetupAndAuthView';
|
||||
import { UserSettingsView } from './features/settings/UserSettingsView';
|
||||
import { AdminSettingsView } from './features/settings/AdminSettingsView';
|
||||
import { HelpView } from './features/help/HelpView';
|
||||
import { GroupsView } from './features/teams/GroupsView';
|
||||
|
||||
type ActiveTab =
|
||||
| 'triage'
|
||||
| 'tasks'
|
||||
| 'workspace'
|
||||
| 'lists'
|
||||
| 'groups'
|
||||
| 'user_settings'
|
||||
| 'admin_settings'
|
||||
| 'help';
|
||||
@@ -118,6 +126,90 @@ export const App: React.FC = () => {
|
||||
const [showProfileMenu, setShowProfileMenu] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Mobile Navigation & Quick-Action State
|
||||
const [showMobileCapture, setShowMobileCapture] = useState(false);
|
||||
const [showMobileProfile, setShowMobileProfile] = useState(false);
|
||||
|
||||
// Mobile Web-App (PWA) Installation State
|
||||
const [isStandaloneApp, setIsStandaloneApp] = useState<boolean>(() => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return (
|
||||
window.matchMedia('(display-mode: standalone)').matches ||
|
||||
(window.navigator as any).standalone === true
|
||||
);
|
||||
});
|
||||
const [deferredPwaPrompt, setDeferredPwaPrompt] = useState<any>(() =>
|
||||
typeof window !== 'undefined' ? (window as any).__deferredPwaPrompt || null : null
|
||||
);
|
||||
const [showPwaInstallModal, setShowPwaInstallModal] = useState(false);
|
||||
const [showManualPwaGuide, setShowManualPwaGuide] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const syncPwaPrompt = () => {
|
||||
if ((window as any).__deferredPwaPrompt) {
|
||||
setDeferredPwaPrompt((window as any).__deferredPwaPrompt);
|
||||
}
|
||||
};
|
||||
const onBeforeInstallPrompt = (e: Event) => {
|
||||
e.preventDefault();
|
||||
(window as any).__deferredPwaPrompt = e;
|
||||
setDeferredPwaPrompt(e);
|
||||
};
|
||||
const onAppInstalled = () => {
|
||||
(window as any).__deferredPwaPrompt = null;
|
||||
setDeferredPwaPrompt(null);
|
||||
setIsStandaloneApp(true);
|
||||
setShowPwaInstallModal(false);
|
||||
setShowManualPwaGuide(false);
|
||||
};
|
||||
|
||||
window.addEventListener('beforeinstallprompt', onBeforeInstallPrompt);
|
||||
window.addEventListener('mindsift:pwa-ready', syncPwaPrompt);
|
||||
window.addEventListener('appinstalled', onAppInstalled);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeinstallprompt', onBeforeInstallPrompt);
|
||||
window.removeEventListener('mindsift:pwa-ready', syncPwaPrompt);
|
||||
window.removeEventListener('appinstalled', onAppInstalled);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Immediately offer Web-App installation on mobile if not yet running as installed standalone app
|
||||
useEffect(() => {
|
||||
if (!user || isStandaloneApp) return;
|
||||
if (window.innerWidth < 640 && !sessionStorage.getItem('mindsift_pwa_dismissed')) {
|
||||
setShowPwaInstallModal(true);
|
||||
}
|
||||
}, [user?.id, isStandaloneApp]);
|
||||
|
||||
const handleInstallWebApp = async () => {
|
||||
triggerHaptic(15);
|
||||
const promptEvent = deferredPwaPrompt || (window as any).__deferredPwaPrompt;
|
||||
if (promptEvent && typeof promptEvent.prompt === 'function') {
|
||||
try {
|
||||
await promptEvent.prompt();
|
||||
const choice = await promptEvent.userChoice;
|
||||
if (choice && choice.outcome === 'accepted') {
|
||||
(window as any).__deferredPwaPrompt = null;
|
||||
setDeferredPwaPrompt(null);
|
||||
setShowPwaInstallModal(false);
|
||||
setShowManualPwaGuide(false);
|
||||
}
|
||||
} catch {
|
||||
setShowManualPwaGuide(true);
|
||||
}
|
||||
} else {
|
||||
setShowManualPwaGuide(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismissPwaModal = () => {
|
||||
triggerHaptic(10);
|
||||
sessionStorage.setItem('mindsift_pwa_dismissed', '1');
|
||||
setShowPwaInstallModal(false);
|
||||
setShowManualPwaGuide(false);
|
||||
};
|
||||
|
||||
const handleOpenHelp = () => {
|
||||
triggerHaptic(10);
|
||||
setPreviousTab((prev) => (activeTab === 'help' ? prev : activeTab));
|
||||
@@ -273,8 +365,12 @@ export const App: React.FC = () => {
|
||||
|
||||
const handleTriggerAddButton = () => {
|
||||
triggerHaptic(20);
|
||||
if (window.innerWidth < 640) {
|
||||
setShowMobileCapture(true);
|
||||
} else {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
setCaptureFocusCount((c) => c + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
@@ -310,19 +406,71 @@ export const App: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const isStaff = user.role === 'admin' || user.role === 'team_admin';
|
||||
const roleLabel =
|
||||
user.role === 'admin'
|
||||
? 'Server-Admin'
|
||||
: user.role === 'team_admin'
|
||||
? 'Team-Admin'
|
||||
: 'Mitglied';
|
||||
const isServerAdmin = user.role === 'admin';
|
||||
const roleLabel = isServerAdmin ? 'Server-Admin' : 'Mitglied';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-canvas text-main flex flex-col font-sans selection:bg-accent/30">
|
||||
{/* Top Header Bar */}
|
||||
<header className="sticky top-0 z-30 bg-panel border-b border-border shadow-sm">
|
||||
<div className="max-w-6xl mx-auto px-3 sm:px-4 py-2 flex items-start justify-between gap-3 sm:gap-6">
|
||||
{/* Top Header Bar: Floating on mobile, solid panel on desktop */}
|
||||
<header className="sticky top-0 z-30 bg-canvas/85 backdrop-blur-md sm:bg-panel sm:border-b sm:border-border sm:shadow-sm">
|
||||
{/* Mobile Header: Floating Bar with Larger Logo Left (Triage Home) & Profile Avatar Right */}
|
||||
<div className="sm:hidden flex items-center justify-between h-14 px-4 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
setActiveTab('triage');
|
||||
}}
|
||||
className="h-11 px-2 -ml-2 rounded-xl flex items-center gap-2.5 cursor-pointer select-none active:scale-95 transition-transform"
|
||||
title="Zur Startseite (Triage)"
|
||||
>
|
||||
<div className="w-8 h-8 bg-accent text-on-accent rounded-xl shadow-sm flex items-center justify-center">
|
||||
<Brain className="w-4 h-4" />
|
||||
</div>
|
||||
<span className="font-bold text-base text-main tracking-tight">
|
||||
{authStatus.instanceName || 'MindSift'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
{!isStandaloneApp && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
setShowManualPwaGuide(false);
|
||||
setShowPwaInstallModal(true);
|
||||
}}
|
||||
className="h-9 px-2.5 rounded-xl bg-accent/15 border border-accent/40 text-accent text-xs font-semibold flex items-center gap-1.5 active:scale-95 transition-transform cursor-pointer"
|
||||
title="Als Web-App auf dem Smartphone installieren"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
<span>App</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
setShowMobileProfile(true);
|
||||
}}
|
||||
className={`w-11 h-11 -mr-1.5 rounded-full flex items-center justify-center transition-transform active:scale-95 cursor-pointer ${
|
||||
activeTab === 'user_settings' || activeTab === 'admin_settings' || activeTab === 'help'
|
||||
? 'ring-2 ring-accent'
|
||||
: ''
|
||||
}`}
|
||||
title="Profil & Einstellungen"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-accent text-on-accent text-xs font-bold shadow-sm flex items-center justify-center">
|
||||
{user.displayName ? user.displayName.slice(0, 1).toUpperCase() : 'U'}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop Header: 3-column Layout (Logo, InstantCaptureBar, Profile Pill) */}
|
||||
<div className="hidden sm:flex max-w-6xl mx-auto px-4 py-2 items-start justify-between gap-6">
|
||||
{/* Logo & Headline */}
|
||||
<div
|
||||
onClick={() => {
|
||||
@@ -334,14 +482,9 @@ export const App: React.FC = () => {
|
||||
<div className="w-7 h-7 bg-accent text-on-accent rounded-btn flex items-center justify-center">
|
||||
<Brain className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-bold text-sm sm:text-base text-main tracking-tight leading-none">
|
||||
{authStatus.instanceName || 'MindSift'}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted mt-0.5 hidden sm:inline-block">
|
||||
Der Kopf frei in drei Sekunden
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick-Capture Input Bubble directly in Header (above Tabs) */}
|
||||
@@ -427,6 +570,20 @@ export const App: React.FC = () => {
|
||||
<span>Kontoeinstellungen</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
setShowProfileMenu(false);
|
||||
setActiveTab('groups');
|
||||
}}
|
||||
className={`w-full px-3.5 py-2.5 text-left flex items-center gap-2.5 hover:bg-panel-elevated transition-colors ${
|
||||
activeTab === 'groups' ? 'text-accent font-semibold' : 'text-main'
|
||||
}`}
|
||||
>
|
||||
<Users className="w-4 h-4 text-accent" />
|
||||
<span>Gruppen</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleOpenHelp}
|
||||
className={`w-full px-3.5 py-2.5 text-left flex items-center gap-2.5 hover:bg-panel-elevated transition-colors ${
|
||||
@@ -437,7 +594,7 @@ export const App: React.FC = () => {
|
||||
<span>Hilfe & Anleitung</span>
|
||||
</button>
|
||||
|
||||
{isStaff && (
|
||||
{isServerAdmin && (
|
||||
<button
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
@@ -471,8 +628,8 @@ export const App: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation tabs attached to header */}
|
||||
<nav className="max-w-6xl mx-auto px-1 sm:px-2 border-t border-border flex items-stretch justify-between">
|
||||
{/* Desktop Navigation tabs attached to header */}
|
||||
<nav className="hidden sm:flex max-w-6xl mx-auto px-2 border-t border-border items-stretch justify-between">
|
||||
<div className="flex items-stretch overflow-x-auto no-scrollbar">
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -559,7 +716,7 @@ export const App: React.FC = () => {
|
||||
</header>
|
||||
|
||||
{/* Main Container */}
|
||||
<main className="flex-1 max-w-6xl w-full mx-auto px-3 sm:px-4 pt-3 pb-6">
|
||||
<main className="flex-1 max-w-6xl w-full mx-auto px-3 sm:px-4 pt-3 pb-24 sm:pb-6">
|
||||
<ViewErrorBoundary onReset={() => setActiveTab('triage')}>
|
||||
<section>
|
||||
{activeTab === 'triage' && (
|
||||
@@ -574,6 +731,7 @@ export const App: React.FC = () => {
|
||||
|
||||
{activeTab === 'tasks' && (
|
||||
<TasksView
|
||||
currentUser={user}
|
||||
onRefreshStats={refreshAll}
|
||||
onOpenDocument={handleOpenDocument}
|
||||
refreshTrigger={refreshRevision}
|
||||
@@ -596,6 +754,13 @@ export const App: React.FC = () => {
|
||||
<SharedListsView onRefreshGlobal={refreshAll} currentUser={user} />
|
||||
)}
|
||||
|
||||
{activeTab === 'groups' && (
|
||||
<GroupsView
|
||||
currentUser={user}
|
||||
onBack={() => setActiveTab(previousTab || 'triage')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'user_settings' && (
|
||||
<UserSettingsView
|
||||
currentUser={user}
|
||||
@@ -609,7 +774,7 @@ export const App: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'admin_settings' && isStaff && (
|
||||
{activeTab === 'admin_settings' && isServerAdmin && (
|
||||
<AdminSettingsView
|
||||
currentUser={user}
|
||||
onBack={() => setActiveTab('triage')}
|
||||
@@ -618,6 +783,397 @@ export const App: React.FC = () => {
|
||||
</section>
|
||||
</ViewErrorBoundary>
|
||||
</main>
|
||||
|
||||
{/* MOBILE BOTTOM NAVIGATION BAR (5 equal columns -> + FAB is dead-center) */}
|
||||
<nav className="sm:hidden fixed bottom-0 left-0 right-0 z-40 bg-panel/95 backdrop-blur-md border-t border-border px-2 pt-1.5 pb-[calc(0.5rem+env(safe-area-inset-bottom))] grid grid-cols-5 items-center shadow-panel">
|
||||
{/* Col 1: Triage */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
setActiveTab('triage');
|
||||
}}
|
||||
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 === 'triage' ? 'text-accent font-semibold' : 'text-muted hover:text-main'
|
||||
}`}
|
||||
title="Triage"
|
||||
>
|
||||
<div className="relative">
|
||||
<Layers className="w-5 h-5" />
|
||||
{stats.inboxCount > 0 && (
|
||||
<span className="absolute -top-1.5 -right-2.5 min-w-[16px] h-[16px] px-1 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-xs pointer-events-none">
|
||||
{stats.inboxCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] leading-tight">Triage</span>
|
||||
</button>
|
||||
|
||||
{/* Col 2: Aufgaben */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
setActiveTab('tasks');
|
||||
}}
|
||||
className={`min-h-[48px] rounded-xl flex flex-col items-center justify-center gap-0.5 transition-colors relative cursor-pointer active:scale-95 ${
|
||||
activeTab === 'tasks' ? 'text-accent font-semibold' : 'text-muted hover:text-main'
|
||||
}`}
|
||||
title="Aufgaben"
|
||||
>
|
||||
<div className="relative">
|
||||
<CheckSquare className="w-5 h-5" />
|
||||
{stats.tasksOpenCount > 0 && (
|
||||
<span className="absolute -top-1.5 -right-2.5 min-w-[16px] h-[16px] px-1 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-xs pointer-events-none">
|
||||
{stats.tasksOpenCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] leading-tight">Aufgaben</span>
|
||||
</button>
|
||||
|
||||
{/* Col 3 (EXACT CENTER): + Quick-Add FAB */}
|
||||
<div className="flex items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
triggerHaptic(15);
|
||||
setShowMobileCapture(true);
|
||||
}}
|
||||
className="w-14 h-14 -mt-6 rounded-full bg-accent text-on-accent shadow-lg flex items-center justify-center active:scale-90 transition-transform border-4 border-panel cursor-pointer"
|
||||
title="Schnell erfassen (+)"
|
||||
>
|
||||
<Plus className="w-7 h-7 stroke-[2.5]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Col 4: Workspace */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
setActiveTab('workspace');
|
||||
}}
|
||||
className={`min-h-[48px] rounded-xl flex flex-col items-center justify-center gap-0.5 transition-colors relative cursor-pointer active:scale-95 ${
|
||||
activeTab === 'workspace' ? 'text-accent font-semibold' : 'text-muted hover:text-main'
|
||||
}`}
|
||||
title="Workspace"
|
||||
>
|
||||
<div className="relative">
|
||||
<FileText className="w-5 h-5" />
|
||||
{(stats.docsTodosOpenCount ?? 0) > 0 && (
|
||||
<span className="absolute -top-1.5 -right-2.5 min-w-[16px] h-[16px] px-1 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-xs pointer-events-none">
|
||||
{stats.docsTodosOpenCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] leading-tight">Workspace</span>
|
||||
</button>
|
||||
|
||||
{/* Col 5: Listen */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
setActiveTab('lists');
|
||||
}}
|
||||
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 === 'lists' ? 'text-accent font-semibold' : 'text-muted hover:text-main'
|
||||
}`}
|
||||
title="Listen"
|
||||
>
|
||||
<ShoppingCart className="w-5 h-5" />
|
||||
<span className="text-[10px] leading-tight">Listen</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* MOBILE QUICK-ADD BOTTOM SHEET / MODAL */}
|
||||
{showMobileCapture && (
|
||||
<div
|
||||
className="sm:hidden fixed inset-0 z-50 flex items-end justify-center bg-black/60 backdrop-blur-xs animate-in fade-in duration-150"
|
||||
onClick={() => setShowMobileCapture(false)}
|
||||
>
|
||||
<div
|
||||
className="w-full bg-panel border-t border-border rounded-t-2xl shadow-panel p-4 pb-[calc(1.5rem+env(safe-area-inset-bottom))] animate-in slide-in-from-bottom duration-200"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between pb-3 border-b border-border mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded-btn bg-accent/15 text-accent flex items-center justify-center">
|
||||
<Plus className="w-4 h-4" />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-main">Schnell erfassen</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowMobileCapture(false)}
|
||||
className="p-1 text-faint hover:text-main rounded-btn transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* InstantCaptureBar */}
|
||||
<div className="pt-1">
|
||||
<InstantCaptureBar
|
||||
onCaptured={(thought, target) => {
|
||||
handleCaptured(thought, target);
|
||||
setShowMobileCapture(false);
|
||||
}}
|
||||
lists={lists}
|
||||
autoFocusTrigger={Date.now()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MOBILE USER PROFILE SHEET */}
|
||||
{showMobileProfile && (
|
||||
<div
|
||||
className="sm:hidden fixed inset-0 z-50 flex items-end justify-center bg-black/60 backdrop-blur-xs animate-in fade-in duration-150"
|
||||
onClick={() => setShowMobileProfile(false)}
|
||||
>
|
||||
<div
|
||||
className="w-full max-h-[85vh] overflow-y-auto bg-panel border-t border-border rounded-t-2xl shadow-panel p-4 pb-[calc(1.5rem+env(safe-area-inset-bottom))] space-y-4 animate-in slide-in-from-bottom duration-200 divide-y divide-border"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* User Info Header */}
|
||||
<div className="flex items-center justify-between pb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-accent text-on-accent text-sm font-bold flex items-center justify-center flex-shrink-0">
|
||||
{user.displayName ? user.displayName.slice(0, 1).toUpperCase() : 'U'}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-sm text-main">{user.displayName}</p>
|
||||
<p className="text-xs text-muted">{user.email}</p>
|
||||
<span className="inline-block mt-1 px-1.5 py-0.5 bg-canvas border border-border rounded-btn text-[10px] text-accent font-semibold">
|
||||
{roleLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowMobileProfile(false)}
|
||||
className="p-1 text-faint hover:text-main rounded-btn transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Theme Quick Switcher */}
|
||||
<div className="pt-3 space-y-2">
|
||||
<span className="text-[11px] font-semibold text-muted uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Palette className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Design-Theme</span>
|
||||
</span>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{AVAILABLE_THEMES.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => handleChangeTheme(t.id)}
|
||||
className={`h-9 px-2.5 text-xs font-medium border rounded-btn flex items-center gap-2 transition-colors cursor-pointer ${
|
||||
activeTheme === t.id
|
||||
? 'bg-panel-elevated border-accent text-main font-semibold'
|
||||
: 'bg-canvas border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="w-3 h-3 border border-strong flex-shrink-0 rounded-full"
|
||||
style={{ backgroundColor: t.previewBg }}
|
||||
/>
|
||||
<span className="truncate">{t.name.replace('MindSift ', '')}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Menu Items */}
|
||||
<div className="pt-3 space-y-1">
|
||||
{!isStandaloneApp && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
setShowMobileProfile(false);
|
||||
setShowManualPwaGuide(false);
|
||||
setShowPwaInstallModal(true);
|
||||
}}
|
||||
className="w-full px-3 py-2.5 rounded-btn text-left flex items-center gap-3 bg-accent/10 border border-accent/30 text-accent font-semibold transition-colors text-xs cursor-pointer mb-1"
|
||||
>
|
||||
<Smartphone className="w-4 h-4 text-accent" />
|
||||
<span>Web-App auf Smartphone installieren</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
setShowMobileProfile(false);
|
||||
setActiveTab('user_settings');
|
||||
}}
|
||||
className={`w-full px-3 py-2.5 rounded-btn text-left flex items-center gap-3 hover:bg-panel-elevated transition-colors text-xs cursor-pointer ${
|
||||
activeTab === 'user_settings' ? 'text-accent font-semibold bg-panel-elevated' : 'text-main'
|
||||
}`}
|
||||
>
|
||||
<Settings className="w-4 h-4 text-accent" />
|
||||
<span>Kontoeinstellungen</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
setShowMobileProfile(false);
|
||||
setActiveTab('groups');
|
||||
}}
|
||||
className={`w-full px-3 py-2.5 rounded-btn text-left flex items-center gap-3 hover:bg-panel-elevated transition-colors text-xs cursor-pointer ${
|
||||
activeTab === 'groups' ? 'text-accent font-semibold bg-panel-elevated' : 'text-main'
|
||||
}`}
|
||||
>
|
||||
<Users className="w-4 h-4 text-accent" />
|
||||
<span>Gruppen</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowMobileProfile(false);
|
||||
handleOpenHelp();
|
||||
}}
|
||||
className={`w-full px-3 py-2.5 rounded-btn text-left flex items-center gap-3 hover:bg-panel-elevated transition-colors text-xs cursor-pointer ${
|
||||
activeTab === 'help' ? 'text-accent font-semibold bg-panel-elevated' : 'text-main'
|
||||
}`}
|
||||
>
|
||||
<HelpCircle className="w-4 h-4 text-accent" />
|
||||
<span>Hilfe & Anleitung</span>
|
||||
</button>
|
||||
|
||||
{isServerAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
setShowMobileProfile(false);
|
||||
setActiveTab('admin_settings');
|
||||
}}
|
||||
className={`w-full px-3 py-2.5 rounded-btn text-left flex items-center gap-3 hover:bg-panel-elevated transition-colors text-xs cursor-pointer ${
|
||||
activeTab === 'admin_settings' ? 'text-accent font-semibold bg-panel-elevated' : 'text-main'
|
||||
}`}
|
||||
>
|
||||
<Shield className="w-4 h-4 text-accent" />
|
||||
<span>Administration</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Logout */}
|
||||
<div className="pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowMobileProfile(false);
|
||||
handleLogout();
|
||||
}}
|
||||
className="w-full px-3 py-2.5 rounded-btn text-left flex items-center gap-3 text-status-err hover:bg-status-err-bg transition-colors text-xs font-medium cursor-pointer"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
<span>Abmelden</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* IMMEDIATE MOBILE WEB-APP INSTALLATION OFFER SHEET */}
|
||||
{showPwaInstallModal && !isStandaloneApp && (
|
||||
<div
|
||||
className="sm:hidden fixed inset-0 z-50 flex items-end justify-center bg-black/65 backdrop-blur-xs animate-in fade-in duration-150"
|
||||
onClick={handleDismissPwaModal}
|
||||
>
|
||||
<div
|
||||
className="w-full bg-panel border-t border-border rounded-t-2xl shadow-panel p-5 pb-[calc(1.75rem+env(safe-area-inset-bottom))] space-y-4 animate-in slide-in-from-bottom duration-200"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-2xl bg-accent text-on-accent shadow-md flex items-center justify-center flex-shrink-0">
|
||||
<Brain className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-main leading-tight">
|
||||
{authStatus.instanceName || 'MindSift'} als App installieren
|
||||
</h3>
|
||||
<p className="text-xs text-muted mt-0.5">
|
||||
Vollbild ohne Browser-Leiste, schnellerer Start & Direktzugriff vom Home-Bildschirm.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismissPwaModal}
|
||||
className="p-1.5 text-faint hover:text-main rounded-btn cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!showManualPwaGuide ? (
|
||||
<div className="space-y-2.5 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleInstallWebApp}
|
||||
className="btn-primary w-full h-12 text-sm font-semibold rounded-xl flex items-center justify-center gap-2 shadow-md active:scale-98 transition-transform cursor-pointer"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
<span>Jetzt als Web-App installieren</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismissPwaModal}
|
||||
className="btn-action w-full h-10 text-xs rounded-xl justify-center cursor-pointer"
|
||||
>
|
||||
Später im Browser weiter nutzen
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 pt-1">
|
||||
<div className="p-3.5 rounded-xl bg-canvas border border-border space-y-2.5 text-xs text-main">
|
||||
<p className="font-semibold text-accent flex items-center gap-1.5">
|
||||
<Smartphone className="w-4 h-4" />
|
||||
<span>In 2 Sekunden zum Home-Bildschirm hinzufügen:</span>
|
||||
</p>
|
||||
<ol className="space-y-2 text-muted list-decimal list-inside">
|
||||
<li>
|
||||
Tippe im Browser auf{' '}
|
||||
<strong className="text-main inline-flex items-center gap-1">
|
||||
<Share className="w-3.5 h-3.5 text-accent inline" /> Teilen
|
||||
</strong>{' '}
|
||||
(iPhone/Safari) oder das{' '}
|
||||
<strong className="text-main">Browser-Menü (⋮)</strong>.
|
||||
</li>
|
||||
<li>
|
||||
Wähle{' '}
|
||||
<strong className="text-main">„Zum Home-Bildschirm“</strong> bzw.{' '}
|
||||
<strong className="text-main">„App installieren“</strong>.
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismissPwaModal}
|
||||
className="btn-primary w-full h-11 text-xs font-semibold rounded-xl justify-center cursor-pointer"
|
||||
>
|
||||
Verstanden
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Users,
|
||||
User as UserIcon,
|
||||
X,
|
||||
Plus,
|
||||
Trash2,
|
||||
Lock,
|
||||
Share2,
|
||||
Check,
|
||||
} from 'lucide-react';
|
||||
import { User, Team, UnifiedShare } from '../lib/types';
|
||||
import { UserAutocomplete } from '../features/UserAutocomplete';
|
||||
|
||||
export interface UnifiedShareModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
resourceName: string;
|
||||
resourceIcon?: string | React.ReactNode;
|
||||
isOwner: boolean;
|
||||
currentUserId?: string;
|
||||
shares: UnifiedShare[];
|
||||
teams: Team[];
|
||||
collaborators: User[];
|
||||
allowRoleSelection?: boolean; // Default true (editor vs viewer)
|
||||
onAddShare: (targetType: 'team' | 'user', targetId: string, permission: 'editor' | 'viewer') => Promise<void>;
|
||||
onRemoveShare: (shareId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
title = 'Freigeben & Berechtigungen',
|
||||
resourceName,
|
||||
resourceIcon,
|
||||
isOwner,
|
||||
currentUserId,
|
||||
shares,
|
||||
teams,
|
||||
collaborators,
|
||||
allowRoleSelection = true,
|
||||
onAddShare,
|
||||
onRemoveShare,
|
||||
}) => {
|
||||
const [shareType, setShareType] = useState<'team' | 'user'>('team');
|
||||
const [selectedTeamId, setSelectedTeamId] = useState('');
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
const [selectedPermission, setSelectedPermission] = useState<'editor' | 'viewer'>('editor');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleAdd = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
const targetId = shareType === 'team' ? selectedTeamId : selectedUser?.id;
|
||||
if (!targetId) {
|
||||
setError(shareType === 'team' ? 'Bitte eine Gruppe auswählen' : 'Bitte einen Benutzer auswählen');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await onAddShare(shareType, targetId, selectedPermission);
|
||||
setSelectedTeamId('');
|
||||
setSelectedUser(null);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Fehler beim Hinzufügen der Freigabe');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (shareId: string) => {
|
||||
if (!window.confirm('Möchtest du diese Freigabe wirklich aufheben?')) return;
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
await onRemoveShare(shareId);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Fehler beim Entfernen der Freigabe');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const excludedUserIds = [
|
||||
...(currentUserId ? [currentUserId] : []),
|
||||
...shares.filter((s) => s.targetType === 'user').map((s) => s.targetId),
|
||||
];
|
||||
|
||||
const availableTeams = teams.filter(
|
||||
(t) => !shares.some((s) => s.targetType === 'team' && s.targetId === t.id)
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="panel-surface w-full max-w-lg p-5 shadow-panel rounded-card border border-border space-y-4 animate-in fade-in zoom-in-95 duration-150">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between pb-3 border-b border-border">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="w-8 h-8 rounded-btn bg-accent/15 text-accent flex items-center justify-center shrink-0">
|
||||
{resourceIcon ? (
|
||||
typeof resourceIcon === 'string' ? (
|
||||
<span className="text-base">{resourceIcon}</span>
|
||||
) : (
|
||||
resourceIcon
|
||||
)
|
||||
) : (
|
||||
<Share2 className="w-4 h-4" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-bold text-main truncate">{title}</h3>
|
||||
<p className="text-[11px] text-muted truncate">
|
||||
Objekt: <span className="font-semibold text-main">{resourceName}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1.5 text-muted hover:text-main rounded-btn hover:bg-panel transition-colors cursor-pointer"
|
||||
title="Schließen"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-2.5 bg-status-danger/10 border border-status-danger/30 text-status-danger rounded text-xs">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Share Section (Only if owner / manager) */}
|
||||
{isOwner ? (
|
||||
<form onSubmit={handleAdd} className="space-y-3 p-3 bg-panel-elevated border border-border rounded-card">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-semibold text-main">Freigabe hinzufügen:</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShareType('team');
|
||||
setSelectedUser(null);
|
||||
setError(null);
|
||||
}}
|
||||
className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1.5 border transition-colors cursor-pointer ${
|
||||
shareType === 'team'
|
||||
? 'bg-panel border-accent text-accent font-semibold shadow-xs'
|
||||
: 'bg-canvas border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
<Users className="w-3.5 h-3.5" />
|
||||
<span>Gruppe</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShareType('user');
|
||||
setSelectedTeamId('');
|
||||
setError(null);
|
||||
}}
|
||||
className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1.5 border transition-colors cursor-pointer ${
|
||||
shareType === 'user'
|
||||
? 'bg-panel border-accent text-accent font-semibold shadow-xs'
|
||||
: 'bg-canvas border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
<UserIcon className="w-3.5 h-3.5" />
|
||||
<span>Benutzer</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{shareType === 'team' ? (
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<select
|
||||
value={selectedTeamId}
|
||||
onChange={(e) => setSelectedTeamId(e.target.value)}
|
||||
className="input-field bg-panel h-8 px-2.5 text-xs flex-1 cursor-pointer"
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="" className="bg-panel text-muted">-- Gruppe auswählen --</option>
|
||||
{availableTeams.map((t) => (
|
||||
<option key={t.id} value={t.id} className="bg-panel text-main">
|
||||
👥 {t.name} ({t.members?.length || 1} Mitglieder)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{allowRoleSelection && (
|
||||
<select
|
||||
value={selectedPermission}
|
||||
onChange={(e) => setSelectedPermission(e.target.value as 'editor' | 'viewer')}
|
||||
className="input-field bg-panel h-8 px-2.5 text-xs w-full sm:w-36 cursor-pointer shrink-0"
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="editor" className="bg-panel text-main">Bearbeiten</option>
|
||||
<option value="viewer" className="bg-panel text-main">Nur Lesen</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<div className="flex-1">
|
||||
<UserAutocomplete
|
||||
users={collaborators}
|
||||
selectedUser={selectedUser}
|
||||
onSelectUser={setSelectedUser}
|
||||
excludeUserIds={excludedUserIds}
|
||||
disabled={loading}
|
||||
placeholder="Benutzer suchen (Name oder @Benutzername)…"
|
||||
/>
|
||||
</div>
|
||||
{allowRoleSelection && (
|
||||
<select
|
||||
value={selectedPermission}
|
||||
onChange={(e) => setSelectedPermission(e.target.value as 'editor' | 'viewer')}
|
||||
className="input-field bg-panel h-8 px-2.5 text-xs w-full sm:w-36 cursor-pointer shrink-0"
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="editor" className="bg-panel text-main">Bearbeiten</option>
|
||||
<option value="viewer" className="bg-panel text-main">Nur Lesen</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end pt-1">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || (shareType === 'team' ? !selectedTeamId : !selectedUser)}
|
||||
className="btn-primary text-xs h-8 px-3.5 flex items-center gap-1.5 cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>
|
||||
{loading
|
||||
? 'Wird gespeichert…'
|
||||
: shareType === 'team'
|
||||
? 'Gruppe freigeben'
|
||||
: 'Benutzer hinzufügen'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="p-3 bg-panel-elevated border border-border rounded-card text-xs text-muted flex items-center gap-2">
|
||||
<Lock className="w-4 h-4 text-faint shrink-0" />
|
||||
<span>Nur der Eigentümer kann neue Freigaben einrichten.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active Shares List */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs font-semibold text-muted uppercase tracking-wider px-0.5">
|
||||
<span>Aktive Freigaben</span>
|
||||
<span className="font-mono text-[11px] font-normal lowercase">
|
||||
{shares.length} {shares.length === 1 ? 'Freigabe' : 'Freigaben'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 max-h-56 overflow-y-auto pr-0.5">
|
||||
{shares.length === 0 ? (
|
||||
<div className="p-4 bg-canvas border border-border rounded-card text-xs text-muted flex items-center gap-2.5 justify-center text-center">
|
||||
<Lock className="w-4 h-4 text-faint shrink-0" />
|
||||
<span>Dieses Element ist aktuell privat (nur für dich sichtbar).</span>
|
||||
</div>
|
||||
) : (
|
||||
shares.map((sh) => {
|
||||
const isTeam = sh.targetType === 'team';
|
||||
const roleLabel =
|
||||
sh.permission === 'viewer' || sh.role === 'viewer' ? 'Leser' : 'Bearbeiter';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={sh.id}
|
||||
className="p-2.5 bg-canvas border border-border rounded-btn flex items-center justify-between gap-3 text-xs hover:border-border/80 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div
|
||||
className={`w-7 h-7 rounded-btn flex items-center justify-center shrink-0 ${
|
||||
isTeam
|
||||
? 'bg-accent/15 text-accent'
|
||||
: 'bg-surface-2 text-main border border-border'
|
||||
}`}
|
||||
>
|
||||
{isTeam ? <Users className="w-3.5 h-3.5" /> : <UserIcon className="w-3.5 h-3.5" />}
|
||||
</div>
|
||||
<div className="truncate">
|
||||
<div className="font-medium text-main truncate flex items-center gap-1.5">
|
||||
<span className="truncate">{sh.targetName || (isTeam ? 'Gruppe' : sh.targetEmail || 'Benutzer')}</span>
|
||||
<span
|
||||
className={`text-[9px] uppercase font-semibold px-1 py-0.2 rounded ${
|
||||
isTeam ? 'badge-neutral' : 'bg-surface-2 text-faint'
|
||||
}`}
|
||||
>
|
||||
{isTeam ? 'Gruppe' : 'Benutzer'}
|
||||
</span>
|
||||
</div>
|
||||
{sh.targetEmail && sh.targetEmail !== sh.targetName && (
|
||||
<div className="text-[11px] text-muted truncate">{sh.targetEmail}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span
|
||||
className={`text-[10px] px-2 py-0.5 rounded font-semibold ${
|
||||
roleLabel === 'Bearbeiter'
|
||||
? 'bg-accent/15 text-accent border border-accent/30'
|
||||
: 'bg-surface-2 text-muted border border-border'
|
||||
}`}
|
||||
>
|
||||
{roleLabel}
|
||||
</span>
|
||||
{isOwner && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemove(sh.id)}
|
||||
disabled={loading}
|
||||
className="p-1 text-faint hover:text-status-err rounded transition-colors cursor-pointer"
|
||||
title="Freigabe entfernen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="pt-2 border-t border-border flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="btn-action text-xs h-8 px-4 cursor-pointer"
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,205 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Search, X, Check } from 'lucide-react';
|
||||
import { User } from '../lib/types';
|
||||
|
||||
interface UserAutocompleteProps {
|
||||
users: User[];
|
||||
selectedUser: User | null;
|
||||
onSelectUser: (user: User | null) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
excludeUserIds?: string[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
|
||||
users,
|
||||
selectedUser,
|
||||
onSelectUser,
|
||||
placeholder = 'Benutzer suchen (Name, @Benutzername oder E-Mail)…',
|
||||
disabled = false,
|
||||
excludeUserIds = [],
|
||||
className = '',
|
||||
}) => {
|
||||
const [query, setQuery] = useState('');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Filter users based on query and exclusions
|
||||
const filteredUsers = React.useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
const available = users.filter((u) => !excludeUserIds.includes(u.id));
|
||||
if (!q) {
|
||||
return available.slice(0, 15);
|
||||
}
|
||||
return available
|
||||
.filter((u) => {
|
||||
const nameMatch = (u.displayName || '').toLowerCase().includes(q);
|
||||
const emailMatch = (u.email || '').toLowerCase().includes(q);
|
||||
const userMatch = (u.username || '').toLowerCase().includes(q);
|
||||
return nameMatch || emailMatch || userMatch;
|
||||
})
|
||||
.slice(0, 15);
|
||||
}, [users, query, excludeUserIds]);
|
||||
|
||||
const handleSelect = (user: User) => {
|
||||
onSelectUser(user);
|
||||
setQuery('');
|
||||
setIsOpen(false);
|
||||
setHighlightedIndex(-1);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
onSelectUser(null);
|
||||
setQuery('');
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (!isOpen) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'Enter') {
|
||||
setIsOpen(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setHighlightedIndex((prev) =>
|
||||
prev < filteredUsers.length - 1 ? prev + 1 : 0
|
||||
);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setHighlightedIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : filteredUsers.length - 1
|
||||
);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (highlightedIndex >= 0 && highlightedIndex < filteredUsers.length) {
|
||||
handleSelect(filteredUsers[highlightedIndex]);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={`relative ${className}`}>
|
||||
{selectedUser ? (
|
||||
<div className="h-8 px-2.5 bg-panel-elevated border border-accent/40 rounded-btn flex items-center justify-between gap-2 text-xs">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="w-5 h-5 rounded-full bg-accent/20 text-accent font-bold text-[10px] flex items-center justify-center shrink-0">
|
||||
{(selectedUser.displayName || selectedUser.email || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
<span className="font-semibold text-main truncate">
|
||||
{selectedUser.displayName || selectedUser.email}
|
||||
</span>
|
||||
{selectedUser.username && (
|
||||
<span className="text-[11px] text-muted truncate">
|
||||
@{selectedUser.username}
|
||||
</span>
|
||||
)}
|
||||
{selectedUser.email && selectedUser.displayName && (
|
||||
<span className="text-[11px] text-muted truncate hidden sm:inline">
|
||||
({selectedUser.email})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="text-muted hover:text-main p-0.5 rounded hover:bg-canvas transition-colors"
|
||||
title="Auswahl aufheben"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<Search className="w-3.5 h-3.5 text-muted absolute left-2.5 top-1/2 -translate-y-1/2 pointer-events-none" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setIsOpen(true);
|
||||
setHighlightedIndex(-1);
|
||||
}}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
className="w-full h-8 pl-8 pr-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs placeholder:text-muted transition-colors disabled:opacity-50"
|
||||
/>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute top-full left-0 right-0 z-50 mt-1 max-h-56 overflow-y-auto bg-panel border border-border rounded-card shadow-panel py-1 space-y-0.5">
|
||||
{filteredUsers.length === 0 ? (
|
||||
<div className="p-3 text-xs text-muted text-center">
|
||||
Kein passender Benutzer gefunden
|
||||
</div>
|
||||
) : (
|
||||
filteredUsers.map((u, idx) => {
|
||||
const isHighlighted = idx === highlightedIndex;
|
||||
return (
|
||||
<button
|
||||
key={u.id}
|
||||
type="button"
|
||||
onClick={() => handleSelect(u)}
|
||||
onMouseEnter={() => setHighlightedIndex(idx)}
|
||||
className={`w-full px-2.5 py-1.5 flex items-center justify-between gap-2 text-left text-xs transition-colors cursor-pointer ${
|
||||
isHighlighted
|
||||
? 'bg-accent/15 text-main font-medium'
|
||||
: 'hover:bg-panel-elevated text-main'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="w-5 h-5 rounded-full bg-accent/20 text-accent font-bold text-[10px] flex items-center justify-center shrink-0">
|
||||
{(u.displayName || u.email || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
<div className="truncate">
|
||||
<span className="font-semibold text-main">
|
||||
{u.displayName || u.email}
|
||||
</span>
|
||||
{u.username && (
|
||||
<span className="ml-1 text-[11px] text-muted">
|
||||
@{u.username}
|
||||
</span>
|
||||
)}
|
||||
{u.email && u.displayName && (
|
||||
<span className="ml-1.5 text-[10px] text-muted truncate">
|
||||
{u.email}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isHighlighted && (
|
||||
<Check className="w-3.5 h-3.5 text-accent shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -356,12 +356,9 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
||||
<div className="w-7 h-7 bg-accent text-on-accent rounded-btn flex items-center justify-center">
|
||||
<Brain className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-sm font-bold text-main tracking-tight leading-none">
|
||||
{authStatus.instanceName || 'MindSift'}
|
||||
</h1>
|
||||
<p className="text-[11px] text-muted mt-0.5">Der Kopf frei in drei Sekunden</p>
|
||||
</div>
|
||||
</div>
|
||||
<Lock className="w-4 h-4 text-faint" />
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Search,
|
||||
Layers,
|
||||
FileText,
|
||||
@@ -20,30 +19,19 @@ import {
|
||||
} from 'lucide-react';
|
||||
|
||||
interface HelpViewProps {
|
||||
onBack: () => void;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
type HelpCategory = 'all' | 'workspace' | 'triage' | 'tasks' | 'shortcuts';
|
||||
|
||||
export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
|
||||
export const HelpView: React.FC<HelpViewProps> = () => {
|
||||
const [activeCategory, setActiveCategory] = useState<HelpCategory>('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-5xl mx-auto space-y-4 pb-12">
|
||||
<div className="w-full space-y-4 pb-12">
|
||||
{/* Header Bar */}
|
||||
<div className="panel-surface p-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="btn-action text-xs flex items-center gap-1.5"
|
||||
title="Zurück zur vorherigen Ansicht"
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
<span>Zurück</span>
|
||||
</button>
|
||||
|
||||
<div>
|
||||
<h1 className="text-base sm:text-lg font-bold text-main flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-accent" />
|
||||
@@ -53,7 +41,6 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
|
||||
Alles Wichtige zur Tastaturbedienung, Outliner-Notizen, Triage und Zusammenarbeit
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative w-full sm:w-64">
|
||||
|
||||
@@ -5,15 +5,18 @@ import {
|
||||
Circle,
|
||||
Trash2,
|
||||
Users,
|
||||
Copy,
|
||||
Check,
|
||||
Radio,
|
||||
Sparkles,
|
||||
UserPlus,
|
||||
Share,
|
||||
Share2,
|
||||
User as UserIcon,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { ListItem, SharedList, Space, User, WSMessage } from '../../lib/types';
|
||||
import { ListItem, SharedList, Space, Team, User, WSMessage } from '../../lib/types';
|
||||
import { api, buildWebSocketURL, triggerHaptic } from '../../lib/api';
|
||||
import { UserAutocomplete } from '../UserAutocomplete';
|
||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||
|
||||
interface SharedListsViewProps {
|
||||
onRefreshGlobal: () => void;
|
||||
@@ -75,28 +78,33 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
const [newItemQty, setNewItemQty] = useState('');
|
||||
|
||||
const [wsConnected, setWsConnected] = useState(false);
|
||||
const [copiedInvite, setCopiedInvite] = useState(false);
|
||||
|
||||
const [showNewListModal, setShowNewListModal] = useState(false);
|
||||
const [newListTitle, setNewListTitle] = useState('');
|
||||
const [newListIcon, setNewListIcon] = useState('🛒');
|
||||
|
||||
const [showJoinModal, setShowJoinModal] = useState(false);
|
||||
const [joinCode, setJoinCode] = useState('');
|
||||
const [showNewSpaceModal, setShowNewSpaceModal] = useState(false);
|
||||
const [newSpaceName, setNewSpaceName] = useState('');
|
||||
|
||||
const [showMembersModal, setShowMembersModal] = useState(false);
|
||||
const [showShareModal, setShowShareModal] = useState(false);
|
||||
const [collaborators, setCollaborators] = useState<User[]>([]);
|
||||
const [teams, setTeams] = useState<Team[]>([]);
|
||||
const [addShareType, setAddShareType] = useState<'team' | 'user'>('team');
|
||||
const [selectedTeamId, setSelectedTeamId] = useState('');
|
||||
const [selectedShareUser, setSelectedShareUser] = useState<User | null>(null);
|
||||
const [inviteTarget, setInviteTarget] = useState('');
|
||||
const [inviteRole, setInviteRole] = useState<'editor' | 'viewer'>('editor');
|
||||
const [invitingLoading, setInvitingLoading] = useState(false);
|
||||
const [inviteError, setInviteError] = useState<string | null>(null);
|
||||
const [shareError, setShareError] = useState<string | null>(null);
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
|
||||
const loadSpaces = async () => {
|
||||
const sp = await api.getSpaces();
|
||||
let sp = await api.getSpaces();
|
||||
if (sp.length === 0) {
|
||||
try {
|
||||
const created = await api.createSpace('Meine Listen', '🛒', 'Persönliche und geteilte Einkaufslisten');
|
||||
sp = [created];
|
||||
} catch {
|
||||
// fallback
|
||||
}
|
||||
}
|
||||
setSpaces(sp);
|
||||
if (sp.length > 0) {
|
||||
if (!activeSpace) {
|
||||
@@ -281,108 +289,105 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
onRefreshGlobal();
|
||||
};
|
||||
|
||||
const handleCreateSpace = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newSpaceName.trim()) return;
|
||||
const created = await api.createSpace(newSpaceName.trim(), '🏡', 'Geteilter Familien- & Team-Bereich');
|
||||
setNewSpaceName('');
|
||||
setShowNewSpaceModal(false);
|
||||
const sp = await api.getSpaces();
|
||||
setSpaces(sp);
|
||||
setActiveSpace(created);
|
||||
};
|
||||
|
||||
const handleJoinSpace = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!joinCode.trim()) return;
|
||||
const handleDeleteList = async (listId: string) => {
|
||||
if (!window.confirm('Diese Liste wirklich löschen?')) return;
|
||||
try {
|
||||
const joined = await api.joinSpace(joinCode.trim());
|
||||
setJoinCode('');
|
||||
setShowJoinModal(false);
|
||||
const sp = await api.getSpaces();
|
||||
setSpaces(sp);
|
||||
setActiveSpace(joined);
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Einladungscode ungültig');
|
||||
await api.deleteList(listId);
|
||||
if (activeSpace) {
|
||||
await loadListsForSpace(activeSpace.id);
|
||||
}
|
||||
};
|
||||
|
||||
const copyInviteCode = () => {
|
||||
if (!activeSpace) return;
|
||||
navigator.clipboard.writeText(activeSpace.inviteCode);
|
||||
setCopiedInvite(true);
|
||||
setTimeout(() => setCopiedInvite(false), 2000);
|
||||
};
|
||||
|
||||
const handleAddMember = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!activeSpace) return;
|
||||
const target = inviteTarget.trim();
|
||||
if (!target) return;
|
||||
setInvitingLoading(true);
|
||||
setInviteError(null);
|
||||
try {
|
||||
const isId = collaborators.some((u) => u.id === target);
|
||||
const newMember = await api.addSpaceMember(activeSpace.id, {
|
||||
userId: isId ? target : undefined,
|
||||
email: !isId ? target : undefined,
|
||||
role: inviteRole,
|
||||
});
|
||||
setActiveSpace((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
members: [...(prev.members || []).filter((m) => m.userId !== newMember.userId), newMember],
|
||||
}
|
||||
: prev
|
||||
);
|
||||
setSpaces((prev) =>
|
||||
prev.map((s) =>
|
||||
s.id === activeSpace.id
|
||||
? {
|
||||
...s,
|
||||
members: [...(s.members || []).filter((m) => m.userId !== newMember.userId), newMember],
|
||||
}
|
||||
: s
|
||||
)
|
||||
);
|
||||
setInviteTarget('');
|
||||
onRefreshGlobal();
|
||||
} catch (err: any) {
|
||||
setInviteError(err.message || 'Mitglied konnte nicht eingeladen werden');
|
||||
alert(err.message || 'Liste konnte nicht gelöscht werden');
|
||||
}
|
||||
};
|
||||
|
||||
const openShareModal = async () => {
|
||||
if (!activeSpace) return;
|
||||
setShowShareModal(true);
|
||||
setShareError(null);
|
||||
setSelectedTeamId('');
|
||||
setSelectedShareUser(null);
|
||||
try {
|
||||
const [usersList, teamsList, sharesList] = await Promise.all([
|
||||
api.getUsersDirectory ? api.getUsersDirectory().catch(() => api.getCollaborators().catch(() => [])) : api.getCollaborators().catch(() => []),
|
||||
api.getUserTeams().catch(() => []),
|
||||
api.getSpaceShares ? api.getSpaceShares(activeSpace.id).catch(() => []) : Promise.resolve([]),
|
||||
]);
|
||||
setCollaborators(usersList);
|
||||
setTeams(teamsList);
|
||||
setActiveSpace((prev) => (prev ? { ...prev, shares: sharesList } : prev));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddShare = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!activeSpace) return;
|
||||
setInvitingLoading(true);
|
||||
setShareError(null);
|
||||
try {
|
||||
if (addShareType === 'team') {
|
||||
if (!selectedTeamId) {
|
||||
setShareError('Bitte ein Team oder eine Gruppe auswählen');
|
||||
setInvitingLoading(false);
|
||||
return;
|
||||
}
|
||||
await api.addSpaceMember(activeSpace.id, {
|
||||
teamId: selectedTeamId,
|
||||
role: inviteRole,
|
||||
});
|
||||
setSelectedTeamId('');
|
||||
} else {
|
||||
if (!selectedShareUser) {
|
||||
setShareError('Bitte einen bestehenden Benutzer auswählen');
|
||||
setInvitingLoading(false);
|
||||
return;
|
||||
}
|
||||
await api.addSpaceMember(activeSpace.id, {
|
||||
userId: selectedShareUser.id,
|
||||
role: inviteRole,
|
||||
});
|
||||
setSelectedShareUser(null);
|
||||
}
|
||||
|
||||
const [sp, freshShares] = await Promise.all([
|
||||
api.getSpaces(),
|
||||
api.getSpaceShares ? api.getSpaceShares(activeSpace.id).catch(() => []) : Promise.resolve([]),
|
||||
]);
|
||||
setSpaces(sp);
|
||||
const updated = sp.find((s) => s.id === activeSpace.id);
|
||||
if (updated) {
|
||||
setActiveSpace({ ...updated, shares: freshShares });
|
||||
}
|
||||
onRefreshGlobal();
|
||||
} catch (err: any) {
|
||||
setShareError(err.message || 'Freigabe konnte nicht hinzugefügt werden');
|
||||
} finally {
|
||||
setInvitingLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveMember = async (targetUserId: string) => {
|
||||
const handleRemoveMember = async (targetId: string) => {
|
||||
if (!activeSpace) return;
|
||||
if (!window.confirm('Dieses Mitglied wirklich aus der Einkaufsliste / dem Bereich entfernen?')) return;
|
||||
if (!window.confirm('Freigabe wirklich aufheben?')) return;
|
||||
setInvitingLoading(true);
|
||||
setInviteError(null);
|
||||
setShareError(null);
|
||||
try {
|
||||
await api.removeSpaceMember(activeSpace.id, targetUserId);
|
||||
setActiveSpace((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
members: (prev.members || []).filter((m) => m.userId !== targetUserId),
|
||||
await api.removeSpaceMember(activeSpace.id, targetId);
|
||||
const [sp, freshShares] = await Promise.all([
|
||||
api.getSpaces(),
|
||||
api.getSpaceShares ? api.getSpaceShares(activeSpace.id).catch(() => []) : Promise.resolve([]),
|
||||
]);
|
||||
setSpaces(sp);
|
||||
const updated = sp.find((s) => s.id === activeSpace.id);
|
||||
if (updated) {
|
||||
setActiveSpace({ ...updated, shares: freshShares });
|
||||
}
|
||||
: prev
|
||||
);
|
||||
setSpaces((prev) =>
|
||||
prev.map((s) =>
|
||||
s.id === activeSpace.id
|
||||
? {
|
||||
...s,
|
||||
members: (s.members || []).filter((m) => m.userId !== targetUserId),
|
||||
}
|
||||
: s
|
||||
)
|
||||
);
|
||||
onRefreshGlobal();
|
||||
} catch (err: any) {
|
||||
setInviteError(err.message || 'Mitglied konnte nicht entfernt werden');
|
||||
setShareError(err.message || 'Freigabe konnte nicht entfernt werden');
|
||||
} finally {
|
||||
setInvitingLoading(false);
|
||||
}
|
||||
@@ -433,153 +438,117 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-3">
|
||||
{/* Top Space Bar & Realtime Indicator */}
|
||||
<div className="px-3 py-2 panel-surface flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{spaces.map((sp) => (
|
||||
<button
|
||||
key={sp.id}
|
||||
onClick={() => setActiveSpace(sp)}
|
||||
className={`h-8 px-3 text-xs sm:text-sm font-medium flex items-center gap-1.5 transition-colors border rounded-btn ${
|
||||
activeSpace?.id === sp.id
|
||||
? 'bg-accent text-on-accent border-accent'
|
||||
: 'bg-canvas text-muted hover:text-main border-border'
|
||||
}`}
|
||||
>
|
||||
<span>{sp.icon}</span>
|
||||
<span>{sp.name}</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={() => setShowNewSpaceModal((prev) => !prev)}
|
||||
className="btn-action h-8"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5 text-accent" /> Space
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowJoinModal((prev) => !prev)}
|
||||
className="btn-action h-8"
|
||||
>
|
||||
<UserPlus className="w-3.5 h-3.5 text-accent" /> Code einlösen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Realtime Status & Invite User Button */}
|
||||
{activeSpace && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`h-8 w-8 flex items-center justify-center rounded-btn ${
|
||||
wsConnected
|
||||
? 'badge-ok'
|
||||
: 'badge-neutral'
|
||||
}`}
|
||||
title={wsConnected ? 'Live synchronisiert (Online)' : 'Offline'}
|
||||
>
|
||||
<Radio className={`w-4 h-4 ${wsConnected ? 'animate-pulse text-status-ok' : 'text-muted'}`} />
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowMembersModal(true);
|
||||
api.getUsersDirectory().then(setCollaborators).catch(() => {});
|
||||
}}
|
||||
title="User zu dieser Einkaufsliste einladen & verwalten"
|
||||
className="btn-action h-8 flex items-center gap-1.5"
|
||||
>
|
||||
<UserPlus className="w-3.5 h-3.5 text-accent" />
|
||||
<span>User einladen</span>
|
||||
{activeSpace.members && activeSpace.members.length > 1 && (
|
||||
<span className="ml-1 px-1.5 py-0.2 bg-surface-2 text-main text-[10px] font-semibold rounded-full border border-border">
|
||||
{activeSpace.members.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create or Join Space Modals */}
|
||||
{showNewSpaceModal && (
|
||||
<form
|
||||
onSubmit={handleCreateSpace}
|
||||
className="p-2 panel-surface flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={newSpaceName}
|
||||
onChange={(e) => setNewSpaceName(e.target.value)}
|
||||
placeholder="Name des neuen Familien-/Team-Spaces (z. B. Urlaub 2026)…"
|
||||
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"
|
||||
>
|
||||
Space anlegen
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{showJoinModal && (
|
||||
<form
|
||||
onSubmit={handleJoinSpace}
|
||||
className="p-2 panel-surface flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={joinCode}
|
||||
onChange={(e) => setJoinCode(e.target.value)}
|
||||
placeholder="Invite-Code des Partners eingeben…"
|
||||
className="flex-1 h-9 px-3 input-field text-sm uppercase"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary h-9 px-4 text-xs sm:text-sm"
|
||||
>
|
||||
Beitreten
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* List Tabs within Active Space */}
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{/* Top Header: List Tabs & Actions */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2">
|
||||
{/* List Tabs */}
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{lists.map((lst) => {
|
||||
const openCount = lst.items.filter((i) => !i.isCompleted).length;
|
||||
const isSelected = activeList?.id === lst.id;
|
||||
return (
|
||||
<div key={lst.id} className="relative group flex items-center">
|
||||
<button
|
||||
key={lst.id}
|
||||
onClick={() => setActiveListId(lst.id)}
|
||||
className={`h-8 px-3 text-xs sm:text-sm font-medium flex items-center gap-1.5 transition-all border rounded-btn ${
|
||||
className={`h-8 px-3 text-xs font-medium border rounded-btn flex items-center gap-1.5 transition-colors ${
|
||||
isSelected
|
||||
? 'bg-accent text-on-accent border-accent'
|
||||
: 'bg-panel text-muted hover:text-main border-border'
|
||||
? 'bg-panel border-accent text-main'
|
||||
: 'bg-canvas border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
<span>{lst.icon}</span>
|
||||
<span>{lst.title}</span>
|
||||
<span className={`px-1.5 py-0.2 text-[10px] font-semibold rounded-pill ${
|
||||
isSelected ? 'bg-white/20 text-on-accent' : 'badge-neutral'
|
||||
}`}>
|
||||
{openCount}
|
||||
</span>
|
||||
{openCount > 0 && (
|
||||
<span className="ml-1 badge-neutral">{openCount}</span>
|
||||
)}
|
||||
</button>
|
||||
{isSelected && lists.length > 1 && (
|
||||
<button
|
||||
onClick={() => handleDeleteList(lst.id)}
|
||||
title="Liste löschen"
|
||||
className="h-8 px-1.5 bg-panel border-y border-r border-accent text-faint hover:text-status-err"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<button
|
||||
onClick={() => setShowNewListModal((prev) => !prev)}
|
||||
className="btn-action h-8"
|
||||
className="h-8 px-2.5 bg-canvas hover:bg-panel border border-border border-dashed rounded-btn text-xs text-muted hover:text-main flex items-center gap-1 transition-colors"
|
||||
title="Neue Liste anlegen"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5 text-accent" />
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Neue Liste</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Right Header Section: Geteilt Badge, Icon-Only Share Button & Live Sync */}
|
||||
<div className="flex items-center gap-2">
|
||||
{activeSpace && (() => {
|
||||
const isOwner = !activeSpace.ownerId || activeSpace.ownerId === currentUser?.id;
|
||||
const sharesCount =
|
||||
(activeSpace.shares?.length || 0) +
|
||||
(activeSpace.members?.filter((m) => m.userId !== activeSpace.ownerId).length || 0);
|
||||
const isShared =
|
||||
(activeSpace.shares && activeSpace.shares.length > 0) ||
|
||||
(activeSpace.members && activeSpace.members.length > 1);
|
||||
const ownerMember = activeSpace.members?.find((m) => m.role === 'owner' || m.userId === activeSpace.ownerId);
|
||||
const ownerDisplayName = activeSpace.ownerName || ownerMember?.displayName || ownerMember?.email || 'Eigentümer';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isShared && (
|
||||
isOwner ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openShareModal}
|
||||
className="px-2 py-0.5 rounded text-[10px] font-semibold bg-accent/15 text-accent border border-accent/30 flex items-center gap-1 hover:bg-accent/25 hover:border-accent/50 transition-colors cursor-pointer"
|
||||
title="Teilen-Menü öffnen: Geteilt mit Nutzern/Gruppen anzeigen & verwalten"
|
||||
>
|
||||
<Users className="w-2.5 h-2.5" />
|
||||
<span>Geteilt ({sharesCount > 0 ? sharesCount : activeSpace.members?.length || 1})</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openShareModal}
|
||||
className="px-2 py-0.5 rounded text-[10px] font-medium border border-border text-muted flex items-center gap-1 bg-surface-subtle hover:bg-panel-elevated hover:text-main transition-colors cursor-pointer"
|
||||
title="Teilen & Berechtigungen öffnen"
|
||||
>
|
||||
<Users className="w-2.5 h-2.5" />
|
||||
<span>Geteilt von {ownerDisplayName}</span>
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={openShareModal}
|
||||
className="btn-icon"
|
||||
title="Einkaufsliste teilen und Berechtigungen"
|
||||
>
|
||||
<Share className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Live Sync Status */}
|
||||
<div
|
||||
className={`h-8 w-8 flex items-center justify-center rounded-btn border ${
|
||||
wsConnected
|
||||
? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-500'
|
||||
: 'bg-canvas border-border text-muted'
|
||||
}`}
|
||||
title={wsConnected ? 'Live synchronisiert (Online)' : 'Offline'}
|
||||
>
|
||||
<Radio className={`w-3.5 h-3.5 ${wsConnected ? 'animate-pulse' : ''}`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showNewListModal && (
|
||||
<form
|
||||
onSubmit={handleCreateList}
|
||||
@@ -770,170 +739,82 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Space & Shopping List Members Modal */}
|
||||
{showMembersModal && activeSpace && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs">
|
||||
<div className="panel-surface w-full max-w-lg p-5 shadow-panel rounded-card border border-border space-y-4">
|
||||
{/* Modal Header */}
|
||||
<div className="flex items-center justify-between pb-2 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-accent" />
|
||||
<h3 className="text-base font-bold text-main">
|
||||
Einkaufsliste teilen: {activeSpace.name}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowMembersModal(false);
|
||||
setInviteError(null);
|
||||
{/* Unified Space & Shopping List Sharing Modal */}
|
||||
{showShareModal && activeSpace && (
|
||||
<UnifiedShareModal
|
||||
isOpen={showShareModal}
|
||||
onClose={() => setShowShareModal(false)}
|
||||
title={`Einkaufsliste teilen: ${activeSpace.name}`}
|
||||
resourceName={activeSpace.name}
|
||||
resourceIcon={activeSpace.icon || '🛒'}
|
||||
isOwner={activeSpace.ownerId === currentUser?.id || currentUser?.role === 'admin'}
|
||||
currentUserId={currentUser?.id}
|
||||
shares={[
|
||||
...(activeSpace.shares || [])
|
||||
.filter((s) => s.targetType === 'team')
|
||||
.map((s) => ({
|
||||
id: s.id,
|
||||
targetType: 'team' as const,
|
||||
targetId: s.targetId,
|
||||
targetName: s.targetName || 'Gruppe',
|
||||
permission: s.role === 'viewer' ? ('viewer' as const) : ('editor' as const),
|
||||
role: s.role,
|
||||
createdAt: s.createdAt,
|
||||
})),
|
||||
...(activeSpace.members || [])
|
||||
.filter((m) => m.userId !== activeSpace.ownerId)
|
||||
.map((m) => ({
|
||||
id: m.userId,
|
||||
targetType: 'user' as const,
|
||||
targetId: m.userId,
|
||||
targetName: m.displayName || m.email,
|
||||
targetEmail: m.email,
|
||||
permission: m.role === 'viewer' ? ('viewer' as const) : ('editor' as const),
|
||||
role: m.role,
|
||||
createdAt: m.joinedAt,
|
||||
})),
|
||||
]}
|
||||
teams={teams}
|
||||
collaborators={collaborators}
|
||||
allowRoleSelection={true}
|
||||
onAddShare={async (targetType, targetId, permission) => {
|
||||
if (targetType === 'team') {
|
||||
await api.addSpaceMember(activeSpace.id, {
|
||||
teamId: targetId,
|
||||
role: permission,
|
||||
});
|
||||
} else {
|
||||
await api.addSpaceMember(activeSpace.id, {
|
||||
userId: targetId,
|
||||
role: permission,
|
||||
});
|
||||
}
|
||||
const [sp, freshShares] = await Promise.all([
|
||||
api.getSpaces(),
|
||||
api.getSpaceShares ? api.getSpaceShares(activeSpace.id).catch(() => []) : Promise.resolve([]),
|
||||
]);
|
||||
setSpaces(sp);
|
||||
const updated = sp.find((s) => s.id === activeSpace.id);
|
||||
if (updated) {
|
||||
setActiveSpace({ ...updated, shares: freshShares });
|
||||
}
|
||||
onRefreshGlobal();
|
||||
}}
|
||||
onRemoveShare={async (shareId) => {
|
||||
await api.removeSpaceMember(activeSpace.id, shareId);
|
||||
const [sp, freshShares] = await Promise.all([
|
||||
api.getSpaces(),
|
||||
api.getSpaceShares ? api.getSpaceShares(activeSpace.id).catch(() => []) : Promise.resolve([]),
|
||||
]);
|
||||
setSpaces(sp);
|
||||
const updated = sp.find((s) => s.id === activeSpace.id);
|
||||
if (updated) {
|
||||
setActiveSpace({ ...updated, shares: freshShares });
|
||||
}
|
||||
onRefreshGlobal();
|
||||
}}
|
||||
className="btn-icon"
|
||||
title="Schließen"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{inviteError && (
|
||||
<div className="p-2.5 bg-status-danger/10 border border-status-danger/30 text-status-danger rounded text-xs">
|
||||
{inviteError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Invite User Form */}
|
||||
<form onSubmit={handleAddMember} className="space-y-2.5 p-3 bg-panel-elevated border border-border rounded-card">
|
||||
<div className="text-xs font-semibold text-main">User direkt einladen</div>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<select
|
||||
value={inviteTarget}
|
||||
onChange={(e) => setInviteTarget(e.target.value)}
|
||||
className="input-field h-8 px-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs flex-1 cursor-pointer"
|
||||
>
|
||||
<option value="" className="bg-panel text-muted">User auswählen oder Name/E-Mail eingeben…</option>
|
||||
{collaborators
|
||||
.filter(
|
||||
(u) =>
|
||||
u.id !== currentUser?.id &&
|
||||
!(activeSpace.members || []).some((m) => m.userId === u.id)
|
||||
)
|
||||
.map((u) => (
|
||||
<option key={u.id} value={u.id} className="bg-panel text-main">
|
||||
{u.displayName || u.username || u.email} ({u.username ? `@${u.username}` : u.email})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={inviteRole}
|
||||
onChange={(e) => setInviteRole(e.target.value as 'editor' | 'viewer')}
|
||||
className="input-field h-8 px-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs w-full sm:w-36 cursor-pointer"
|
||||
>
|
||||
<option value="editor" className="bg-panel text-main">Bearbeiten</option>
|
||||
<option value="viewer" className="bg-panel text-main">Nur Lesen</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Direct Username / Email input if user not selected from list */}
|
||||
{(!inviteTarget || !collaborators.some((u) => u.id === inviteTarget)) && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Oder Benutzername / E-Mail direkt eingeben…"
|
||||
value={inviteTarget}
|
||||
onChange={(e) => setInviteTarget(e.target.value)}
|
||||
className="input-field h-8 px-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs w-full"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end pt-1">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={invitingLoading || !inviteTarget.trim()}
|
||||
className="btn-primary text-xs h-8 px-4 flex items-center gap-1.5"
|
||||
>
|
||||
<UserPlus className="w-3.5 h-3.5" />
|
||||
<span>{invitingLoading ? 'Wird hinzugefügt…' : 'Einladen'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Current Space Members List */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-semibold text-muted uppercase tracking-wider">
|
||||
Mitglieder in dieser Liste ({activeSpace.members?.length || 1})
|
||||
</div>
|
||||
<div className="space-y-1.5 max-h-48 overflow-y-auto">
|
||||
{(activeSpace.members || []).map((m) => {
|
||||
const isOwner = m.role === 'owner' || m.userId === activeSpace.ownerId;
|
||||
const isMe = m.userId === currentUser?.id;
|
||||
const canRemove =
|
||||
(activeSpace.ownerId === currentUser?.id || currentUser?.role === 'admin') &&
|
||||
!isOwner;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={m.userId}
|
||||
className="p-2.5 bg-canvas border border-border rounded-btn flex items-center justify-between gap-3 text-xs"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="w-7 h-7 rounded-full bg-surface-2 flex items-center justify-center text-xs font-bold text-accent shrink-0">
|
||||
{(m.displayName || m.email || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
<div className="truncate">
|
||||
<div className="font-medium text-main truncate">
|
||||
{m.displayName || m.email} {isMe && <span className="text-muted">(Du)</span>}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted truncate">{m.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span
|
||||
className={`text-[10px] px-2 py-0.5 rounded font-semibold ${
|
||||
isOwner
|
||||
? 'bg-accent/15 text-accent'
|
||||
: 'bg-surface-2 text-muted'
|
||||
}`}
|
||||
>
|
||||
{isOwner ? 'Eigentümer' : m.role === 'editor' ? 'Bearbeiter' : 'Leser'}
|
||||
</span>
|
||||
{canRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveMember(m.userId)}
|
||||
disabled={invitingLoading}
|
||||
className="btn-icon text-muted hover:text-status-danger p-1"
|
||||
title="Mitglied entfernen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invite Code Alternative */}
|
||||
<div className="pt-2 border-t border-border flex items-center justify-between text-xs text-muted">
|
||||
<span>Alternativ per Code beitreten:</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyInviteCode}
|
||||
className="btn-action h-7 text-xs flex items-center gap-1.5"
|
||||
title="Code kopieren"
|
||||
>
|
||||
<span>Code: {activeSpace.inviteCode}</span>
|
||||
{copiedInvite ? (
|
||||
<Check className="w-3 h-3 text-status-ok" />
|
||||
) : (
|
||||
<Copy className="w-3 h-3 text-muted" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -10,16 +10,13 @@ import {
|
||||
Copy,
|
||||
Check,
|
||||
ArrowLeft,
|
||||
Share2,
|
||||
Send,
|
||||
AlertCircle,
|
||||
Building2,
|
||||
UserPlus,
|
||||
Share2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Organization,
|
||||
SystemSettings,
|
||||
Team,
|
||||
User,
|
||||
UserInvite,
|
||||
UserRole,
|
||||
@@ -31,7 +28,7 @@ interface AdminSettingsViewProps {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
type AdminSection = 'users' | 'orgs' | 'server' | 'smtp';
|
||||
type AdminSection = 'users' | 'server' | 'smtp';
|
||||
|
||||
export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
currentUser,
|
||||
@@ -50,22 +47,11 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
|
||||
const [inviteEmail, setInviteEmail] = useState('');
|
||||
const [inviteRole, setInviteRole] = useState<UserRole>('member');
|
||||
const [inviteTeamId, setInviteTeamId] = useState('');
|
||||
const [copiedInviteId, setCopiedInviteId] = useState<string | null>(null);
|
||||
|
||||
const [resetPwdUserId, setResetPwdUserId] = useState<string | null>(null);
|
||||
const [resetPwdVal, setResetPwdVal] = useState('');
|
||||
|
||||
// --- Organizations & Teams State ---
|
||||
const [orgs, setOrgs] = useState<Organization[]>([]);
|
||||
const [allTeams, setAllTeams] = useState<Team[]>([]);
|
||||
const [newOrgName, setNewOrgName] = useState('');
|
||||
const [selectedOrgId, setSelectedOrgId] = useState('');
|
||||
const [newTeamName, setNewTeamName] = useState('');
|
||||
const [newTeamDesc, setNewTeamDesc] = useState('');
|
||||
const [memberSelectByTeam, setMemberSelectByTeam] = useState<Record<string, string>>({});
|
||||
const [memberRoleByTeam, setMemberRoleByTeam] = useState<Record<string, string>>({});
|
||||
|
||||
// --- Server & SMTP Settings State ---
|
||||
const [settings, setSettings] = useState<SystemSettings>({
|
||||
sharingMode: 'teams_only',
|
||||
@@ -86,19 +72,12 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
|
||||
const loadAll = async () => {
|
||||
try {
|
||||
const [uList, invList, orgList, teamList] = await Promise.all([
|
||||
const [uList, invList] = await Promise.all([
|
||||
api.adminGetUsers(),
|
||||
api.adminGetInvites(),
|
||||
api.getOrganizations(),
|
||||
api.getUserTeams(),
|
||||
]);
|
||||
setUsers(uList);
|
||||
setInvites(invList);
|
||||
setOrgs(orgList);
|
||||
setAllTeams(teamList);
|
||||
if (orgList.length > 0 && !selectedOrgId) {
|
||||
setSelectedOrgId(orgList[0].id);
|
||||
}
|
||||
|
||||
if (isServerAdmin) {
|
||||
const sys = await api.adminGetSettings();
|
||||
@@ -157,7 +136,6 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
const inv = await api.adminCreateInvite({
|
||||
email: inviteEmail.trim(),
|
||||
role: inviteRole,
|
||||
teamId: inviteTeamId,
|
||||
});
|
||||
setInvites((prev) => [inv, ...prev]);
|
||||
setInviteEmail('');
|
||||
@@ -239,91 +217,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// HANDLERS: ORGANIZATIONS & TEAMS
|
||||
// ============================================================================
|
||||
const handleCreateOrg = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newOrgName.trim()) return;
|
||||
setStatusMsg(null);
|
||||
try {
|
||||
const created = await api.createOrganization(newOrgName.trim());
|
||||
setNewOrgName('');
|
||||
setSelectedOrgId(created.id);
|
||||
await loadAll();
|
||||
setStatusMsg({ type: 'ok', text: `Organisation "${created.name}" angelegt.` });
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteOrg = async (org: Organization) => {
|
||||
if (!window.confirm(`Organisation "${org.name}" inklusive aller zugehörigen Teams löschen?`))
|
||||
return;
|
||||
setStatusMsg(null);
|
||||
try {
|
||||
await api.deleteOrganization(org.id);
|
||||
await loadAll();
|
||||
setStatusMsg({ type: 'ok', text: `Organisation "${org.name}" gelöscht.` });
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateTeam = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedOrgId || !newTeamName.trim()) return;
|
||||
setStatusMsg(null);
|
||||
try {
|
||||
const created = await api.createOrgTeam(
|
||||
selectedOrgId,
|
||||
newTeamName.trim(),
|
||||
newTeamDesc.trim()
|
||||
);
|
||||
setNewTeamName('');
|
||||
setNewTeamDesc('');
|
||||
await loadAll();
|
||||
setStatusMsg({ type: 'ok', text: `Team "${created.name}" wurde erstellt.` });
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTeam = async (team: Team) => {
|
||||
if (!window.confirm(`Team "${team.name}" wirklich löschen?`)) return;
|
||||
setStatusMsg(null);
|
||||
try {
|
||||
await api.deleteTeam(team.id);
|
||||
await loadAll();
|
||||
setStatusMsg({ type: 'ok', text: `Team "${team.name}" gelöscht.` });
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddMemberToTeam = async (teamId: string) => {
|
||||
const selectedUserId = memberSelectByTeam[teamId];
|
||||
if (!selectedUserId) return;
|
||||
const role = memberRoleByTeam[teamId] || 'member';
|
||||
setStatusMsg(null);
|
||||
try {
|
||||
await api.addTeamMember(teamId, { userId: selectedUserId, role });
|
||||
await loadAll();
|
||||
setStatusMsg({ type: 'ok', text: 'Teammitglied hinzugefügt.' });
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveMemberFromTeam = async (teamId: string, userId: string) => {
|
||||
setStatusMsg(null);
|
||||
try {
|
||||
await api.removeTeamMember(teamId, userId);
|
||||
await loadAll();
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// HANDLERS: SERVER & SMTP SETTINGS
|
||||
@@ -362,12 +256,6 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Collect all teams across orgs for invite dropdown
|
||||
const flattenedTeams: Team[] =
|
||||
orgs.flatMap((o) => o.teams || []).length > 0
|
||||
? orgs.flatMap((o) => o.teams || [])
|
||||
: allTeams;
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4">
|
||||
{/* Top Page Header */}
|
||||
@@ -384,17 +272,15 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
<div>
|
||||
<h1 className="text-base font-bold text-main tracking-tight flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 text-accent" />
|
||||
<span>Administration & Team-Steuerung</span>
|
||||
<span>Server-Administration</span>
|
||||
</h1>
|
||||
<p className="text-xs text-muted">
|
||||
Benutzerverwaltung, Einladungscodes, Organisationen, Teams, Isolationsmodus & Mail-Server
|
||||
Benutzerverwaltung, Einladungscodes, Isolationsmodus & Mail-Server
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="badge-accent">
|
||||
{isServerAdmin ? 'Server-Administrator' : 'Team-Administrator'}
|
||||
</span>
|
||||
<span className="badge-accent">Server-Administrator</span>
|
||||
</div>
|
||||
|
||||
{statusMsg && (
|
||||
@@ -439,21 +325,6 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setSection('orgs')}
|
||||
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
|
||||
section === 'orgs'
|
||||
? 'bg-panel-elevated text-main border-l-2 border-l-accent'
|
||||
: 'text-muted hover:text-main hover:bg-canvas/50'
|
||||
}`}
|
||||
>
|
||||
<Building2 className="w-4 h-4 text-accent flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-semibold">Organisationen & Teams</p>
|
||||
<p className="text-[10px] text-faint">Teams & Mitglieder zuweisen</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isServerAdmin && (
|
||||
<>
|
||||
<button
|
||||
@@ -558,8 +429,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
className="w-full min-w-0 h-8 px-2 input-field bg-panel"
|
||||
>
|
||||
<option value="member">Mitglied (Standard)</option>
|
||||
<option value="team_admin">Team-Admin (Darf einladen)</option>
|
||||
{isServerAdmin && <option value="admin">Server-Admin (Vollzugriff)</option>}
|
||||
<option value="admin">Server-Admin (Vollzugriff)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -584,7 +454,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
</h3>
|
||||
|
||||
<form onSubmit={handleCreateInvite} className="space-y-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="min-w-0">
|
||||
<label className="block text-muted mb-1">
|
||||
Gebundene E-Mail (optional)
|
||||
@@ -599,33 +469,14 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<label className="block text-muted mb-1">Zugewiesene Rolle</label>
|
||||
<label className="block text-muted mb-1">Zugewiesene Server-Rolle</label>
|
||||
<select
|
||||
value={inviteRole}
|
||||
onChange={(e) => setInviteRole(e.target.value as UserRole)}
|
||||
className="w-full min-w-0 h-8 px-2 input-field bg-panel"
|
||||
>
|
||||
<option value="member">Mitglied</option>
|
||||
<option value="team_admin">Team-Admin</option>
|
||||
{isServerAdmin && <option value="admin">Server-Admin</option>}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<label className="block text-muted mb-1">
|
||||
Automatisch Team zuweisen (optional)
|
||||
</label>
|
||||
<select
|
||||
value={inviteTeamId}
|
||||
onChange={(e) => setInviteTeamId(e.target.value)}
|
||||
className="w-full min-w-0 h-8 px-2 input-field bg-panel"
|
||||
>
|
||||
<option value="">Kein direktes Team</option>
|
||||
{flattenedTeams.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
<option value="admin">Server-Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -728,8 +579,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
title="Benutzerrolle ändern"
|
||||
>
|
||||
<option value="member">Mitglied</option>
|
||||
<option value="team_admin">Team-Admin</option>
|
||||
{isServerAdmin && <option value="admin">Server-Admin</option>}
|
||||
<option value="admin">Server-Admin</option>
|
||||
</select>
|
||||
|
||||
{isServerAdmin && (
|
||||
@@ -796,238 +646,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ================================================================= */}
|
||||
{/* SECTION 2: ORGANISATIONEN & TEAMS */}
|
||||
{/* ================================================================= */}
|
||||
{section === 'orgs' && (
|
||||
<div className="space-y-6">
|
||||
<div className="border-b border-border pb-3">
|
||||
<h2 className="text-sm font-bold text-main">Organisationen & Teams verwalten</h2>
|
||||
<p className="text-muted mt-0.5">
|
||||
Erstelle Organisationen und unterteile sie in Teams. Weise Benutzern Teams zu, damit
|
||||
sie im Modus "Teams isoliert" gemeinsam an Listen und Aufgaben arbeiten können.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Create Organization & Team */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-canvas border border-border rounded-card space-y-3">
|
||||
<h3 className="font-bold text-main">1. Neue Organisation gründen</h3>
|
||||
<form onSubmit={handleCreateOrg} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newOrgName}
|
||||
onChange={(e) => setNewOrgName(e.target.value)}
|
||||
placeholder="z.B. Homelab, Familie, Firma…"
|
||||
required
|
||||
className="flex-1 min-w-0 h-8 px-2.5 input-field"
|
||||
/>
|
||||
<button type="submit" className="btn-primary h-8 px-3 text-xs flex-shrink-0">
|
||||
Anlegen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-canvas border border-border rounded-card space-y-3">
|
||||
<h3 className="font-bold text-main">2. Neues Team in Organisation anlegen</h3>
|
||||
{orgs.length === 0 ? (
|
||||
<p className="text-faint">
|
||||
Bitte lege zuerst links eine Organisation an.
|
||||
</p>
|
||||
) : (
|
||||
<form onSubmit={handleCreateTeam} className="space-y-2">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<select
|
||||
value={selectedOrgId}
|
||||
onChange={(e) => setSelectedOrgId(e.target.value)}
|
||||
className="w-full min-w-0 h-8 px-2 input-field bg-panel"
|
||||
>
|
||||
{orgs.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
Org: {o.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
value={newTeamName}
|
||||
onChange={(e) => setNewTeamName(e.target.value)}
|
||||
placeholder="Team-Name (z.B. Infrastruktur)"
|
||||
required
|
||||
className="w-full min-w-0 h-8 px-2.5 input-field"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newTeamDesc}
|
||||
onChange={(e) => setNewTeamDesc(e.target.value)}
|
||||
placeholder="Kurzbeschreibung (optional)…"
|
||||
className="flex-1 min-w-0 h-8 px-2.5 input-field"
|
||||
/>
|
||||
<button type="submit" className="btn-primary h-8 px-3 text-xs flex-shrink-0">
|
||||
Team erstellen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Organizations & Nested Teams Display */}
|
||||
<div className="space-y-4">
|
||||
{orgs.length === 0 ? (
|
||||
<div className="p-6 bg-canvas border border-border rounded-card text-center text-faint">
|
||||
Noch keine Organisationen vorhanden.
|
||||
</div>
|
||||
) : (
|
||||
orgs.map((org) => {
|
||||
const orgTeams = Array.isArray(org.teams) ? org.teams : [];
|
||||
return (
|
||||
<div key={org.id} className="bg-canvas border border-border rounded-card overflow-hidden">
|
||||
<div className="p-3 bg-panel-elevated border-b border-border flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="w-4 h-4 text-accent" />
|
||||
<span className="font-bold text-sm text-main">{org.name}</span>
|
||||
<span className="text-[11px] text-muted">
|
||||
({orgTeams.length} {orgTeams.length === 1 ? 'Team' : 'Teams'})
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeleteOrg(org)}
|
||||
className="btn-icon btn-icon-danger"
|
||||
title="Organisation löschen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-3 space-y-3">
|
||||
{orgTeams.length === 0 ? (
|
||||
<p className="text-faint text-xs">
|
||||
Noch keine Teams in dieser Organisation.
|
||||
</p>
|
||||
) : (
|
||||
orgTeams.map((team) => {
|
||||
const members = Array.isArray(team.members) ? team.members : [];
|
||||
return (
|
||||
<div
|
||||
key={team.id}
|
||||
className="p-3 bg-panel border border-border rounded-card space-y-3"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-border pb-2">
|
||||
<div>
|
||||
<span className="font-bold text-main flex items-center gap-1.5">
|
||||
<Users className="w-3.5 h-3.5 text-accent" />
|
||||
<span>{team.name}</span>
|
||||
</span>
|
||||
{team.description && (
|
||||
<p className="text-[11px] text-muted mt-0.5">
|
||||
{team.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeleteTeam(team)}
|
||||
className="btn-icon btn-icon-danger"
|
||||
title="Team löschen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Team Members */}
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-[11px] font-semibold text-muted">
|
||||
Mitglieder ({members.length}):
|
||||
</p>
|
||||
{members.length === 0 ? (
|
||||
<p className="text-[11px] text-faint">
|
||||
Noch keine Mitglieder zugewiesen.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{members.map((m) => (
|
||||
<div
|
||||
key={m.userId}
|
||||
className="px-2 py-1 bg-canvas border border-border rounded-btn flex items-center gap-2"
|
||||
>
|
||||
<span className="text-main font-medium">
|
||||
{m.displayName}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted">
|
||||
({m.role})
|
||||
</span>
|
||||
<button
|
||||
onClick={() =>
|
||||
handleRemoveMemberFromTeam(team.id, m.userId)
|
||||
}
|
||||
className="text-faint hover:text-status-err"
|
||||
title="Aus Team entfernen"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add User to Team */}
|
||||
<div className="pt-2 border-t border-border flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
value={memberSelectByTeam[team.id] || ''}
|
||||
onChange={(e) =>
|
||||
setMemberSelectByTeam((prev) => ({
|
||||
...prev,
|
||||
[team.id]: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="flex-1 min-w-[160px] h-7 px-2 input-field text-[11px] bg-canvas"
|
||||
>
|
||||
<option value="">Benutzer auswählen…</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.displayName} ({u.email})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={memberRoleByTeam[team.id] || 'member'}
|
||||
onChange={(e) =>
|
||||
setMemberRoleByTeam((prev) => ({
|
||||
...prev,
|
||||
[team.id]: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="h-7 px-2 input-field text-[11px] bg-canvas"
|
||||
>
|
||||
<option value="member">Mitglied</option>
|
||||
<option value="team_admin">Team-Admin</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAddMemberToTeam(team.id)}
|
||||
disabled={!memberSelectByTeam[team.id]}
|
||||
className="btn-action h-7 px-2.5 text-[11px] disabled:opacity-40"
|
||||
>
|
||||
+ Zum Team hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ================================================================= */}
|
||||
{/* SECTION 3: SERVER & SHARING-ISOLATION (SERVER-ADMIN ONLY) */}
|
||||
|
||||
@@ -236,8 +236,6 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
const roleBadgeLabel =
|
||||
currentUser.role === 'admin'
|
||||
? 'Server-Admin'
|
||||
: currentUser.role === 'team_admin'
|
||||
? 'Team-Admin'
|
||||
: 'Mitglied';
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,654 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Users,
|
||||
Plus,
|
||||
Trash2,
|
||||
ArrowLeft,
|
||||
Check,
|
||||
AlertCircle,
|
||||
UserPlus,
|
||||
Shield,
|
||||
Pencil,
|
||||
X,
|
||||
LogOut,
|
||||
} from 'lucide-react';
|
||||
import { Team, User } from '../../lib/types';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
import { UserAutocomplete } from '../UserAutocomplete';
|
||||
|
||||
interface GroupsViewProps {
|
||||
currentUser: User;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export const GroupsView: React.FC<GroupsViewProps> = ({ currentUser, onBack }) => {
|
||||
const [teams, setTeams] = useState<Team[]>([]);
|
||||
const [directoryUsers, setDirectoryUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [statusMsg, setStatusMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
||||
|
||||
// Create Group Modal State
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [newGroupName, setNewGroupName] = useState('');
|
||||
const [newGroupDesc, setNewGroupDesc] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
// Edit Group State
|
||||
const [editingTeamId, setEditingTeamId] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState('');
|
||||
const [editDesc, setEditDesc] = useState('');
|
||||
|
||||
// Add Member State per Team
|
||||
const [selectedUserByTeam, setSelectedUserByTeam] = useState<Record<string, User | null>>({});
|
||||
const [memberRoleByTeam, setMemberRoleByTeam] = useState<Record<string, 'admin' | 'member'>>({});
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [userTeams, allUsers] = await Promise.all([
|
||||
api.getUserTeams(),
|
||||
api.getUsersDirectory().catch(() => []),
|
||||
]);
|
||||
setTeams(userTeams);
|
||||
setDirectoryUsers(allUsers);
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Laden der Gruppen' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const handleCreateGroup = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const name = newGroupName.trim();
|
||||
if (!name) return;
|
||||
|
||||
setCreating(true);
|
||||
setStatusMsg(null);
|
||||
triggerHaptic(15);
|
||||
|
||||
try {
|
||||
const created = await api.createTeam(name, newGroupDesc.trim());
|
||||
setNewGroupName('');
|
||||
setNewGroupDesc('');
|
||||
setShowCreateModal(false);
|
||||
await loadData();
|
||||
setStatusMsg({ type: 'ok', text: `Gruppe "${created.name}" erfolgreich erstellt.` });
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Erstellen der Gruppe' });
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartEdit = (team: Team) => {
|
||||
setEditingTeamId(team.id);
|
||||
setEditName(team.name);
|
||||
setEditDesc(team.description || '');
|
||||
};
|
||||
|
||||
const handleSaveEdit = async (teamId: string) => {
|
||||
const name = editName.trim();
|
||||
if (!name) return;
|
||||
|
||||
setStatusMsg(null);
|
||||
triggerHaptic(10);
|
||||
try {
|
||||
await api.updateTeam(teamId, name, editDesc.trim());
|
||||
setEditingTeamId(null);
|
||||
await loadData();
|
||||
setStatusMsg({ type: 'ok', text: 'Gruppe erfolgreich aktualisiert.' });
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Aktualisieren der Gruppe' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTeam = async (team: Team) => {
|
||||
if (!window.confirm(`Gruppe "${team.name}" wirklich löschen? Alle Freigaben für diese Gruppe werden entfernt.`)) {
|
||||
return;
|
||||
}
|
||||
setStatusMsg(null);
|
||||
triggerHaptic(15);
|
||||
try {
|
||||
await api.deleteTeam(team.id);
|
||||
await loadData();
|
||||
setStatusMsg({ type: 'ok', text: `Gruppe "${team.name}" gelöscht.` });
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Löschen der Gruppe' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddMember = async (teamId: string) => {
|
||||
const user = selectedUserByTeam[teamId];
|
||||
if (!user) return;
|
||||
const role = memberRoleByTeam[teamId] || 'member';
|
||||
|
||||
setStatusMsg(null);
|
||||
triggerHaptic(10);
|
||||
try {
|
||||
await api.addTeamMember(teamId, { userId: user.id, role });
|
||||
setSelectedUserByTeam((prev) => ({ ...prev, [teamId]: null }));
|
||||
await loadData();
|
||||
setStatusMsg({ type: 'ok', text: `${user.displayName} wurde zur Gruppe hinzugefügt.` });
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Hinzufügen des Mitglieds' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeMemberRole = async (teamId: string, memberUserId: string, newRole: 'admin' | 'member') => {
|
||||
setStatusMsg(null);
|
||||
triggerHaptic(10);
|
||||
try {
|
||||
await api.updateTeamMemberRole(teamId, memberUserId, newRole);
|
||||
await loadData();
|
||||
setStatusMsg({
|
||||
type: 'ok',
|
||||
text: `Rolle erfolgreich zu "${newRole === 'admin' ? 'Gruppen-Admin' : 'Mitglied'}" geändert.`,
|
||||
});
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Ändern der Rolle' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveMember = async (teamId: string, memberUserId: string, memberName: string) => {
|
||||
const isSelf = memberUserId === currentUser.id;
|
||||
const msg = isSelf
|
||||
? 'Möchtest du diese Gruppe wirklich verlassen?'
|
||||
: `Mitglied "${memberName}" wirklich aus der Gruppe entfernen?`;
|
||||
|
||||
if (!window.confirm(msg)) return;
|
||||
|
||||
setStatusMsg(null);
|
||||
triggerHaptic(10);
|
||||
try {
|
||||
await api.removeTeamMember(teamId, memberUserId);
|
||||
await loadData();
|
||||
setStatusMsg({
|
||||
type: 'ok',
|
||||
text: isSelf ? 'Du hast die Gruppe verlassen.' : `Mitglied "${memberName}" wurde entfernt.`,
|
||||
});
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Entfernen des Mitglieds' });
|
||||
}
|
||||
};
|
||||
|
||||
// Group teams into managed (admin) and member-only
|
||||
const managedTeams = teams.filter((t) =>
|
||||
(t.members || []).some((m) => m.userId === currentUser.id && m.role === 'admin')
|
||||
);
|
||||
|
||||
const memberTeams = teams.filter(
|
||||
(t) =>
|
||||
(t.members || []).some((m) => m.userId === currentUser.id && m.role === 'member') &&
|
||||
!managedTeams.some((mt) => mt.id === t.id)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-5">
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 border-b border-border pb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="btn-action h-8 px-2.5 text-xs flex items-center gap-1.5 cursor-pointer"
|
||||
title="Zurück"
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
<span>Zurück</span>
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-base font-bold text-main tracking-tight flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-accent" />
|
||||
<span>Gruppen & Teams</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-panel-elevated text-muted border border-border">
|
||||
{teams.length}
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-xs text-muted mt-0.5">
|
||||
Erstelle und verwalte Gruppen (z. B. Familie, WG oder Projektteams) für gemeinsame Notizen, Aufgaben und Listen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
setShowCreateModal(true);
|
||||
}}
|
||||
className="btn-primary h-8 px-3.5 text-xs flex items-center gap-1.5 self-start sm:self-auto cursor-pointer"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Neue Gruppe</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status Messages */}
|
||||
{statusMsg && (
|
||||
<div
|
||||
className={`p-3 text-xs flex items-center justify-between gap-2 rounded-card ${
|
||||
statusMsg.type === 'ok' ? 'alert-ok' : 'alert-err'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{statusMsg.type === 'ok' ? (
|
||||
<Check className="w-4 h-4 text-accent flex-shrink-0" />
|
||||
) : (
|
||||
<AlertCircle className="w-4 h-4 text-status-err flex-shrink-0" />
|
||||
)}
|
||||
<span>{statusMsg.text}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setStatusMsg(null)}
|
||||
className="text-muted hover:text-main text-[11px] cursor-pointer"
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal: Neue Gruppe erstellen */}
|
||||
{showCreateModal && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-xs p-4 animate-in fade-in duration-150"
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md bg-panel border border-border rounded-xl shadow-panel p-5 space-y-4 animate-in zoom-in-95 duration-150"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between pb-3 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-btn bg-accent/15 text-accent flex items-center justify-center">
|
||||
<Users className="w-4 h-4" />
|
||||
</div>
|
||||
<h3 className="font-bold text-sm text-main">Neue Gruppe erstellen</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
className="p-1 text-muted hover:text-main rounded-btn transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCreateGroup} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-muted text-xs mb-1 font-medium">
|
||||
Name der Gruppe *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newGroupName}
|
||||
onChange={(e) => setNewGroupName(e.target.value)}
|
||||
placeholder="z. B. Familie, WG, Arbeit, Projekt X…"
|
||||
required
|
||||
autoFocus
|
||||
className="w-full h-8 px-2.5 input-field text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-muted text-xs mb-1 font-medium">
|
||||
Beschreibung (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newGroupDesc}
|
||||
onChange={(e) => setNewGroupDesc(e.target.value)}
|
||||
placeholder="z. B. Gemeinsame Einkaufslisten & Notizen…"
|
||||
className="w-full h-8 px-2.5 input-field text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-canvas border border-border rounded-btn text-[11px] text-muted space-y-1">
|
||||
<p className="font-semibold text-main flex items-center gap-1.5">
|
||||
<Shield className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Du wirst Gruppen-Admin</span>
|
||||
</p>
|
||||
<p>
|
||||
Als Ersteller kannst du weitere Mitglieder hinzufügen, Rollen zuweisen und die Gruppe verwalten.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2 border-t border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
className="btn-action h-8 px-3 text-xs cursor-pointer"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creating || !newGroupName.trim()}
|
||||
className="btn-primary h-8 px-4 text-xs flex items-center gap-1.5 cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Gruppe anlegen</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content Area */}
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-muted text-xs">Lade Gruppen…</div>
|
||||
) : teams.length === 0 ? (
|
||||
<div className="p-10 bg-canvas border border-border rounded-card text-center space-y-3">
|
||||
<div className="w-12 h-12 rounded-full bg-panel-elevated text-muted mx-auto flex items-center justify-center">
|
||||
<Users className="w-6 h-6 text-accent opacity-75" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-main text-sm">Noch keine Gruppen vorhanden</h3>
|
||||
<p className="text-muted text-xs mt-1 max-w-md mx-auto">
|
||||
Erstelle deine erste Gruppe, um Aufgaben, Einkaufslisten oder Dokumente gemeinsam mit anderen zu nutzen.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
setShowCreateModal(true);
|
||||
}}
|
||||
className="btn-primary h-8 px-4 text-xs inline-flex items-center gap-1.5 cursor-pointer"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Erste Gruppe anlegen</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* SECTION 1: Gruppen, die ich verwalte */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xs font-bold text-main uppercase tracking-wider text-muted flex items-center gap-2">
|
||||
<Shield className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Gruppen, die ich verwalte ({managedTeams.length})</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{managedTeams.length === 0 ? (
|
||||
<div className="p-4 bg-canvas border border-border rounded-card text-xs text-muted">
|
||||
Du bist derzeit noch in keiner Gruppe als Admin eingetragen. Klicke oben auf "Neue Gruppe", um eine eigene Gruppe zu erstellen.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{managedTeams.map((team) => {
|
||||
const members = team.members || [];
|
||||
const isEditing = editingTeamId === team.id;
|
||||
const memberUserIds = members.map((m) => m.userId);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={team.id}
|
||||
className="p-4 bg-canvas border border-border rounded-card space-y-4 shadow-sm"
|
||||
>
|
||||
{/* Team Header */}
|
||||
<div className="flex items-start justify-between gap-3 border-b border-border pb-3">
|
||||
<div className="flex items-start gap-2.5 flex-1 min-w-0">
|
||||
<div className="w-8 h-8 rounded-btn bg-accent/15 text-accent flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<Users className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<div className="flex-1 space-y-2 max-w-lg">
|
||||
<input
|
||||
type="text"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
placeholder="Gruppenname"
|
||||
className="w-full h-8 px-2.5 input-field text-xs font-bold"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={editDesc}
|
||||
onChange={(e) => setEditDesc(e.target.value)}
|
||||
placeholder="Optionale Beschreibung"
|
||||
className="w-full h-7 px-2.5 input-field text-xs"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSaveEdit(team.id)}
|
||||
className="btn-primary h-7 px-3 text-[11px] cursor-pointer"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingTeamId(null)}
|
||||
className="btn-action h-7 px-2.5 text-[11px] cursor-pointer"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-bold text-sm text-main truncate">
|
||||
{team.name}
|
||||
</span>
|
||||
<span className="badge-accent text-[10px] py-0.5 px-2">
|
||||
Gruppen-Admin
|
||||
</span>
|
||||
<span className="text-[11px] px-1.5 py-0.5 rounded-full bg-panel-elevated text-muted border border-border">
|
||||
{members.length} {members.length === 1 ? 'Mitglied' : 'Mitglieder'}
|
||||
</span>
|
||||
</div>
|
||||
{team.description && (
|
||||
<p className="text-xs text-muted mt-1 break-words">
|
||||
{team.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isEditing && (
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => handleStartEdit(team)}
|
||||
className="btn-action h-7 px-2.5 text-xs flex items-center gap-1 cursor-pointer"
|
||||
title="Gruppe bearbeiten"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">Bearbeiten</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteTeam(team)}
|
||||
className="btn-icon btn-icon-danger"
|
||||
title="Gruppe löschen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Members List */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] font-semibold text-muted uppercase tracking-wider">
|
||||
Mitglieder ({members.length})
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{members.map((m) => {
|
||||
const isSelf = m.userId === currentUser.id;
|
||||
const isAdmin = m.role === 'admin';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={m.userId}
|
||||
className="p-2.5 bg-panel border border-border rounded-btn flex items-center justify-between gap-2 text-xs"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="w-6 h-6 rounded-full bg-accent/20 text-accent text-[10px] font-bold flex items-center justify-center flex-shrink-0">
|
||||
{(m.displayName || 'U').slice(0, 1).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-main font-medium truncate text-xs">
|
||||
{m.displayName} {isSelf && <span className="text-faint">(Du)</span>}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted truncate">
|
||||
{m.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
<select
|
||||
value={m.role}
|
||||
onChange={(e) =>
|
||||
handleChangeMemberRole(
|
||||
team.id,
|
||||
m.userId,
|
||||
e.target.value as 'admin' | 'member'
|
||||
)
|
||||
}
|
||||
className={`h-6 px-1.5 text-[10px] rounded input-field bg-panel cursor-pointer ${
|
||||
isAdmin
|
||||
? 'border-accent text-accent font-semibold'
|
||||
: 'text-muted'
|
||||
}`}
|
||||
title="Rolle ändern"
|
||||
>
|
||||
<option value="member" className="bg-panel text-main">Mitglied</option>
|
||||
<option value="admin" className="bg-panel text-main">Gruppen-Admin</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
onClick={() => handleRemoveMember(team.id, m.userId, m.displayName)}
|
||||
className="p-1 text-faint hover:text-status-err rounded transition-colors cursor-pointer"
|
||||
title={isSelf ? 'Gruppe verlassen' : 'Mitglied entfernen'}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Member Row */}
|
||||
<div className="pt-3 border-t border-border flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
|
||||
<div className="flex-1 min-w-[220px]">
|
||||
<UserAutocomplete
|
||||
users={directoryUsers}
|
||||
selectedUser={selectedUserByTeam[team.id] || null}
|
||||
onSelectUser={(u) =>
|
||||
setSelectedUserByTeam((prev) => ({ ...prev, [team.id]: u }))
|
||||
}
|
||||
excludeUserIds={memberUserIds}
|
||||
placeholder="Benutzer suchen zum Hinzufügen…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={memberRoleByTeam[team.id] || 'member'}
|
||||
onChange={(e) =>
|
||||
setMemberRoleByTeam((prev) => ({
|
||||
...prev,
|
||||
[team.id]: e.target.value as 'admin' | 'member',
|
||||
}))
|
||||
}
|
||||
className="h-8 px-2.5 input-field text-xs bg-panel"
|
||||
>
|
||||
<option value="member">Mitglied</option>
|
||||
<option value="admin">Gruppen-Admin</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAddMember(team.id)}
|
||||
disabled={!selectedUserByTeam[team.id]}
|
||||
className="btn-primary h-8 px-3.5 text-xs flex items-center justify-center gap-1.5 cursor-pointer disabled:opacity-40"
|
||||
>
|
||||
<UserPlus className="w-3.5 h-3.5" />
|
||||
<span>Hinzufügen</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SECTION 2: Meine Mitgliedschaften (als Mitglied) */}
|
||||
{memberTeams.length > 0 && (
|
||||
<div className="space-y-3 pt-2">
|
||||
<h2 className="text-xs font-bold text-main uppercase tracking-wider text-muted flex items-center gap-2">
|
||||
<Users className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Meine Mitgliedschaften ({memberTeams.length})</span>
|
||||
</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
{memberTeams.map((team) => {
|
||||
const members = team.members || [];
|
||||
const admins = members.filter((m) => m.role === 'admin');
|
||||
|
||||
return (
|
||||
<div
|
||||
key={team.id}
|
||||
className="p-4 bg-canvas border border-border rounded-card space-y-3 shadow-sm"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 border-b border-border pb-3">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div className="w-8 h-8 rounded-btn bg-panel-elevated text-muted flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<Users className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-sm text-main">{team.name}</span>
|
||||
<span className="text-[11px] px-1.5 py-0.5 rounded-full bg-panel-elevated text-muted border border-border">
|
||||
{members.length} {members.length === 1 ? 'Mitglied' : 'Mitglieder'}
|
||||
</span>
|
||||
</div>
|
||||
{team.description && (
|
||||
<p className="text-xs text-muted mt-1">{team.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleRemoveMember(team.id, currentUser.id, currentUser.displayName)}
|
||||
className="btn-action text-status-err hover:bg-status-err-bg h-7 px-2.5 text-xs flex items-center gap-1.5 cursor-pointer"
|
||||
title="Gruppe verlassen"
|
||||
>
|
||||
<LogOut className="w-3.5 h-3.5" />
|
||||
<span>Gruppe verlassen</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Admins & Members list in read-only mode */}
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-[11px] font-semibold text-muted">
|
||||
Gruppen-Admins:{' '}
|
||||
<span className="text-main font-normal">
|
||||
{admins.map((a) => a.displayName).join(', ') || 'Keiner'}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-[11px] font-semibold text-muted">
|
||||
Alle Mitglieder:{' '}
|
||||
<span className="text-faint font-normal">
|
||||
{members.map((m) => m.displayName).join(', ')}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,295 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
X,
|
||||
Plus,
|
||||
Trash2,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
RotateCcw,
|
||||
Check,
|
||||
Columns,
|
||||
Layers,
|
||||
} from 'lucide-react';
|
||||
import { KanbanColumn } from '../../lib/types';
|
||||
import { triggerHaptic } from '../../lib/api';
|
||||
|
||||
interface ManageColumnsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
initialColumns: KanbanColumn[];
|
||||
onSave: (columns: KanbanColumn[]) => Promise<void>;
|
||||
listTitle?: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_KANBAN_COLUMNS: KanbanColumn[] = [
|
||||
{ id: 'todo', name: 'Zu erledigen' },
|
||||
{ id: 'in_progress', name: 'In Bearbeitung', color: 'accent' },
|
||||
{ id: 'waiting', name: 'Wartet auf andere', color: 'amber' },
|
||||
{ id: 'done', name: 'Erledigt', isDone: true, color: 'emerald' },
|
||||
];
|
||||
|
||||
export const ManageColumnsModal: React.FC<ManageColumnsModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
initialColumns,
|
||||
onSave,
|
||||
listTitle,
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const [columns, setColumns] = useState<KanbanColumn[]>(() => {
|
||||
return initialColumns && initialColumns.length > 0 ? initialColumns : DEFAULT_KANBAN_COLUMNS;
|
||||
});
|
||||
const [newColName, setNewColName] = useState('');
|
||||
const [newColIsDone, setNewColIsDone] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setColumns(initialColumns && initialColumns.length > 0 ? initialColumns : DEFAULT_KANBAN_COLUMNS);
|
||||
}, [initialColumns, isOpen]);
|
||||
|
||||
const handleMove = (index: number, direction: 'up' | 'down') => {
|
||||
triggerHaptic(10);
|
||||
const targetIndex = direction === 'up' ? index - 1 : index + 1;
|
||||
if (targetIndex < 0 || targetIndex >= columns.length) return;
|
||||
const next = [...columns];
|
||||
const temp = next[index];
|
||||
next[index] = next[targetIndex];
|
||||
next[targetIndex] = temp;
|
||||
setColumns(next);
|
||||
};
|
||||
|
||||
const handleRename = (id: string, newName: string) => {
|
||||
setColumns((prev) =>
|
||||
prev.map((c) => (c.id === id ? { ...c, name: newName } : c))
|
||||
);
|
||||
};
|
||||
|
||||
const handleToggleDone = (id: string) => {
|
||||
triggerHaptic(10);
|
||||
setColumns((prev) =>
|
||||
prev.map((c) => (c.id === id ? { ...c, isDone: !c.isDone } : c))
|
||||
);
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
if (columns.length <= 1) {
|
||||
alert('Mindestens eine Spalte muss vorhanden sein.');
|
||||
return;
|
||||
}
|
||||
triggerHaptic(10);
|
||||
setColumns((prev) => prev.filter((c) => c.id !== id));
|
||||
};
|
||||
|
||||
const handleAddColumn = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const name = newColName.trim();
|
||||
if (!name) return;
|
||||
triggerHaptic(10);
|
||||
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '_').slice(0, 20) || 'col';
|
||||
const id = `${slug}_${Date.now().toString(36).slice(-4)}`;
|
||||
setColumns((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id,
|
||||
name,
|
||||
isDone: newColIsDone,
|
||||
},
|
||||
]);
|
||||
setNewColName('');
|
||||
setNewColIsDone(false);
|
||||
};
|
||||
|
||||
const handleResetToDefault = () => {
|
||||
if (window.confirm('Spalten auf Standard zurücksetzen? (Zu erledigen, In Bearbeitung, Wartet auf andere, Erledigt)')) {
|
||||
triggerHaptic(15);
|
||||
setColumns(DEFAULT_KANBAN_COLUMNS);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (columns.length === 0) {
|
||||
alert('Es muss mindestens eine Spalte vorhanden sein.');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
triggerHaptic(15);
|
||||
try {
|
||||
await onSave(columns);
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Fehler beim Speichern der Spalten');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 modal-overlay z-50 flex items-center justify-center p-3 sm:p-4">
|
||||
<div
|
||||
className="w-full max-w-md panel-surface shadow-panel border border-border my-auto flex flex-col max-h-[90vh] overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-panel">
|
||||
<div className="flex items-center gap-2">
|
||||
<Columns className="w-4 h-4 text-accent" />
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-main">Kanban-Spalten anpassen</h3>
|
||||
{listTitle && (
|
||||
<p className="text-[10px] text-muted truncate">Liste: {listTitle}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-muted hover:text-main p-1.5 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 space-y-4 overflow-y-auto flex-1">
|
||||
<p className="text-xs text-muted">
|
||||
Passe die Spalten für das Kanban-Board an. Aufgaben können in jede beliebige Spalte verschoben werden.
|
||||
</p>
|
||||
|
||||
{/* Columns List */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs font-semibold text-main">Aktive Spalten ({columns.length}):</label>
|
||||
<div className="space-y-1.5 max-h-60 overflow-y-auto pr-1">
|
||||
{columns.map((col, idx) => (
|
||||
<div
|
||||
key={col.id}
|
||||
className="p-2 bg-canvas border border-border rounded-card flex items-center gap-2"
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
disabled={idx === 0}
|
||||
onClick={() => handleMove(idx, 'up')}
|
||||
className="text-muted hover:text-main disabled:opacity-20 p-0.5"
|
||||
title="Nach oben"
|
||||
>
|
||||
<ArrowUp className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={idx === columns.length - 1}
|
||||
onClick={() => handleMove(idx, 'down')}
|
||||
className="text-muted hover:text-main disabled:opacity-20 p-0.5"
|
||||
title="Nach unten"
|
||||
>
|
||||
<ArrowDown className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={col.name}
|
||||
onChange={(e) => handleRename(col.id, e.target.value)}
|
||||
className="flex-1 h-7 px-2 text-xs input-field"
|
||||
placeholder="Spaltenname"
|
||||
/>
|
||||
|
||||
{/* IsDone Toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleDone(col.id)}
|
||||
className={`h-7 px-2 text-[10px] rounded-btn border transition-colors flex items-center gap-1 ${
|
||||
col.isDone
|
||||
? 'bg-status-ok/15 border-status-ok text-status-ok font-semibold'
|
||||
: 'bg-panel border-border text-faint hover:text-muted'
|
||||
}`}
|
||||
title={col.isDone ? 'Gilt als Erledigt' : 'Gilt als Offen'}
|
||||
>
|
||||
<Check className={`w-2.5 h-2.5 ${col.isDone ? 'stroke-[3]' : 'opacity-40'}`} />
|
||||
<span>Erledigt</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(col.id)}
|
||||
disabled={columns.length <= 1}
|
||||
className="text-muted hover:text-status-err disabled:opacity-20 p-1 transition-colors"
|
||||
title="Spalte löschen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add New Column Form */}
|
||||
<form onSubmit={handleAddColumn} className="space-y-2 pt-2 border-t border-border">
|
||||
<label className="block text-xs font-semibold text-main">Neue Spalte hinzufügen:</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={newColName}
|
||||
onChange={(e) => setNewColName(e.target.value)}
|
||||
placeholder="z.B. Wartet auf Feedback, Review…"
|
||||
className="flex-1 h-8 px-2.5 text-xs input-field"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNewColIsDone((prev) => !prev)}
|
||||
className={`h-8 px-2.5 text-xs rounded-btn border transition-colors flex items-center gap-1 ${
|
||||
newColIsDone
|
||||
? 'bg-status-ok/15 border-status-ok text-status-ok font-semibold'
|
||||
: 'bg-panel border-border text-muted hover:text-main'
|
||||
}`}
|
||||
title="Sollen Aufgaben in dieser Spalte als erledigt gelten?"
|
||||
>
|
||||
<Check className="w-3 h-3" />
|
||||
<span>Erledigt</span>
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!newColName.trim()}
|
||||
className="btn-primary h-8 px-3 text-xs flex items-center gap-1 disabled:opacity-40"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Hinzufügen</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Reset button */}
|
||||
<div className="pt-2 flex justify-start">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResetToDefault}
|
||||
className="text-xs text-muted hover:text-main flex items-center gap-1"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3 text-accent" />
|
||||
<span>Auf Standard-Spalten zurücksetzen</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 px-4 py-2.5 border-t border-border bg-panel">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="btn-action h-7 px-3 text-xs"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="btn-primary h-7 px-3 text-xs disabled:opacity-50"
|
||||
>
|
||||
Spalten speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,665 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
X,
|
||||
Check,
|
||||
Calendar,
|
||||
Trash2,
|
||||
FileText,
|
||||
Plus,
|
||||
CheckSquare,
|
||||
Clock,
|
||||
Layers,
|
||||
Hash,
|
||||
AlertTriangle,
|
||||
Flame,
|
||||
ChevronRight,
|
||||
Folder,
|
||||
} from 'lucide-react';
|
||||
import { Thought, SubTask, TaskList, KanbanColumn } from '../../lib/types';
|
||||
import { triggerHaptic } from '../../lib/api';
|
||||
|
||||
interface TaskDetailModalProps {
|
||||
task: Thought | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
activeColumns: KanbanColumn[];
|
||||
taskLists: TaskList[];
|
||||
onUpdateTitle: (task: Thought, newTitle: string) => Promise<void>;
|
||||
onUpdateStatus: (task: Thought, newStatus: string) => Promise<void>;
|
||||
onUpdatePriority: (task: Thought, newPriority: string) => Promise<void>;
|
||||
onUpdateDueDate: (task: Thought, newDueDate: string) => Promise<void>;
|
||||
onUpdateTaskList: (task: Thought, newListId: string) => Promise<void>;
|
||||
onUpdateNotes: (task: Thought, newNotes: string) => Promise<void>;
|
||||
onUpdateTags?: (task: Thought, newTags: string[]) => Promise<void>;
|
||||
onToggleSubtask: (task: Thought, subtaskId: string) => Promise<void>;
|
||||
onAddSubtask: (task: Thought, title: string) => Promise<void>;
|
||||
onDeleteSubtask: (task: Thought, subtaskId: string) => Promise<void>;
|
||||
onConvertToDoc: (task: Thought) => void;
|
||||
onDeleteTask: (taskId: string) => void;
|
||||
}
|
||||
|
||||
export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
task,
|
||||
isOpen,
|
||||
onClose,
|
||||
activeColumns,
|
||||
taskLists,
|
||||
onUpdateTitle,
|
||||
onUpdateStatus,
|
||||
onUpdatePriority,
|
||||
onUpdateDueDate,
|
||||
onUpdateTaskList,
|
||||
onUpdateNotes,
|
||||
onUpdateTags,
|
||||
onToggleSubtask,
|
||||
onAddSubtask,
|
||||
onDeleteSubtask,
|
||||
onConvertToDoc,
|
||||
onDeleteTask,
|
||||
}) => {
|
||||
if (!isOpen || !task) return null;
|
||||
|
||||
const [titleDraft, setTitleDraft] = useState(task.content);
|
||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||
|
||||
const [isEditingNotes, setIsEditingNotes] = useState(false);
|
||||
const [notesDraft, setNotesDraft] = useState(task.notes || '');
|
||||
|
||||
const [newSubtaskTitle, setNewSubtaskTitle] = useState('');
|
||||
const [newTagDraft, setNewTagDraft] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setTitleDraft(task.content);
|
||||
setNotesDraft(task.notes || '');
|
||||
setIsEditingTitle(false);
|
||||
setIsEditingNotes(false);
|
||||
}, [task.id]);
|
||||
|
||||
// Handle escape key to close
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (isEditingNotes) {
|
||||
setIsEditingNotes(false);
|
||||
} else if (isEditingTitle) {
|
||||
setIsEditingTitle(false);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose, isEditingNotes, isEditingTitle]);
|
||||
|
||||
const handleTitleSubmit = async () => {
|
||||
const trimmed = titleDraft.trim();
|
||||
if (!trimmed) {
|
||||
setTitleDraft(task.content);
|
||||
setIsEditingTitle(false);
|
||||
return;
|
||||
}
|
||||
if (trimmed !== task.content) {
|
||||
await onUpdateTitle(task, trimmed);
|
||||
}
|
||||
setIsEditingTitle(false);
|
||||
};
|
||||
|
||||
const handleNotesSubmit = async () => {
|
||||
await onUpdateNotes(task, notesDraft);
|
||||
setIsEditingNotes(false);
|
||||
};
|
||||
|
||||
const handleAddSubtaskSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmed = newSubtaskTitle.trim();
|
||||
if (!trimmed) return;
|
||||
await onAddSubtask(task, trimmed);
|
||||
setNewSubtaskTitle('');
|
||||
};
|
||||
|
||||
const handleAddTagSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
let cleaned = newTagDraft.trim().replace(/^#+/, '').toLowerCase();
|
||||
if (!cleaned) return;
|
||||
const currentTags = Array.isArray(task.tags) ? [...task.tags] : [];
|
||||
if (!currentTags.includes(cleaned)) {
|
||||
const updated = [...currentTags, cleaned];
|
||||
if (onUpdateTags) {
|
||||
await onUpdateTags(task, updated);
|
||||
} else {
|
||||
await onUpdateTitle(task, `${task.content} #${cleaned}`);
|
||||
}
|
||||
}
|
||||
setNewTagDraft('');
|
||||
};
|
||||
|
||||
const handleRemoveTag = async (tagToRemove: string) => {
|
||||
const currentTags = Array.isArray(task.tags) ? [...task.tags] : [];
|
||||
const updated = currentTags.filter((t) => t !== tagToRemove);
|
||||
if (onUpdateTags) {
|
||||
await onUpdateTags(task, updated);
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuickDueDate = (daysOffset: number | null) => {
|
||||
if (daysOffset === null) {
|
||||
onUpdateDueDate(task, '');
|
||||
return;
|
||||
}
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + daysOffset);
|
||||
const dateStr = d.toISOString().split('T')[0];
|
||||
onUpdateDueDate(task, dateStr);
|
||||
};
|
||||
|
||||
const currentColumn = activeColumns.find((c) => c.id === task.kanbanStatus) ||
|
||||
(task.isCompleted
|
||||
? activeColumns.find((c) => c.isDone) || activeColumns[activeColumns.length - 1]
|
||||
: activeColumns.find((c) => !c.isDone) || activeColumns[0]);
|
||||
|
||||
const subtasks = Array.isArray(task.subtasks) ? task.subtasks : [];
|
||||
const completedSubtasks = subtasks.filter((s) => s.isCompleted).length;
|
||||
const subtasksPercent = subtasks.length > 0 ? Math.round((completedSubtasks / subtasks.length) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 modal-overlay z-50 flex items-center justify-center p-3 sm:p-4 overflow-y-auto">
|
||||
<div
|
||||
className="w-full max-w-2xl panel-surface shadow-panel border border-border my-auto flex flex-col max-h-[92vh] overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Top Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-panel">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<button
|
||||
onClick={() => {
|
||||
triggerHaptic(15);
|
||||
const nextState = !task.isCompleted;
|
||||
const doneCol = activeColumns.find((c) => c.isDone) || activeColumns[activeColumns.length - 1];
|
||||
const todoCol = activeColumns.find((c) => !c.isDone) || activeColumns[0];
|
||||
const nextKanban = nextState ? (doneCol ? doneCol.id : 'done') : (todoCol ? todoCol.id : 'todo');
|
||||
onUpdateStatus(task, nextKanban);
|
||||
}}
|
||||
className={`w-5 h-5 checkbox-square flex items-center justify-center flex-shrink-0 transition-colors border ${
|
||||
task.isCompleted
|
||||
? 'bg-status-ok border-status-ok text-on-accent'
|
||||
: 'border-strong hover:border-accent text-transparent'
|
||||
}`}
|
||||
title={task.isCompleted ? 'Als offen markieren' : 'Als erledigt markieren'}
|
||||
>
|
||||
<Check className="w-3.5 h-3.5 stroke-[3]" />
|
||||
</button>
|
||||
<span className="text-xs font-semibold text-main truncate">
|
||||
{task.isCompleted ? 'Erledigte Aufgabe' : 'Aufgabe bearbeiten'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onConvertToDoc(task);
|
||||
}}
|
||||
title="Als Dokument / Notiz vertiefen"
|
||||
className="btn-action h-7 px-2 text-xs flex items-center gap-1"
|
||||
>
|
||||
<FileText className="w-3.5 h-3.5 text-accent" />
|
||||
<span className="hidden sm:inline">Als Notiz vertiefen</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (window.confirm('Möchten Sie diese Aufgabe wirklich löschen?')) {
|
||||
onClose();
|
||||
onDeleteTask(task.id);
|
||||
}
|
||||
}}
|
||||
title="Aufgabe löschen"
|
||||
className="text-muted hover:text-status-err p-1.5 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-muted hover:text-main p-1.5 transition-colors"
|
||||
title="Schließen"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Body */}
|
||||
<div className="p-4 overflow-y-auto space-y-4 flex-1">
|
||||
{/* Task Title */}
|
||||
<div>
|
||||
{isEditingTitle ? (
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
value={titleDraft}
|
||||
onChange={(e) => setTitleDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleTitleSubmit();
|
||||
} else if (e.key === 'Escape') {
|
||||
setIsEditingTitle(false);
|
||||
setTitleDraft(task.content);
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
rows={2}
|
||||
className="w-full text-base font-semibold input-field p-2.5 resize-none leading-snug"
|
||||
/>
|
||||
<div className="flex justify-end gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsEditingTitle(false);
|
||||
setTitleDraft(task.content);
|
||||
}}
|
||||
className="btn-action h-7 px-2.5 text-xs"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTitleSubmit}
|
||||
className="btn-primary h-7 px-3 text-xs"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => setIsEditingTitle(true)}
|
||||
className="group cursor-pointer p-2 rounded-card hover:bg-panel transition-colors border border-transparent hover:border-border"
|
||||
title="Klicken zum Bearbeiten des Titels"
|
||||
>
|
||||
<h2
|
||||
className={`text-base font-semibold break-words leading-snug ${
|
||||
task.isCompleted ? 'line-through text-faint' : 'text-main'
|
||||
}`}
|
||||
>
|
||||
{task.content}
|
||||
</h2>
|
||||
<span className="text-[10px] text-muted opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
Klicken zum Umbenennen
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Kanban Status Buttons */}
|
||||
<div className="space-y-1.5 pt-1 border-t border-border">
|
||||
<label className="block text-xs font-semibold text-main">
|
||||
Zustand / Kanban-Status:
|
||||
</label>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{activeColumns.map((col) => {
|
||||
const isActive = (task.kanbanStatus === col.id) || (!task.kanbanStatus && col.id === 'todo');
|
||||
return (
|
||||
<button
|
||||
key={col.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
onUpdateStatus(task, col.id);
|
||||
}}
|
||||
className={`h-8 px-3 text-xs rounded-btn font-medium transition-all flex items-center gap-1.5 border ${
|
||||
isActive
|
||||
? 'bg-panel border-accent text-accent font-semibold shadow-sm'
|
||||
: 'bg-canvas border-border text-muted hover:text-main hover:border-strong'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
col.isDone
|
||||
? 'bg-status-ok'
|
||||
: col.id === 'in_progress'
|
||||
? 'bg-accent'
|
||||
: col.id === 'waiting'
|
||||
? 'bg-status-warn'
|
||||
: 'bg-faint'
|
||||
}`}
|
||||
/>
|
||||
<span>{col.name}</span>
|
||||
{isActive && <Check className="w-3 h-3 text-accent stroke-[3] ml-0.5" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Priority Buttons */}
|
||||
<div className="space-y-1.5 pt-1 border-t border-border">
|
||||
<label className="block text-xs font-semibold text-main">
|
||||
Priorität:
|
||||
</label>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUpdatePriority(task, 'normal')}
|
||||
className={`h-7 px-3 text-xs rounded-btn font-medium transition-colors border ${
|
||||
!task.priority || task.priority === 'normal' || task.priority === 'low'
|
||||
? 'bg-panel border-accent text-main font-semibold'
|
||||
: 'bg-canvas border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
Normal / Keine
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUpdatePriority(task, 'high')}
|
||||
className={`h-7 px-3 text-xs rounded-btn font-medium transition-colors border flex items-center gap-1 ${
|
||||
task.priority === 'high'
|
||||
? 'bg-status-warn/15 border-status-warn text-status-warn font-semibold'
|
||||
: 'bg-canvas border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
<AlertTriangle className="w-3 h-3 text-status-warn" />
|
||||
<span>Wichtig</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUpdatePriority(task, 'urgent')}
|
||||
className={`h-7 px-3 text-xs rounded-btn font-medium transition-colors border flex items-center gap-1 ${
|
||||
task.priority === 'urgent'
|
||||
? 'bg-status-err/15 border-status-err text-status-err font-semibold'
|
||||
: 'bg-canvas border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
<Flame className="w-3 h-3 text-status-err" />
|
||||
<span>Dringend</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Due Date & List Selection Row */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-1 border-t border-border">
|
||||
{/* Due Date */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-semibold text-main flex items-center gap-1">
|
||||
<Calendar className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Fälligkeitsdatum:</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="date"
|
||||
value={task.dueDate || ''}
|
||||
onChange={(e) => onUpdateDueDate(task, e.target.value)}
|
||||
className="flex-1 h-8 input-field px-2 text-xs bg-panel"
|
||||
/>
|
||||
{task.dueDate && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUpdateDueDate(task, '')}
|
||||
className="btn-action h-8 px-2 text-xs text-muted hover:text-status-err"
|
||||
title="Datum entfernen"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 text-[10px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleQuickDueDate(0)}
|
||||
className="px-1.5 py-0.5 bg-canvas border border-border rounded-btn text-muted hover:text-main"
|
||||
>
|
||||
Heute
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleQuickDueDate(1)}
|
||||
className="px-1.5 py-0.5 bg-canvas border border-border rounded-btn text-muted hover:text-main"
|
||||
>
|
||||
Morgen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleQuickDueDate(7)}
|
||||
className="px-1.5 py-0.5 bg-canvas border border-border rounded-btn text-muted hover:text-main"
|
||||
>
|
||||
+1 Woche
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task List assignment */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-semibold text-main flex items-center gap-1">
|
||||
<Folder className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Aufgabenliste:</span>
|
||||
</label>
|
||||
<select
|
||||
value={task.taskListId || ''}
|
||||
onChange={(e) => onUpdateTaskList(task, e.target.value)}
|
||||
className="w-full h-8 input-field px-2.5 text-xs bg-panel"
|
||||
>
|
||||
<option value="">-- Keine Liste (Allgemein) --</option>
|
||||
{taskLists.map((tl) => (
|
||||
<option key={tl.id} value={tl.id}>
|
||||
{tl.icon} {tl.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hashtags Section */}
|
||||
<div className="space-y-1.5 pt-1 border-t border-border">
|
||||
<label className="block text-xs font-semibold text-main flex items-center gap-1">
|
||||
<Hash className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Hashtags:</span>
|
||||
</label>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{Array.isArray(task.tags) &&
|
||||
task.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-pill bg-panel border border-border text-xs text-main"
|
||||
>
|
||||
<span>#{tag}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveTag(tag)}
|
||||
className="text-muted hover:text-status-err"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<form onSubmit={handleAddTagSubmit} className="inline-flex items-center gap-1">
|
||||
<input
|
||||
type="text"
|
||||
value={newTagDraft}
|
||||
onChange={(e) => setNewTagDraft(e.target.value)}
|
||||
placeholder="+ #hashtag"
|
||||
className="h-6 w-24 px-1.5 text-xs input-field"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subtasks Checklist */}
|
||||
<div className="space-y-2 pt-1 border-t border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-xs font-semibold text-main flex items-center gap-1.5">
|
||||
<CheckSquare className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Unteraufgaben ({completedSubtasks}/{subtasks.length}):</span>
|
||||
</label>
|
||||
{subtasks.length > 0 && (
|
||||
<span className="text-[11px] font-mono text-muted">{subtasksPercent}%</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{subtasks.length > 0 && (
|
||||
<div className="w-full h-1 bg-border rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all duration-300 ${
|
||||
subtasksPercent === 100 ? 'bg-status-ok' : 'bg-accent'
|
||||
}`}
|
||||
style={{ width: `${subtasksPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subtask items list */}
|
||||
{subtasks.length > 0 && (
|
||||
<div className="space-y-1 max-h-48 overflow-y-auto pr-1">
|
||||
{subtasks.map((st) => (
|
||||
<div
|
||||
key={st.id}
|
||||
className="p-1.5 bg-canvas border border-border rounded-card flex items-center justify-between text-xs group"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleSubtask(task, st.id)}
|
||||
className={`w-3.5 h-3.5 checkbox-square flex items-center justify-center flex-shrink-0 transition-colors border ${
|
||||
st.isCompleted
|
||||
? 'bg-status-ok border-status-ok text-on-accent'
|
||||
: 'border-strong hover:border-accent text-transparent'
|
||||
}`}
|
||||
>
|
||||
<Check className="w-2.5 h-2.5 stroke-[3]" />
|
||||
</button>
|
||||
<span
|
||||
className={`truncate ${
|
||||
st.isCompleted ? 'line-through text-faint' : 'text-main'
|
||||
}`}
|
||||
>
|
||||
{st.title}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeleteSubtask(task, st.id)}
|
||||
className="opacity-0 group-hover:opacity-100 text-faint hover:text-status-err p-0.5 transition-opacity"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline add subtask form */}
|
||||
<form onSubmit={handleAddSubtaskSubmit} className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={newSubtaskTitle}
|
||||
onChange={(e) => setNewSubtaskTitle(e.target.value)}
|
||||
placeholder="Neue Unteraufgabe hinzufügen…"
|
||||
className="flex-1 h-7 px-2 text-xs input-field"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!newSubtaskTitle.trim()}
|
||||
className="btn-action h-7 px-2.5 text-xs disabled:opacity-40"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
<span>Hinzufügen</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Notes Section */}
|
||||
<div className="space-y-2 pt-1 border-t border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-xs font-semibold text-main flex items-center gap-1.5">
|
||||
<FileText className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Detailnotizen:</span>
|
||||
</label>
|
||||
{!isEditingNotes && task.notes && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setNotesDraft(task.notes || '');
|
||||
setIsEditingNotes(true);
|
||||
}}
|
||||
className="text-[11px] text-accent hover:underline"
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditingNotes ? (
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
value={notesDraft}
|
||||
onChange={(e) => setNotesDraft(e.target.value)}
|
||||
placeholder="Detailnotizen eingeben… (Markdown unterstützt)"
|
||||
rows={6}
|
||||
autoFocus
|
||||
className="w-full input-field p-2.5 text-xs font-mono leading-relaxed resize-y min-h-[100px]"
|
||||
/>
|
||||
<div className="flex justify-end gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsEditingNotes(false);
|
||||
setNotesDraft(task.notes || '');
|
||||
}}
|
||||
className="btn-action h-7 px-2.5 text-xs"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNotesSubmit}
|
||||
className="btn-primary h-7 px-3 text-xs"
|
||||
>
|
||||
Notiz speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : task.notes ? (
|
||||
<div
|
||||
onClick={() => {
|
||||
setNotesDraft(task.notes || '');
|
||||
setIsEditingNotes(true);
|
||||
}}
|
||||
className="p-3 bg-canvas border border-border rounded-card text-xs text-muted leading-relaxed whitespace-pre-wrap cursor-pointer hover:border-strong transition-colors"
|
||||
title="Klicken zum Bearbeiten"
|
||||
>
|
||||
{task.notes}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setNotesDraft('');
|
||||
setIsEditingNotes(true);
|
||||
}}
|
||||
className="w-full py-2.5 px-3 border border-dashed border-border rounded-card text-xs text-muted hover:text-main hover:bg-panel transition-colors text-left flex items-center gap-1.5"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Detailnotiz hinzufügen…</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border bg-panel">
|
||||
<div className="text-[11px] text-faint">
|
||||
Erstellt: {new Date(task.createdAt).toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="btn-action h-7 px-3 text-xs"
|
||||
>
|
||||
Fertig
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { motion, useMotionValue, useTransform, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
X,
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
SkipForward,
|
||||
Edit3,
|
||||
Layers,
|
||||
MoreHorizontal,
|
||||
} from 'lucide-react';
|
||||
import { SharedList, Thought } from '../../lib/types';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
@@ -29,11 +30,184 @@ interface TriageDeckProps {
|
||||
onFocusCapture: () => void;
|
||||
}
|
||||
|
||||
interface SwipeableQueueItemProps {
|
||||
item: Thought;
|
||||
lists: SharedList[];
|
||||
onBringToFront: (id: string) => void;
|
||||
onOpenActionSheet: (item: Thought) => void;
|
||||
onAction: (
|
||||
action: 'keep' | 'discard',
|
||||
options?: {
|
||||
convertTo?: 'task' | 'document' | 'list_item';
|
||||
targetListId?: string;
|
||||
dueDate?: string;
|
||||
thoughtOverride?: Thought;
|
||||
}
|
||||
) => void;
|
||||
}
|
||||
|
||||
const SwipeableQueueItem: React.FC<SwipeableQueueItemProps> = ({
|
||||
item,
|
||||
lists,
|
||||
onBringToFront,
|
||||
onOpenActionSheet,
|
||||
onAction,
|
||||
}) => {
|
||||
const qx = useMotionValue(0);
|
||||
const rightBgOpacity = useTransform(qx, [20, 85], [0, 1]);
|
||||
const leftBgOpacity = useTransform(qx, [-85, -20], [1, 0]);
|
||||
const didDragRef = useRef(false);
|
||||
const holdTimerRef = useRef<number | null>(null);
|
||||
const startPosRef = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
const clearHoldTimer = () => {
|
||||
if (holdTimerRef.current !== null) {
|
||||
window.clearTimeout(holdTimerRef.current);
|
||||
holdTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden select-none">
|
||||
{/* Swipe Right Background Indicator (Als Aufgabe) */}
|
||||
<motion.div
|
||||
style={{ opacity: rightBgOpacity }}
|
||||
className="pointer-events-none absolute inset-0 bg-accent/20 flex items-center justify-start px-4 text-accent font-semibold text-xs uppercase tracking-wide"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<CheckSquare className="w-4 h-4" />
|
||||
<span>Als Aufgabe 👉</span>
|
||||
</span>
|
||||
</motion.div>
|
||||
|
||||
{/* Swipe Left Background Indicator (Wegwerfen) */}
|
||||
<motion.div
|
||||
style={{ opacity: leftBgOpacity }}
|
||||
className="pointer-events-none absolute inset-0 bg-status-err-bg flex items-center justify-end px-4 text-status-err font-semibold text-xs uppercase tracking-wide"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span>👈 Wegwerfen</span>
|
||||
<X className="w-4 h-4" />
|
||||
</span>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
style={{ x: qx }}
|
||||
drag="x"
|
||||
dragDirectionLock
|
||||
dragConstraints={{ left: 0, right: 0 }}
|
||||
dragElastic={0.65}
|
||||
onPointerDown={(e) => {
|
||||
didDragRef.current = false;
|
||||
startPosRef.current = { x: e.clientX, y: e.clientY };
|
||||
clearHoldTimer();
|
||||
holdTimerRef.current = window.setTimeout(() => {
|
||||
if (!didDragRef.current) {
|
||||
didDragRef.current = true;
|
||||
triggerHaptic([20, 40]);
|
||||
onOpenActionSheet(item);
|
||||
}
|
||||
}, 420);
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
if (startPosRef.current) {
|
||||
const dx = Math.abs(e.clientX - startPosRef.current.x);
|
||||
const dy = Math.abs(e.clientY - startPosRef.current.y);
|
||||
if (dx > 10 || dy > 10) {
|
||||
clearHoldTimer();
|
||||
}
|
||||
}
|
||||
}}
|
||||
onPointerUp={clearHoldTimer}
|
||||
onPointerCancel={clearHoldTimer}
|
||||
onDragStart={() => {
|
||||
didDragRef.current = true;
|
||||
clearHoldTimer();
|
||||
}}
|
||||
onDragEnd={(_, info) => {
|
||||
clearHoldTimer();
|
||||
if (info.offset.x > 85 || info.velocity.x > 450) {
|
||||
onAction('keep', { convertTo: 'task', thoughtOverride: item });
|
||||
} else if (info.offset.x < -85 || info.velocity.x < -450) {
|
||||
onAction('discard', { thoughtOverride: item });
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!didDragRef.current) {
|
||||
onBringToFront(item.id);
|
||||
}
|
||||
}}
|
||||
className="relative bg-panel px-4 py-3.5 sm:px-3.5 sm:py-2.5 min-h-[56px] sm:min-h-[44px] flex items-center justify-between gap-3 hover:bg-panel-hover transition-colors group cursor-pointer"
|
||||
>
|
||||
<div className="flex-1 min-w-0 flex items-center gap-3">
|
||||
<span className="w-2 h-2 rounded-full bg-accent flex-shrink-0" />
|
||||
<span className="text-sm sm:text-xs font-medium text-main line-clamp-2 sm:truncate">
|
||||
{item.content}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Desktop quick buttons (hidden on mobile so queue items are clean & easy to tap/swipe) */}
|
||||
<div
|
||||
className="hidden sm:flex items-center gap-1.5 flex-shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAction('keep', { convertTo: 'task', thoughtOverride: item })}
|
||||
title="Direkt zur Aufgabe machen"
|
||||
className="h-7 px-2.5 bg-canvas hover:bg-accent hover:text-on-accent border border-border rounded-btn text-xs text-muted flex items-center gap-1 transition-colors cursor-pointer"
|
||||
>
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
<span>Aufgabe</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAction('keep', { convertTo: 'document', thoughtOverride: item })}
|
||||
title="Direkt als Workspace-Notiz anlegen"
|
||||
className="h-7 px-2.5 bg-canvas hover:bg-panel-elevated border border-border rounded-btn text-xs text-muted hover:text-main flex items-center gap-1 transition-colors cursor-pointer"
|
||||
>
|
||||
<FileText className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Notiz</span>
|
||||
</button>
|
||||
|
||||
{lists.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onAction('keep', {
|
||||
convertTo: 'list_item',
|
||||
targetListId: lists[0].id,
|
||||
thoughtOverride: item,
|
||||
})
|
||||
}
|
||||
title={`Direkt auf Liste "${lists[0].title}" setzen`}
|
||||
className="h-7 px-2.5 bg-canvas hover:bg-panel-elevated border border-border rounded-btn text-xs text-muted hover:text-main flex items-center gap-1 transition-colors cursor-pointer"
|
||||
>
|
||||
<ShoppingCart className="w-3.5 h-3.5 text-accent" />
|
||||
<span className="hidden md:inline">{lists[0].title}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAction('discard', { thoughtOverride: item })}
|
||||
title="Wegwerfen"
|
||||
className="h-7 w-7 bg-canvas hover:bg-panel-elevated border border-border rounded-btn text-muted hover:text-main flex items-center justify-center transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
deck,
|
||||
lists,
|
||||
onDeckChange,
|
||||
onOpenDocument,
|
||||
onFocusCapture,
|
||||
}) => {
|
||||
const [cards, setCards] = useState<Thought[]>(deck);
|
||||
@@ -45,6 +219,12 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
});
|
||||
const [showListSelector, setShowListSelector] = useState(false);
|
||||
|
||||
// Long-Press Action Sheet state (for Triage Card or Queue Item)
|
||||
const [actionSheetCard, setActionSheetCard] = useState<Thought | null>(null);
|
||||
const [sheetMode, setSheetMode] = useState<'main' | 'date' | 'list'>('main');
|
||||
const cardHoldTimerRef = useRef<number | null>(null);
|
||||
const cardPointerStartRef = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
// Inline edit state for cleaning up a hasty Quick Dump note before converting
|
||||
const [isEditingCard, setIsEditingCard] = useState(false);
|
||||
const [editDraft, setEditDraft] = useState('');
|
||||
@@ -61,6 +241,18 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
setEditDraft(activeCard?.content || '');
|
||||
}, [activeCard?.id]);
|
||||
|
||||
const clearCardHoldTimer = () => {
|
||||
if (cardHoldTimerRef.current !== null) {
|
||||
window.clearTimeout(cardHoldTimerRef.current);
|
||||
cardHoldTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const openActionSheetForCard = (card: Thought) => {
|
||||
setSheetMode('main');
|
||||
setActionSheetCard(card);
|
||||
};
|
||||
|
||||
const x = useMotionValue(0);
|
||||
const rotate = useTransform(x, [-220, 0, 220], [-8, 0, 8]);
|
||||
const keepOpacity = useTransform(x, [25, 110], [0, 1]);
|
||||
@@ -90,32 +282,39 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
setLastTriaged(targetCard);
|
||||
setShowDateModal(false);
|
||||
setShowListSelector(false);
|
||||
setActionSheetCard(null);
|
||||
setIsEditingCard(false);
|
||||
x.set(0);
|
||||
|
||||
try {
|
||||
const updated = await api.triageAction(targetCard.id, action, {
|
||||
await api.triageAction(targetCard.id, action, {
|
||||
convertTo: options?.convertTo,
|
||||
targetListId: options?.targetListId,
|
||||
dueDate: options?.dueDate,
|
||||
});
|
||||
onDeckChange();
|
||||
if (options?.convertTo === 'document' && updated.convertedRefId) {
|
||||
onOpenDocument(updated.convertedRefId);
|
||||
}
|
||||
} catch {
|
||||
onDeckChange();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkipCard = () => {
|
||||
const handleSkipCard = (targetId?: string) => {
|
||||
if (cards.length <= 1) return;
|
||||
triggerHaptic(10);
|
||||
setShowDateModal(false);
|
||||
setShowListSelector(false);
|
||||
setActionSheetCard(null);
|
||||
setIsEditingCard(false);
|
||||
x.set(0);
|
||||
if (targetId && targetId !== cards[0]?.id) {
|
||||
setCards((prev) => {
|
||||
const chosen = prev.find((c) => c.id === targetId);
|
||||
if (!chosen) return prev;
|
||||
return [...prev.filter((c) => c.id !== targetId), chosen];
|
||||
});
|
||||
} else {
|
||||
setCards((prev) => [...prev.slice(1), prev[0]]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBringToFront = (thoughtId: string) => {
|
||||
@@ -182,12 +381,14 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
} else if (e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleSkipCard();
|
||||
} else if (e.key === 'Escape' && actionSheetCard) {
|
||||
setActionSheetCard(null);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [activeCard, cards.length]);
|
||||
}, [activeCard, cards.length, actionSheetCard]);
|
||||
|
||||
const priorityBadge = (priority: string) => {
|
||||
switch (priority) {
|
||||
@@ -233,12 +434,13 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
if (!activeCard) {
|
||||
return (
|
||||
<div className="max-w-xl mx-auto mt-2 p-6 panel-surface text-center">
|
||||
<div className="w-11 h-11 bg-accent text-on-accent rounded-btn flex items-center justify-center mx-auto mb-3 shadow-sm">
|
||||
<Sparkles className="w-5 h-5" />
|
||||
<div className="w-12 h-12 bg-accent text-on-accent rounded-xl flex items-center justify-center mx-auto mb-3 shadow-sm">
|
||||
<Sparkles className="w-6 h-6" />
|
||||
</div>
|
||||
<h2 className="text-lg font-bold text-main mb-1">Triage-Deck ist leer</h2>
|
||||
<p className="text-muted text-xs sm:text-sm max-w-md mx-auto mb-4">
|
||||
Alle Quick Dump Notizen sind einsortiert oder verworfen. Tippe oben in die Leiste (Taste{' '}
|
||||
Alle Quick Dump Notizen sind einsortiert oder verworfen. Tippe auf das{' '}
|
||||
<strong className="text-main">+</strong> unten in der Mitte (oder Taste{' '}
|
||||
<kbd className="px-1.5 py-0.5 bg-canvas border border-border rounded-btn text-main">C</kbd>
|
||||
), um neue Gedanken reinzurotzen.
|
||||
</p>
|
||||
@@ -246,7 +448,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
<button
|
||||
onClick={onFocusCapture}
|
||||
className="btn-primary h-8 px-4"
|
||||
className="btn-primary h-10 px-5 text-sm"
|
||||
>
|
||||
+ Quick Dump erfassen
|
||||
</button>
|
||||
@@ -254,9 +456,9 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
{lastTriaged && (
|
||||
<button
|
||||
onClick={handleUndo}
|
||||
className="btn-action h-8 px-3"
|
||||
className="btn-action h-10 px-4 text-sm"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5 text-accent" />
|
||||
<RotateCcw className="w-4 h-4 text-accent" />
|
||||
Letzte Aktion rückgängig
|
||||
</button>
|
||||
)}
|
||||
@@ -270,7 +472,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
{/* Top Deck Status & Undo Bar */}
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="badge-accent">
|
||||
<span className="badge-accent text-xs py-1 px-2.5">
|
||||
{cards.length} {cards.length === 1 ? 'Quick Dump' : 'Quick Dumps'} offen
|
||||
</span>
|
||||
<span className="hidden sm:inline text-xs text-faint">•</span>
|
||||
@@ -282,16 +484,16 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
{lastTriaged && (
|
||||
<button
|
||||
onClick={handleUndo}
|
||||
className="btn-action h-7 px-2.5 text-xs"
|
||||
className="btn-action h-8 px-3 text-xs"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3 text-accent" />
|
||||
<RotateCcw className="w-3.5 h-3.5 text-accent" />
|
||||
Rückgängig
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Card Stack Container */}
|
||||
<div className="relative h-[280px] sm:h-[300px] w-full flex items-center justify-center">
|
||||
<div className="relative h-[290px] sm:h-[300px] w-full flex items-center justify-center">
|
||||
{/* Background Preview Card */}
|
||||
{nextCard && (
|
||||
<div
|
||||
@@ -304,7 +506,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active Draggable Card */}
|
||||
{/* Active Draggable & Long-Pressable Card */}
|
||||
<AnimatePresence mode="popLayout">
|
||||
<motion.div
|
||||
key={activeCard.id}
|
||||
@@ -313,7 +515,29 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
dragConstraints={{ left: 0, right: 0 }}
|
||||
dragElastic={0.8}
|
||||
transition={{ type: 'spring', stiffness: 350, damping: 25 }}
|
||||
onPointerDown={(e) => {
|
||||
if (isEditingCard) return;
|
||||
cardPointerStartRef.current = { x: e.clientX, y: e.clientY };
|
||||
clearCardHoldTimer();
|
||||
cardHoldTimerRef.current = window.setTimeout(() => {
|
||||
triggerHaptic([20, 40]);
|
||||
openActionSheetForCard(activeCard);
|
||||
}, 420);
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
if (cardPointerStartRef.current) {
|
||||
const dx = Math.abs(e.clientX - cardPointerStartRef.current.x);
|
||||
const dy = Math.abs(e.clientY - cardPointerStartRef.current.y);
|
||||
if (dx > 12 || dy > 12) {
|
||||
clearCardHoldTimer();
|
||||
}
|
||||
}
|
||||
}}
|
||||
onPointerUp={clearCardHoldTimer}
|
||||
onPointerCancel={clearCardHoldTimer}
|
||||
onDragStart={clearCardHoldTimer}
|
||||
onDragEnd={(_, info) => {
|
||||
clearCardHoldTimer();
|
||||
if (info.offset.x > 105 || info.velocity.x > 520) {
|
||||
handleAction('keep', { convertTo: 'task' });
|
||||
} else if (info.offset.x < -105 || info.velocity.x < -520) {
|
||||
@@ -323,19 +547,19 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
initial={{ scale: 0.97, opacity: 0, y: 10 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
exit={{ scale: 0.93, opacity: 0 }}
|
||||
className="relative w-full h-full panel-surface p-4 sm:p-5 cursor-grab active:cursor-grabbing flex flex-col justify-between overflow-hidden"
|
||||
className="relative w-full h-full panel-surface p-5 cursor-grab active:cursor-grabbing flex flex-col justify-between overflow-hidden"
|
||||
>
|
||||
{/* Dynamic Swipe Overlays */}
|
||||
<motion.div
|
||||
style={{ opacity: keepOpacity }}
|
||||
className="pointer-events-none absolute top-3.5 right-3.5 px-2.5 py-1 bg-accent-dim border border-accent rounded-btn text-accent font-semibold text-xs tracking-wide uppercase"
|
||||
className="pointer-events-none absolute top-3.5 right-3.5 px-3 py-1.5 bg-accent-dim border border-accent rounded-btn text-accent font-semibold text-xs tracking-wide uppercase"
|
||||
>
|
||||
Als Aufgabe 👉
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
style={{ opacity: discardOpacity }}
|
||||
className="pointer-events-none absolute top-3.5 left-3.5 px-2.5 py-1 bg-panel-elevated border border-strong rounded-btn text-main font-semibold text-xs tracking-wide uppercase"
|
||||
className="pointer-events-none absolute top-3.5 left-3.5 px-3 py-1.5 bg-panel-elevated border border-strong rounded-btn text-main font-semibold text-xs tracking-wide uppercase"
|
||||
>
|
||||
👈 Wegwerfen
|
||||
</motion.div>
|
||||
@@ -346,19 +570,10 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
{priorityBadge(activeCard.priority)}
|
||||
{sourceBadge(activeCard.source)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditDraft(activeCard.content);
|
||||
setIsEditingCard((prev) => !prev);
|
||||
}}
|
||||
title="Quick Dump Text vor dem Zuordnen anpassen"
|
||||
className="h-6 px-2 bg-canvas hover:bg-panel-hover border border-border rounded-btn text-[11px] text-muted hover:text-main flex items-center gap-1"
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Edit3 className="w-3 h-3" />
|
||||
<span>Bearbeiten</span>
|
||||
</button>
|
||||
<span className="text-xs text-muted flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{new Date(activeCard.createdAt).toLocaleString('de-DE', {
|
||||
@@ -368,46 +583,59 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openActionSheetForCard(activeCard)}
|
||||
title="Weitere Optionen (oder Karte gedrückt halten)"
|
||||
className="h-8 w-8 bg-canvas hover:bg-panel-hover border border-border rounded-btn text-muted hover:text-main flex items-center justify-center cursor-pointer"
|
||||
>
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card Body */}
|
||||
<div className="my-auto py-2">
|
||||
<div
|
||||
className="my-auto py-2"
|
||||
onPointerDown={(e) => {
|
||||
if (isEditingCard) e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{isEditingCard ? (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-2.5">
|
||||
<textarea
|
||||
value={editDraft}
|
||||
onChange={(e) => setEditDraft(e.target.value)}
|
||||
rows={3}
|
||||
autoFocus
|
||||
className="w-full input-field p-2 text-sm resize-none"
|
||||
className="w-full input-field p-2.5 text-sm resize-none"
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditingCard(false)}
|
||||
className="btn-action h-7 px-2.5 text-xs"
|
||||
className="btn-action h-9 px-3.5 text-xs"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveInlineEdit}
|
||||
className="btn-primary h-7 px-3 text-xs"
|
||||
className="btn-primary h-9 px-4 text-xs"
|
||||
>
|
||||
Text speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-base sm:text-xl font-medium text-main leading-relaxed break-words whitespace-pre-wrap">
|
||||
<p className="text-lg sm:text-xl font-medium text-main leading-relaxed break-words whitespace-pre-wrap">
|
||||
{activeCard.content}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Card Footer: Tags & Hints */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 pt-2.5 border-t border-border">
|
||||
{/* Card Footer: Tags & Touch Gesture Hints */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 pt-3 border-t border-border">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{activeCard.tags && activeCard.tags.length > 0 ? (
|
||||
activeCard.tags.map((tag) => (
|
||||
@@ -417,7 +645,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
))
|
||||
) : (
|
||||
<span className="text-xs text-faint">
|
||||
Links wischen = Wegwerfen • Rechts wischen = Als Aufgabe sichern
|
||||
👈 Wegwerfen · 👉 Aufgabe · Gedrückt halten = Mehr Optionen
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -432,9 +660,9 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Convert to Task with Due Date Popover */}
|
||||
{/* Desktop-only Popovers */}
|
||||
{showDateModal && (
|
||||
<div className="p-3 panel-surface flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="hidden sm:flex p-3 panel-surface flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 text-xs text-muted">
|
||||
<Calendar className="w-4 h-4 text-accent" />
|
||||
<span>Aufgabe mit Fälligkeitsdatum anlegen:</span>
|
||||
@@ -448,7 +676,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleAction('keep', { convertTo: 'task', dueDate: taskDueDate })}
|
||||
className="btn-primary h-7 px-3 text-xs"
|
||||
className="btn-primary h-8 px-3 text-xs"
|
||||
>
|
||||
Als Termin-Aufgabe speichern
|
||||
</button>
|
||||
@@ -456,9 +684,8 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Convert to Shared List Item Popover */}
|
||||
{showListSelector && (
|
||||
<div className="p-3 panel-surface">
|
||||
<div className="hidden sm:block p-3 panel-surface">
|
||||
<p className="text-xs text-muted mb-2">
|
||||
Quick Dump direkt einer Liste zuordnen:
|
||||
</p>
|
||||
@@ -470,7 +697,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
onClick={() =>
|
||||
handleAction('keep', { convertTo: 'list_item', targetListId: lst.id })
|
||||
}
|
||||
className="btn-action h-7 px-2.5 text-xs"
|
||||
className="btn-action h-8 px-3 text-xs"
|
||||
>
|
||||
<span>{lst.icon}</span>
|
||||
<span>{lst.title}</span>
|
||||
@@ -483,168 +710,320 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Primary Triage Decision Bar */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-6 gap-1.5">
|
||||
{/* 1. Swipe Left: Discard */}
|
||||
{/* Primary Triage Decision Bar: Hidden on mobile (handled via Swipe + Long-Press), shown on Desktop */}
|
||||
<div className="hidden sm:flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => handleAction('discard')}
|
||||
title="Wegwerfen (Taste H oder Pfeil links)"
|
||||
className="btn-action h-8 px-2.5 justify-center"
|
||||
className="btn-action flex-1 h-9 px-3 justify-center whitespace-nowrap"
|
||||
>
|
||||
<X className="w-3.5 h-3.5 text-muted" />
|
||||
<X className="w-3.5 h-3.5 text-muted flex-shrink-0" />
|
||||
<span>Wegwerfen</span>
|
||||
</button>
|
||||
|
||||
{/* 2. Skip / Later */}
|
||||
<button
|
||||
onClick={handleSkipCard}
|
||||
onClick={() => handleSkipCard()}
|
||||
disabled={cards.length <= 1}
|
||||
title="Später entscheiden – ans Ende schieben (Leertaste)"
|
||||
className={`btn-action h-8 px-2.5 justify-center ${
|
||||
className={`btn-action flex-1 h-9 px-3 justify-center whitespace-nowrap ${
|
||||
cards.length <= 1 ? 'opacity-40 cursor-not-allowed' : ''
|
||||
}`}
|
||||
>
|
||||
<SkipForward className="w-3.5 h-3.5 text-muted" />
|
||||
<SkipForward className="w-3.5 h-3.5 text-muted flex-shrink-0" />
|
||||
<span>Später</span>
|
||||
</button>
|
||||
|
||||
{/* 3. Assign to List */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowDateModal(false);
|
||||
setShowListSelector((prev) => !prev);
|
||||
}}
|
||||
title="Auf eine geteilte Liste setzen"
|
||||
className={`btn-action h-8 px-2.5 justify-center ${
|
||||
className={`btn-action flex-1 h-9 px-3 justify-center whitespace-nowrap ${
|
||||
showListSelector ? 'border-accent text-accent bg-accent-dim' : ''
|
||||
}`}
|
||||
>
|
||||
<ShoppingCart className="w-3.5 h-3.5 text-accent" />
|
||||
<ShoppingCart className="w-3.5 h-3.5 text-accent flex-shrink-0" />
|
||||
<span>Auf Liste</span>
|
||||
</button>
|
||||
|
||||
{/* 4. Convert to Workspace Note */}
|
||||
<button
|
||||
onClick={() => handleAction('keep', { convertTo: 'document' })}
|
||||
title="In eine Workspace-Notiz umwandeln (Taste D)"
|
||||
className="btn-action h-8 px-2.5 justify-center"
|
||||
className="btn-action flex-1 h-9 px-3 justify-center whitespace-nowrap"
|
||||
>
|
||||
<FileText className="w-3.5 h-3.5 text-accent" />
|
||||
<FileText className="w-3.5 h-3.5 text-accent flex-shrink-0" />
|
||||
<span>Als Notiz</span>
|
||||
</button>
|
||||
|
||||
{/* 5. Task with Date */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowListSelector(false);
|
||||
setShowDateModal((prev) => !prev);
|
||||
}}
|
||||
title="Als Aufgabe mit Fälligkeitsdatum (Taste T)"
|
||||
className={`btn-action h-8 px-2.5 justify-center ${
|
||||
className={`btn-action flex-1 h-9 px-3 justify-center whitespace-nowrap ${
|
||||
showDateModal ? 'border-accent text-accent bg-accent-dim' : ''
|
||||
}`}
|
||||
>
|
||||
<Calendar className="w-3.5 h-3.5 text-accent" />
|
||||
<Calendar className="w-3.5 h-3.5 text-accent flex-shrink-0" />
|
||||
<span>Mit Termin</span>
|
||||
</button>
|
||||
|
||||
{/* 6. Swipe Right: Immediate Task */}
|
||||
<button
|
||||
onClick={() => handleAction('keep', { convertTo: 'task' })}
|
||||
title="Sofort als Aufgabe übernehmen (Taste L oder Pfeil rechts)"
|
||||
className="btn-primary h-8 px-3 justify-center"
|
||||
className="btn-primary flex-[1.25] h-9 px-4 justify-center whitespace-nowrap font-semibold"
|
||||
>
|
||||
<CheckSquare className="w-3.5 h-3.5" />
|
||||
<span>Als Aufgabe</span>
|
||||
<CheckSquare className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span>Aufgabe</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Waiting Quick Dump Queue List */}
|
||||
{/* Waiting Quick Dump Queue List (Touch-sized & Swipeable Left/Right) */}
|
||||
{cards.length > 1 && (
|
||||
<div className="pt-2">
|
||||
<div className="flex items-center justify-between mb-1.5 px-0.5">
|
||||
<div className="flex items-center justify-between mb-2 px-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted flex items-center gap-1.5">
|
||||
<Layers className="w-3.5 h-3.5 text-accent" />
|
||||
Warteschlange ({cards.length - 1} weitere Quick Dumps)
|
||||
Warteschlange ({cards.length - 1})
|
||||
</span>
|
||||
<span className="text-[11px] text-faint">
|
||||
Klicken zum Vorholen oder direkt zuordnen
|
||||
Tippen = Vorholen · 👈 Wegwerfen · 👉 Aufgabe
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="panel-surface divide-y divide-border max-h-60 overflow-y-auto">
|
||||
<div className="panel-surface divide-y divide-border max-h-80 overflow-y-auto overflow-x-hidden">
|
||||
{cards.slice(1).map((item) => (
|
||||
<div
|
||||
<SwipeableQueueItem
|
||||
key={item.id}
|
||||
className="px-3 py-2 flex items-center justify-between gap-2 hover:bg-panel-hover transition-colors group"
|
||||
item={item}
|
||||
lists={lists}
|
||||
onBringToFront={handleBringToFront}
|
||||
onOpenActionSheet={openActionSheetForCard}
|
||||
onAction={handleAction}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* LONG-PRESS / MORE OPTIONS ACTION SHEET (For Triage Card & Queue Items) */}
|
||||
{actionSheetCard && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/60 backdrop-blur-xs p-0 sm:p-4 animate-in fade-in duration-150"
|
||||
onClick={() => setActionSheetCard(null)}
|
||||
>
|
||||
<div
|
||||
onClick={() => handleBringToFront(item.id)}
|
||||
className="flex-1 min-w-0 cursor-pointer flex items-center gap-2"
|
||||
className="w-full sm:max-w-md bg-panel border-t sm:border border-border rounded-t-2xl sm:rounded-2xl shadow-panel p-4 pb-[calc(1.5rem+env(safe-area-inset-bottom))] sm:pb-4 space-y-3 animate-in slide-in-from-bottom duration-200"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 bg-accent flex-shrink-0" />
|
||||
<span className="text-xs text-muted truncate group-hover:text-main">
|
||||
{item.content}
|
||||
{/* Sheet Header with Preview */}
|
||||
<div className="flex items-start justify-between gap-3 pb-2.5 border-b border-border">
|
||||
<div className="min-w-0">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-accent">
|
||||
Quick Dump zuordnen
|
||||
</span>
|
||||
<p className="text-sm font-medium text-main line-clamp-2 mt-0.5">
|
||||
{actionSheetCard.content}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActionSheetCard(null)}
|
||||
className="p-1.5 text-faint hover:text-main rounded-btn cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{sheetMode === 'main' && (
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleAction('keep', { convertTo: 'task', thoughtOverride: item })
|
||||
}
|
||||
title="Direkt zur Aufgabe machen"
|
||||
className="h-6 px-2 bg-canvas hover:bg-accent hover:text-on-accent border border-border rounded-btn text-[11px] text-muted flex items-center gap-1 transition-colors"
|
||||
onClick={() => setSheetMode('list')}
|
||||
className="min-h-[52px] px-3.5 py-2.5 rounded-xl bg-canvas hover:bg-panel-elevated border border-border text-left flex items-center gap-3 transition-colors cursor-pointer active:scale-98"
|
||||
>
|
||||
<Check className="w-3 h-3" />
|
||||
<span className="hidden sm:inline">Aufgabe</span>
|
||||
<ShoppingCart className="w-5 h-5 text-accent flex-shrink-0" />
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-main">Auf Liste</div>
|
||||
<div className="text-[11px] text-muted">Einkauf / Geteilt</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleAction('keep', { convertTo: 'document', thoughtOverride: item })
|
||||
}
|
||||
title="Direkt als Workspace-Notiz anlegen"
|
||||
className="h-6 px-2 bg-canvas hover:bg-panel-elevated border border-border rounded-btn text-[11px] text-muted hover:text-main flex items-center gap-1 transition-colors"
|
||||
onClick={() => setSheetMode('date')}
|
||||
className="min-h-[52px] px-3.5 py-2.5 rounded-xl bg-canvas hover:bg-panel-elevated border border-border text-left flex items-center gap-3 transition-colors cursor-pointer active:scale-98"
|
||||
>
|
||||
<FileText className="w-3 h-3 text-accent" />
|
||||
<span className="hidden sm:inline">Notiz</span>
|
||||
<Calendar className="w-5 h-5 text-accent flex-shrink-0" />
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-main">Mit Termin</div>
|
||||
<div className="text-[11px] text-muted">Datum wählen</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{lists.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleAction('keep', {
|
||||
convertTo: 'list_item',
|
||||
targetListId: lists[0].id,
|
||||
thoughtOverride: item,
|
||||
convertTo: 'document',
|
||||
thoughtOverride: actionSheetCard,
|
||||
})
|
||||
}
|
||||
title={`Direkt auf Liste "${lists[0].title}" setzen`}
|
||||
className="h-6 px-2 bg-canvas hover:bg-panel-elevated border border-border rounded-btn text-[11px] text-muted hover:text-main flex items-center gap-1 transition-colors"
|
||||
className="min-h-[52px] px-3.5 py-2.5 rounded-xl bg-canvas hover:bg-panel-elevated border border-border text-left flex items-center gap-3 transition-colors cursor-pointer active:scale-98"
|
||||
>
|
||||
<ShoppingCart className="w-3 h-3 text-accent" />
|
||||
<span className="hidden md:inline">{lists[0].title}</span>
|
||||
<FileText className="w-5 h-5 text-accent flex-shrink-0" />
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-main">Als Notiz</div>
|
||||
<div className="text-[11px] text-muted">Im Workspace</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAction('discard', { thoughtOverride: item })}
|
||||
title="Wegwerfen"
|
||||
className="h-6 w-6 bg-canvas hover:bg-panel-elevated border border-border rounded-btn text-muted hover:text-main flex items-center justify-center transition-colors"
|
||||
disabled={cards.length <= 1}
|
||||
onClick={() => handleSkipCard(actionSheetCard.id)}
|
||||
className={`min-h-[52px] px-3.5 py-2.5 rounded-xl bg-canvas hover:bg-panel-elevated border border-border text-left flex items-center gap-3 transition-colors cursor-pointer active:scale-98 ${
|
||||
cards.length <= 1 ? 'opacity-40 cursor-not-allowed' : ''
|
||||
}`}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
<SkipForward className="w-5 h-5 text-muted flex-shrink-0" />
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-main">Später</div>
|
||||
<div className="text-[11px] text-muted">Ans Ende schieben</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Edit Text Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const target = actionSheetCard;
|
||||
setActionSheetCard(null);
|
||||
if (target.id !== activeCard.id) {
|
||||
handleBringToFront(target.id);
|
||||
}
|
||||
setEditDraft(target.content);
|
||||
setIsEditingCard(true);
|
||||
}}
|
||||
className="w-full min-h-[46px] px-3.5 py-2 rounded-xl bg-canvas hover:bg-panel-elevated border border-border flex items-center justify-center gap-2 text-sm font-medium text-main transition-colors cursor-pointer"
|
||||
>
|
||||
<Edit3 className="w-4 h-4 text-accent" />
|
||||
<span>Text vor dem Zuordnen bearbeiten</span>
|
||||
</button>
|
||||
|
||||
{/* Primary Swipe Equivalents */}
|
||||
<div className="grid grid-cols-2 gap-2 pt-1 border-t border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleAction('discard', { thoughtOverride: actionSheetCard })
|
||||
}
|
||||
className="min-h-[46px] px-3 py-2 rounded-xl bg-status-err-bg border border-status-err/30 text-status-err text-sm font-semibold flex items-center justify-center gap-2 cursor-pointer"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
<span>Wegwerfen</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleAction('keep', {
|
||||
convertTo: 'task',
|
||||
thoughtOverride: actionSheetCard,
|
||||
})
|
||||
}
|
||||
className="min-h-[46px] px-4 py-2 rounded-xl bg-accent text-on-accent text-sm font-semibold flex items-center justify-center gap-2 whitespace-nowrap cursor-pointer"
|
||||
>
|
||||
<CheckSquare className="w-4 h-4 flex-shrink-0" />
|
||||
<span>Aufgabe</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
|
||||
{sheetMode === 'date' && (
|
||||
<div className="space-y-3">
|
||||
<label className="block text-xs font-medium text-muted">
|
||||
Fälligkeitsdatum für die Aufgabe wählen:
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={taskDueDate}
|
||||
onChange={(e) => setTaskDueDate(e.target.value)}
|
||||
className="input-field w-full h-11 px-3 text-sm"
|
||||
/>
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSheetMode('main')}
|
||||
className="btn-action flex-1 h-11 text-sm"
|
||||
>
|
||||
Zurück
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleAction('keep', {
|
||||
convertTo: 'task',
|
||||
dueDate: taskDueDate,
|
||||
thoughtOverride: actionSheetCard,
|
||||
})
|
||||
}
|
||||
className="btn-primary flex-1 h-11 text-sm font-semibold"
|
||||
>
|
||||
Mit Termin speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sheetMode === 'list' && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-medium text-muted">
|
||||
Auf welche Liste soll dieser Punkt gesetzt werden?
|
||||
</p>
|
||||
<div className="max-h-60 overflow-y-auto space-y-1.5">
|
||||
{lists.length > 0 ? (
|
||||
lists.map((lst) => (
|
||||
<button
|
||||
key={lst.id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleAction('keep', {
|
||||
convertTo: 'list_item',
|
||||
targetListId: lst.id,
|
||||
thoughtOverride: actionSheetCard,
|
||||
})
|
||||
}
|
||||
className="w-full min-h-[48px] px-3.5 py-2.5 rounded-xl bg-canvas hover:bg-panel-elevated border border-border flex items-center gap-3 text-sm font-medium text-main transition-colors cursor-pointer"
|
||||
>
|
||||
<span className="text-lg">{lst.icon}</span>
|
||||
<span>{lst.title}</span>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="py-4 text-center text-xs text-muted">
|
||||
Keine Listen vorhanden.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSheetMode('main')}
|
||||
className="btn-action w-full h-10 text-xs"
|
||||
>
|
||||
Zurück
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import React, { useState, useMemo, useRef, useEffect } from 'react';
|
||||
import Prism from 'prismjs';
|
||||
import 'prismjs/components/prism-python';
|
||||
import 'prismjs/components/prism-typescript';
|
||||
import 'prismjs/components/prism-bash';
|
||||
import 'prismjs/components/prism-json';
|
||||
import 'prismjs/components/prism-go';
|
||||
import 'prismjs/components/prism-sql';
|
||||
import 'prismjs/components/prism-yaml';
|
||||
import 'prismjs/components/prism-rust';
|
||||
import 'prismjs/components/prism-c';
|
||||
import 'prismjs/components/prism-cpp';
|
||||
import 'prismjs/components/prism-java';
|
||||
import 'prismjs/components/prism-php';
|
||||
import 'prismjs/components/prism-docker';
|
||||
import 'prismjs/components/prism-markdown';
|
||||
import { Code, Copy, Check, Eye, Edit3 } from 'lucide-react';
|
||||
import { DocumentBlock } from '../../lib/types';
|
||||
|
||||
export interface CodeBlockItemProps {
|
||||
block: DocumentBlock;
|
||||
isReadOnly: boolean;
|
||||
onChangeContent: (content: string) => void;
|
||||
onChangeLang: (lang: string) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
inputRef?: (el: HTMLTextAreaElement | null) => void;
|
||||
}
|
||||
|
||||
export const CODE_LANGUAGES = [
|
||||
{ id: 'javascript', label: 'JavaScript' },
|
||||
{ id: 'typescript', label: 'TypeScript' },
|
||||
{ id: 'python', label: 'Python' },
|
||||
{ id: 'go', label: 'Go' },
|
||||
{ id: 'bash', label: 'Bash / Shell' },
|
||||
{ id: 'json', label: 'JSON' },
|
||||
{ id: 'html', label: 'HTML / XML', alias: 'markup' },
|
||||
{ id: 'css', label: 'CSS' },
|
||||
{ id: 'sql', label: 'SQL' },
|
||||
{ id: 'rust', label: 'Rust' },
|
||||
{ id: 'cpp', label: 'C++' },
|
||||
{ id: 'c', label: 'C' },
|
||||
{ id: 'java', label: 'Java' },
|
||||
{ id: 'php', label: 'PHP' },
|
||||
{ id: 'yaml', label: 'YAML' },
|
||||
{ id: 'docker', label: 'Dockerfile' },
|
||||
{ id: 'markdown', label: 'Markdown' },
|
||||
{ id: 'text', label: 'Plain Text' },
|
||||
];
|
||||
|
||||
export const CodeBlockItem: React.FC<CodeBlockItemProps> = ({
|
||||
block,
|
||||
isReadOnly,
|
||||
onChangeContent,
|
||||
onChangeLang,
|
||||
onFocus,
|
||||
onBlur,
|
||||
inputRef,
|
||||
}) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const localTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const currentLang = block.lang || 'javascript';
|
||||
|
||||
// Highlighted HTML generated by Prism
|
||||
const highlightedCode = useMemo(() => {
|
||||
const code = block.content || '';
|
||||
if (!code) return '';
|
||||
|
||||
const prismLang = currentLang === 'html' ? 'markup' : currentLang;
|
||||
const grammar = Prism.languages[prismLang] || Prism.languages.javascript;
|
||||
if (!grammar) {
|
||||
return code
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
try {
|
||||
return Prism.highlight(code, grammar, prismLang);
|
||||
} catch {
|
||||
return code
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
}, [block.content, currentLang]);
|
||||
|
||||
// Code lines for line numbers
|
||||
const lines = useMemo(() => {
|
||||
return (block.content || '').split('\n');
|
||||
}, [block.content]);
|
||||
|
||||
const handleCopy = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(block.content || '');
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Fallback
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Escape') {
|
||||
setIsEditing(false);
|
||||
return;
|
||||
}
|
||||
// Tab key indent/outdent
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const textarea = e.currentTarget;
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const val = textarea.value;
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (val.substring(start - 2, start) === ' ') {
|
||||
const newVal = val.substring(0, start - 2) + val.substring(start);
|
||||
onChangeContent(newVal);
|
||||
setTimeout(() => {
|
||||
textarea.selectionStart = textarea.selectionEnd = Math.max(0, start - 2);
|
||||
}, 0);
|
||||
}
|
||||
} else {
|
||||
const newVal = val.substring(0, start) + ' ' + val.substring(end);
|
||||
onChangeContent(newVal);
|
||||
setTimeout(() => {
|
||||
textarea.selectionStart = textarea.selectionEnd = start + 2;
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const switchToEdit = () => {
|
||||
if (isReadOnly) return;
|
||||
setIsEditing(true);
|
||||
setTimeout(() => {
|
||||
localTextareaRef.current?.focus();
|
||||
}, 20);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-card border border-border bg-panel overflow-hidden shadow-xs my-1 group/code">
|
||||
{/* Code Block Header Toolbar */}
|
||||
<div className="flex items-center justify-between px-3 py-1.5 bg-canvas border-b border-border text-xs">
|
||||
{/* Left: Language Selection */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Code className="w-3.5 h-3.5 text-accent flex-shrink-0" />
|
||||
<select
|
||||
value={currentLang}
|
||||
disabled={isReadOnly}
|
||||
onChange={(e) => onChangeLang(e.target.value)}
|
||||
className="bg-transparent text-main font-mono text-[11px] font-medium border border-transparent hover:border-border focus:border-accent rounded px-1.5 py-0.5 outline-none cursor-pointer"
|
||||
>
|
||||
{CODE_LANGUAGES.map((lang) => (
|
||||
<option key={lang.id} value={lang.id} className="bg-panel text-main">
|
||||
{lang.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Right: Mode Toggle & Copy */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{!isReadOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isEditing) {
|
||||
setIsEditing(false);
|
||||
} else {
|
||||
switchToEdit();
|
||||
}
|
||||
}}
|
||||
className="px-2 py-0.5 rounded text-[11px] font-medium text-muted hover:text-main hover:bg-panel-elevated transition-colors flex items-center gap-1 cursor-pointer"
|
||||
title={isEditing ? 'Zur farbigen Vorschau wechseln (Esc)' : 'Code bearbeiten'}
|
||||
>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Eye className="w-3 h-3 text-accent" />
|
||||
<span>Vorschau</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Edit3 className="w-3 h-3 text-muted" />
|
||||
<span>Bearbeiten</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className={`px-2 py-0.5 rounded text-[11px] font-medium transition-colors flex items-center gap-1 cursor-pointer ${
|
||||
copied
|
||||
? 'text-emerald-500 bg-emerald-500/10'
|
||||
: 'text-muted hover:text-main hover:bg-panel-elevated'
|
||||
}`}
|
||||
title="Code in Zwischenablage kopieren"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="w-3 h-3 text-emerald-500" />
|
||||
<span>Kopiert!</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-3 h-3 opacity-70" />
|
||||
<span>Kopieren</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Code Block Content */}
|
||||
{isEditing && !isReadOnly ? (
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={(el) => {
|
||||
localTextareaRef.current = el;
|
||||
if (inputRef) inputRef(el);
|
||||
}}
|
||||
rows={Math.max(3, lines.length)}
|
||||
value={block.content || ''}
|
||||
onFocus={() => onFocus && onFocus()}
|
||||
onBlur={() => {
|
||||
if (onBlur) onBlur();
|
||||
}}
|
||||
onChange={(e) => onChangeContent(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="// Code hier eingeben..."
|
||||
className="w-full p-3 bg-panel-elevated/40 font-mono text-xs text-main leading-relaxed focus:outline-none resize-none placeholder:text-faint"
|
||||
/>
|
||||
<div className="px-3 py-1 bg-canvas/40 border-t border-border/50 text-[10px] text-faint flex items-center justify-between">
|
||||
<span>Tab: Einrücken (2 Leerzeichen) • Shift+Tab: Ausrücken</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditing(false)}
|
||||
className="hover:text-accent cursor-pointer"
|
||||
>
|
||||
Vorschau anzeigen (Esc)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={switchToEdit}
|
||||
className={`relative p-3 font-mono text-xs overflow-x-auto ${
|
||||
isReadOnly ? '' : 'cursor-pointer hover:bg-panel-elevated/20 transition-colors'
|
||||
}`}
|
||||
title={isReadOnly ? undefined : 'Klicken zum Bearbeiten'}
|
||||
>
|
||||
{block.content ? (
|
||||
<div className="flex gap-3">
|
||||
{/* Line numbers */}
|
||||
<div className="select-none text-right text-faint opacity-50 font-mono text-xs leading-relaxed flex-shrink-0">
|
||||
{lines.map((_, idx) => (
|
||||
<div key={idx}>{idx + 1}</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Syntax highlighted code */}
|
||||
<pre className="m-0 p-0 font-mono text-xs leading-relaxed whitespace-pre overflow-x-auto flex-1">
|
||||
<code
|
||||
dangerouslySetInnerHTML={{ __html: highlightedCode }}
|
||||
className={`language-${currentLang}`}
|
||||
/>
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-faint italic py-2">
|
||||
{isReadOnly ? '(Kein Code hinterlegt)' : '(Klicken, um Code einzugeben...)'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
@@ -358,4 +358,62 @@ body {
|
||||
border-left: 2px solid var(--accent-primary);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
/* Prism Syntax Highlighting Tokens */
|
||||
.token.comment,
|
||||
.token.prolog,
|
||||
.token.doctype,
|
||||
.token.cdata {
|
||||
color: #71717a;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.token.punctuation {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.token.property,
|
||||
.token.tag,
|
||||
.token.boolean,
|
||||
.token.number,
|
||||
.token.constant,
|
||||
.token.symbol,
|
||||
.token.deleted {
|
||||
color: #f43f5e;
|
||||
}
|
||||
|
||||
.token.selector,
|
||||
.token.attr-name,
|
||||
.token.string,
|
||||
.token.char,
|
||||
.token.builtin,
|
||||
.token.inserted {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.token.operator,
|
||||
.token.entity,
|
||||
.token.url,
|
||||
.language-css .token.string,
|
||||
.style .token.string {
|
||||
color: #38bdf8;
|
||||
}
|
||||
|
||||
.token.atrule,
|
||||
.token.attr-value,
|
||||
.token.keyword {
|
||||
color: #818cf8;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.token.function,
|
||||
.token.class-name {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.token.regex,
|
||||
.token.important,
|
||||
.token.variable {
|
||||
color: #f97316;
|
||||
}
|
||||
}
|
||||
+83
-6
@@ -6,15 +6,18 @@ import {
|
||||
DocumentBlock,
|
||||
DocShare,
|
||||
DocLockInfo,
|
||||
KanbanColumn,
|
||||
ListItem,
|
||||
Organization,
|
||||
PriorityLevel,
|
||||
SharedList,
|
||||
Space,
|
||||
SpaceMember,
|
||||
SpaceShare,
|
||||
SystemSettings,
|
||||
Team,
|
||||
TaskList,
|
||||
TaskListShare,
|
||||
Thought,
|
||||
SubTask,
|
||||
TagDetails,
|
||||
@@ -312,11 +315,12 @@ export const api = {
|
||||
content?: string;
|
||||
isCompleted?: boolean;
|
||||
dueDate?: string;
|
||||
priority?: PriorityLevel;
|
||||
priority?: string;
|
||||
taskListId?: string;
|
||||
kanbanStatus?: 'todo' | 'in_progress' | 'done';
|
||||
kanbanStatus?: string;
|
||||
notes?: string;
|
||||
subtasks?: SubTask[];
|
||||
tags?: string[];
|
||||
}
|
||||
): Promise<Thought> {
|
||||
return request<Thought>(`/api/v1/thoughts/${thoughtId}`, {
|
||||
@@ -336,14 +340,21 @@ export const api = {
|
||||
// --- TASK LISTS ---
|
||||
async getTaskLists(): Promise<TaskList[]> {
|
||||
const res = await request<TaskList[]>('/api/v1/task-lists');
|
||||
return ensureArray(res);
|
||||
return ensureArray(res).map((tl) => ({
|
||||
...tl,
|
||||
members: ensureArray(tl.members),
|
||||
shares: ensureArray(tl.shares),
|
||||
columns: ensureArray(tl.columns),
|
||||
}));
|
||||
},
|
||||
|
||||
async createTaskList(payload: {
|
||||
title: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
teamId?: string;
|
||||
spaceId?: string;
|
||||
columns?: KanbanColumn[];
|
||||
}): Promise<TaskList> {
|
||||
return request<TaskList>('/api/v1/task-lists', {
|
||||
method: 'POST',
|
||||
@@ -357,7 +368,9 @@ export const api = {
|
||||
title?: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
teamId?: string;
|
||||
spaceId?: string;
|
||||
columns?: KanbanColumn[];
|
||||
}
|
||||
): Promise<TaskList> {
|
||||
return request<TaskList>(`/api/v1/task-lists/${id}`, {
|
||||
@@ -370,6 +383,25 @@ export const api = {
|
||||
await request(`/api/v1/task-lists/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
async getTaskListShares(id: string): Promise<TaskListShare[]> {
|
||||
const res = await request<TaskListShare[]>(`/api/v1/task-lists/${id}/shares`);
|
||||
return ensureArray(res);
|
||||
},
|
||||
|
||||
async addTaskListShare(
|
||||
id: string,
|
||||
payload: { targetType: 'team' | 'user'; targetId: string }
|
||||
): Promise<TaskListShare> {
|
||||
return request<TaskListShare>(`/api/v1/task-lists/${id}/shares`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
|
||||
async removeTaskListShare(id: string, shareId: string): Promise<void> {
|
||||
await request(`/api/v1/task-lists/${id}/shares/${shareId}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
// --- WORKSPACE FOLDERS ---
|
||||
async getFolders(): Promise<string[]> {
|
||||
const res = await request<string[]>('/api/v1/folders');
|
||||
@@ -411,6 +443,20 @@ export const api = {
|
||||
};
|
||||
},
|
||||
|
||||
async updateTagPageNote(
|
||||
tag: string,
|
||||
content: string
|
||||
): Promise<{ tag: string; content: string; updatedAt: string }> {
|
||||
const clean = encodeURIComponent(tag.replace(/^#/, ''));
|
||||
return request<{ tag: string; content: string; updatedAt: string }>(
|
||||
`/api/v1/workspace/tags/${clean}/note`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ content }),
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
// --- WORKSPACE DOCUMENTS ---
|
||||
async getDocuments(): Promise<Document[]> {
|
||||
const res = await request<Document[]>('/api/v1/documents');
|
||||
@@ -483,7 +529,7 @@ export const api = {
|
||||
|
||||
async createDocShare(
|
||||
docId: string,
|
||||
payload: { userId?: string; email?: string; permission: 'read' | 'write' }
|
||||
payload: { userId?: string; email?: string; teamId?: string; permission: 'read' | 'write' }
|
||||
): Promise<DocShare> {
|
||||
return request<DocShare>(`/api/v1/documents/${docId}/shares`, {
|
||||
method: 'POST',
|
||||
@@ -544,9 +590,15 @@ export const api = {
|
||||
return ensureArray(res).map((s) => ({
|
||||
...s,
|
||||
members: ensureArray(s.members),
|
||||
shares: ensureArray(s.shares),
|
||||
}));
|
||||
},
|
||||
|
||||
async getSpaceShares(spaceId: string): Promise<SpaceShare[]> {
|
||||
const res = await request<SpaceShare[]>(`/api/v1/spaces/${spaceId}/shares`);
|
||||
return ensureArray(res);
|
||||
},
|
||||
|
||||
async createSpace(name: string, icon: string, description: string): Promise<Space> {
|
||||
return request<Space>('/api/v1/spaces', {
|
||||
method: 'POST',
|
||||
@@ -561,8 +613,8 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async addSpaceMember(spaceId: string, target: { userId?: string; email?: string; role?: string }): Promise<SpaceMember> {
|
||||
return request<SpaceMember>(`/api/v1/spaces/${spaceId}/members`, {
|
||||
async addSpaceMember(spaceId: string, target: { userId?: string; email?: string; teamId?: string; role?: string }): Promise<any> {
|
||||
return request<any>(`/api/v1/spaces/${spaceId}/members`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(target),
|
||||
});
|
||||
@@ -664,6 +716,20 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async createTeam(name: string, description = ''): Promise<Team> {
|
||||
return request<Team>('/api/v1/teams', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, description }),
|
||||
});
|
||||
},
|
||||
|
||||
async updateTeam(teamId: string, name: string, description = ''): Promise<Team> {
|
||||
return request<Team>(`/api/v1/teams/${teamId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ name, description }),
|
||||
});
|
||||
},
|
||||
|
||||
async deleteTeam(teamId: string): Promise<void> {
|
||||
await request(`/api/v1/teams/${teamId}`, { method: 'DELETE' });
|
||||
},
|
||||
@@ -683,6 +749,17 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async updateTeamMemberRole(
|
||||
teamId: string,
|
||||
userId: string,
|
||||
role: 'admin' | 'member'
|
||||
): Promise<void> {
|
||||
await request(`/api/v1/teams/${teamId}/members/${userId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ role }),
|
||||
});
|
||||
},
|
||||
|
||||
async removeTeamMember(teamId: string, userId: string): Promise<void> {
|
||||
await request(`/api/v1/teams/${teamId}/members/${userId}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export type ThoughtStatus = 'inbox' | 'kept' | 'discarded' | 'converted';
|
||||
export type PriorityLevel = 'low' | 'normal' | 'high' | 'urgent';
|
||||
export type UserRole = 'admin' | 'team_admin' | 'member';
|
||||
export type UserRole = 'admin' | 'member';
|
||||
|
||||
export interface AuthStatus {
|
||||
needsSetup: boolean;
|
||||
@@ -56,7 +56,7 @@ export interface Thought {
|
||||
convertedRefId?: string;
|
||||
dueDate?: string;
|
||||
taskListId?: string;
|
||||
kanbanStatus?: 'todo' | 'in_progress' | 'done';
|
||||
kanbanStatus?: string;
|
||||
notes?: string;
|
||||
subtasks?: SubTask[];
|
||||
alarmSent?: boolean;
|
||||
@@ -64,13 +64,43 @@ export interface Thought {
|
||||
triagedAt?: string;
|
||||
}
|
||||
|
||||
export interface KanbanColumn {
|
||||
id: string;
|
||||
name: string;
|
||||
color?: string;
|
||||
isDone?: boolean;
|
||||
}
|
||||
|
||||
export interface TaskListShare {
|
||||
id: string;
|
||||
taskListId: string;
|
||||
targetType: 'team' | 'user';
|
||||
targetId: string;
|
||||
targetName?: string;
|
||||
email?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface TaskListMember {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface TaskList {
|
||||
id: string;
|
||||
userId: string;
|
||||
spaceId?: string;
|
||||
spaceName?: string;
|
||||
teamId?: string;
|
||||
teamName?: string;
|
||||
ownerId?: string;
|
||||
ownerName?: string;
|
||||
members?: SpaceMember[];
|
||||
isOwner?: boolean;
|
||||
members?: TaskListMember[];
|
||||
shares?: TaskListShare[];
|
||||
columns?: KanbanColumn[];
|
||||
title: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
@@ -89,7 +119,7 @@ export interface Organization {
|
||||
|
||||
export interface Team {
|
||||
id: string;
|
||||
orgId: string;
|
||||
orgId?: string;
|
||||
name: string;
|
||||
description: string;
|
||||
members?: TeamMember[];
|
||||
@@ -101,7 +131,7 @@ export interface TeamMember {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
email: string;
|
||||
role: 'owner' | 'team_admin' | 'editor' | 'member' | 'viewer';
|
||||
role: 'admin' | 'member';
|
||||
joinedAt: string;
|
||||
}
|
||||
|
||||
@@ -133,7 +163,11 @@ export interface DocumentBlock {
|
||||
export interface DocShare {
|
||||
id: string;
|
||||
docId: string;
|
||||
userId: string;
|
||||
targetType?: 'user' | 'team';
|
||||
targetId?: string;
|
||||
targetName?: string;
|
||||
teamId?: string;
|
||||
userId?: string;
|
||||
userEmail?: string;
|
||||
displayName?: string;
|
||||
permission: 'read' | 'write';
|
||||
@@ -194,6 +228,8 @@ export interface TagDocumentGroup {
|
||||
|
||||
export interface TagDetails {
|
||||
tag: string;
|
||||
noteContent?: string;
|
||||
noteUpdatedAt?: string;
|
||||
documents: TagDocumentGroup[];
|
||||
totalMatches: number;
|
||||
}
|
||||
@@ -207,6 +243,29 @@ export interface SpaceMember {
|
||||
joinedAt: string;
|
||||
}
|
||||
|
||||
export interface UnifiedShare {
|
||||
id: string;
|
||||
targetType: 'team' | 'user';
|
||||
targetId: string;
|
||||
targetName?: string;
|
||||
targetEmail?: string;
|
||||
permission?: 'editor' | 'viewer';
|
||||
role?: string;
|
||||
createdBy?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SpaceShare {
|
||||
id: string;
|
||||
spaceId: string;
|
||||
targetType: 'team' | 'user';
|
||||
targetId: string;
|
||||
targetName?: string;
|
||||
role: 'editor' | 'viewer';
|
||||
createdBy?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Space {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -215,7 +274,9 @@ export interface Space {
|
||||
inviteCode: string;
|
||||
isPersonal: boolean;
|
||||
ownerId: string;
|
||||
ownerName?: string;
|
||||
members?: SpaceMember[];
|
||||
shares?: SpaceShare[];
|
||||
listsCount: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
+293
-17
@@ -89,11 +89,14 @@ func (s *Server) Router() http.Handler {
|
||||
r.Patch("/api/v1/triage/{id}", s.handleTriageAction)
|
||||
r.Get("/api/v1/tasks", s.handleGetTasks)
|
||||
|
||||
// Task Lists
|
||||
// Task Lists & Shares
|
||||
r.Get("/api/v1/task-lists", s.handleGetTaskLists)
|
||||
r.Post("/api/v1/task-lists", s.handleCreateTaskList)
|
||||
r.Put("/api/v1/task-lists/{id}", s.handleUpdateTaskList)
|
||||
r.Delete("/api/v1/task-lists/{id}", s.handleDeleteTaskList)
|
||||
r.Get("/api/v1/task-lists/{id}/shares", s.handleGetTaskListShares)
|
||||
r.Post("/api/v1/task-lists/{id}/shares", s.handleCreateTaskListShare)
|
||||
r.Delete("/api/v1/task-lists/{id}/shares/{shareId}", s.handleDeleteTaskListShare)
|
||||
|
||||
// Workspace Documents & Folders
|
||||
r.Get("/api/v1/folders", s.handleGetFolders)
|
||||
@@ -103,6 +106,7 @@ func (s *Server) Router() http.Handler {
|
||||
// Workspace Tags & Pages
|
||||
r.Get("/api/v1/workspace/tags", s.handleGetWorkspaceTags)
|
||||
r.Get("/api/v1/workspace/tags/{tag}", s.handleGetTagDetails)
|
||||
r.Put("/api/v1/workspace/tags/{tag}/note", s.handleUpdateTagPageNote)
|
||||
|
||||
r.Get("/api/v1/documents", s.handleGetDocuments)
|
||||
r.Post("/api/v1/documents", s.handleCreateDocument)
|
||||
@@ -125,6 +129,7 @@ func (s *Server) Router() http.Handler {
|
||||
r.Get("/api/v1/spaces", s.handleGetSpaces)
|
||||
r.Post("/api/v1/spaces", s.handleCreateSpace)
|
||||
r.Post("/api/v1/spaces/join", s.handleJoinSpace)
|
||||
r.Get("/api/v1/spaces/{id}/shares", s.handleGetSpaceShares)
|
||||
r.Post("/api/v1/spaces/{id}/members", s.handleAddSpaceMember)
|
||||
r.Delete("/api/v1/spaces/{id}/members/{userId}", s.handleRemoveSpaceMember)
|
||||
r.Get("/api/v1/spaces/{id}/lists", s.handleGetSpaceLists)
|
||||
@@ -144,8 +149,11 @@ func (s *Server) Router() http.Handler {
|
||||
r.Get("/api/v1/organizations/{id}/teams", s.handleGetOrgTeams)
|
||||
r.Post("/api/v1/organizations/{id}/teams", s.handleCreateOrgTeam)
|
||||
r.Get("/api/v1/teams", s.handleGetUserTeams)
|
||||
r.Post("/api/v1/teams", s.handleCreateTeamDirect)
|
||||
r.Put("/api/v1/teams/{id}", s.handleUpdateTeam)
|
||||
r.Delete("/api/v1/teams/{id}", s.handleDeleteTeam)
|
||||
r.Post("/api/v1/teams/{id}/members", s.handleAddTeamMember)
|
||||
r.Put("/api/v1/teams/{id}/members/{userId}", s.handleUpdateTeamMemberRole)
|
||||
r.Delete("/api/v1/teams/{id}/members/{userId}", s.handleRemoveTeamMember)
|
||||
|
||||
// API Keys Management
|
||||
@@ -262,8 +270,8 @@ func (s *Server) staffMiddleware(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
user, err := s.store.GetUserByID(userID)
|
||||
if err != nil || user == nil || (user.Role != "admin" && user.Role != "team_admin") {
|
||||
writeError(w, http.StatusForbidden, "Admin- oder Team-Admin-Berechtigung erforderlich")
|
||||
if err != nil || user == nil || user.Role != "admin" {
|
||||
writeError(w, http.StatusForbidden, "Admin-Berechtigung erforderlich")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
@@ -686,18 +694,19 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
|
||||
KanbanStatus *string `json:"kanbanStatus"`
|
||||
Notes *string `json:"notes"`
|
||||
Subtasks *[]domain.SubTask `json:"subtasks"`
|
||||
Tags *[]string `json:"tags"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := s.store.UpdateThoughtDetails(userID, thoughtID, req.Content, req.IsCompleted, req.DueDate, req.Priority, req.TaskListID, req.KanbanStatus, req.Notes, req.Subtasks)
|
||||
updated, err := s.store.UpdateThoughtDetails(userID, thoughtID, req.Content, req.IsCompleted, req.DueDate, req.Priority, req.TaskListID, req.KanbanStatus, req.Notes, req.Subtasks, req.Tags)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.wsHub.BroadcastTaskEvent(updated.UserID, "TASK_UPDATED", updated)
|
||||
s.wsHub.BroadcastToAll("TASK_UPDATED", updated)
|
||||
writeJSON(w, http.StatusOK, updated)
|
||||
}
|
||||
|
||||
@@ -708,7 +717,7 @@ func (s *Server) handleDeleteThought(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
s.wsHub.BroadcastTaskEvent(userID, "TASK_DELETED", map[string]string{"id": thoughtID})
|
||||
s.wsHub.BroadcastToAll("TASK_DELETED", map[string]string{"id": thoughtID})
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||
}
|
||||
|
||||
@@ -898,6 +907,28 @@ func (s *Server) handleGetTagDetails(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, details)
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateTagPageNote(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
tag := chi.URLParam(r, "tag")
|
||||
var req struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
updatedAt, err := s.store.UpdateTagPageNote(userID, tag, req.Content)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"tag": tag,
|
||||
"content": req.Content,
|
||||
"updatedAt": updatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// --- DOCUMENT SHARING & COLLABORATION ---
|
||||
|
||||
func (s *Server) handleGetDocShares(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -916,18 +947,35 @@ func (s *Server) handleCreateDocShare(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
UserID string `json:"userId"`
|
||||
Email string `json:"email"`
|
||||
TeamID string `json:"teamId"`
|
||||
Permission string `json:"permission"` // read | write
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
|
||||
if req.TeamID != "" {
|
||||
share, err := s.store.CreateDocTeamShare(userID, docID, req.TeamID, req.Permission)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
members, _ := s.store.GetTeamMembers(req.TeamID)
|
||||
for _, tm := range members {
|
||||
s.wsHub.BroadcastToUser(tm.UserID, "DOC_SHARED", share)
|
||||
}
|
||||
s.wsHub.BroadcastDocEvent(docID, "DOC_SHARED", share)
|
||||
writeJSON(w, http.StatusCreated, share)
|
||||
return
|
||||
}
|
||||
|
||||
target := req.UserID
|
||||
if target == "" {
|
||||
target = req.Email
|
||||
}
|
||||
if target == "" {
|
||||
writeError(w, http.StatusBadRequest, "Benutzer-ID oder E-Mail erforderlich")
|
||||
writeError(w, http.StatusBadRequest, "Benutzer-ID, E-Mail oder Team erforderlich")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1084,18 +1132,39 @@ func (s *Server) handleAddSpaceMember(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
UserID string `json:"userId"`
|
||||
Email string `json:"email"`
|
||||
TeamID string `json:"teamId"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Role != "owner" && req.Role != "editor" && req.Role != "viewer" {
|
||||
req.Role = "editor"
|
||||
}
|
||||
|
||||
if req.TeamID != "" {
|
||||
share, err := s.store.CreateSpaceTeamShare(currentUserID, spaceID, req.TeamID, req.Role)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
members, _ := s.store.GetTeamMembers(req.TeamID)
|
||||
for _, tm := range members {
|
||||
s.wsHub.BroadcastToUser(tm.UserID, "SPACE_JOINED", map[string]interface{}{"spaceId": spaceID})
|
||||
}
|
||||
s.wsHub.Broadcast(spaceID, "", "SPACE_SHARED", share)
|
||||
writeJSON(w, http.StatusCreated, share)
|
||||
return
|
||||
}
|
||||
|
||||
target := req.UserID
|
||||
if target == "" {
|
||||
target = req.Email
|
||||
}
|
||||
if target == "" {
|
||||
writeError(w, http.StatusBadRequest, "Benutzer-ID, Benutzername oder E-Mail erforderlich")
|
||||
writeError(w, http.StatusBadRequest, "Benutzer-ID, Benutzername, E-Mail oder Team erforderlich")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1111,18 +1180,31 @@ func (s *Server) handleAddSpaceMember(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, member)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetSpaceShares(w http.ResponseWriter, r *http.Request) {
|
||||
spaceID := chi.URLParam(r, "id")
|
||||
shares, err := s.store.GetSpaceShares(spaceID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, shares)
|
||||
}
|
||||
|
||||
func (s *Server) handleRemoveSpaceMember(w http.ResponseWriter, r *http.Request) {
|
||||
currentUserID := auth.GetUserID(r.Context())
|
||||
spaceID := chi.URLParam(r, "id")
|
||||
targetUserID := chi.URLParam(r, "userId")
|
||||
|
||||
if err := s.store.RemoveSpaceMember(currentUserID, spaceID, targetUserID); err != nil {
|
||||
// If prefixed with sm_, strip it for individual space member removal
|
||||
cleanTargetID := strings.TrimPrefix(targetUserID, "sm_")
|
||||
|
||||
if err := s.store.RemoveSpaceMember(currentUserID, spaceID, cleanTargetID); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.wsHub.BroadcastToUser(targetUserID, "SPACE_LEFT", map[string]interface{}{"spaceId": spaceID})
|
||||
s.wsHub.Broadcast(spaceID, "", "MEMBER_REMOVED", map[string]interface{}{"spaceId": spaceID, "userId": targetUserID})
|
||||
s.wsHub.BroadcastToUser(cleanTargetID, "SPACE_LEFT", map[string]interface{}{"spaceId": spaceID})
|
||||
s.wsHub.Broadcast(spaceID, "", "MEMBER_REMOVED", map[string]interface{}{"spaceId": spaceID, "userId": cleanTargetID})
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||||
}
|
||||
@@ -1565,6 +1647,7 @@ func (s *Server) handleCreateTaskList(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
var req struct {
|
||||
SpaceID string `json:"spaceId"`
|
||||
TeamID string `json:"teamId"`
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon"`
|
||||
Color string `json:"color"`
|
||||
@@ -1573,7 +1656,7 @@ func (s *Server) handleCreateTaskList(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
tl, err := s.store.CreateTaskList(userID, req.SpaceID, req.Title, req.Icon, req.Color)
|
||||
tl, err := s.store.CreateTaskList(userID, req.SpaceID, req.TeamID, req.Title, req.Icon, req.Color)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
@@ -1588,17 +1671,20 @@ func (s *Server) handleUpdateTaskList(w http.ResponseWriter, r *http.Request) {
|
||||
Title *string `json:"title"`
|
||||
Icon *string `json:"icon"`
|
||||
Color *string `json:"color"`
|
||||
TeamID *string `json:"teamId"`
|
||||
SpaceID *string `json:"spaceId"`
|
||||
Columns *[]domain.KanbanColumn `json:"columns"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
tl, err := s.store.UpdateTaskList(userID, listID, req.Title, req.Icon, req.Color, req.SpaceID)
|
||||
tl, err := s.store.UpdateTaskList(userID, listID, req.Title, req.Icon, req.Color, req.TeamID, req.SpaceID, req.Columns)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.wsHub.BroadcastToAll("TASK_LIST_UPDATED", tl)
|
||||
writeJSON(w, http.StatusOK, tl)
|
||||
}
|
||||
|
||||
@@ -1609,6 +1695,74 @@ func (s *Server) handleDeleteTaskList(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
s.wsHub.BroadcastToAll("TASK_LIST_DELETED", map[string]string{"id": listID})
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetTaskListShares(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
listID := chi.URLParam(r, "id")
|
||||
if _, err := s.store.GetTaskListByID(userID, listID); err != nil {
|
||||
writeError(w, http.StatusNotFound, "Aufgabenliste nicht gefunden oder kein Zugriff")
|
||||
return
|
||||
}
|
||||
shares, err := s.store.GetTaskListShares(listID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, shares)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateTaskListShare(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
listID := chi.URLParam(r, "id")
|
||||
tl, err := s.store.GetTaskListByID(userID, listID)
|
||||
if err != nil || tl == nil {
|
||||
writeError(w, http.StatusNotFound, "Aufgabenliste nicht gefunden")
|
||||
return
|
||||
}
|
||||
if tl.UserID != userID {
|
||||
writeError(w, http.StatusForbidden, "Nur der Eigentümer kann die Liste freigeben")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
TargetType string `json:"targetType"` // team | user
|
||||
TargetID string `json:"targetId"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
share, err := s.store.AddTaskListShare(listID, req.TargetType, req.TargetID, userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
updatedList, _ := s.store.GetTaskListByID(userID, listID)
|
||||
s.wsHub.BroadcastToAll("TASK_LIST_UPDATED", updatedList)
|
||||
writeJSON(w, http.StatusCreated, share)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteTaskListShare(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
listID := chi.URLParam(r, "id")
|
||||
shareID := chi.URLParam(r, "shareId")
|
||||
tl, err := s.store.GetTaskListByID(userID, listID)
|
||||
if err != nil || tl == nil {
|
||||
writeError(w, http.StatusNotFound, "Aufgabenliste nicht gefunden")
|
||||
return
|
||||
}
|
||||
if tl.UserID != userID {
|
||||
writeError(w, http.StatusForbidden, "Nur der Eigentümer kann Freigaben entfernen")
|
||||
return
|
||||
}
|
||||
if err := s.store.RemoveTaskListShare(listID, shareID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
updatedList, _ := s.store.GetTaskListByID(userID, listID)
|
||||
s.wsHub.BroadcastToAll("TASK_LIST_UPDATED", updatedList)
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||
}
|
||||
|
||||
@@ -1677,13 +1831,88 @@ func (s *Server) handleCreateOrgTeam(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
// Automatically add creator to team
|
||||
_ = s.store.AddTeamMember(team.ID, userID, "team_admin")
|
||||
_ = s.store.AddTeamMember(team.ID, userID, "admin")
|
||||
team.Members, _ = s.store.GetTeamMembers(team.ID)
|
||||
writeJSON(w, http.StatusCreated, team)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteTeam(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) isTeamAdmin(userID, teamID string) bool {
|
||||
if userID == "" || teamID == "" {
|
||||
return false
|
||||
}
|
||||
role, err := s.store.GetTeamMemberRole(teamID, userID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return role == "admin"
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateTeamDirect(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
OrgID string `json:"orgId"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
writeError(w, http.StatusBadRequest, "Name für das Team / die Gruppe ist erforderlich")
|
||||
return
|
||||
}
|
||||
orgID := strings.TrimSpace(req.OrgID)
|
||||
if orgID == "" {
|
||||
orgID = "default"
|
||||
}
|
||||
team, err := s.store.CreateTeam(orgID, name, req.Description)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
// Automatically add creator to team as group admin
|
||||
_ = s.store.AddTeamMember(team.ID, userID, "admin")
|
||||
team.Members, _ = s.store.GetTeamMembers(team.ID)
|
||||
writeJSON(w, http.StatusCreated, team)
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateTeam(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
teamID := chi.URLParam(r, "id")
|
||||
if !s.isTeamAdmin(userID, teamID) {
|
||||
writeError(w, http.StatusForbidden, "Nur Gruppen-Administratoren können die Gruppe bearbeiten")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
writeError(w, http.StatusBadRequest, "Name für das Team / die Gruppe ist erforderlich")
|
||||
return
|
||||
}
|
||||
updated, err := s.store.UpdateTeam(teamID, name, req.Description)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, updated)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteTeam(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
teamID := chi.URLParam(r, "id")
|
||||
if !s.isTeamAdmin(userID, teamID) {
|
||||
writeError(w, http.StatusForbidden, "Nur Gruppen-Administratoren können die Gruppe löschen")
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteTeam(teamID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -1702,7 +1931,12 @@ func (s *Server) handleGetUserTeams(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleAddTeamMember(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
teamID := chi.URLParam(r, "id")
|
||||
if !s.isTeamAdmin(userID, teamID) {
|
||||
writeError(w, http.StatusForbidden, "Nur Gruppen-Administratoren können Mitglieder hinzufügen")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
UserID string `json:"userId"`
|
||||
Email string `json:"email"`
|
||||
@@ -1725,7 +1959,39 @@ func (s *Server) handleAddTeamMember(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "Benutzer-ID oder E-Mail erforderlich")
|
||||
return
|
||||
}
|
||||
if err := s.store.AddTeamMember(teamID, targetUserID, req.Role); err != nil {
|
||||
role := strings.ToLower(strings.TrimSpace(req.Role))
|
||||
if role != "admin" && role != "member" {
|
||||
role = "member"
|
||||
}
|
||||
if err := s.store.AddTeamMember(teamID, targetUserID, role); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
members, _ := s.store.GetTeamMembers(teamID)
|
||||
writeJSON(w, http.StatusOK, members)
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateTeamMemberRole(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
teamID := chi.URLParam(r, "id")
|
||||
memberUserID := chi.URLParam(r, "userId")
|
||||
if !s.isTeamAdmin(userID, teamID) {
|
||||
writeError(w, http.StatusForbidden, "Nur Gruppen-Administratoren können Mitgliedsrollen ändern")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
role := strings.ToLower(strings.TrimSpace(req.Role))
|
||||
if role != "admin" && role != "member" {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige Rolle (erlaubt sind: admin, member)")
|
||||
return
|
||||
}
|
||||
if err := s.store.UpdateTeamMemberRole(teamID, memberUserID, role); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -1734,10 +2000,20 @@ func (s *Server) handleAddTeamMember(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleRemoveTeamMember(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
teamID := chi.URLParam(r, "id")
|
||||
memberUserID := chi.URLParam(r, "userId")
|
||||
|
||||
isSelf := userID == memberUserID
|
||||
isAdmin := s.isTeamAdmin(userID, teamID)
|
||||
|
||||
if !isSelf && !isAdmin {
|
||||
writeError(w, http.StatusForbidden, "Nicht berechtigt, dieses Mitglied zu entfernen")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.RemoveTeamMember(teamID, memberUserID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"removed": true})
|
||||
|
||||
@@ -145,7 +145,7 @@ func TestFirstBootSetupAndInviteRegistrationAndNonNullSlices(t *testing.T) {
|
||||
}
|
||||
|
||||
// 5. Create an Invite as Admin and register with that Invite Code
|
||||
invBody := []byte(`{"role":"team_admin","validDays":7}`)
|
||||
invBody := []byte(`{"role":"admin","validDays":7}`)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/admin/invites", bytes.NewReader(invBody))
|
||||
req.Header.Set("Authorization", "Bearer "+setupRes.Token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -163,7 +163,7 @@ func TestFirstBootSetupAndInviteRegistrationAndNonNullSlices(t *testing.T) {
|
||||
regWithInviteBody, _ := json.Marshal(map[string]string{
|
||||
"email": "invited@example.com",
|
||||
"password": "password123",
|
||||
"displayName": "Invited Team Admin",
|
||||
"displayName": "Invited Admin",
|
||||
"inviteCode": createdInvite.Code,
|
||||
})
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(regWithInviteBody))
|
||||
@@ -177,8 +177,8 @@ func TestFirstBootSetupAndInviteRegistrationAndNonNullSlices(t *testing.T) {
|
||||
User domain.User `json:"user"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &invitedRes)
|
||||
if invitedRes.User.Role != "team_admin" {
|
||||
t.Fatalf("expected invited user to receive role 'team_admin', got %q", invitedRes.User.Role)
|
||||
if invitedRes.User.Role != "admin" {
|
||||
t.Fatalf("expected invited user to receive role 'admin', got %q", invitedRes.User.Role)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -618,6 +618,44 @@ func TestSpaceMembersDirectInvitationAPI(t *testing.T) {
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK for remove member, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// 5. Test adding a team to a space
|
||||
team, err := store.CreateTeam("", "WG Team", "Haushalt")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create team: %v", err)
|
||||
}
|
||||
_ = store.AddTeamMember(team.ID, normalUser.ID, "member")
|
||||
|
||||
addTeamBody := []byte(`{"teamId":"` + team.ID + `","role":"editor"}`)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/spaces/"+createdSpace.ID+"/members", bytes.NewReader(addTeamBody))
|
||||
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated && rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 201 or 200 for add space team, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// User should see the space again because their team was added
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/spaces", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+userToken)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK for user spaces after team add, got %d", rec.Code)
|
||||
}
|
||||
userSpaces = nil
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &userSpaces)
|
||||
found = false
|
||||
for _, s := range userSpaces {
|
||||
if s.ID == createdSpace.ID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected space %s to be visible to user via team, got %+v", createdSpace.ID, userSpaces)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceTagsAPI(t *testing.T) {
|
||||
|
||||
@@ -98,7 +98,7 @@ type User struct {
|
||||
Username string `json:"username,omitempty"`
|
||||
PasswordHash string `json:"-"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"` // admin | team_admin | member
|
||||
Role string `json:"role"` // admin | member
|
||||
IsActive bool `json:"isActive"`
|
||||
AlarmChannels []string `json:"alarmChannels"` // email | discord | webhook
|
||||
AlarmEmail string `json:"alarmEmail"`
|
||||
@@ -112,7 +112,7 @@ type UserInvite struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Role string `json:"role"` // member | team_admin | admin
|
||||
Role string `json:"role"` // member | admin
|
||||
TeamID string `json:"teamId,omitempty"`
|
||||
TeamName string `json:"teamName,omitempty"`
|
||||
CreatedBy string `json:"createdBy"`
|
||||
@@ -158,13 +158,42 @@ type Thought struct {
|
||||
TriagedAt string `json:"triagedAt,omitempty"`
|
||||
}
|
||||
|
||||
type TaskListShare struct {
|
||||
ID string `json:"id"`
|
||||
TaskListID string `json:"taskListId"`
|
||||
TargetType string `json:"targetType"` // team | user
|
||||
TargetID string `json:"targetId"`
|
||||
TargetName string `json:"targetName,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type TaskListMember struct {
|
||||
UserID string `json:"userId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"` // owner | member
|
||||
}
|
||||
|
||||
type KanbanColumn struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color,omitempty"`
|
||||
IsDone bool `json:"isDone,omitempty"`
|
||||
}
|
||||
|
||||
type TaskList struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
SpaceID string `json:"spaceId,omitempty"`
|
||||
SpaceName string `json:"spaceName,omitempty"`
|
||||
TeamID string `json:"teamId,omitempty"`
|
||||
TeamName string `json:"teamName,omitempty"`
|
||||
OwnerName string `json:"ownerName,omitempty"`
|
||||
Members []SpaceMember `json:"members,omitempty"`
|
||||
IsOwner bool `json:"isOwner"`
|
||||
Members []TaskListMember `json:"members,omitempty"`
|
||||
Shares []TaskListShare `json:"shares,omitempty"`
|
||||
Columns []KanbanColumn `json:"columns,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon"`
|
||||
Color string `json:"color"`
|
||||
@@ -183,7 +212,7 @@ type Organization struct {
|
||||
|
||||
type Team struct {
|
||||
ID string `json:"id"`
|
||||
OrgID string `json:"orgId"`
|
||||
OrgID string `json:"orgId,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Members []TeamMember `json:"members"`
|
||||
@@ -195,7 +224,7 @@ type TeamMember struct {
|
||||
UserID string `json:"userId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"` // team_admin | member | owner
|
||||
Role string `json:"role"` // admin | member
|
||||
JoinedAt string `json:"joinedAt"`
|
||||
}
|
||||
|
||||
@@ -225,7 +254,10 @@ type DocumentBlock struct {
|
||||
type DocShare struct {
|
||||
ID string `json:"id"`
|
||||
DocID string `json:"docId"`
|
||||
UserID string `json:"userId"`
|
||||
TargetType string `json:"targetType,omitempty"` // user | team
|
||||
TargetID string `json:"targetId,omitempty"`
|
||||
TargetName string `json:"targetName,omitempty"`
|
||||
UserID string `json:"userId,omitempty"`
|
||||
UserEmail string `json:"userEmail,omitempty"`
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
Permission string `json:"permission"` // read | write
|
||||
@@ -286,6 +318,8 @@ type TagDocumentGroup struct {
|
||||
|
||||
type TagDetails struct {
|
||||
Tag string `json:"tag"`
|
||||
NoteContent string `json:"noteContent"`
|
||||
NoteUpdatedAt string `json:"noteUpdatedAt,omitempty"`
|
||||
Documents []TagDocumentGroup `json:"documents"`
|
||||
TotalMatches int `json:"totalMatches"`
|
||||
}
|
||||
@@ -299,6 +333,17 @@ type SpaceMember struct {
|
||||
JoinedAt string `json:"joinedAt"`
|
||||
}
|
||||
|
||||
type SpaceShare struct {
|
||||
ID string `json:"id"`
|
||||
SpaceID string `json:"spaceId"`
|
||||
TargetType string `json:"targetType"` // team | user
|
||||
TargetID string `json:"targetId"`
|
||||
TargetName string `json:"targetName,omitempty"`
|
||||
Role string `json:"role"` // editor | viewer
|
||||
CreatedBy string `json:"createdBy"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type Space struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -308,6 +353,7 @@ type Space struct {
|
||||
IsPersonal bool `json:"isPersonal"`
|
||||
OwnerID string `json:"ownerId"`
|
||||
Members []SpaceMember `json:"members"`
|
||||
Shares []SpaceShare `json:"shares,omitempty"`
|
||||
ListsCount int `json:"listsCount"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
+852
-101
File diff suppressed because it is too large.
Load diff
@@ -99,7 +99,7 @@ func TestStoreLifecycle(t *testing.T) {
|
||||
}
|
||||
|
||||
// 5. Test TaskLists CRUD
|
||||
taskList, err := store.CreateTaskList(user.ID, sp.ID, "Haus & Garten", "🏡", "#10b981")
|
||||
taskList, err := store.CreateTaskList(user.ID, sp.ID, "", "Haus & Garten", "🏡", "#10b981")
|
||||
if err != nil || taskList.ID == "" {
|
||||
t.Fatalf("CreateTaskList failed: %v", err)
|
||||
}
|
||||
@@ -122,6 +122,20 @@ func TestStoreLifecycle(t *testing.T) {
|
||||
t.Fatalf("AddTeamMember failed: %v", err)
|
||||
}
|
||||
|
||||
// Test TaskList Sharing with Team
|
||||
shareTeam, err := store.AddTaskListShare(taskList.ID, "team", team.ID, user.ID)
|
||||
if err != nil || shareTeam.ID == "" {
|
||||
t.Fatalf("AddTaskListShare team failed: %v", err)
|
||||
}
|
||||
tlShares, err := store.GetTaskListShares(taskList.ID)
|
||||
if err != nil || len(tlShares) == 0 {
|
||||
t.Fatalf("GetTaskListShares failed: %v", err)
|
||||
}
|
||||
fetchedTL, err := store.GetTaskListByID(user.ID, taskList.ID)
|
||||
if err != nil || len(fetchedTL.Shares) == 0 {
|
||||
t.Fatalf("GetTaskListByID with shares failed: %v", err)
|
||||
}
|
||||
|
||||
// 7. Test System Settings
|
||||
sysSettings, err := store.GetSystemSettings()
|
||||
if err != nil || sysSettings.InstanceName == "" {
|
||||
|
||||
Reference in new issue
Block a user