diff --git a/BRAIN.md b/BRAIN.md index facbfd1..a899806 100644 --- a/BRAIN.md +++ b/BRAIN.md @@ -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. diff --git a/frontend/index.html b/frontend/index.html index b020e97..d0175fd 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,12 +4,23 @@ + + - - MindSift — Der Kopf frei in drei Sekunden + + + MindSift +
@@ -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(() => {}); + }); } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ef3a8a0..2201d64 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 266a668..ee43213 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" }, diff --git a/frontend/public/icon.svg b/frontend/public/icon.svg new file mode 100644 index 0000000..fec3035 --- /dev/null +++ b/frontend/public/icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json index 3c2d767..6c1cbb7 100644 --- a/frontend/public/manifest.json +++ b/frontend/public/manifest.json @@ -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,", + "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": [ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5f09e67..9c5deac 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(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(() => { + if (typeof window === 'undefined') return false; + return ( + window.matchMedia('(display-mode: standalone)').matches || + (window.navigator as any).standalone === true + ); + }); + const [deferredPwaPrompt, setDeferredPwaPrompt] = useState(() => + 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); - window.scrollTo({ top: 0, behavior: 'smooth' }); - setCaptureFocusCount((c) => c + 1); + 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 (
- {/* Top Header Bar */} -
-
+ {/* Top Header Bar: Floating on mobile, solid panel on desktop */} +
+ {/* Mobile Header: Floating Bar with Larger Logo Left (Triage Home) & Profile Avatar Right */} +
+ + +
+ {!isStandaloneApp && ( + + )} + + +
+
+ + {/* Desktop Header: 3-column Layout (Logo, InstantCaptureBar, Profile Pill) */} +
{/* Logo & Headline */}
{ @@ -334,14 +482,9 @@ export const App: React.FC = () => {
-
- - {authStatus.instanceName || 'MindSift'} - - - Der Kopf frei in drei Sekunden - -
+ + {authStatus.instanceName || 'MindSift'} +
{/* Quick-Capture Input Bubble directly in Header (above Tabs) */} @@ -427,6 +570,20 @@ export const App: React.FC = () => { Kontoeinstellungen + + - {isStaff && ( + {isServerAdmin && (
- {/* Navigation tabs attached to header */} -
{/* Main Container */} -
+
setActiveTab('triage')}>
{activeTab === 'triage' && ( @@ -574,6 +731,7 @@ export const App: React.FC = () => { {activeTab === 'tasks' && ( { )} + {activeTab === 'groups' && ( + setActiveTab(previousTab || 'triage')} + /> + )} + {activeTab === 'user_settings' && ( { /> )} - {activeTab === 'admin_settings' && isStaff && ( + {activeTab === 'admin_settings' && isServerAdmin && ( setActiveTab('triage')} @@ -618,6 +783,397 @@ export const App: React.FC = () => {
+ + {/* MOBILE BOTTOM NAVIGATION BAR (5 equal columns -> + FAB is dead-center) */} + + + {/* MOBILE QUICK-ADD BOTTOM SHEET / MODAL */} + {showMobileCapture && ( +
setShowMobileCapture(false)} + > +
e.stopPropagation()} + > + {/* Header */} +
+
+
+ +
+

Schnell erfassen

+
+ +
+ + {/* InstantCaptureBar */} +
+ { + handleCaptured(thought, target); + setShowMobileCapture(false); + }} + lists={lists} + autoFocusTrigger={Date.now()} + /> +
+
+
+ )} + + {/* MOBILE USER PROFILE SHEET */} + {showMobileProfile && ( +
setShowMobileProfile(false)} + > +
e.stopPropagation()} + > + {/* User Info Header */} +
+
+
+ {user.displayName ? user.displayName.slice(0, 1).toUpperCase() : 'U'} +
+
+

{user.displayName}

+

{user.email}

+ + {roleLabel} + +
+
+ +
+ + {/* Theme Quick Switcher */} +
+ + + Design-Theme + +
+ {AVAILABLE_THEMES.map((t) => ( + + ))} +
+
+ + {/* Menu Items */} +
+ {!isStandaloneApp && ( + + )} + + + + + + + + {isServerAdmin && ( + + )} +
+ + {/* Logout */} +
+ +
+
+
+ )} + + {/* IMMEDIATE MOBILE WEB-APP INSTALLATION OFFER SHEET */} + {showPwaInstallModal && !isStandaloneApp && ( +
+
e.stopPropagation()} + > +
+
+
+ +
+
+

+ {authStatus.instanceName || 'MindSift'} als App installieren +

+

+ Vollbild ohne Browser-Leiste, schnellerer Start & Direktzugriff vom Home-Bildschirm. +

+
+
+ +
+ + {!showManualPwaGuide ? ( +
+ + +
+ ) : ( +
+
+

+ + In 2 Sekunden zum Home-Bildschirm hinzufügen: +

+
    +
  1. + Tippe im Browser auf{' '} + + Teilen + {' '} + (iPhone/Safari) oder das{' '} + Browser-Menü (⋮). +
  2. +
  3. + Wähle{' '} + „Zum Home-Bildschirm“ bzw.{' '} + „App installieren“. +
  4. +
+
+ +
+ )} +
+
+ )}
); }; diff --git a/frontend/src/components/UnifiedShareModal.tsx b/frontend/src/components/UnifiedShareModal.tsx new file mode 100644 index 0000000..fc487ee --- /dev/null +++ b/frontend/src/components/UnifiedShareModal.tsx @@ -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; + onRemoveShare: (shareId: string) => Promise; +} + +export const UnifiedShareModal: React.FC = ({ + 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(null); + const [selectedPermission, setSelectedPermission] = useState<'editor' | 'viewer'>('editor'); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
+ {/* Header */} +
+
+
+ {resourceIcon ? ( + typeof resourceIcon === 'string' ? ( + {resourceIcon} + ) : ( + resourceIcon + ) + ) : ( + + )} +
+
+

{title}

+

+ Objekt: {resourceName} +

+
+
+ +
+ + {error && ( +
+ {error} +
+ )} + + {/* Add Share Section (Only if owner / manager) */} + {isOwner ? ( +
+
+ Freigabe hinzufügen: +
+ + +
+
+ + {shareType === 'team' ? ( +
+ + {allowRoleSelection && ( + + )} +
+ ) : ( +
+
+ +
+ {allowRoleSelection && ( + + )} +
+ )} + +
+ +
+
+ ) : ( +
+ + Nur der Eigentümer kann neue Freigaben einrichten. +
+ )} + + {/* Active Shares List */} +
+
+ Aktive Freigaben + + {shares.length} {shares.length === 1 ? 'Freigabe' : 'Freigaben'} + +
+ +
+ {shares.length === 0 ? ( +
+ + Dieses Element ist aktuell privat (nur für dich sichtbar). +
+ ) : ( + shares.map((sh) => { + const isTeam = sh.targetType === 'team'; + const roleLabel = + sh.permission === 'viewer' || sh.role === 'viewer' ? 'Leser' : 'Bearbeiter'; + + return ( +
+
+
+ {isTeam ? : } +
+
+
+ {sh.targetName || (isTeam ? 'Gruppe' : sh.targetEmail || 'Benutzer')} + + {isTeam ? 'Gruppe' : 'Benutzer'} + +
+ {sh.targetEmail && sh.targetEmail !== sh.targetName && ( +
{sh.targetEmail}
+ )} +
+
+ +
+ + {roleLabel} + + {isOwner && ( + + )} +
+
+ ); + }) + )} +
+
+ + {/* Footer */} +
+ +
+
+
+ ); +}; diff --git a/frontend/src/features/UserAutocomplete.tsx b/frontend/src/features/UserAutocomplete.tsx new file mode 100644 index 0000000..b818b01 --- /dev/null +++ b/frontend/src/features/UserAutocomplete.tsx @@ -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 = ({ + 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(-1); + const containerRef = useRef(null); + const inputRef = useRef(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) => { + 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 ( +
+ {selectedUser ? ( +
+
+
+ {(selectedUser.displayName || selectedUser.email || '?')[0].toUpperCase()} +
+ + {selectedUser.displayName || selectedUser.email} + + {selectedUser.username && ( + + @{selectedUser.username} + + )} + {selectedUser.email && selectedUser.displayName && ( + + ({selectedUser.email}) + + )} +
+ {!disabled && ( + + )} +
+ ) : ( +
+ + { + 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 && ( +
+ {filteredUsers.length === 0 ? ( +
+ Kein passender Benutzer gefunden +
+ ) : ( + filteredUsers.map((u, idx) => { + const isHighlighted = idx === highlightedIndex; + return ( + + ); + }) + )} +
+ )} +
+ )} +
+ ); +}; diff --git a/frontend/src/features/auth/SetupAndAuthView.tsx b/frontend/src/features/auth/SetupAndAuthView.tsx index 1c26b27..384b7bb 100644 --- a/frontend/src/features/auth/SetupAndAuthView.tsx +++ b/frontend/src/features/auth/SetupAndAuthView.tsx @@ -356,12 +356,9 @@ export const SetupAndAuthView: React.FC = ({
-
-

- {authStatus.instanceName || 'MindSift'} -

-

Der Kopf frei in drei Sekunden

-
+

+ {authStatus.instanceName || 'MindSift'} +

diff --git a/frontend/src/features/help/HelpView.tsx b/frontend/src/features/help/HelpView.tsx index 8fe9e3a..19b91dc 100644 --- a/frontend/src/features/help/HelpView.tsx +++ b/frontend/src/features/help/HelpView.tsx @@ -1,6 +1,5 @@ import React, { useState } from 'react'; import { - ArrowLeft, Search, Layers, FileText, @@ -20,39 +19,27 @@ import { } from 'lucide-react'; interface HelpViewProps { - onBack: () => void; + onBack?: () => void; } type HelpCategory = 'all' | 'workspace' | 'triage' | 'tasks' | 'shortcuts'; -export const HelpView: React.FC = ({ onBack }) => { +export const HelpView: React.FC = () => { const [activeCategory, setActiveCategory] = useState('all'); const [searchQuery, setSearchQuery] = useState(''); return ( -
+
{/* Header Bar */}
-
- - -
-

- - Hilfe & Dokumentation -

-

- Alles Wichtige zur Tastaturbedienung, Outliner-Notizen, Triage und Zusammenarbeit -

-
+
+

+ + Hilfe & Dokumentation +

+

+ Alles Wichtige zur Tastaturbedienung, Outliner-Notizen, Triage und Zusammenarbeit +

{/* Search */} diff --git a/frontend/src/features/lists/SharedListsView.tsx b/frontend/src/features/lists/SharedListsView.tsx index e1bdbff..e992ece 100644 --- a/frontend/src/features/lists/SharedListsView.tsx +++ b/frontend/src/features/lists/SharedListsView.tsx @@ -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 = ({ 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([]); + const [teams, setTeams] = useState([]); + const [addShareType, setAddShareType] = useState<'team' | 'user'>('team'); + const [selectedTeamId, setSelectedTeamId] = useState(''); + const [selectedShareUser, setSelectedShareUser] = useState(null); const [inviteTarget, setInviteTarget] = useState(''); const [inviteRole, setInviteRole] = useState<'editor' | 'viewer'>('editor'); const [invitingLoading, setInvitingLoading] = useState(false); - const [inviteError, setInviteError] = useState(null); + const [shareError, setShareError] = useState(null); const wsRef = useRef(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 = ({ 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); + await api.deleteList(listId); + if (activeSpace) { + await loadListsForSpace(activeSpace.id); + } + onRefreshGlobal(); } catch (err: any) { - alert(err.message || 'Einladungscode ungültig'); + alert(err.message || 'Liste konnte nicht gelöscht werden'); } }; - const copyInviteCode = () => { + const openShareModal = async () => { if (!activeSpace) return; - navigator.clipboard.writeText(activeSpace.inviteCode); - setCopiedInvite(true); - setTimeout(() => setCopiedInvite(false), 2000); + 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 handleAddMember = async (e: React.FormEvent) => { + const handleAddShare = async (e: React.FormEvent) => { e.preventDefault(); if (!activeSpace) return; - const target = inviteTarget.trim(); - if (!target) return; setInvitingLoading(true); - setInviteError(null); + setShareError(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(''); + 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) { - setInviteError(err.message || 'Mitglied konnte nicht eingeladen werden'); + 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), - } - : prev - ); - setSpaces((prev) => - prev.map((s) => - s.id === activeSpace.id - ? { - ...s, - members: (s.members || []).filter((m) => m.userId !== targetUserId), - } - : s - ) - ); + 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 }); + } onRefreshGlobal(); } catch (err: any) { - setInviteError(err.message || 'Mitglied konnte nicht entfernt werden'); + setShareError(err.message || 'Freigabe konnte nicht entfernt werden'); } finally { setInvitingLoading(false); } @@ -433,151 +438,115 @@ export const SharedListsView: React.FC = ({ onRefreshGloba return (
- {/* Top Space Bar & Realtime Indicator */} -
-
- {spaces.map((sp) => ( - - ))} + {/* Top Header: List Tabs & Actions */} +
+ {/* List Tabs */} +
+ {lists.map((lst) => { + const openCount = lst.items.filter((i) => !i.isCompleted).length; + const isSelected = activeList?.id === lst.id; + return ( +
+ + {isSelected && lists.length > 1 && ( + + )} +
+ ); + })} - -
- {/* Realtime Status & Invite User Button */} - {activeSpace && ( -
-
- -
+ {/* Right Header Section: Geteilt Badge, Icon-Only Share Button & Live Sync */} +
+ {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 ( +
+ {isShared && ( + isOwner ? ( + + ) : ( + + ) + )} + + +
+ ); + })()} + + {/* Live Sync Status */} +
+
- )} -
- - {/* Create or Join Space Modals */} - {showNewSpaceModal && ( -
- 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 - /> - -
- )} - - {showJoinModal && ( -
- setJoinCode(e.target.value)} - placeholder="Invite-Code des Partners eingeben…" - className="flex-1 h-9 px-3 input-field text-sm uppercase" - autoFocus - /> - -
- )} - - {/* List Tabs within Active Space */} -
- {lists.map((lst) => { - const openCount = lst.items.filter((i) => !i.isCompleted).length; - const isSelected = activeList?.id === lst.id; - return ( - - ); - })} - - +
{showNewListModal && ( @@ -770,169 +739,81 @@ export const SharedListsView: React.FC = ({ onRefreshGloba
) : null} - {/* Space & Shopping List Members Modal */} - {showMembersModal && activeSpace && ( -
-
- {/* Modal Header */} -
-
- -

- Einkaufsliste teilen: {activeSpace.name} -

-
- -
- - {inviteError && ( -
- {inviteError} -
- )} - - {/* Invite User Form */} -
-
User direkt einladen
-
- - -
- - {/* Direct Username / Email input if user not selected from list */} - {(!inviteTarget || !collaborators.some((u) => u.id === inviteTarget)) && ( - 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" - /> - )} - -
- -
-
- - {/* Current Space Members List */} -
-
- Mitglieder in dieser Liste ({activeSpace.members?.length || 1}) -
-
- {(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 ( -
-
-
- {(m.displayName || m.email || '?')[0].toUpperCase()} -
-
-
- {m.displayName || m.email} {isMe && (Du)} -
-
{m.email}
-
-
- -
- - {isOwner ? 'Eigentümer' : m.role === 'editor' ? 'Bearbeiter' : 'Leser'} - - {canRemove && ( - - )} -
-
- ); - })} -
-
- - {/* Invite Code Alternative */} -
- Alternativ per Code beitreten: - -
-
-
+ {/* Unified Space & Shopping List Sharing Modal */} + {showShareModal && activeSpace && ( + 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(); + }} + /> )}
); diff --git a/frontend/src/features/settings/AdminSettingsView.tsx b/frontend/src/features/settings/AdminSettingsView.tsx index a344b11..9f26048 100644 --- a/frontend/src/features/settings/AdminSettingsView.tsx +++ b/frontend/src/features/settings/AdminSettingsView.tsx @@ -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 = ({ currentUser, @@ -50,22 +47,11 @@ export const AdminSettingsView: React.FC = ({ const [inviteEmail, setInviteEmail] = useState(''); const [inviteRole, setInviteRole] = useState('member'); - const [inviteTeamId, setInviteTeamId] = useState(''); const [copiedInviteId, setCopiedInviteId] = useState(null); const [resetPwdUserId, setResetPwdUserId] = useState(null); const [resetPwdVal, setResetPwdVal] = useState(''); - // --- Organizations & Teams State --- - const [orgs, setOrgs] = useState([]); - const [allTeams, setAllTeams] = useState([]); - const [newOrgName, setNewOrgName] = useState(''); - const [selectedOrgId, setSelectedOrgId] = useState(''); - const [newTeamName, setNewTeamName] = useState(''); - const [newTeamDesc, setNewTeamDesc] = useState(''); - const [memberSelectByTeam, setMemberSelectByTeam] = useState>({}); - const [memberRoleByTeam, setMemberRoleByTeam] = useState>({}); - // --- Server & SMTP Settings State --- const [settings, setSettings] = useState({ sharingMode: 'teams_only', @@ -86,19 +72,12 @@ export const AdminSettingsView: React.FC = ({ 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 = ({ 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 = ({ } }; - // ============================================================================ - // 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 = ({ } }; - // 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 (
{/* Top Page Header */} @@ -384,17 +272,15 @@ export const AdminSettingsView: React.FC = ({

- Administration & Team-Steuerung + Server-Administration

- Benutzerverwaltung, Einladungscodes, Organisationen, Teams, Isolationsmodus & Mail-Server + Benutzerverwaltung, Einladungscodes, Isolationsmodus & Mail-Server

- - {isServerAdmin ? 'Server-Administrator' : 'Team-Administrator'} - + Server-Administrator
{statusMsg && ( @@ -439,21 +325,6 @@ export const AdminSettingsView: React.FC = ({
- - {isServerAdmin && ( <>
@@ -584,7 +454,7 @@ export const AdminSettingsView: React.FC = ({
-
+
- + -
- -
- -
@@ -728,8 +579,7 @@ export const AdminSettingsView: React.FC = ({ title="Benutzerrolle ändern" > - - {isServerAdmin && } + {isServerAdmin && ( @@ -796,238 +646,7 @@ export const AdminSettingsView: React.FC = ({
)} - {/* ================================================================= */} - {/* SECTION 2: ORGANISATIONEN & TEAMS */} - {/* ================================================================= */} - {section === 'orgs' && ( -
-
-

Organisationen & Teams verwalten

-

- 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. -

-
- {/* Create Organization & Team */} -
-
-

1. Neue Organisation gründen

- - setNewOrgName(e.target.value)} - placeholder="z.B. Homelab, Familie, Firma…" - required - className="flex-1 min-w-0 h-8 px-2.5 input-field" - /> - - -
- -
-

2. Neues Team in Organisation anlegen

- {orgs.length === 0 ? ( -

- Bitte lege zuerst links eine Organisation an. -

- ) : ( -
-
- - setNewTeamName(e.target.value)} - placeholder="Team-Name (z.B. Infrastruktur)" - required - className="w-full min-w-0 h-8 px-2.5 input-field" - /> -
-
- setNewTeamDesc(e.target.value)} - placeholder="Kurzbeschreibung (optional)…" - className="flex-1 min-w-0 h-8 px-2.5 input-field" - /> - -
-
- )} -
-
- - {/* Organizations & Nested Teams Display */} -
- {orgs.length === 0 ? ( -
- Noch keine Organisationen vorhanden. -
- ) : ( - orgs.map((org) => { - const orgTeams = Array.isArray(org.teams) ? org.teams : []; - return ( -
-
-
- - {org.name} - - ({orgTeams.length} {orgTeams.length === 1 ? 'Team' : 'Teams'}) - -
- -
- -
- {orgTeams.length === 0 ? ( -

- Noch keine Teams in dieser Organisation. -

- ) : ( - orgTeams.map((team) => { - const members = Array.isArray(team.members) ? team.members : []; - return ( -
-
-
- - - {team.name} - - {team.description && ( -

- {team.description} -

- )} -
- -
- - {/* Team Members */} -
-

- Mitglieder ({members.length}): -

- {members.length === 0 ? ( -

- Noch keine Mitglieder zugewiesen. -

- ) : ( -
- {members.map((m) => ( -
- - {m.displayName} - - - ({m.role}) - - -
- ))} -
- )} -
- - {/* Add User to Team */} -
- - - - - -
-
- ); - }) - )} -
-
- ); - }) - )} -
-
- )} {/* ================================================================= */} {/* SECTION 3: SERVER & SHARING-ISOLATION (SERVER-ADMIN ONLY) */} diff --git a/frontend/src/features/settings/UserSettingsView.tsx b/frontend/src/features/settings/UserSettingsView.tsx index 9a96aa5..532b4d0 100644 --- a/frontend/src/features/settings/UserSettingsView.tsx +++ b/frontend/src/features/settings/UserSettingsView.tsx @@ -236,8 +236,6 @@ export const UserSettingsView: React.FC = ({ const roleBadgeLabel = currentUser.role === 'admin' ? 'Server-Admin' - : currentUser.role === 'team_admin' - ? 'Team-Admin' : 'Mitglied'; return ( diff --git a/frontend/src/features/teams/GroupsView.tsx b/frontend/src/features/teams/GroupsView.tsx new file mode 100644 index 0000000..7273072 --- /dev/null +++ b/frontend/src/features/teams/GroupsView.tsx @@ -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 = ({ currentUser, onBack }) => { + const [teams, setTeams] = useState([]); + const [directoryUsers, setDirectoryUsers] = useState([]); + 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(null); + const [editName, setEditName] = useState(''); + const [editDesc, setEditDesc] = useState(''); + + // Add Member State per Team + const [selectedUserByTeam, setSelectedUserByTeam] = useState>({}); + const [memberRoleByTeam, setMemberRoleByTeam] = useState>({}); + + 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 ( +
+ {/* Page Header */} +
+
+ +
+

+ + Gruppen & Teams + + {teams.length} + +

+

+ Erstelle und verwalte Gruppen (z. B. Familie, WG oder Projektteams) für gemeinsame Notizen, Aufgaben und Listen. +

+
+
+ + +
+ + {/* Status Messages */} + {statusMsg && ( +
+
+ {statusMsg.type === 'ok' ? ( + + ) : ( + + )} + {statusMsg.text} +
+ +
+ )} + + {/* Modal: Neue Gruppe erstellen */} + {showCreateModal && ( +
setShowCreateModal(false)} + > +
e.stopPropagation()} + > +
+
+
+ +
+

Neue Gruppe erstellen

+
+ +
+ +
+
+ + 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" + /> +
+ +
+ + setNewGroupDesc(e.target.value)} + placeholder="z. B. Gemeinsame Einkaufslisten & Notizen…" + className="w-full h-8 px-2.5 input-field text-xs" + /> +
+ +
+

+ + Du wirst Gruppen-Admin +

+

+ Als Ersteller kannst du weitere Mitglieder hinzufügen, Rollen zuweisen und die Gruppe verwalten. +

+
+ +
+ + +
+
+
+
+ )} + + {/* Main Content Area */} + {loading ? ( +
Lade Gruppen…
+ ) : teams.length === 0 ? ( +
+
+ +
+
+

Noch keine Gruppen vorhanden

+

+ Erstelle deine erste Gruppe, um Aufgaben, Einkaufslisten oder Dokumente gemeinsam mit anderen zu nutzen. +

+
+ +
+ ) : ( +
+ {/* SECTION 1: Gruppen, die ich verwalte */} +
+
+

+ + Gruppen, die ich verwalte ({managedTeams.length}) +

+
+ + {managedTeams.length === 0 ? ( +
+ Du bist derzeit noch in keiner Gruppe als Admin eingetragen. Klicke oben auf "Neue Gruppe", um eine eigene Gruppe zu erstellen. +
+ ) : ( +
+ {managedTeams.map((team) => { + const members = team.members || []; + const isEditing = editingTeamId === team.id; + const memberUserIds = members.map((m) => m.userId); + + return ( +
+ {/* Team Header */} +
+
+
+ +
+ + {isEditing ? ( +
+ setEditName(e.target.value)} + placeholder="Gruppenname" + className="w-full h-8 px-2.5 input-field text-xs font-bold" + /> + setEditDesc(e.target.value)} + placeholder="Optionale Beschreibung" + className="w-full h-7 px-2.5 input-field text-xs" + /> +
+ + +
+
+ ) : ( +
+
+ + {team.name} + + + Gruppen-Admin + + + {members.length} {members.length === 1 ? 'Mitglied' : 'Mitglieder'} + +
+ {team.description && ( +

+ {team.description} +

+ )} +
+ )} +
+ + {!isEditing && ( +
+ + +
+ )} +
+ + {/* Members List */} +
+

+ Mitglieder ({members.length}) +

+
+ {members.map((m) => { + const isSelf = m.userId === currentUser.id; + const isAdmin = m.role === 'admin'; + + return ( +
+
+
+ {(m.displayName || 'U').slice(0, 1).toUpperCase()} +
+
+

+ {m.displayName} {isSelf && (Du)} +

+

+ {m.email} +

+
+
+ +
+ + + +
+
+ ); + })} +
+
+ + {/* Add Member Row */} +
+
+ + setSelectedUserByTeam((prev) => ({ ...prev, [team.id]: u })) + } + excludeUserIds={memberUserIds} + placeholder="Benutzer suchen zum Hinzufügen…" + /> +
+ + + + +
+
+ ); + })} +
+ )} +
+ + {/* SECTION 2: Meine Mitgliedschaften (als Mitglied) */} + {memberTeams.length > 0 && ( +
+

+ + Meine Mitgliedschaften ({memberTeams.length}) +

+ +
+ {memberTeams.map((team) => { + const members = team.members || []; + const admins = members.filter((m) => m.role === 'admin'); + + return ( +
+
+
+
+ +
+
+
+ {team.name} + + {members.length} {members.length === 1 ? 'Mitglied' : 'Mitglieder'} + +
+ {team.description && ( +

{team.description}

+ )} +
+
+ + +
+ + {/* Admins & Members list in read-only mode */} +
+

+ Gruppen-Admins:{' '} + + {admins.map((a) => a.displayName).join(', ') || 'Keiner'} + +

+

+ Alle Mitglieder:{' '} + + {members.map((m) => m.displayName).join(', ')} + +

+
+
+ ); + })} +
+
+ )} +
+ )} +
+ ); +}; diff --git a/frontend/src/features/triage/ManageColumnsModal.tsx b/frontend/src/features/triage/ManageColumnsModal.tsx new file mode 100644 index 0000000..c437605 --- /dev/null +++ b/frontend/src/features/triage/ManageColumnsModal.tsx @@ -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; + 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 = ({ + isOpen, + onClose, + initialColumns, + onSave, + listTitle, +}) => { + if (!isOpen) return null; + + const [columns, setColumns] = useState(() => { + 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 ( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+ +
+

Kanban-Spalten anpassen

+ {listTitle && ( +

Liste: {listTitle}

+ )} +
+
+ +
+ + {/* Content */} +
+

+ Passe die Spalten für das Kanban-Board an. Aufgaben können in jede beliebige Spalte verschoben werden. +

+ + {/* Columns List */} +
+ +
+ {columns.map((col, idx) => ( +
+
+ + +
+ + handleRename(col.id, e.target.value)} + className="flex-1 h-7 px-2 text-xs input-field" + placeholder="Spaltenname" + /> + + {/* IsDone Toggle */} + + + +
+ ))} +
+
+ + {/* Add New Column Form */} +
+ +
+ setNewColName(e.target.value)} + placeholder="z.B. Wartet auf Feedback, Review…" + className="flex-1 h-8 px-2.5 text-xs input-field" + /> + + +
+
+ + {/* Reset button */} +
+ +
+
+ + {/* Footer */} +
+ + +
+
+
+ ); +}; diff --git a/frontend/src/features/triage/TaskDetailModal.tsx b/frontend/src/features/triage/TaskDetailModal.tsx new file mode 100644 index 0000000..4f138c6 --- /dev/null +++ b/frontend/src/features/triage/TaskDetailModal.tsx @@ -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; + onUpdateStatus: (task: Thought, newStatus: string) => Promise; + onUpdatePriority: (task: Thought, newPriority: string) => Promise; + onUpdateDueDate: (task: Thought, newDueDate: string) => Promise; + onUpdateTaskList: (task: Thought, newListId: string) => Promise; + onUpdateNotes: (task: Thought, newNotes: string) => Promise; + onUpdateTags?: (task: Thought, newTags: string[]) => Promise; + onToggleSubtask: (task: Thought, subtaskId: string) => Promise; + onAddSubtask: (task: Thought, title: string) => Promise; + onDeleteSubtask: (task: Thought, subtaskId: string) => Promise; + onConvertToDoc: (task: Thought) => void; + onDeleteTask: (taskId: string) => void; +} + +export const TaskDetailModal: React.FC = ({ + 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 ( +
+
e.stopPropagation()} + > + {/* Top Header */} +
+
+ + + {task.isCompleted ? 'Erledigte Aufgabe' : 'Aufgabe bearbeiten'} + +
+ +
+ + + +
+
+ + {/* Scrollable Body */} +
+ {/* Task Title */} +
+ {isEditingTitle ? ( +
+