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`):**
|
- [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.
|
- 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.
|
- 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.
|
- Ö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 statt kleinem Modal (`App.tsx`, `UserSettingsView.tsx`, `AdminSettingsView.tsx`):**
|
- [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ü:
|
- 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).
|
- **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).
|
- **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.
|
- 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`):**
|
- [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'`).
|
- 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`**
|
1. **`users`**
|
||||||
- `id TEXT PRIMARY KEY`, `email TEXT UNIQUE`, `password_hash TEXT`, `display_name TEXT`
|
- `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`
|
- `is_active INTEGER DEFAULT 1`, `created_at DATETIME`, `updated_at DATETIME`
|
||||||
2. **`system_settings`** (Instanzweite globale Konfiguration, 1 Row mit `id = 'singleton'`)
|
2. **`system_settings`** (Instanzweite globale Konfiguration, 1 Row mit `id = 'singleton'`)
|
||||||
- `sharing_mode TEXT DEFAULT 'teams_only'` (`strict_private`, `teams_only`, `open`)
|
- `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`
|
- `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)
|
3. **`user_invites`** (Einladungscodes für neue Registrierungen)
|
||||||
- `id TEXT PRIMARY KEY`, `code TEXT UNIQUE` (`MS-XXXXXXXX`), `email TEXT` (optional gebunden)
|
- `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`
|
- `created_by TEXT`, `used_by TEXT`, `expires_at DATETIME`, `created_at DATETIME`
|
||||||
4. **`organizations` & `organization_teams` & `team_members`**
|
4. **`organizations` & `organization_teams` & `team_members`**
|
||||||
- Strukturierung von Benutzern in Abteilungen, Familien oder Projekt-Teams.
|
- Strukturierung von Benutzern in Abteilungen, Familien oder Projekt-Teams.
|
||||||
|
|||||||
+16
-5
@@ -4,12 +4,23 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
<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="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-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="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." />
|
<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="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>" />
|
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||||
<title>MindSift — Der Kopf frei in drei Sekunden</title>
|
<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>
|
</head>
|
||||||
<body class="bg-[#0b0f19] text-[#e3e3e3] antialiased selection:bg-[#8ab4f8]/30 selection:text-[#8ab4f8]">
|
<body class="bg-[#0b0f19] text-[#e3e3e3] antialiased selection:bg-[#8ab4f8]/30 selection:text-[#8ab4f8]">
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
@@ -19,9 +30,9 @@
|
|||||||
caches.keys().then((keys) => keys.forEach((k) => caches.delete(k))).catch(() => {});
|
caches.keys().then((keys) => keys.forEach((k) => caches.delete(k))).catch(() => {});
|
||||||
}
|
}
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
navigator.serviceWorker.getRegistrations().then((regs) => {
|
window.addEventListener('load', () => {
|
||||||
regs.forEach((r) => r.unregister());
|
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||||
}).catch(() => {});
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Generated
+17
@@ -8,8 +8,10 @@
|
|||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/prismjs": "^1.26.6",
|
||||||
"framer-motion": "^11.15.0",
|
"framer-motion": "^11.15.0",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
|
"prismjs": "^1.30.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
},
|
},
|
||||||
@@ -1224,6 +1226,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/prop-types": {
|
||||||
"version": "15.7.15",
|
"version": "15.7.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||||
@@ -2242,6 +2250,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
|
|||||||
@@ -11,8 +11,10 @@
|
|||||||
"test:run": "echo 'Frontend checks passed'"
|
"test:run": "echo 'Frontend checks passed'"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/prismjs": "^1.26.6",
|
||||||
"framer-motion": "^11.15.0",
|
"framer-motion": "^11.15.0",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
|
"prismjs": "^1.30.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^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",
|
"name": "MindSift — Der Kopf frei in drei Sekunden",
|
||||||
"short_name": "MindSift",
|
"short_name": "MindSift",
|
||||||
"description": "Instant Brain-Dump, Tinder-Style Triage, Workspace & Echtzeit-Familienlisten.",
|
"description": "Instant Brain-Dump, Tinder-Style Triage, Workspace & Echtzeit-Familienlisten.",
|
||||||
"start_url": "/?mode=capture",
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#0b0f19",
|
"background_color": "#0b0f19",
|
||||||
"theme_color": "#0b0f19",
|
"theme_color": "#0b0f19",
|
||||||
"orientation": "portrait-primary",
|
"orientation": "portrait-primary",
|
||||||
"icons": [
|
"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",
|
"sizes": "192x192 512x512",
|
||||||
"type": "image/svg+xml",
|
"type": "image/svg+xml",
|
||||||
"purpose": "any maskable"
|
"purpose": "maskable"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"shortcuts": [
|
"shortcuts": [
|
||||||
|
|||||||
+576
-20
@@ -12,6 +12,12 @@ import {
|
|||||||
RotateCcw,
|
RotateCcw,
|
||||||
Palette,
|
Palette,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
|
Plus,
|
||||||
|
X,
|
||||||
|
Download,
|
||||||
|
Smartphone,
|
||||||
|
Share,
|
||||||
|
Users,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { AuthStatus, DashboardStats, SharedList, Thought, User } from './lib/types';
|
import { AuthStatus, DashboardStats, SharedList, Thought, User } from './lib/types';
|
||||||
import {
|
import {
|
||||||
@@ -37,12 +43,14 @@ import { SetupAndAuthView } from './features/auth/SetupAndAuthView';
|
|||||||
import { UserSettingsView } from './features/settings/UserSettingsView';
|
import { UserSettingsView } from './features/settings/UserSettingsView';
|
||||||
import { AdminSettingsView } from './features/settings/AdminSettingsView';
|
import { AdminSettingsView } from './features/settings/AdminSettingsView';
|
||||||
import { HelpView } from './features/help/HelpView';
|
import { HelpView } from './features/help/HelpView';
|
||||||
|
import { GroupsView } from './features/teams/GroupsView';
|
||||||
|
|
||||||
type ActiveTab =
|
type ActiveTab =
|
||||||
| 'triage'
|
| 'triage'
|
||||||
| 'tasks'
|
| 'tasks'
|
||||||
| 'workspace'
|
| 'workspace'
|
||||||
| 'lists'
|
| 'lists'
|
||||||
|
| 'groups'
|
||||||
| 'user_settings'
|
| 'user_settings'
|
||||||
| 'admin_settings'
|
| 'admin_settings'
|
||||||
| 'help';
|
| 'help';
|
||||||
@@ -118,6 +126,90 @@ export const App: React.FC = () => {
|
|||||||
const [showProfileMenu, setShowProfileMenu] = useState(false);
|
const [showProfileMenu, setShowProfileMenu] = useState(false);
|
||||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
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 = () => {
|
const handleOpenHelp = () => {
|
||||||
triggerHaptic(10);
|
triggerHaptic(10);
|
||||||
setPreviousTab((prev) => (activeTab === 'help' ? prev : activeTab));
|
setPreviousTab((prev) => (activeTab === 'help' ? prev : activeTab));
|
||||||
@@ -273,8 +365,12 @@ export const App: React.FC = () => {
|
|||||||
|
|
||||||
const handleTriggerAddButton = () => {
|
const handleTriggerAddButton = () => {
|
||||||
triggerHaptic(20);
|
triggerHaptic(20);
|
||||||
|
if (window.innerWidth < 640) {
|
||||||
|
setShowMobileCapture(true);
|
||||||
|
} else {
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
setCaptureFocusCount((c) => c + 1);
|
setCaptureFocusCount((c) => c + 1);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
@@ -310,19 +406,71 @@ export const App: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isStaff = user.role === 'admin' || user.role === 'team_admin';
|
const isServerAdmin = user.role === 'admin';
|
||||||
const roleLabel =
|
const roleLabel = isServerAdmin ? 'Server-Admin' : 'Mitglied';
|
||||||
user.role === 'admin'
|
|
||||||
? 'Server-Admin'
|
|
||||||
: user.role === 'team_admin'
|
|
||||||
? 'Team-Admin'
|
|
||||||
: 'Mitglied';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-canvas text-main flex flex-col font-sans selection:bg-accent/30">
|
<div className="min-h-screen bg-canvas text-main flex flex-col font-sans selection:bg-accent/30">
|
||||||
{/* Top Header Bar */}
|
{/* Top Header Bar: Floating on mobile, solid panel on desktop */}
|
||||||
<header className="sticky top-0 z-30 bg-panel border-b border-border shadow-sm">
|
<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">
|
||||||
<div className="max-w-6xl mx-auto px-3 sm:px-4 py-2 flex items-start justify-between gap-3 sm:gap-6">
|
{/* 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 */}
|
{/* Logo & Headline */}
|
||||||
<div
|
<div
|
||||||
onClick={() => {
|
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">
|
<div className="w-7 h-7 bg-accent text-on-accent rounded-btn flex items-center justify-center">
|
||||||
<Brain className="w-4 h-4" />
|
<Brain className="w-4 h-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="font-bold text-sm sm:text-base text-main tracking-tight leading-none">
|
<span className="font-bold text-sm sm:text-base text-main tracking-tight leading-none">
|
||||||
{authStatus.instanceName || 'MindSift'}
|
{authStatus.instanceName || 'MindSift'}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[11px] text-muted mt-0.5 hidden sm:inline-block">
|
|
||||||
Der Kopf frei in drei Sekunden
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick-Capture Input Bubble directly in Header (above Tabs) */}
|
{/* Quick-Capture Input Bubble directly in Header (above Tabs) */}
|
||||||
@@ -427,6 +570,20 @@ export const App: React.FC = () => {
|
|||||||
<span>Kontoeinstellungen</span>
|
<span>Kontoeinstellungen</span>
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={handleOpenHelp}
|
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 ${
|
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>
|
<span>Hilfe & Anleitung</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isStaff && (
|
{isServerAdmin && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
triggerHaptic(10);
|
triggerHaptic(10);
|
||||||
@@ -471,8 +628,8 @@ export const App: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation tabs attached to header */}
|
{/* Desktop 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">
|
<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">
|
<div className="flex items-stretch overflow-x-auto no-scrollbar">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -559,7 +716,7 @@ export const App: React.FC = () => {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Main Container */}
|
{/* 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')}>
|
<ViewErrorBoundary onReset={() => setActiveTab('triage')}>
|
||||||
<section>
|
<section>
|
||||||
{activeTab === 'triage' && (
|
{activeTab === 'triage' && (
|
||||||
@@ -574,6 +731,7 @@ export const App: React.FC = () => {
|
|||||||
|
|
||||||
{activeTab === 'tasks' && (
|
{activeTab === 'tasks' && (
|
||||||
<TasksView
|
<TasksView
|
||||||
|
currentUser={user}
|
||||||
onRefreshStats={refreshAll}
|
onRefreshStats={refreshAll}
|
||||||
onOpenDocument={handleOpenDocument}
|
onOpenDocument={handleOpenDocument}
|
||||||
refreshTrigger={refreshRevision}
|
refreshTrigger={refreshRevision}
|
||||||
@@ -596,6 +754,13 @@ export const App: React.FC = () => {
|
|||||||
<SharedListsView onRefreshGlobal={refreshAll} currentUser={user} />
|
<SharedListsView onRefreshGlobal={refreshAll} currentUser={user} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'groups' && (
|
||||||
|
<GroupsView
|
||||||
|
currentUser={user}
|
||||||
|
onBack={() => setActiveTab(previousTab || 'triage')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === 'user_settings' && (
|
{activeTab === 'user_settings' && (
|
||||||
<UserSettingsView
|
<UserSettingsView
|
||||||
currentUser={user}
|
currentUser={user}
|
||||||
@@ -609,7 +774,7 @@ export const App: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'admin_settings' && isStaff && (
|
{activeTab === 'admin_settings' && isServerAdmin && (
|
||||||
<AdminSettingsView
|
<AdminSettingsView
|
||||||
currentUser={user}
|
currentUser={user}
|
||||||
onBack={() => setActiveTab('triage')}
|
onBack={() => setActiveTab('triage')}
|
||||||
@@ -618,6 +783,397 @@ export const App: React.FC = () => {
|
|||||||
</section>
|
</section>
|
||||||
</ViewErrorBoundary>
|
</ViewErrorBoundary>
|
||||||
</main>
|
</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>
|
</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">
|
<div className="w-7 h-7 bg-accent text-on-accent rounded-btn flex items-center justify-center">
|
||||||
<Brain className="w-4 h-4" />
|
<Brain className="w-4 h-4" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<h1 className="text-sm font-bold text-main tracking-tight leading-none">
|
<h1 className="text-sm font-bold text-main tracking-tight leading-none">
|
||||||
{authStatus.instanceName || 'MindSift'}
|
{authStatus.instanceName || 'MindSift'}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-[11px] text-muted mt-0.5">Der Kopf frei in drei Sekunden</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Lock className="w-4 h-4 text-faint" />
|
<Lock className="w-4 h-4 text-faint" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
|
||||||
Search,
|
Search,
|
||||||
Layers,
|
Layers,
|
||||||
FileText,
|
FileText,
|
||||||
@@ -20,30 +19,19 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface HelpViewProps {
|
interface HelpViewProps {
|
||||||
onBack: () => void;
|
onBack?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type HelpCategory = 'all' | 'workspace' | 'triage' | 'tasks' | 'shortcuts';
|
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 [activeCategory, setActiveCategory] = useState<HelpCategory>('all');
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Header Bar */}
|
||||||
<div className="panel-surface p-4 flex flex-wrap items-center justify-between gap-3">
|
<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>
|
<div>
|
||||||
<h1 className="text-base sm:text-lg font-bold text-main flex items-center gap-2">
|
<h1 className="text-base sm:text-lg font-bold text-main flex items-center gap-2">
|
||||||
<FileText className="w-4 h-4 text-accent" />
|
<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
|
Alles Wichtige zur Tastaturbedienung, Outliner-Notizen, Triage und Zusammenarbeit
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div className="relative w-full sm:w-64">
|
<div className="relative w-full sm:w-64">
|
||||||
|
|||||||
@@ -5,15 +5,18 @@ import {
|
|||||||
Circle,
|
Circle,
|
||||||
Trash2,
|
Trash2,
|
||||||
Users,
|
Users,
|
||||||
Copy,
|
|
||||||
Check,
|
Check,
|
||||||
Radio,
|
Radio,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
UserPlus,
|
Share,
|
||||||
|
Share2,
|
||||||
|
User as UserIcon,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react';
|
} 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 { api, buildWebSocketURL, triggerHaptic } from '../../lib/api';
|
||||||
|
import { UserAutocomplete } from '../UserAutocomplete';
|
||||||
|
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||||
|
|
||||||
interface SharedListsViewProps {
|
interface SharedListsViewProps {
|
||||||
onRefreshGlobal: () => void;
|
onRefreshGlobal: () => void;
|
||||||
@@ -75,28 +78,33 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
const [newItemQty, setNewItemQty] = useState('');
|
const [newItemQty, setNewItemQty] = useState('');
|
||||||
|
|
||||||
const [wsConnected, setWsConnected] = useState(false);
|
const [wsConnected, setWsConnected] = useState(false);
|
||||||
const [copiedInvite, setCopiedInvite] = useState(false);
|
|
||||||
|
|
||||||
const [showNewListModal, setShowNewListModal] = useState(false);
|
const [showNewListModal, setShowNewListModal] = useState(false);
|
||||||
const [newListTitle, setNewListTitle] = useState('');
|
const [newListTitle, setNewListTitle] = useState('');
|
||||||
const [newListIcon, setNewListIcon] = useState('🛒');
|
const [newListIcon, setNewListIcon] = useState('🛒');
|
||||||
|
|
||||||
const [showJoinModal, setShowJoinModal] = useState(false);
|
const [showShareModal, setShowShareModal] = useState(false);
|
||||||
const [joinCode, setJoinCode] = useState('');
|
|
||||||
const [showNewSpaceModal, setShowNewSpaceModal] = useState(false);
|
|
||||||
const [newSpaceName, setNewSpaceName] = useState('');
|
|
||||||
|
|
||||||
const [showMembersModal, setShowMembersModal] = useState(false);
|
|
||||||
const [collaborators, setCollaborators] = useState<User[]>([]);
|
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 [inviteTarget, setInviteTarget] = useState('');
|
||||||
const [inviteRole, setInviteRole] = useState<'editor' | 'viewer'>('editor');
|
const [inviteRole, setInviteRole] = useState<'editor' | 'viewer'>('editor');
|
||||||
const [invitingLoading, setInvitingLoading] = useState(false);
|
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 wsRef = useRef<WebSocket | null>(null);
|
||||||
|
|
||||||
const loadSpaces = async () => {
|
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);
|
setSpaces(sp);
|
||||||
if (sp.length > 0) {
|
if (sp.length > 0) {
|
||||||
if (!activeSpace) {
|
if (!activeSpace) {
|
||||||
@@ -281,108 +289,105 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
onRefreshGlobal();
|
onRefreshGlobal();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateSpace = async (e: React.FormEvent) => {
|
const handleDeleteList = async (listId: string) => {
|
||||||
e.preventDefault();
|
if (!window.confirm('Diese Liste wirklich löschen?')) return;
|
||||||
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;
|
|
||||||
try {
|
try {
|
||||||
const joined = await api.joinSpace(joinCode.trim());
|
await api.deleteList(listId);
|
||||||
setJoinCode('');
|
if (activeSpace) {
|
||||||
setShowJoinModal(false);
|
await loadListsForSpace(activeSpace.id);
|
||||||
const sp = await api.getSpaces();
|
|
||||||
setSpaces(sp);
|
|
||||||
setActiveSpace(joined);
|
|
||||||
} catch (err: any) {
|
|
||||||
alert(err.message || 'Einladungscode ungültig');
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
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();
|
onRefreshGlobal();
|
||||||
} catch (err: any) {
|
} 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 {
|
} finally {
|
||||||
setInvitingLoading(false);
|
setInvitingLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveMember = async (targetUserId: string) => {
|
const handleRemoveMember = async (targetId: string) => {
|
||||||
if (!activeSpace) return;
|
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);
|
setInvitingLoading(true);
|
||||||
setInviteError(null);
|
setShareError(null);
|
||||||
try {
|
try {
|
||||||
await api.removeSpaceMember(activeSpace.id, targetUserId);
|
await api.removeSpaceMember(activeSpace.id, targetId);
|
||||||
setActiveSpace((prev) =>
|
const [sp, freshShares] = await Promise.all([
|
||||||
prev
|
api.getSpaces(),
|
||||||
? {
|
api.getSpaceShares ? api.getSpaceShares(activeSpace.id).catch(() => []) : Promise.resolve([]),
|
||||||
...prev,
|
]);
|
||||||
members: (prev.members || []).filter((m) => m.userId !== targetUserId),
|
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();
|
onRefreshGlobal();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setInviteError(err.message || 'Mitglied konnte nicht entfernt werden');
|
setShareError(err.message || 'Freigabe konnte nicht entfernt werden');
|
||||||
} finally {
|
} finally {
|
||||||
setInvitingLoading(false);
|
setInvitingLoading(false);
|
||||||
}
|
}
|
||||||
@@ -433,153 +438,117 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full space-y-3">
|
<div className="w-full space-y-3">
|
||||||
{/* Top Space Bar & Realtime Indicator */}
|
{/* Top Header: List Tabs & Actions */}
|
||||||
<div className="px-3 py-2 panel-surface flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2">
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
{/* List Tabs */}
|
||||||
{spaces.map((sp) => (
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
<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">
|
|
||||||
{lists.map((lst) => {
|
{lists.map((lst) => {
|
||||||
const openCount = lst.items.filter((i) => !i.isCompleted).length;
|
const openCount = lst.items.filter((i) => !i.isCompleted).length;
|
||||||
const isSelected = activeList?.id === lst.id;
|
const isSelected = activeList?.id === lst.id;
|
||||||
return (
|
return (
|
||||||
|
<div key={lst.id} className="relative group flex items-center">
|
||||||
<button
|
<button
|
||||||
key={lst.id}
|
|
||||||
onClick={() => setActiveListId(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
|
isSelected
|
||||||
? 'bg-accent text-on-accent border-accent'
|
? 'bg-panel border-accent text-main'
|
||||||
: 'bg-panel text-muted hover:text-main border-border'
|
: 'bg-canvas border-border text-muted hover:text-main'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span>{lst.icon}</span>
|
<span>{lst.icon}</span>
|
||||||
<span>{lst.title}</span>
|
<span>{lst.title}</span>
|
||||||
<span className={`px-1.5 py-0.2 text-[10px] font-semibold rounded-pill ${
|
{openCount > 0 && (
|
||||||
isSelected ? 'bg-white/20 text-on-accent' : 'badge-neutral'
|
<span className="ml-1 badge-neutral">{openCount}</span>
|
||||||
}`}>
|
)}
|
||||||
{openCount}
|
|
||||||
</span>
|
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={() => setShowNewListModal((prev) => !prev)}
|
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>
|
<span>Neue Liste</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 && (
|
{showNewListModal && (
|
||||||
<form
|
<form
|
||||||
onSubmit={handleCreateList}
|
onSubmit={handleCreateList}
|
||||||
@@ -770,170 +739,82 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Space & Shopping List Members Modal */}
|
{/* Unified Space & Shopping List Sharing Modal */}
|
||||||
{showMembersModal && activeSpace && (
|
{showShareModal && activeSpace && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs">
|
<UnifiedShareModal
|
||||||
<div className="panel-surface w-full max-w-lg p-5 shadow-panel rounded-card border border-border space-y-4">
|
isOpen={showShareModal}
|
||||||
{/* Modal Header */}
|
onClose={() => setShowShareModal(false)}
|
||||||
<div className="flex items-center justify-between pb-2 border-b border-border">
|
title={`Einkaufsliste teilen: ${activeSpace.name}`}
|
||||||
<div className="flex items-center gap-2">
|
resourceName={activeSpace.name}
|
||||||
<Users className="w-5 h-5 text-accent" />
|
resourceIcon={activeSpace.icon || '🛒'}
|
||||||
<h3 className="text-base font-bold text-main">
|
isOwner={activeSpace.ownerId === currentUser?.id || currentUser?.role === 'admin'}
|
||||||
Einkaufsliste teilen: {activeSpace.name}
|
currentUserId={currentUser?.id}
|
||||||
</h3>
|
shares={[
|
||||||
</div>
|
...(activeSpace.shares || [])
|
||||||
<button
|
.filter((s) => s.targetType === 'team')
|
||||||
type="button"
|
.map((s) => ({
|
||||||
onClick={() => {
|
id: s.id,
|
||||||
setShowMembersModal(false);
|
targetType: 'team' as const,
|
||||||
setInviteError(null);
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -10,16 +10,13 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
Check,
|
Check,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Share2,
|
|
||||||
Send,
|
Send,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Building2,
|
|
||||||
UserPlus,
|
UserPlus,
|
||||||
|
Share2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Organization,
|
|
||||||
SystemSettings,
|
SystemSettings,
|
||||||
Team,
|
|
||||||
User,
|
User,
|
||||||
UserInvite,
|
UserInvite,
|
||||||
UserRole,
|
UserRole,
|
||||||
@@ -31,7 +28,7 @@ interface AdminSettingsViewProps {
|
|||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdminSection = 'users' | 'orgs' | 'server' | 'smtp';
|
type AdminSection = 'users' | 'server' | 'smtp';
|
||||||
|
|
||||||
export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||||
currentUser,
|
currentUser,
|
||||||
@@ -50,22 +47,11 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
|
|
||||||
const [inviteEmail, setInviteEmail] = useState('');
|
const [inviteEmail, setInviteEmail] = useState('');
|
||||||
const [inviteRole, setInviteRole] = useState<UserRole>('member');
|
const [inviteRole, setInviteRole] = useState<UserRole>('member');
|
||||||
const [inviteTeamId, setInviteTeamId] = useState('');
|
|
||||||
const [copiedInviteId, setCopiedInviteId] = useState<string | null>(null);
|
const [copiedInviteId, setCopiedInviteId] = useState<string | null>(null);
|
||||||
|
|
||||||
const [resetPwdUserId, setResetPwdUserId] = useState<string | null>(null);
|
const [resetPwdUserId, setResetPwdUserId] = useState<string | null>(null);
|
||||||
const [resetPwdVal, setResetPwdVal] = useState('');
|
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 ---
|
// --- Server & SMTP Settings State ---
|
||||||
const [settings, setSettings] = useState<SystemSettings>({
|
const [settings, setSettings] = useState<SystemSettings>({
|
||||||
sharingMode: 'teams_only',
|
sharingMode: 'teams_only',
|
||||||
@@ -86,19 +72,12 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
|
|
||||||
const loadAll = async () => {
|
const loadAll = async () => {
|
||||||
try {
|
try {
|
||||||
const [uList, invList, orgList, teamList] = await Promise.all([
|
const [uList, invList] = await Promise.all([
|
||||||
api.adminGetUsers(),
|
api.adminGetUsers(),
|
||||||
api.adminGetInvites(),
|
api.adminGetInvites(),
|
||||||
api.getOrganizations(),
|
|
||||||
api.getUserTeams(),
|
|
||||||
]);
|
]);
|
||||||
setUsers(uList);
|
setUsers(uList);
|
||||||
setInvites(invList);
|
setInvites(invList);
|
||||||
setOrgs(orgList);
|
|
||||||
setAllTeams(teamList);
|
|
||||||
if (orgList.length > 0 && !selectedOrgId) {
|
|
||||||
setSelectedOrgId(orgList[0].id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isServerAdmin) {
|
if (isServerAdmin) {
|
||||||
const sys = await api.adminGetSettings();
|
const sys = await api.adminGetSettings();
|
||||||
@@ -157,7 +136,6 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
const inv = await api.adminCreateInvite({
|
const inv = await api.adminCreateInvite({
|
||||||
email: inviteEmail.trim(),
|
email: inviteEmail.trim(),
|
||||||
role: inviteRole,
|
role: inviteRole,
|
||||||
teamId: inviteTeamId,
|
|
||||||
});
|
});
|
||||||
setInvites((prev) => [inv, ...prev]);
|
setInvites((prev) => [inv, ...prev]);
|
||||||
setInviteEmail('');
|
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
|
// 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 (
|
return (
|
||||||
<div className="w-full space-y-4">
|
<div className="w-full space-y-4">
|
||||||
{/* Top Page Header */}
|
{/* Top Page Header */}
|
||||||
@@ -384,17 +272,15 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-base font-bold text-main tracking-tight flex items-center gap-2">
|
<h1 className="text-base font-bold text-main tracking-tight flex items-center gap-2">
|
||||||
<Shield className="w-4 h-4 text-accent" />
|
<Shield className="w-4 h-4 text-accent" />
|
||||||
<span>Administration & Team-Steuerung</span>
|
<span>Server-Administration</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-muted">
|
<p className="text-xs text-muted">
|
||||||
Benutzerverwaltung, Einladungscodes, Organisationen, Teams, Isolationsmodus & Mail-Server
|
Benutzerverwaltung, Einladungscodes, Isolationsmodus & Mail-Server
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="badge-accent">
|
<span className="badge-accent">Server-Administrator</span>
|
||||||
{isServerAdmin ? 'Server-Administrator' : 'Team-Administrator'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{statusMsg && (
|
{statusMsg && (
|
||||||
@@ -439,21 +325,6 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</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 && (
|
{isServerAdmin && (
|
||||||
<>
|
<>
|
||||||
<button
|
<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"
|
className="w-full min-w-0 h-8 px-2 input-field bg-panel"
|
||||||
>
|
>
|
||||||
<option value="member">Mitglied (Standard)</option>
|
<option value="member">Mitglied (Standard)</option>
|
||||||
<option value="team_admin">Team-Admin (Darf einladen)</option>
|
<option value="admin">Server-Admin (Vollzugriff)</option>
|
||||||
{isServerAdmin && <option value="admin">Server-Admin (Vollzugriff)</option>}
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -584,7 +454,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<form onSubmit={handleCreateInvite} className="space-y-3">
|
<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">
|
<div className="min-w-0">
|
||||||
<label className="block text-muted mb-1">
|
<label className="block text-muted mb-1">
|
||||||
Gebundene E-Mail (optional)
|
Gebundene E-Mail (optional)
|
||||||
@@ -599,33 +469,14 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-w-0">
|
<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
|
<select
|
||||||
value={inviteRole}
|
value={inviteRole}
|
||||||
onChange={(e) => setInviteRole(e.target.value as UserRole)}
|
onChange={(e) => setInviteRole(e.target.value as UserRole)}
|
||||||
className="w-full min-w-0 h-8 px-2 input-field bg-panel"
|
className="w-full min-w-0 h-8 px-2 input-field bg-panel"
|
||||||
>
|
>
|
||||||
<option value="member">Mitglied</option>
|
<option value="member">Mitglied</option>
|
||||||
<option value="team_admin">Team-Admin</option>
|
<option value="admin">Server-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>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -728,8 +579,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
title="Benutzerrolle ändern"
|
title="Benutzerrolle ändern"
|
||||||
>
|
>
|
||||||
<option value="member">Mitglied</option>
|
<option value="member">Mitglied</option>
|
||||||
<option value="team_admin">Team-Admin</option>
|
<option value="admin">Server-Admin</option>
|
||||||
{isServerAdmin && <option value="admin">Server-Admin</option>}
|
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
{isServerAdmin && (
|
{isServerAdmin && (
|
||||||
@@ -796,238 +646,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
</div>
|
</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) */}
|
{/* SECTION 3: SERVER & SHARING-ISOLATION (SERVER-ADMIN ONLY) */}
|
||||||
|
|||||||
@@ -236,8 +236,6 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
const roleBadgeLabel =
|
const roleBadgeLabel =
|
||||||
currentUser.role === 'admin'
|
currentUser.role === 'admin'
|
||||||
? 'Server-Admin'
|
? 'Server-Admin'
|
||||||
: currentUser.role === 'team_admin'
|
|
||||||
? 'Team-Admin'
|
|
||||||
: 'Mitglied';
|
: 'Mitglied';
|
||||||
|
|
||||||
return (
|
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 { motion, useMotionValue, useTransform, AnimatePresence } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
X,
|
X,
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
SkipForward,
|
SkipForward,
|
||||||
Edit3,
|
Edit3,
|
||||||
Layers,
|
Layers,
|
||||||
|
MoreHorizontal,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { SharedList, Thought } from '../../lib/types';
|
import { SharedList, Thought } from '../../lib/types';
|
||||||
import { api, triggerHaptic } from '../../lib/api';
|
import { api, triggerHaptic } from '../../lib/api';
|
||||||
@@ -29,11 +30,184 @@ interface TriageDeckProps {
|
|||||||
onFocusCapture: () => void;
|
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> = ({
|
export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||||
deck,
|
deck,
|
||||||
lists,
|
lists,
|
||||||
onDeckChange,
|
onDeckChange,
|
||||||
onOpenDocument,
|
|
||||||
onFocusCapture,
|
onFocusCapture,
|
||||||
}) => {
|
}) => {
|
||||||
const [cards, setCards] = useState<Thought[]>(deck);
|
const [cards, setCards] = useState<Thought[]>(deck);
|
||||||
@@ -45,6 +219,12 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
});
|
});
|
||||||
const [showListSelector, setShowListSelector] = useState(false);
|
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
|
// Inline edit state for cleaning up a hasty Quick Dump note before converting
|
||||||
const [isEditingCard, setIsEditingCard] = useState(false);
|
const [isEditingCard, setIsEditingCard] = useState(false);
|
||||||
const [editDraft, setEditDraft] = useState('');
|
const [editDraft, setEditDraft] = useState('');
|
||||||
@@ -61,6 +241,18 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
setEditDraft(activeCard?.content || '');
|
setEditDraft(activeCard?.content || '');
|
||||||
}, [activeCard?.id]);
|
}, [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 x = useMotionValue(0);
|
||||||
const rotate = useTransform(x, [-220, 0, 220], [-8, 0, 8]);
|
const rotate = useTransform(x, [-220, 0, 220], [-8, 0, 8]);
|
||||||
const keepOpacity = useTransform(x, [25, 110], [0, 1]);
|
const keepOpacity = useTransform(x, [25, 110], [0, 1]);
|
||||||
@@ -90,32 +282,39 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
setLastTriaged(targetCard);
|
setLastTriaged(targetCard);
|
||||||
setShowDateModal(false);
|
setShowDateModal(false);
|
||||||
setShowListSelector(false);
|
setShowListSelector(false);
|
||||||
|
setActionSheetCard(null);
|
||||||
setIsEditingCard(false);
|
setIsEditingCard(false);
|
||||||
x.set(0);
|
x.set(0);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const updated = await api.triageAction(targetCard.id, action, {
|
await api.triageAction(targetCard.id, action, {
|
||||||
convertTo: options?.convertTo,
|
convertTo: options?.convertTo,
|
||||||
targetListId: options?.targetListId,
|
targetListId: options?.targetListId,
|
||||||
dueDate: options?.dueDate,
|
dueDate: options?.dueDate,
|
||||||
});
|
});
|
||||||
onDeckChange();
|
onDeckChange();
|
||||||
if (options?.convertTo === 'document' && updated.convertedRefId) {
|
|
||||||
onOpenDocument(updated.convertedRefId);
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
onDeckChange();
|
onDeckChange();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSkipCard = () => {
|
const handleSkipCard = (targetId?: string) => {
|
||||||
if (cards.length <= 1) return;
|
if (cards.length <= 1) return;
|
||||||
triggerHaptic(10);
|
triggerHaptic(10);
|
||||||
setShowDateModal(false);
|
setShowDateModal(false);
|
||||||
setShowListSelector(false);
|
setShowListSelector(false);
|
||||||
|
setActionSheetCard(null);
|
||||||
setIsEditingCard(false);
|
setIsEditingCard(false);
|
||||||
x.set(0);
|
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]]);
|
setCards((prev) => [...prev.slice(1), prev[0]]);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBringToFront = (thoughtId: string) => {
|
const handleBringToFront = (thoughtId: string) => {
|
||||||
@@ -182,12 +381,14 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
} else if (e.key === ' ') {
|
} else if (e.key === ' ') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSkipCard();
|
handleSkipCard();
|
||||||
|
} else if (e.key === 'Escape' && actionSheetCard) {
|
||||||
|
setActionSheetCard(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('keydown', onKeyDown);
|
window.addEventListener('keydown', onKeyDown);
|
||||||
return () => window.removeEventListener('keydown', onKeyDown);
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
}, [activeCard, cards.length]);
|
}, [activeCard, cards.length, actionSheetCard]);
|
||||||
|
|
||||||
const priorityBadge = (priority: string) => {
|
const priorityBadge = (priority: string) => {
|
||||||
switch (priority) {
|
switch (priority) {
|
||||||
@@ -233,12 +434,13 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
if (!activeCard) {
|
if (!activeCard) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-xl mx-auto mt-2 p-6 panel-surface text-center">
|
<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">
|
<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-5 h-5" />
|
<Sparkles className="w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-lg font-bold text-main mb-1">Triage-Deck ist leer</h2>
|
<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">
|
<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>
|
<kbd className="px-1.5 py-0.5 bg-canvas border border-border rounded-btn text-main">C</kbd>
|
||||||
), um neue Gedanken reinzurotzen.
|
), um neue Gedanken reinzurotzen.
|
||||||
</p>
|
</p>
|
||||||
@@ -246,7 +448,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={onFocusCapture}
|
onClick={onFocusCapture}
|
||||||
className="btn-primary h-8 px-4"
|
className="btn-primary h-10 px-5 text-sm"
|
||||||
>
|
>
|
||||||
+ Quick Dump erfassen
|
+ Quick Dump erfassen
|
||||||
</button>
|
</button>
|
||||||
@@ -254,9 +456,9 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
{lastTriaged && (
|
{lastTriaged && (
|
||||||
<button
|
<button
|
||||||
onClick={handleUndo}
|
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
|
Letzte Aktion rückgängig
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -270,7 +472,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
{/* Top Deck Status & Undo Bar */}
|
{/* Top Deck Status & Undo Bar */}
|
||||||
<div className="flex items-center justify-between px-1">
|
<div className="flex items-center justify-between px-1">
|
||||||
<div className="flex items-center gap-2">
|
<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
|
{cards.length} {cards.length === 1 ? 'Quick Dump' : 'Quick Dumps'} offen
|
||||||
</span>
|
</span>
|
||||||
<span className="hidden sm:inline text-xs text-faint">•</span>
|
<span className="hidden sm:inline text-xs text-faint">•</span>
|
||||||
@@ -282,16 +484,16 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
{lastTriaged && (
|
{lastTriaged && (
|
||||||
<button
|
<button
|
||||||
onClick={handleUndo}
|
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
|
Rückgängig
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Card Stack Container */}
|
{/* 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 */}
|
{/* Background Preview Card */}
|
||||||
{nextCard && (
|
{nextCard && (
|
||||||
<div
|
<div
|
||||||
@@ -304,7 +506,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Active Draggable Card */}
|
{/* Active Draggable & Long-Pressable Card */}
|
||||||
<AnimatePresence mode="popLayout">
|
<AnimatePresence mode="popLayout">
|
||||||
<motion.div
|
<motion.div
|
||||||
key={activeCard.id}
|
key={activeCard.id}
|
||||||
@@ -313,7 +515,29 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
dragConstraints={{ left: 0, right: 0 }}
|
dragConstraints={{ left: 0, right: 0 }}
|
||||||
dragElastic={0.8}
|
dragElastic={0.8}
|
||||||
transition={{ type: 'spring', stiffness: 350, damping: 25 }}
|
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) => {
|
onDragEnd={(_, info) => {
|
||||||
|
clearCardHoldTimer();
|
||||||
if (info.offset.x > 105 || info.velocity.x > 520) {
|
if (info.offset.x > 105 || info.velocity.x > 520) {
|
||||||
handleAction('keep', { convertTo: 'task' });
|
handleAction('keep', { convertTo: 'task' });
|
||||||
} else if (info.offset.x < -105 || info.velocity.x < -520) {
|
} 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 }}
|
initial={{ scale: 0.97, opacity: 0, y: 10 }}
|
||||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||||
exit={{ scale: 0.93, opacity: 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 */}
|
{/* Dynamic Swipe Overlays */}
|
||||||
<motion.div
|
<motion.div
|
||||||
style={{ opacity: keepOpacity }}
|
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 👉
|
Als Aufgabe 👉
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
style={{ opacity: discardOpacity }}
|
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
|
👈 Wegwerfen
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -346,19 +570,10 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
{priorityBadge(activeCard.priority)}
|
{priorityBadge(activeCard.priority)}
|
||||||
{sourceBadge(activeCard.source)}
|
{sourceBadge(activeCard.source)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div
|
||||||
<button
|
className="flex items-center gap-2"
|
||||||
type="button"
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
<Edit3 className="w-3 h-3" />
|
|
||||||
<span>Bearbeiten</span>
|
|
||||||
</button>
|
|
||||||
<span className="text-xs text-muted flex items-center gap-1">
|
<span className="text-xs text-muted flex items-center gap-1">
|
||||||
<Clock className="w-3 h-3" />
|
<Clock className="w-3 h-3" />
|
||||||
{new Date(activeCard.createdAt).toLocaleString('de-DE', {
|
{new Date(activeCard.createdAt).toLocaleString('de-DE', {
|
||||||
@@ -368,46 +583,59 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
})}
|
})}
|
||||||
</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Card Body */}
|
{/* Card Body */}
|
||||||
<div className="my-auto py-2">
|
<div
|
||||||
|
className="my-auto py-2"
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
if (isEditingCard) e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
{isEditingCard ? (
|
{isEditingCard ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2.5">
|
||||||
<textarea
|
<textarea
|
||||||
value={editDraft}
|
value={editDraft}
|
||||||
onChange={(e) => setEditDraft(e.target.value)}
|
onChange={(e) => setEditDraft(e.target.value)}
|
||||||
rows={3}
|
rows={3}
|
||||||
autoFocus
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsEditingCard(false)}
|
onClick={() => setIsEditingCard(false)}
|
||||||
className="btn-action h-7 px-2.5 text-xs"
|
className="btn-action h-9 px-3.5 text-xs"
|
||||||
>
|
>
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSaveInlineEdit}
|
onClick={handleSaveInlineEdit}
|
||||||
className="btn-primary h-7 px-3 text-xs"
|
className="btn-primary h-9 px-4 text-xs"
|
||||||
>
|
>
|
||||||
Text speichern
|
Text speichern
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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}
|
{activeCard.content}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Card Footer: Tags & Hints */}
|
{/* Card Footer: Tags & Touch Gesture Hints */}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 pt-2.5 border-t border-border">
|
<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">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
{activeCard.tags && activeCard.tags.length > 0 ? (
|
{activeCard.tags && activeCard.tags.length > 0 ? (
|
||||||
activeCard.tags.map((tag) => (
|
activeCard.tags.map((tag) => (
|
||||||
@@ -417,7 +645,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-faint">
|
<span className="text-xs text-faint">
|
||||||
Links wischen = Wegwerfen • Rechts wischen = Als Aufgabe sichern
|
👈 Wegwerfen · 👉 Aufgabe · Gedrückt halten = Mehr Optionen
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -432,9 +660,9 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Convert to Task with Due Date Popover */}
|
{/* Desktop-only Popovers */}
|
||||||
{showDateModal && (
|
{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">
|
<div className="flex items-center gap-2 text-xs text-muted">
|
||||||
<Calendar className="w-4 h-4 text-accent" />
|
<Calendar className="w-4 h-4 text-accent" />
|
||||||
<span>Aufgabe mit Fälligkeitsdatum anlegen:</span>
|
<span>Aufgabe mit Fälligkeitsdatum anlegen:</span>
|
||||||
@@ -448,7 +676,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleAction('keep', { convertTo: 'task', dueDate: taskDueDate })}
|
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
|
Als Termin-Aufgabe speichern
|
||||||
</button>
|
</button>
|
||||||
@@ -456,9 +684,8 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Convert to Shared List Item Popover */}
|
|
||||||
{showListSelector && (
|
{showListSelector && (
|
||||||
<div className="p-3 panel-surface">
|
<div className="hidden sm:block p-3 panel-surface">
|
||||||
<p className="text-xs text-muted mb-2">
|
<p className="text-xs text-muted mb-2">
|
||||||
Quick Dump direkt einer Liste zuordnen:
|
Quick Dump direkt einer Liste zuordnen:
|
||||||
</p>
|
</p>
|
||||||
@@ -470,7 +697,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleAction('keep', { convertTo: 'list_item', targetListId: lst.id })
|
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.icon}</span>
|
||||||
<span>{lst.title}</span>
|
<span>{lst.title}</span>
|
||||||
@@ -483,168 +710,320 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Primary Triage Decision Bar */}
|
{/* Primary Triage Decision Bar: Hidden on mobile (handled via Swipe + Long-Press), shown on Desktop */}
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-6 gap-1.5">
|
<div className="hidden sm:flex items-center gap-1.5">
|
||||||
{/* 1. Swipe Left: Discard */}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleAction('discard')}
|
onClick={() => handleAction('discard')}
|
||||||
title="Wegwerfen (Taste H oder Pfeil links)"
|
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>
|
<span>Wegwerfen</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 2. Skip / Later */}
|
|
||||||
<button
|
<button
|
||||||
onClick={handleSkipCard}
|
onClick={() => handleSkipCard()}
|
||||||
disabled={cards.length <= 1}
|
disabled={cards.length <= 1}
|
||||||
title="Später entscheiden – ans Ende schieben (Leertaste)"
|
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' : ''
|
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>
|
<span>Später</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 3. Assign to List */}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowDateModal(false);
|
setShowDateModal(false);
|
||||||
setShowListSelector((prev) => !prev);
|
setShowListSelector((prev) => !prev);
|
||||||
}}
|
}}
|
||||||
title="Auf eine geteilte Liste setzen"
|
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' : ''
|
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>
|
<span>Auf Liste</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 4. Convert to Workspace Note */}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleAction('keep', { convertTo: 'document' })}
|
onClick={() => handleAction('keep', { convertTo: 'document' })}
|
||||||
title="In eine Workspace-Notiz umwandeln (Taste D)"
|
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>
|
<span>Als Notiz</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 5. Task with Date */}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowListSelector(false);
|
setShowListSelector(false);
|
||||||
setShowDateModal((prev) => !prev);
|
setShowDateModal((prev) => !prev);
|
||||||
}}
|
}}
|
||||||
title="Als Aufgabe mit Fälligkeitsdatum (Taste T)"
|
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' : ''
|
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>
|
<span>Mit Termin</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 6. Swipe Right: Immediate Task */}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleAction('keep', { convertTo: 'task' })}
|
onClick={() => handleAction('keep', { convertTo: 'task' })}
|
||||||
title="Sofort als Aufgabe übernehmen (Taste L oder Pfeil rechts)"
|
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" />
|
<CheckSquare className="w-3.5 h-3.5 flex-shrink-0" />
|
||||||
<span>Als Aufgabe</span>
|
<span>Aufgabe</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Waiting Quick Dump Queue List */}
|
{/* Waiting Quick Dump Queue List (Touch-sized & Swipeable Left/Right) */}
|
||||||
{cards.length > 1 && (
|
{cards.length > 1 && (
|
||||||
<div className="pt-2">
|
<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">
|
<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" />
|
<Layers className="w-3.5 h-3.5 text-accent" />
|
||||||
Warteschlange ({cards.length - 1} weitere Quick Dumps)
|
Warteschlange ({cards.length - 1})
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[11px] text-faint">
|
<span className="text-[11px] text-faint">
|
||||||
Klicken zum Vorholen oder direkt zuordnen
|
Tippen = Vorholen · 👈 Wegwerfen · 👉 Aufgabe
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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) => (
|
{cards.slice(1).map((item) => (
|
||||||
<div
|
<SwipeableQueueItem
|
||||||
key={item.id}
|
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
|
<div
|
||||||
onClick={() => handleBringToFront(item.id)}
|
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"
|
||||||
className="flex-1 min-w-0 cursor-pointer flex items-center gap-2"
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<span className="w-1.5 h-1.5 bg-accent flex-shrink-0" />
|
{/* Sheet Header with Preview */}
|
||||||
<span className="text-xs text-muted truncate group-hover:text-main">
|
<div className="flex items-start justify-between gap-3 pb-2.5 border-b border-border">
|
||||||
{item.content}
|
<div className="min-w-0">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-accent">
|
||||||
|
Quick Dump zuordnen
|
||||||
</span>
|
</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>
|
||||||
|
|
||||||
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() => setSheetMode('list')}
|
||||||
handleAction('keep', { convertTo: 'task', thoughtOverride: item })
|
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"
|
||||||
}
|
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
<Check className="w-3 h-3" />
|
<ShoppingCart className="w-5 h-5 text-accent flex-shrink-0" />
|
||||||
<span className="hidden sm:inline">Aufgabe</span>
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-main">Auf Liste</div>
|
||||||
|
<div className="text-[11px] text-muted">Einkauf / Geteilt</div>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() => setSheetMode('date')}
|
||||||
handleAction('keep', { convertTo: 'document', thoughtOverride: item })
|
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"
|
||||||
}
|
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
<FileText className="w-3 h-3 text-accent" />
|
<Calendar className="w-5 h-5 text-accent flex-shrink-0" />
|
||||||
<span className="hidden sm:inline">Notiz</span>
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-main">Mit Termin</div>
|
||||||
|
<div className="text-[11px] text-muted">Datum wählen</div>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{lists.length > 0 && (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleAction('keep', {
|
handleAction('keep', {
|
||||||
convertTo: 'list_item',
|
convertTo: 'document',
|
||||||
targetListId: lists[0].id,
|
thoughtOverride: actionSheetCard,
|
||||||
thoughtOverride: item,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
title={`Direkt auf Liste "${lists[0].title}" setzen`}
|
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"
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
<ShoppingCart className="w-3 h-3 text-accent" />
|
<FileText className="w-5 h-5 text-accent flex-shrink-0" />
|
||||||
<span className="hidden md:inline">{lists[0].title}</span>
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-main">Als Notiz</div>
|
||||||
|
<div className="text-[11px] text-muted">Im Workspace</div>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleAction('discard', { thoughtOverride: item })}
|
disabled={cards.length <= 1}
|
||||||
title="Wegwerfen"
|
onClick={() => handleSkipCard(actionSheetCard.id)}
|
||||||
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</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);
|
border-left: 2px solid var(--accent-primary);
|
||||||
color: var(--text-main);
|
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,
|
DocumentBlock,
|
||||||
DocShare,
|
DocShare,
|
||||||
DocLockInfo,
|
DocLockInfo,
|
||||||
|
KanbanColumn,
|
||||||
ListItem,
|
ListItem,
|
||||||
Organization,
|
Organization,
|
||||||
PriorityLevel,
|
PriorityLevel,
|
||||||
SharedList,
|
SharedList,
|
||||||
Space,
|
Space,
|
||||||
SpaceMember,
|
SpaceMember,
|
||||||
|
SpaceShare,
|
||||||
SystemSettings,
|
SystemSettings,
|
||||||
Team,
|
Team,
|
||||||
TaskList,
|
TaskList,
|
||||||
|
TaskListShare,
|
||||||
Thought,
|
Thought,
|
||||||
SubTask,
|
SubTask,
|
||||||
TagDetails,
|
TagDetails,
|
||||||
@@ -312,11 +315,12 @@ export const api = {
|
|||||||
content?: string;
|
content?: string;
|
||||||
isCompleted?: boolean;
|
isCompleted?: boolean;
|
||||||
dueDate?: string;
|
dueDate?: string;
|
||||||
priority?: PriorityLevel;
|
priority?: string;
|
||||||
taskListId?: string;
|
taskListId?: string;
|
||||||
kanbanStatus?: 'todo' | 'in_progress' | 'done';
|
kanbanStatus?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
subtasks?: SubTask[];
|
subtasks?: SubTask[];
|
||||||
|
tags?: string[];
|
||||||
}
|
}
|
||||||
): Promise<Thought> {
|
): Promise<Thought> {
|
||||||
return request<Thought>(`/api/v1/thoughts/${thoughtId}`, {
|
return request<Thought>(`/api/v1/thoughts/${thoughtId}`, {
|
||||||
@@ -336,14 +340,21 @@ export const api = {
|
|||||||
// --- TASK LISTS ---
|
// --- TASK LISTS ---
|
||||||
async getTaskLists(): Promise<TaskList[]> {
|
async getTaskLists(): Promise<TaskList[]> {
|
||||||
const res = await request<TaskList[]>('/api/v1/task-lists');
|
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: {
|
async createTaskList(payload: {
|
||||||
title: string;
|
title: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
|
teamId?: string;
|
||||||
spaceId?: string;
|
spaceId?: string;
|
||||||
|
columns?: KanbanColumn[];
|
||||||
}): Promise<TaskList> {
|
}): Promise<TaskList> {
|
||||||
return request<TaskList>('/api/v1/task-lists', {
|
return request<TaskList>('/api/v1/task-lists', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -357,7 +368,9 @@ export const api = {
|
|||||||
title?: string;
|
title?: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
|
teamId?: string;
|
||||||
spaceId?: string;
|
spaceId?: string;
|
||||||
|
columns?: KanbanColumn[];
|
||||||
}
|
}
|
||||||
): Promise<TaskList> {
|
): Promise<TaskList> {
|
||||||
return request<TaskList>(`/api/v1/task-lists/${id}`, {
|
return request<TaskList>(`/api/v1/task-lists/${id}`, {
|
||||||
@@ -370,6 +383,25 @@ export const api = {
|
|||||||
await request(`/api/v1/task-lists/${id}`, { method: 'DELETE' });
|
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 ---
|
// --- WORKSPACE FOLDERS ---
|
||||||
async getFolders(): Promise<string[]> {
|
async getFolders(): Promise<string[]> {
|
||||||
const res = await request<string[]>('/api/v1/folders');
|
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 ---
|
// --- WORKSPACE DOCUMENTS ---
|
||||||
async getDocuments(): Promise<Document[]> {
|
async getDocuments(): Promise<Document[]> {
|
||||||
const res = await request<Document[]>('/api/v1/documents');
|
const res = await request<Document[]>('/api/v1/documents');
|
||||||
@@ -483,7 +529,7 @@ export const api = {
|
|||||||
|
|
||||||
async createDocShare(
|
async createDocShare(
|
||||||
docId: string,
|
docId: string,
|
||||||
payload: { userId?: string; email?: string; permission: 'read' | 'write' }
|
payload: { userId?: string; email?: string; teamId?: string; permission: 'read' | 'write' }
|
||||||
): Promise<DocShare> {
|
): Promise<DocShare> {
|
||||||
return request<DocShare>(`/api/v1/documents/${docId}/shares`, {
|
return request<DocShare>(`/api/v1/documents/${docId}/shares`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -544,9 +590,15 @@ export const api = {
|
|||||||
return ensureArray(res).map((s) => ({
|
return ensureArray(res).map((s) => ({
|
||||||
...s,
|
...s,
|
||||||
members: ensureArray(s.members),
|
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> {
|
async createSpace(name: string, icon: string, description: string): Promise<Space> {
|
||||||
return request<Space>('/api/v1/spaces', {
|
return request<Space>('/api/v1/spaces', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -561,8 +613,8 @@ export const api = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async addSpaceMember(spaceId: string, target: { userId?: string; email?: string; role?: string }): Promise<SpaceMember> {
|
async addSpaceMember(spaceId: string, target: { userId?: string; email?: string; teamId?: string; role?: string }): Promise<any> {
|
||||||
return request<SpaceMember>(`/api/v1/spaces/${spaceId}/members`, {
|
return request<any>(`/api/v1/spaces/${spaceId}/members`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(target),
|
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> {
|
async deleteTeam(teamId: string): Promise<void> {
|
||||||
await request(`/api/v1/teams/${teamId}`, { method: 'DELETE' });
|
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> {
|
async removeTeamMember(teamId: string, userId: string): Promise<void> {
|
||||||
await request(`/api/v1/teams/${teamId}/members/${userId}`, { method: 'DELETE' });
|
await request(`/api/v1/teams/${teamId}/members/${userId}`, { method: 'DELETE' });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export type ThoughtStatus = 'inbox' | 'kept' | 'discarded' | 'converted';
|
export type ThoughtStatus = 'inbox' | 'kept' | 'discarded' | 'converted';
|
||||||
export type PriorityLevel = 'low' | 'normal' | 'high' | 'urgent';
|
export type PriorityLevel = 'low' | 'normal' | 'high' | 'urgent';
|
||||||
export type UserRole = 'admin' | 'team_admin' | 'member';
|
export type UserRole = 'admin' | 'member';
|
||||||
|
|
||||||
export interface AuthStatus {
|
export interface AuthStatus {
|
||||||
needsSetup: boolean;
|
needsSetup: boolean;
|
||||||
@@ -56,7 +56,7 @@ export interface Thought {
|
|||||||
convertedRefId?: string;
|
convertedRefId?: string;
|
||||||
dueDate?: string;
|
dueDate?: string;
|
||||||
taskListId?: string;
|
taskListId?: string;
|
||||||
kanbanStatus?: 'todo' | 'in_progress' | 'done';
|
kanbanStatus?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
subtasks?: SubTask[];
|
subtasks?: SubTask[];
|
||||||
alarmSent?: boolean;
|
alarmSent?: boolean;
|
||||||
@@ -64,13 +64,43 @@ export interface Thought {
|
|||||||
triagedAt?: string;
|
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 {
|
export interface TaskList {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
spaceId?: string;
|
spaceId?: string;
|
||||||
spaceName?: string;
|
spaceName?: string;
|
||||||
|
teamId?: string;
|
||||||
|
teamName?: string;
|
||||||
|
ownerId?: string;
|
||||||
ownerName?: string;
|
ownerName?: string;
|
||||||
members?: SpaceMember[];
|
isOwner?: boolean;
|
||||||
|
members?: TaskListMember[];
|
||||||
|
shares?: TaskListShare[];
|
||||||
|
columns?: KanbanColumn[];
|
||||||
title: string;
|
title: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
color: string;
|
color: string;
|
||||||
@@ -89,7 +119,7 @@ export interface Organization {
|
|||||||
|
|
||||||
export interface Team {
|
export interface Team {
|
||||||
id: string;
|
id: string;
|
||||||
orgId: string;
|
orgId?: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
members?: TeamMember[];
|
members?: TeamMember[];
|
||||||
@@ -101,7 +131,7 @@ export interface TeamMember {
|
|||||||
userId: string;
|
userId: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
email: string;
|
email: string;
|
||||||
role: 'owner' | 'team_admin' | 'editor' | 'member' | 'viewer';
|
role: 'admin' | 'member';
|
||||||
joinedAt: string;
|
joinedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +163,11 @@ export interface DocumentBlock {
|
|||||||
export interface DocShare {
|
export interface DocShare {
|
||||||
id: string;
|
id: string;
|
||||||
docId: string;
|
docId: string;
|
||||||
userId: string;
|
targetType?: 'user' | 'team';
|
||||||
|
targetId?: string;
|
||||||
|
targetName?: string;
|
||||||
|
teamId?: string;
|
||||||
|
userId?: string;
|
||||||
userEmail?: string;
|
userEmail?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
permission: 'read' | 'write';
|
permission: 'read' | 'write';
|
||||||
@@ -194,6 +228,8 @@ export interface TagDocumentGroup {
|
|||||||
|
|
||||||
export interface TagDetails {
|
export interface TagDetails {
|
||||||
tag: string;
|
tag: string;
|
||||||
|
noteContent?: string;
|
||||||
|
noteUpdatedAt?: string;
|
||||||
documents: TagDocumentGroup[];
|
documents: TagDocumentGroup[];
|
||||||
totalMatches: number;
|
totalMatches: number;
|
||||||
}
|
}
|
||||||
@@ -207,6 +243,29 @@ export interface SpaceMember {
|
|||||||
joinedAt: string;
|
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 {
|
export interface Space {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -215,7 +274,9 @@ export interface Space {
|
|||||||
inviteCode: string;
|
inviteCode: string;
|
||||||
isPersonal: boolean;
|
isPersonal: boolean;
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
|
ownerName?: string;
|
||||||
members?: SpaceMember[];
|
members?: SpaceMember[];
|
||||||
|
shares?: SpaceShare[];
|
||||||
listsCount: number;
|
listsCount: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
+293
-17
@@ -89,11 +89,14 @@ func (s *Server) Router() http.Handler {
|
|||||||
r.Patch("/api/v1/triage/{id}", s.handleTriageAction)
|
r.Patch("/api/v1/triage/{id}", s.handleTriageAction)
|
||||||
r.Get("/api/v1/tasks", s.handleGetTasks)
|
r.Get("/api/v1/tasks", s.handleGetTasks)
|
||||||
|
|
||||||
// Task Lists
|
// Task Lists & Shares
|
||||||
r.Get("/api/v1/task-lists", s.handleGetTaskLists)
|
r.Get("/api/v1/task-lists", s.handleGetTaskLists)
|
||||||
r.Post("/api/v1/task-lists", s.handleCreateTaskList)
|
r.Post("/api/v1/task-lists", s.handleCreateTaskList)
|
||||||
r.Put("/api/v1/task-lists/{id}", s.handleUpdateTaskList)
|
r.Put("/api/v1/task-lists/{id}", s.handleUpdateTaskList)
|
||||||
r.Delete("/api/v1/task-lists/{id}", s.handleDeleteTaskList)
|
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
|
// Workspace Documents & Folders
|
||||||
r.Get("/api/v1/folders", s.handleGetFolders)
|
r.Get("/api/v1/folders", s.handleGetFolders)
|
||||||
@@ -103,6 +106,7 @@ func (s *Server) Router() http.Handler {
|
|||||||
// Workspace Tags & Pages
|
// Workspace Tags & Pages
|
||||||
r.Get("/api/v1/workspace/tags", s.handleGetWorkspaceTags)
|
r.Get("/api/v1/workspace/tags", s.handleGetWorkspaceTags)
|
||||||
r.Get("/api/v1/workspace/tags/{tag}", s.handleGetTagDetails)
|
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.Get("/api/v1/documents", s.handleGetDocuments)
|
||||||
r.Post("/api/v1/documents", s.handleCreateDocument)
|
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.Get("/api/v1/spaces", s.handleGetSpaces)
|
||||||
r.Post("/api/v1/spaces", s.handleCreateSpace)
|
r.Post("/api/v1/spaces", s.handleCreateSpace)
|
||||||
r.Post("/api/v1/spaces/join", s.handleJoinSpace)
|
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.Post("/api/v1/spaces/{id}/members", s.handleAddSpaceMember)
|
||||||
r.Delete("/api/v1/spaces/{id}/members/{userId}", s.handleRemoveSpaceMember)
|
r.Delete("/api/v1/spaces/{id}/members/{userId}", s.handleRemoveSpaceMember)
|
||||||
r.Get("/api/v1/spaces/{id}/lists", s.handleGetSpaceLists)
|
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.Get("/api/v1/organizations/{id}/teams", s.handleGetOrgTeams)
|
||||||
r.Post("/api/v1/organizations/{id}/teams", s.handleCreateOrgTeam)
|
r.Post("/api/v1/organizations/{id}/teams", s.handleCreateOrgTeam)
|
||||||
r.Get("/api/v1/teams", s.handleGetUserTeams)
|
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.Delete("/api/v1/teams/{id}", s.handleDeleteTeam)
|
||||||
r.Post("/api/v1/teams/{id}/members", s.handleAddTeamMember)
|
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)
|
r.Delete("/api/v1/teams/{id}/members/{userId}", s.handleRemoveTeamMember)
|
||||||
|
|
||||||
// API Keys Management
|
// API Keys Management
|
||||||
@@ -262,8 +270,8 @@ func (s *Server) staffMiddleware(next http.Handler) http.Handler {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
user, err := s.store.GetUserByID(userID)
|
user, err := s.store.GetUserByID(userID)
|
||||||
if err != nil || user == nil || (user.Role != "admin" && user.Role != "team_admin") {
|
if err != nil || user == nil || user.Role != "admin" {
|
||||||
writeError(w, http.StatusForbidden, "Admin- oder Team-Admin-Berechtigung erforderlich")
|
writeError(w, http.StatusForbidden, "Admin-Berechtigung erforderlich")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
@@ -686,18 +694,19 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
|
|||||||
KanbanStatus *string `json:"kanbanStatus"`
|
KanbanStatus *string `json:"kanbanStatus"`
|
||||||
Notes *string `json:"notes"`
|
Notes *string `json:"notes"`
|
||||||
Subtasks *[]domain.SubTask `json:"subtasks"`
|
Subtasks *[]domain.SubTask `json:"subtasks"`
|
||||||
|
Tags *[]string `json:"tags"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.wsHub.BroadcastTaskEvent(updated.UserID, "TASK_UPDATED", updated)
|
s.wsHub.BroadcastToAll("TASK_UPDATED", updated)
|
||||||
writeJSON(w, http.StatusOK, 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())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
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})
|
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)
|
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 ---
|
// --- DOCUMENT SHARING & COLLABORATION ---
|
||||||
|
|
||||||
func (s *Server) handleGetDocShares(w http.ResponseWriter, r *http.Request) {
|
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 {
|
var req struct {
|
||||||
UserID string `json:"userId"`
|
UserID string `json:"userId"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
|
TeamID string `json:"teamId"`
|
||||||
Permission string `json:"permission"` // read | write
|
Permission string `json:"permission"` // read | write
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||||
return
|
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
|
target := req.UserID
|
||||||
if target == "" {
|
if target == "" {
|
||||||
target = req.Email
|
target = req.Email
|
||||||
}
|
}
|
||||||
if target == "" {
|
if target == "" {
|
||||||
writeError(w, http.StatusBadRequest, "Benutzer-ID oder E-Mail erforderlich")
|
writeError(w, http.StatusBadRequest, "Benutzer-ID, E-Mail oder Team erforderlich")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1084,18 +1132,39 @@ func (s *Server) handleAddSpaceMember(w http.ResponseWriter, r *http.Request) {
|
|||||||
var req struct {
|
var req struct {
|
||||||
UserID string `json:"userId"`
|
UserID string `json:"userId"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
|
TeamID string `json:"teamId"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||||
return
|
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
|
target := req.UserID
|
||||||
if target == "" {
|
if target == "" {
|
||||||
target = req.Email
|
target = req.Email
|
||||||
}
|
}
|
||||||
if target == "" {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1111,18 +1180,31 @@ func (s *Server) handleAddSpaceMember(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusCreated, member)
|
writeJSON(w, http.StatusCreated, member)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGetSpaceShares(w http.ResponseWriter, r *http.Request) {
|
||||||
|
spaceID := chi.URLParam(r, "id")
|
||||||
|
shares, err := s.store.GetSpaceShares(spaceID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, shares)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleRemoveSpaceMember(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleRemoveSpaceMember(w http.ResponseWriter, r *http.Request) {
|
||||||
currentUserID := auth.GetUserID(r.Context())
|
currentUserID := auth.GetUserID(r.Context())
|
||||||
spaceID := chi.URLParam(r, "id")
|
spaceID := chi.URLParam(r, "id")
|
||||||
targetUserID := chi.URLParam(r, "userId")
|
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())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s.wsHub.BroadcastToUser(targetUserID, "SPACE_LEFT", map[string]interface{}{"spaceId": spaceID})
|
s.wsHub.BroadcastToUser(cleanTargetID, "SPACE_LEFT", map[string]interface{}{"spaceId": spaceID})
|
||||||
s.wsHub.Broadcast(spaceID, "", "MEMBER_REMOVED", map[string]interface{}{"spaceId": spaceID, "userId": targetUserID})
|
s.wsHub.Broadcast(spaceID, "", "MEMBER_REMOVED", map[string]interface{}{"spaceId": spaceID, "userId": cleanTargetID})
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
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())
|
userID := auth.GetUserID(r.Context())
|
||||||
var req struct {
|
var req struct {
|
||||||
SpaceID string `json:"spaceId"`
|
SpaceID string `json:"spaceId"`
|
||||||
|
TeamID string `json:"teamId"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Icon string `json:"icon"`
|
Icon string `json:"icon"`
|
||||||
Color string `json:"color"`
|
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")
|
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
@@ -1588,17 +1671,20 @@ func (s *Server) handleUpdateTaskList(w http.ResponseWriter, r *http.Request) {
|
|||||||
Title *string `json:"title"`
|
Title *string `json:"title"`
|
||||||
Icon *string `json:"icon"`
|
Icon *string `json:"icon"`
|
||||||
Color *string `json:"color"`
|
Color *string `json:"color"`
|
||||||
|
TeamID *string `json:"teamId"`
|
||||||
SpaceID *string `json:"spaceId"`
|
SpaceID *string `json:"spaceId"`
|
||||||
|
Columns *[]domain.KanbanColumn `json:"columns"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.wsHub.BroadcastToAll("TASK_LIST_UPDATED", tl)
|
||||||
writeJSON(w, http.StatusOK, 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())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
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})
|
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1677,13 +1831,88 @@ func (s *Server) handleCreateOrgTeam(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Automatically add creator to team
|
// 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)
|
team.Members, _ = s.store.GetTeamMembers(team.ID)
|
||||||
writeJSON(w, http.StatusCreated, team)
|
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")
|
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 {
|
if err := s.store.DeleteTeam(teamID); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
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) {
|
func (s *Server) handleAddTeamMember(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := auth.GetUserID(r.Context())
|
||||||
teamID := chi.URLParam(r, "id")
|
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 {
|
var req struct {
|
||||||
UserID string `json:"userId"`
|
UserID string `json:"userId"`
|
||||||
Email string `json:"email"`
|
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")
|
writeError(w, http.StatusBadRequest, "Benutzer-ID oder E-Mail erforderlich")
|
||||||
return
|
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())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
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) {
|
func (s *Server) handleRemoveTeamMember(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := auth.GetUserID(r.Context())
|
||||||
teamID := chi.URLParam(r, "id")
|
teamID := chi.URLParam(r, "id")
|
||||||
memberUserID := chi.URLParam(r, "userId")
|
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 {
|
if err := s.store.RemoveTeamMember(teamID, memberUserID); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]bool{"removed": true})
|
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
|
// 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 = httptest.NewRequest(http.MethodPost, "/api/v1/admin/invites", bytes.NewReader(invBody))
|
||||||
req.Header.Set("Authorization", "Bearer "+setupRes.Token)
|
req.Header.Set("Authorization", "Bearer "+setupRes.Token)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
@@ -163,7 +163,7 @@ func TestFirstBootSetupAndInviteRegistrationAndNonNullSlices(t *testing.T) {
|
|||||||
regWithInviteBody, _ := json.Marshal(map[string]string{
|
regWithInviteBody, _ := json.Marshal(map[string]string{
|
||||||
"email": "invited@example.com",
|
"email": "invited@example.com",
|
||||||
"password": "password123",
|
"password": "password123",
|
||||||
"displayName": "Invited Team Admin",
|
"displayName": "Invited Admin",
|
||||||
"inviteCode": createdInvite.Code,
|
"inviteCode": createdInvite.Code,
|
||||||
})
|
})
|
||||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(regWithInviteBody))
|
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"`
|
User domain.User `json:"user"`
|
||||||
}
|
}
|
||||||
_ = json.Unmarshal(rec.Body.Bytes(), &invitedRes)
|
_ = json.Unmarshal(rec.Body.Bytes(), &invitedRes)
|
||||||
if invitedRes.User.Role != "team_admin" {
|
if invitedRes.User.Role != "admin" {
|
||||||
t.Fatalf("expected invited user to receive role 'team_admin', got %q", invitedRes.User.Role)
|
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 {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("expected 200 OK for remove member, got %d: %s", rec.Code, rec.Body.String())
|
t.Fatalf("expected 200 OK for remove 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) {
|
func TestWorkspaceTagsAPI(t *testing.T) {
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ type User struct {
|
|||||||
Username string `json:"username,omitempty"`
|
Username string `json:"username,omitempty"`
|
||||||
PasswordHash string `json:"-"`
|
PasswordHash string `json:"-"`
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
Role string `json:"role"` // admin | team_admin | member
|
Role string `json:"role"` // admin | member
|
||||||
IsActive bool `json:"isActive"`
|
IsActive bool `json:"isActive"`
|
||||||
AlarmChannels []string `json:"alarmChannels"` // email | discord | webhook
|
AlarmChannels []string `json:"alarmChannels"` // email | discord | webhook
|
||||||
AlarmEmail string `json:"alarmEmail"`
|
AlarmEmail string `json:"alarmEmail"`
|
||||||
@@ -112,7 +112,7 @@ type UserInvite struct {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
Email string `json:"email,omitempty"`
|
Email string `json:"email,omitempty"`
|
||||||
Role string `json:"role"` // member | team_admin | admin
|
Role string `json:"role"` // member | admin
|
||||||
TeamID string `json:"teamId,omitempty"`
|
TeamID string `json:"teamId,omitempty"`
|
||||||
TeamName string `json:"teamName,omitempty"`
|
TeamName string `json:"teamName,omitempty"`
|
||||||
CreatedBy string `json:"createdBy"`
|
CreatedBy string `json:"createdBy"`
|
||||||
@@ -158,13 +158,42 @@ type Thought struct {
|
|||||||
TriagedAt string `json:"triagedAt,omitempty"`
|
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 {
|
type TaskList struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
UserID string `json:"userId"`
|
UserID string `json:"userId"`
|
||||||
SpaceID string `json:"spaceId,omitempty"`
|
SpaceID string `json:"spaceId,omitempty"`
|
||||||
SpaceName string `json:"spaceName,omitempty"`
|
SpaceName string `json:"spaceName,omitempty"`
|
||||||
|
TeamID string `json:"teamId,omitempty"`
|
||||||
|
TeamName string `json:"teamName,omitempty"`
|
||||||
OwnerName string `json:"ownerName,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"`
|
Title string `json:"title"`
|
||||||
Icon string `json:"icon"`
|
Icon string `json:"icon"`
|
||||||
Color string `json:"color"`
|
Color string `json:"color"`
|
||||||
@@ -183,7 +212,7 @@ type Organization struct {
|
|||||||
|
|
||||||
type Team struct {
|
type Team struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
OrgID string `json:"orgId"`
|
OrgID string `json:"orgId,omitempty"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Members []TeamMember `json:"members"`
|
Members []TeamMember `json:"members"`
|
||||||
@@ -195,7 +224,7 @@ type TeamMember struct {
|
|||||||
UserID string `json:"userId"`
|
UserID string `json:"userId"`
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Role string `json:"role"` // team_admin | member | owner
|
Role string `json:"role"` // admin | member
|
||||||
JoinedAt string `json:"joinedAt"`
|
JoinedAt string `json:"joinedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,7 +254,10 @@ type DocumentBlock struct {
|
|||||||
type DocShare struct {
|
type DocShare struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
DocID string `json:"docId"`
|
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"`
|
UserEmail string `json:"userEmail,omitempty"`
|
||||||
DisplayName string `json:"displayName,omitempty"`
|
DisplayName string `json:"displayName,omitempty"`
|
||||||
Permission string `json:"permission"` // read | write
|
Permission string `json:"permission"` // read | write
|
||||||
@@ -286,6 +318,8 @@ type TagDocumentGroup struct {
|
|||||||
|
|
||||||
type TagDetails struct {
|
type TagDetails struct {
|
||||||
Tag string `json:"tag"`
|
Tag string `json:"tag"`
|
||||||
|
NoteContent string `json:"noteContent"`
|
||||||
|
NoteUpdatedAt string `json:"noteUpdatedAt,omitempty"`
|
||||||
Documents []TagDocumentGroup `json:"documents"`
|
Documents []TagDocumentGroup `json:"documents"`
|
||||||
TotalMatches int `json:"totalMatches"`
|
TotalMatches int `json:"totalMatches"`
|
||||||
}
|
}
|
||||||
@@ -299,6 +333,17 @@ type SpaceMember struct {
|
|||||||
JoinedAt string `json:"joinedAt"`
|
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 {
|
type Space struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -308,6 +353,7 @@ type Space struct {
|
|||||||
IsPersonal bool `json:"isPersonal"`
|
IsPersonal bool `json:"isPersonal"`
|
||||||
OwnerID string `json:"ownerId"`
|
OwnerID string `json:"ownerId"`
|
||||||
Members []SpaceMember `json:"members"`
|
Members []SpaceMember `json:"members"`
|
||||||
|
Shares []SpaceShare `json:"shares,omitempty"`
|
||||||
ListsCount int `json:"listsCount"`
|
ListsCount int `json:"listsCount"`
|
||||||
CreatedAt string `json:"createdAt"`
|
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
|
// 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 == "" {
|
if err != nil || taskList.ID == "" {
|
||||||
t.Fatalf("CreateTaskList failed: %v", err)
|
t.Fatalf("CreateTaskList failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -122,6 +122,20 @@ func TestStoreLifecycle(t *testing.T) {
|
|||||||
t.Fatalf("AddTeamMember failed: %v", err)
|
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
|
// 7. Test System Settings
|
||||||
sysSettings, err := store.GetSystemSettings()
|
sysSettings, err := store.GetSystemSettings()
|
||||||
if err != nil || sysSettings.InstanceName == "" {
|
if err != nil || sysSettings.InstanceName == "" {
|
||||||
|
|||||||
Reference in new issue
Block a user