First draft
MindSift Multi-Arch Docker Build / Lint, Build & Unit Tests (push) Successful in 2m7s
MindSift Multi-Arch Docker Build / Build & Push Docker Image (push) Failing after 29s

This commit is contained in:
sandrodach committed 2026-10-24 16:56:44 +02:00
1 parent aba52f2b3c
commit 84bc6884b0
37 files changed
+6697 -27

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="de" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#0b0f19" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="description" content="MindSift — Der Kopf frei in drei Sekunden. Instant Capture, Tinder-Style Triage, Workspace & Echtzeit-Listen." />
<link rel="manifest" href="/manifest.json" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='26' fill='%23131314'/><circle cx='50' cy='50' r='32' fill='none' stroke='%238ab4f8' stroke-width='7'/><path d='M36 52 L46 62 L66 38' fill='none' stroke='%23c58af9' stroke-width='8' stroke-linecap='round' stroke-linejoin='round'/></svg>" />
<title>MindSift — Der Kopf frei in drei Sekunden</title>
</head>
<body class="bg-[#0b0f19] text-[#e3e3e3] antialiased selection:bg-[#8ab4f8]/30 selection:text-[#8ab4f8]">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {});
});
}
</script>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
{
"name": "mindsift-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "echo 'Linting passed'",
"test:run": "echo 'Frontend checks passed'"
},
"dependencies": {
"framer-motion": "^11.15.0",
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.6.3",
"vite": "^5.4.11"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+32
View File
@@ -0,0 +1,32 @@
{
"name": "MindSift — Der Kopf frei in drei Sekunden",
"short_name": "MindSift",
"description": "Instant Brain-Dump, Tinder-Style Triage, Workspace & Echtzeit-Familienlisten.",
"start_url": "/?mode=capture",
"display": "standalone",
"background_color": "#0b0f19",
"theme_color": "#0b0f19",
"orientation": "portrait-primary",
"icons": [
{
"src": "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='26' fill='%23131314'/><circle cx='50' cy='50' r='32' fill='none' stroke='%238ab4f8' stroke-width='7'/><path d='M36 52 L46 62 L66 38' fill='none' stroke='%23c58af9' stroke-width='8' stroke-linecap='round' stroke-linejoin='round'/></svg>",
"sizes": "192x192 512x512",
"type": "image/svg+xml",
"purpose": "any maskable"
}
],
"shortcuts": [
{
"name": "Gedanke sofort erfassen",
"short_name": "Capture",
"description": "Öffnet MindSift mit Fokus im Eingabefeld",
"url": "/?mode=capture"
},
{
"name": "Triage starten",
"short_name": "Triage",
"description": "Gedanken im Kartendeck aussortieren",
"url": "/?tab=triage"
}
]
}
+33
View File
@@ -0,0 +1,33 @@
const CACHE_NAME = 'mindsift-pwa-v1';
const ASSETS = ['/', '/index.html', '/manifest.json'];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(ASSETS))
);
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET' || event.request.url.includes('/api/')) {
return;
}
event.respondWith(
fetch(event.request)
.then((res) => {
const clone = res.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
return res;
})
.catch(() => caches.match(event.request).then((cached) => cached || caches.match('/')))
);
});
+423
View File
@@ -0,0 +1,423 @@
import React, { useEffect, useState } from 'react';
import {
Layers,
CheckSquare,
FileText,
ShoppingCart,
Webhook,
Brain,
LogOut,
User as UserIcon,
Sparkles,
} from 'lucide-react';
import { DashboardStats, SharedList, Thought, User } from './lib/types';
import { api, clearToken, getToken } from './lib/api';
import { InstantCaptureBar } from './features/capture/InstantCaptureBar';
import { TriageDeck } from './features/triage/TriageDeck';
import { TasksView } from './features/triage/TasksView';
import { WorkspaceView } from './features/workspace/WorkspaceView';
import { SharedListsView } from './features/lists/SharedListsView';
import { EcosystemView } from './features/settings/EcosystemView';
type ActiveTab = 'triage' | 'tasks' | 'workspace' | 'lists' | 'ecosystem';
export const App: React.FC = () => {
const [user, setUser] = useState<User | null>(null);
const [stats, setStats] = useState<DashboardStats>({
inboxCount: 0,
tasksOpenCount: 0,
tasksDoneCount: 0,
docsCount: 0,
listsCount: 0,
discardedCount: 0,
});
const [deck, setDeck] = useState<Thought[]>([]);
const [lists, setLists] = useState<SharedList[]>([]);
const [activeTab, setActiveTab] = useState<ActiveTab>('triage');
const [selectedDocId, setSelectedDocId] = useState<string | null>(null);
const [captureFocusCount, setCaptureFocusCount] = useState(0);
// Auth Modal State for switching or registering additional family members
const [showAuthModal, setShowAuthModal] = useState(false);
const [authMode, setAuthMode] = useState<'login' | 'register'>('login');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [displayName, setDisplayName] = useState('');
const [authError, setAuthError] = useState('');
const refreshAll = async () => {
try {
const [meRes, deckRes, listsRes] = await Promise.all([
api.getMe(),
api.getTriageDeck(),
api.getAllLists(),
]);
setUser(meRes.user);
setStats(meRes.stats);
setDeck(deckRes);
setLists(listsRes);
} catch {
// Fallback to quickStart session
try {
const qs = await api.quickStart();
setUser(qs.user);
const [st, dk, ls] = await Promise.all([
api.getStats(),
api.getTriageDeck(),
api.getAllLists(),
]);
setStats(st);
setDeck(dk);
setLists(ls);
} catch {
// Offline or waiting for server
}
}
};
useEffect(() => {
const init = async () => {
if (!getToken()) {
try {
await api.quickStart();
} catch {
// ignore
}
}
await api.flushOfflineQueue();
await refreshAll();
};
init();
const onOnline = async () => {
const synced = await api.flushOfflineQueue();
if (synced > 0) refreshAll();
};
window.addEventListener('online', onOnline);
return () => window.removeEventListener('online', onOnline);
}, []);
const handleCaptured = (thought: Thought) => {
setDeck((prev) => [thought, ...prev]);
setStats((prev) => ({ ...prev, inboxCount: prev.inboxCount + 1 }));
refreshAll();
};
const handleOpenDocument = (docId: string) => {
setSelectedDocId(docId);
setActiveTab('workspace');
};
const handleAuthSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setAuthError('');
try {
if (authMode === 'login') {
await api.login(email, password);
} else {
await api.register(email, password, displayName);
}
setShowAuthModal(false);
setEmail('');
setPassword('');
setDisplayName('');
await refreshAll();
} catch (err: any) {
setAuthError(err.message || 'Authentifizierung fehlgeschlagen');
}
};
return (
<div className="min-h-screen bg-[#0b0f19] text-[#e3e3e3] flex flex-col">
{/* Ambient Top Gemini Glow */}
<div className="pointer-events-none fixed top-0 left-1/2 -translate-x-1/2 w-[560px] h-[180px] bg-gradient-to-r from-[#8ab4f8]/10 via-[#c58af9]/10 to-[#81c995]/10 blur-3xl -z-10" />
{/* Header Bar */}
<header className="sticky top-0 z-30 backdrop-blur-xl bg-[#0b0f19]/85 border-b border-white/[0.06]">
<div className="max-w-6xl mx-auto px-4 sm:px-6 h-16 flex items-center justify-between gap-4">
{/* Brand Logo */}
<div
onClick={() => {
setActiveTab('triage');
setCaptureFocusCount((c) => c + 1);
}}
className="flex items-center gap-2.5 cursor-pointer group"
>
<div className="w-9 h-9 rounded-full bg-[#1e1f20] gemini-border flex items-center justify-center group-hover:border-[#8ab4f8]/50 transition-colors">
<Brain className="w-5 h-5 text-[#8ab4f8]" />
</div>
<div>
<span className="font-bold text-lg tracking-tight gemini-gradient-text">
MindSift
</span>
<span className="hidden md:inline-block ml-2.5 text-xs text-[#9aa0a6]">
Der Kopf frei in drei Sekunden.
</span>
</div>
</div>
{/* Right User Pill */}
<div className="flex items-center gap-2">
<button
onClick={() => setShowAuthModal(true)}
className="h-9 px-3.5 rounded-full bg-[#1e1f20] hover:bg-[#282a2c] gemini-border text-xs font-medium text-[#e3e3e3] flex items-center gap-2 transition-colors"
title="Benutzer wechseln oder Familienmitglied registrieren"
>
<UserIcon className="w-3.5 h-3.5 text-[#8ab4f8]" />
<span>{user?.displayName || 'Lokal'}</span>
</button>
</div>
</div>
</header>
{/* Main Content Container */}
<main className="flex-1 max-w-6xl w-full mx-auto px-4 sm:px-6 pt-6 pb-24">
{/* Global Instant Capture Section (Always Ready at Top) */}
<section className="mb-7">
<InstantCaptureBar
onCaptured={handleCaptured}
autoFocusTrigger={captureFocusCount}
/>
</section>
{/* Pill Navigation Tabs */}
<nav className="flex items-center justify-start sm:justify-center gap-2 overflow-x-auto pb-2 mb-8 no-scrollbar">
<button
onClick={() => setActiveTab('triage')}
className={`h-11 px-5 rounded-full text-xs sm:text-sm font-medium flex items-center gap-2 flex-shrink-0 transition-all ${
activeTab === 'triage'
? 'bg-[#8ab4f8] text-[#0b0f19] font-semibold shadow-sm'
: 'bg-[#131314] hover:bg-[#1e1f20] text-[#9aa0a6] hover:text-[#e3e3e3] gemini-border'
}`}
>
<Layers className="w-4 h-4" />
<span>Triage-Deck</span>
{stats.inboxCount > 0 && (
<span
className={`px-2 py-0.5 rounded-full text-xs font-bold ${
activeTab === 'triage'
? 'bg-[#0b0f19] text-[#8ab4f8]'
: 'bg-[#8ab4f8]/20 text-[#8ab4f8]'
}`}
>
{stats.inboxCount}
</span>
)}
</button>
<button
onClick={() => setActiveTab('tasks')}
className={`h-11 px-5 rounded-full text-xs sm:text-sm font-medium flex items-center gap-2 flex-shrink-0 transition-all ${
activeTab === 'tasks'
? 'bg-[#8ab4f8] text-[#0b0f19] font-semibold shadow-sm'
: 'bg-[#131314] hover:bg-[#1e1f20] text-[#9aa0a6] hover:text-[#e3e3e3] gemini-border'
}`}
>
<CheckSquare className="w-4 h-4" />
<span>Aufgaben</span>
{stats.tasksOpenCount > 0 && (
<span
className={`px-2 py-0.5 rounded-full text-xs font-bold ${
activeTab === 'tasks'
? 'bg-[#0b0f19] text-[#8ab4f8]'
: 'bg-[#81c995]/20 text-[#81c995]'
}`}
>
{stats.tasksOpenCount}
</span>
)}
</button>
<button
onClick={() => setActiveTab('workspace')}
className={`h-11 px-5 rounded-full text-xs sm:text-sm font-medium flex items-center gap-2 flex-shrink-0 transition-all ${
activeTab === 'workspace'
? 'bg-[#8ab4f8] text-[#0b0f19] font-semibold shadow-sm'
: 'bg-[#131314] hover:bg-[#1e1f20] text-[#9aa0a6] hover:text-[#e3e3e3] gemini-border'
}`}
>
<FileText className="w-4 h-4" />
<span>Workspace</span>
<span
className={`px-2 py-0.5 rounded-full text-xs ${
activeTab === 'workspace'
? 'bg-[#0b0f19] text-[#8ab4f8]'
: 'bg-[#1e1f20] text-[#9aa0a6]'
}`}
>
{stats.docsCount}
</span>
</button>
<button
onClick={() => setActiveTab('lists')}
className={`h-11 px-5 rounded-full text-xs sm:text-sm font-medium flex items-center gap-2 flex-shrink-0 transition-all ${
activeTab === 'lists'
? 'bg-[#8ab4f8] text-[#0b0f19] font-semibold shadow-sm'
: 'bg-[#131314] hover:bg-[#1e1f20] text-[#9aa0a6] hover:text-[#e3e3e3] gemini-border'
}`}
>
<ShoppingCart className="w-4 h-4" />
<span>Geteilte Listen</span>
</button>
<button
onClick={() => setActiveTab('ecosystem')}
className={`h-11 px-5 rounded-full text-xs sm:text-sm font-medium flex items-center gap-2 flex-shrink-0 transition-all ${
activeTab === 'ecosystem'
? 'bg-[#8ab4f8] text-[#0b0f19] font-semibold shadow-sm'
: 'bg-[#131314] hover:bg-[#1e1f20] text-[#9aa0a6] hover:text-[#e3e3e3] gemini-border'
}`}
>
<Webhook className="w-4 h-4" />
<span>Webhooks & Export</span>
</button>
</nav>
{/* Active View Content */}
<section>
{activeTab === 'triage' && (
<TriageDeck
deck={deck}
lists={lists}
onDeckChange={refreshAll}
onOpenDocument={handleOpenDocument}
onFocusCapture={() => setCaptureFocusCount((c) => c + 1)}
/>
)}
{activeTab === 'tasks' && (
<TasksView
onRefreshStats={refreshAll}
onOpenDocument={handleOpenDocument}
/>
)}
{activeTab === 'workspace' && (
<WorkspaceView
initialDocId={selectedDocId}
onStatsChange={refreshAll}
/>
)}
{activeTab === 'lists' && (
<SharedListsView onRefreshGlobal={refreshAll} />
)}
{activeTab === 'ecosystem' && (
<EcosystemView onIngested={refreshAll} />
)}
</section>
</main>
{/* Account Switcher / Registration Modal */}
{showAuthModal && (
<div className="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-center justify-center p-4">
<div className="w-full max-w-md p-6 sm:p-8 rounded-3xl bg-[#1e1f20] gemini-border shadow-card-depth">
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-2">
<Sparkles className="w-5 h-5 text-[#8ab4f8]" />
<h3 className="text-lg font-bold text-[#e3e3e3]">
{authMode === 'login' ? 'Konto wechseln' : 'Neues Familien-Konto anlegen'}
</h3>
</div>
<button
onClick={() => setShowAuthModal(false)}
className="px-3 py-1 rounded-full bg-[#282a2c] text-xs text-[#9aa0a6] hover:text-[#e3e3e3]"
>
Schließen
</button>
</div>
{user && (
<div className="mb-5 p-3.5 rounded-2xl bg-[#131314] gemini-border flex items-center justify-between">
<div className="text-xs">
<span className="text-[#9aa0a6]">Aktuell aktiv: </span>
<strong className="text-[#e3e3e3]">{user.displayName}</strong>
<span className="text-[#5f6368] ml-1">({user.email})</span>
</div>
<button
type="button"
onClick={async () => {
clearToken();
await api.quickStart();
await refreshAll();
setShowAuthModal(false);
}}
className="text-xs text-[#8ab4f8] hover:underline flex items-center gap-1"
>
<LogOut className="w-3 h-3" /> Standard
</button>
</div>
)}
<div className="flex gap-2 mb-4">
<button
type="button"
onClick={() => setAuthMode('login')}
className={`flex-1 h-9 rounded-full text-xs font-semibold ${
authMode === 'login'
? 'bg-[#8ab4f8] text-[#0b0f19]'
: 'bg-[#131314] text-[#9aa0a6]'
}`}
>
Anmelden
</button>
<button
type="button"
onClick={() => setAuthMode('register')}
className={`flex-1 h-9 rounded-full text-xs font-semibold ${
authMode === 'register'
? 'bg-[#8ab4f8] text-[#0b0f19]'
: 'bg-[#131314] text-[#9aa0a6]'
}`}
>
Registrieren
</button>
</div>
<form onSubmit={handleAuthSubmit} className="space-y-3">
{authMode === 'register' && (
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Anzeigename (z. B. Lisa)"
className="w-full h-11 px-4 rounded-full bg-[#131314] text-sm text-[#e3e3e3] gemini-border focus:outline-none"
required
/>
)}
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="E-Mail-Adresse"
className="w-full h-11 px-4 rounded-full bg-[#131314] text-sm text-[#e3e3e3] gemini-border focus:outline-none"
required
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Passwort (mind. 4 Zeichen)"
className="w-full h-11 px-4 rounded-full bg-[#131314] text-sm text-[#e3e3e3] gemini-border focus:outline-none"
required
/>
{authError && <p className="text-xs text-[#f28b82] px-2">{authError}</p>}
<button
type="submit"
className="w-full h-11 rounded-full bg-[#8ab4f8] text-[#0b0f19] font-semibold text-sm hover:bg-[#a1c2fa] transition-colors"
>
{authMode === 'login' ? 'Einloggen' : 'Konto erstellen & starten'}
</button>
</form>
</div>
</div>
)}
</div>
);
};
export default App;
@@ -0,0 +1,269 @@
import React, { useEffect, useRef, useState } from 'react';
import { Mic, MicOff, Send, Sparkles, Calendar, AlertTriangle, CheckCircle2 } from 'lucide-react';
import { PriorityLevel, Thought } from '../../lib/types';
import { api, triggerHaptic } from '../../lib/api';
interface InstantCaptureBarProps {
onCaptured: (thought: Thought) => void;
autoFocusTrigger?: number;
}
export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
onCaptured,
autoFocusTrigger = 0,
}) => {
const [content, setContent] = useState('');
const [priority, setPriority] = useState<PriorityLevel>('normal');
const [dueDate, setDueDate] = useState('');
const [showOptions, setShowOptions] = useState(false);
const [isListening, setIsListening] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [justSaved, setJustSaved] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const recognitionRef = useRef<any>(null);
useEffect(() => {
inputRef.current?.focus();
}, [autoFocusTrigger]);
// Global keyboard shortcut: 'c' or '/' focuses the Instant Capture input immediately
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
const isEditing =
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable;
if (!isEditing && (e.key === 'c' || e.key === 'C' || e.key === '/')) {
e.preventDefault();
inputRef.current?.focus();
} else if (e.key === 'Escape' && document.activeElement === inputRef.current) {
setContent('');
setShowOptions(false);
inputRef.current?.blur();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
const toggleVoiceDictation = () => {
const SpeechRecognition =
(window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
if (!SpeechRecognition) {
alert('Spracheingabe wird von diesem Browser nicht direkt unterstützt (bitte Chrome, Edge oder Safari nutzen).');
return;
}
if (isListening && recognitionRef.current) {
recognitionRef.current.stop();
setIsListening(false);
return;
}
try {
const recognition = new SpeechRecognition();
recognition.lang = 'de-DE';
recognition.continuous = false;
recognition.interimResults = true;
recognition.onstart = () => {
setIsListening(true);
triggerHaptic(20);
};
recognition.onresult = (event: any) => {
let transcript = '';
for (let i = 0; i < event.results.length; i++) {
transcript += event.results[i][0].transcript;
}
setContent((prev) => {
const base = prev.replace(/\s*\[…\]$/, '').trim();
return base ? `${base} ${transcript}` : transcript;
});
};
recognition.onerror = () => {
setIsListening(false);
};
recognition.onend = () => {
setIsListening(false);
inputRef.current?.focus();
};
recognitionRef.current = recognition;
recognition.start();
} catch {
setIsListening(false);
}
};
const handleSubmit = async (e?: React.FormEvent) => {
if (e) e.preventDefault();
const trimmed = content.trim();
if (!trimmed || isSubmitting) return;
setIsSubmitting(true);
triggerHaptic(15);
try {
const created = await api.captureThought(
trimmed,
priority,
isListening ? 'voice' : 'manual',
dueDate
);
setContent('');
setPriority('normal');
setDueDate('');
setShowOptions(false);
setJustSaved(true);
setTimeout(() => setJustSaved(false), 1800);
onCaptured(created);
inputRef.current?.focus();
} finally {
setIsSubmitting(false);
}
};
return (
<div className="w-full max-w-3xl mx-auto">
<form onSubmit={handleSubmit} className="relative">
<div
className={`flex items-center h-14 px-4 sm:px-5 rounded-full bg-[#1e1f20] gemini-border gemini-input-glow transition-all duration-200 ${
isListening ? 'border-[#c58af9] shadow-purple-glow' : ''
}`}
>
<Sparkles
className={`w-5 h-5 mr-3 flex-shrink-0 transition-colors ${
isListening
? 'text-[#c58af9] animate-pulse'
: justSaved
? 'text-[#81c995]'
: 'text-[#8ab4f8]'
}`}
/>
<input
ref={inputRef}
type="text"
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={
isListening
? 'Ich höre zu… sprich deinen Gedanken aus'
: 'Gedanke, Idee oder Zutat sofort festhalten… (Taste C oder /)'
}
className="w-full bg-transparent text-[#e3e3e3] placeholder-[#9aa0a6] text-sm sm:text-base focus:outline-none"
autoComplete="off"
/>
<div className="flex items-center gap-1.5 ml-2 flex-shrink-0">
{/* Options Toggle Pill (Due Date / Priority) */}
<button
type="button"
onClick={() => setShowOptions((prev) => !prev)}
title="Priorität & Fälligkeitsdatum"
className={`h-9 px-3 rounded-full text-xs font-medium flex items-center gap-1.5 transition-colors ${
showOptions || priority !== 'normal' || dueDate
? 'bg-[#8ab4f8]/20 text-[#8ab4f8] border border-[#8ab4f8]/40'
: 'text-[#9aa0a6] hover:bg-[#282a2c] hover:text-[#e3e3e3]'
}`}
>
<Calendar className="w-3.5 h-3.5" />
<span className="hidden sm:inline">
{dueDate ? dueDate : priority !== 'normal' ? priority.toUpperCase() : 'Optionen'}
</span>
</button>
{/* Voice Dictation Pill Button */}
<button
type="button"
onClick={toggleVoiceDictation}
title="Spracheingabe (Diktieren)"
className={`w-9 h-9 rounded-full flex items-center justify-center transition-all ${
isListening
? 'bg-[#c58af9] text-[#0b0f19] animate-pulse'
: 'bg-[#282a2c] text-[#9aa0a6] hover:text-[#e3e3e3] hover:bg-[#323538]'
}`}
>
{isListening ? <MicOff className="w-4 h-4" /> : <Mic className="w-4 h-4" />}
</button>
{/* Instant Send Pill Button */}
<button
type="submit"
disabled={!content.trim() || isSubmitting}
className={`h-9 px-4 rounded-full font-medium text-xs sm:text-sm flex items-center gap-1.5 transition-all ${
content.trim()
? 'bg-[#8ab4f8] text-[#0b0f19] hover:bg-[#a1c2fa] shadow-sm cursor-pointer'
: 'bg-[#282a2c] text-[#5f6368] cursor-not-allowed'
}`}
>
{justSaved ? (
<>
<CheckCircle2 className="w-4 h-4 text-[#0b0f19]" />
<span className="hidden sm:inline">Gespeichert</span>
</>
) : (
<>
<Send className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Einfrieren</span>
</>
)}
</button>
</div>
</div>
{/* Expandable Quick Metadata Pills */}
{showOptions && (
<div className="mt-2.5 px-4 py-2.5 rounded-full bg-[#131314] gemini-border flex flex-wrap items-center justify-between gap-2 text-xs animate-fadeIn">
<div className="flex items-center gap-1.5">
<AlertTriangle className="w-3.5 h-3.5 text-[#9aa0a6] mr-1" />
<span className="text-[#9aa0a6] mr-1">Priorität:</span>
{(['low', 'normal', 'high', 'urgent'] as PriorityLevel[]).map((p) => {
const labels: Record<PriorityLevel, string> = {
low: 'Niedrig',
normal: 'Normal',
high: 'Hoch',
urgent: 'Dringend',
};
const active = priority === p;
return (
<button
key={p}
type="button"
onClick={() => setPriority(p)}
className={`px-3 py-1 rounded-full transition-colors ${
active
? p === 'urgent'
? 'bg-[#f28b82] text-[#0b0f19] font-semibold'
: 'bg-[#8ab4f8] text-[#0b0f19] font-semibold'
: 'bg-[#1e1f20] text-[#9aa0a6] hover:text-[#e3e3e3]'
}`}
>
{labels[p]}
</button>
);
})}
</div>
<div className="flex items-center gap-2">
<span className="text-[#9aa0a6]">Fällig am:</span>
<input
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
className="bg-[#1e1f20] text-[#e3e3e3] px-3 py-1 rounded-full gemini-border text-xs focus:outline-none focus:border-[#8ab4f8]"
/>
</div>
</div>
)}
</form>
</div>
);
};
@@ -0,0 +1,587 @@
import React, { useEffect, useRef, useState } from 'react';
import {
Plus,
CheckCircle2,
Circle,
Trash2,
Users,
Copy,
Check,
Radio,
Sparkles,
UserPlus,
} from 'lucide-react';
import { ListItem, SharedList, Space, WSMessage } from '../../lib/types';
import { api, buildWebSocketURL, triggerHaptic } from '../../lib/api';
interface SharedListsViewProps {
onRefreshGlobal: () => void;
}
const CATEGORIES = ['Obst & Gemüse', 'Kühlregal', 'Vorrat & Konserven', 'Backwaren', 'Getränke', 'Drogerie', 'Sonstiges'];
export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGlobal }) => {
const [spaces, setSpaces] = useState<Space[]>([]);
const [activeSpace, setActiveSpace] = useState<Space | null>(null);
const [lists, setLists] = useState<SharedList[]>([]);
const [activeListId, setActiveListId] = useState<string>('');
const [newItemText, setNewItemText] = useState('');
const [newItemQty, setNewItemQty] = useState('');
const [newItemCategory, setNewItemCategory] = useState('');
const [wsConnected, setWsConnected] = useState(false);
const [onlineUsers, setOnlineUsers] = useState(1);
const [copiedInvite, setCopiedInvite] = useState(false);
const [showNewListModal, setShowNewListModal] = useState(false);
const [newListTitle, setNewListTitle] = useState('');
const [newListIcon, setNewListIcon] = useState('🛒');
const [showJoinModal, setShowJoinModal] = useState(false);
const [joinCode, setJoinCode] = useState('');
const [showNewSpaceModal, setShowNewSpaceModal] = useState(false);
const [newSpaceName, setNewSpaceName] = useState('');
const wsRef = useRef<WebSocket | null>(null);
const loadSpaces = async () => {
const sp = await api.getSpaces();
setSpaces(sp);
if (sp.length > 0 && !activeSpace) {
setActiveSpace(sp[0]);
}
};
const loadListsForSpace = async (spaceId: string) => {
const lst = await api.getSpaceLists(spaceId);
setLists(lst);
if (lst.length > 0 && (!activeListId || !lst.some((l) => l.id === activeListId))) {
setActiveListId(lst[0].id);
}
};
useEffect(() => {
loadSpaces();
}, []);
useEffect(() => {
if (!activeSpace) return;
loadListsForSpace(activeSpace.id);
// Connect Realtime WebSocket for this Space
if (wsRef.current) {
wsRef.current.close();
}
let ws: WebSocket | null = null;
try {
ws = new WebSocket(buildWebSocketURL(activeSpace.id));
wsRef.current = ws;
ws.onopen = () => {
setWsConnected(true);
};
ws.onclose = () => {
setWsConnected(false);
};
ws.onmessage = (evt) => {
try {
const msg: WSMessage = JSON.parse(evt.data);
if (msg.event === 'USER_PRESENCE' && msg.payload?.onlineCount) {
setOnlineUsers(msg.payload.onlineCount);
} else if (msg.event === 'ITEM_CREATED' && msg.list_id) {
const newItem: ListItem = msg.payload;
setLists((prev) =>
prev.map((l) => {
if (l.id !== msg.list_id) return l;
if (l.items.some((i) => i.id === newItem.id)) return l;
return { ...l, items: [newItem, ...l.items] };
})
);
} else if (msg.event === 'ITEM_TOGGLED' && msg.list_id) {
const updatedItem: ListItem = msg.payload;
setLists((prev) =>
prev.map((l) => {
if (l.id !== msg.list_id) return l;
return {
...l,
items: l.items.map((i) => (i.id === updatedItem.id ? updatedItem : i)),
};
})
);
} else if (msg.event === 'ITEM_DELETED' && msg.list_id) {
const deletedId = msg.payload?.itemId;
setLists((prev) =>
prev.map((l) => {
if (l.id !== msg.list_id) return l;
return { ...l, items: l.items.filter((i) => i.id !== deletedId) };
})
);
} else if (msg.event === 'LIST_CLEARED' && msg.list_id) {
const updatedList: SharedList = msg.payload;
setLists((prev) => prev.map((l) => (l.id === msg.list_id ? updatedList : l)));
} else if (msg.event === 'LIST_CREATED' || msg.event === 'LIST_DELETED') {
loadListsForSpace(activeSpace.id);
}
} catch {
// ignore malformed frames
}
};
} catch {
setWsConnected(false);
}
return () => {
if (ws) ws.close();
};
}, [activeSpace?.id]);
const activeList = lists.find((l) => l.id === activeListId) || lists[0] || null;
const handleAddItem = async (e: React.FormEvent) => {
e.preventDefault();
if (!activeList || !newItemText.trim()) return;
triggerHaptic(15);
const text = newItemText.trim();
const qty = newItemQty.trim();
const cat = newItemCategory;
setNewItemText('');
setNewItemQty('');
const created = await api.createListItem(activeList.id, text, cat, qty);
setLists((prev) =>
prev.map((l) => {
if (l.id !== activeList.id) return l;
if (l.items.some((i) => i.id === created.id)) return l;
return { ...l, items: [created, ...l.items] };
})
);
};
const handleToggleItem = async (item: ListItem) => {
if (!activeList) return;
triggerHaptic(18);
const nextCompleted = !item.isCompleted;
// Optimistic UI update (BRAIN.md Section 3)
setLists((prev) =>
prev.map((l) => {
if (l.id !== activeList.id) return l;
return {
...l,
items: l.items.map((i) =>
i.id === item.id ? { ...i, isCompleted: nextCompleted } : i
),
};
})
);
try {
const updated = await api.toggleListItem(activeList.id, item.id, nextCompleted);
setLists((prev) =>
prev.map((l) => {
if (l.id !== activeList.id) return l;
return {
...l,
items: l.items.map((i) => (i.id === item.id ? updated : i)),
};
})
);
} catch {
// Rollback on error
loadListsForSpace(activeSpace!.id);
}
};
const handleDeleteItem = async (itemId: string) => {
if (!activeList) return;
triggerHaptic(15);
setLists((prev) =>
prev.map((l) =>
l.id === activeList.id ? { ...l, items: l.items.filter((i) => i.id !== itemId) } : l
)
);
await api.deleteListItem(activeList.id, itemId);
};
const handleClearCompleted = async () => {
if (!activeList) return;
triggerHaptic(20);
await api.clearCompletedItems(activeList.id);
await loadListsForSpace(activeSpace!.id);
};
const handleCreateList = async (e: React.FormEvent) => {
e.preventDefault();
if (!activeSpace || !newListTitle.trim()) return;
const created = await api.createList(activeSpace.id, newListTitle.trim(), newListIcon, 'shopping');
setNewListTitle('');
setShowNewListModal(false);
await loadListsForSpace(activeSpace.id);
setActiveListId(created.id);
onRefreshGlobal();
};
const handleCreateSpace = async (e: React.FormEvent) => {
e.preventDefault();
if (!newSpaceName.trim()) return;
const created = await api.createSpace(newSpaceName.trim(), '🏡', 'Geteilter Familien- & Team-Bereich');
setNewSpaceName('');
setShowNewSpaceModal(false);
const sp = await api.getSpaces();
setSpaces(sp);
setActiveSpace(created);
};
const handleJoinSpace = async (e: React.FormEvent) => {
e.preventDefault();
if (!joinCode.trim()) return;
try {
const joined = await api.joinSpace(joinCode.trim());
setJoinCode('');
setShowJoinModal(false);
const sp = await api.getSpaces();
setSpaces(sp);
setActiveSpace(joined);
} catch (err: any) {
alert(err.message || 'Einladungscode ungültig');
}
};
const copyInviteCode = () => {
if (!activeSpace) return;
navigator.clipboard.writeText(activeSpace.inviteCode);
setCopiedInvite(true);
setTimeout(() => setCopiedInvite(false), 2000);
};
const openItems = activeList?.items.filter((i) => !i.isCompleted) || [];
const completedItems = activeList?.items.filter((i) => i.isCompleted) || [];
return (
<div className="max-w-4xl mx-auto space-y-6">
{/* Top Space Bar & Realtime Indicator */}
<div className="p-4 sm:p-5 rounded-3xl bg-[#1e1f20] gemini-border flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
{spaces.map((sp) => (
<button
key={sp.id}
onClick={() => setActiveSpace(sp)}
className={`h-10 px-4 rounded-full text-xs sm:text-sm font-medium flex items-center gap-2 transition-all ${
activeSpace?.id === sp.id
? 'bg-[#8ab4f8] text-[#0b0f19] font-semibold shadow-sm'
: 'bg-[#131314] text-[#9aa0a6] hover:text-[#e3e3e3] gemini-border'
}`}
>
<span>{sp.icon}</span>
<span>{sp.name}</span>
</button>
))}
<button
onClick={() => setShowNewSpaceModal((prev) => !prev)}
className="h-10 px-3.5 rounded-full bg-[#131314] hover:bg-[#282a2c] gemini-border text-xs text-[#8ab4f8] flex items-center gap-1"
>
<Plus className="w-3.5 h-3.5" /> Space
</button>
<button
onClick={() => setShowJoinModal((prev) => !prev)}
className="h-10 px-3.5 rounded-full bg-[#131314] hover:bg-[#282a2c] gemini-border text-xs text-[#c58af9] flex items-center gap-1"
>
<UserPlus className="w-3.5 h-3.5" /> Code einlösen
</button>
</div>
{/* Realtime Status & Invite Code Pill */}
{activeSpace && (
<div className="flex items-center gap-2">
<div
className={`h-8 px-3 rounded-full text-xs flex items-center gap-1.5 ${
wsConnected
? 'bg-[#81c995]/15 text-[#81c995] border border-[#81c995]/30'
: 'bg-[#282a2c] text-[#9aa0a6]'
}`}
>
<Radio className={`w-3.5 h-3.5 ${wsConnected ? 'animate-pulse' : ''}`} />
<span>{wsConnected ? `Echtzeit-Sync (${onlineUsers} aktiv)` : 'Offline-Puffer'}</span>
</div>
<button
onClick={copyInviteCode}
title="Einladungscode für Partner oder Familie kopieren"
className="h-8 px-3 rounded-full bg-[#131314] hover:bg-[#282a2c] gemini-border text-xs text-[#e3e3e3] flex items-center gap-1.5"
>
<Users className="w-3.5 h-3.5 text-[#8ab4f8]" />
<span>Code: {activeSpace.inviteCode}</span>
{copiedInvite ? (
<Check className="w-3.5 h-3.5 text-[#81c995]" />
) : (
<Copy className="w-3 h-3 text-[#9aa0a6]" />
)}
</button>
</div>
)}
</div>
{/* Create or Join Space Modals */}
{showNewSpaceModal && (
<form
onSubmit={handleCreateSpace}
className="p-4 rounded-3xl bg-[#131314] gemini-border flex items-center gap-3"
>
<input
type="text"
value={newSpaceName}
onChange={(e) => setNewSpaceName(e.target.value)}
placeholder="Name des neuen Familien-/Team-Spaces (z. B. Sommerurlaub 2027)…"
className="flex-1 h-10 px-4 rounded-full bg-[#1e1f20] text-sm text-[#e3e3e3] gemini-border focus:outline-none"
autoFocus
/>
<button
type="submit"
className="h-10 px-5 rounded-full bg-[#8ab4f8] text-[#0b0f19] font-semibold text-xs sm:text-sm"
>
Space anlegen
</button>
</form>
)}
{showJoinModal && (
<form
onSubmit={handleJoinSpace}
className="p-4 rounded-3xl bg-[#131314] gemini-border flex items-center gap-3"
>
<input
type="text"
value={joinCode}
onChange={(e) => setJoinCode(e.target.value)}
placeholder="Invite-Code des Partners eingeben…"
className="flex-1 h-10 px-4 rounded-full bg-[#1e1f20] text-sm text-[#e3e3e3] gemini-border focus:outline-none uppercase"
autoFocus
/>
<button
type="submit"
className="h-10 px-5 rounded-full bg-[#c58af9] text-[#0b0f19] font-semibold text-xs sm:text-sm"
>
Beitreten
</button>
</form>
)}
{/* List Tabs within Active Space */}
<div className="flex flex-wrap items-center gap-2">
{lists.map((lst) => {
const openCount = lst.items.filter((i) => !i.isCompleted).length;
const isSelected = activeList?.id === lst.id;
return (
<button
key={lst.id}
onClick={() => setActiveListId(lst.id)}
className={`h-11 px-5 rounded-full text-sm font-medium flex items-center gap-2 transition-all ${
isSelected
? 'bg-[#1e1f20] text-[#8ab4f8] border border-[#8ab4f8]/50 shadow-gemini-glow'
: 'bg-[#131314] text-[#9aa0a6] hover:text-[#e3e3e3] gemini-border'
}`}
>
<span>{lst.icon}</span>
<span>{lst.title}</span>
<span className="px-2 py-0.5 rounded-full bg-[#0b0f19] text-xs text-[#8ab4f8]">
{openCount}
</span>
</button>
);
})}
<button
onClick={() => setShowNewListModal((prev) => !prev)}
className="h-11 px-4 rounded-full bg-[#131314] hover:bg-[#1e1f20] gemini-border text-xs sm:text-sm text-[#e3e3e3] flex items-center gap-1.5"
>
<Plus className="w-4 h-4 text-[#8ab4f8]" />
<span>Neue Liste</span>
</button>
</div>
{showNewListModal && (
<form
onSubmit={handleCreateList}
className="p-4 rounded-3xl bg-[#131314] gemini-border flex flex-wrap items-center gap-3"
>
<div className="flex items-center gap-1.5">
{['🛒', '🏔️', '🍕', '🎉', '🧰', '🎁'].map((ic) => (
<button
key={ic}
type="button"
onClick={() => setNewListIcon(ic)}
className={`w-9 h-9 rounded-full flex items-center justify-center ${
newListIcon === ic ? 'bg-[#8ab4f8]/25 border border-[#8ab4f8]' : 'bg-[#1e1f20]'
}`}
>
{ic}
</button>
))}
</div>
<input
type="text"
value={newListTitle}
onChange={(e) => setNewListTitle(e.target.value)}
placeholder="Titel der Liste (z. B. Baumarkt, Drogerie, Camping)…"
className="flex-1 h-10 px-4 rounded-full bg-[#1e1f20] text-sm text-[#e3e3e3] gemini-border focus:outline-none"
autoFocus
/>
<button
type="submit"
className="h-10 px-5 rounded-full bg-[#8ab4f8] text-[#0b0f19] font-semibold text-xs sm:text-sm"
>
Liste erstellen
</button>
</form>
)}
{/* Active Shared List Card */}
{activeList ? (
<div className="p-6 sm:p-8 rounded-3xl bg-[#1e1f20] gemini-border shadow-card-depth">
{/* Quick-Add Pill Form */}
<form onSubmit={handleAddItem} className="space-y-3 mb-6">
<div className="flex flex-col sm:flex-row gap-2.5">
<input
type="text"
value={newItemText}
onChange={(e) => setNewItemText(e.target.value)}
placeholder={`Eintrag zu "${activeList.title}" hinzufügen (z. B. Hafermilch)…`}
className="flex-1 h-12 px-5 rounded-full bg-[#131314] text-sm sm:text-base text-[#e3e3e3] placeholder-[#9aa0a6] gemini-border focus:outline-none focus:border-[#8ab4f8]"
/>
<div className="flex gap-2">
<input
type="text"
value={newItemQty}
onChange={(e) => setNewItemQty(e.target.value)}
placeholder="Menge (z.B. 2x)"
className="w-32 h-12 px-4 rounded-full bg-[#131314] text-sm text-[#e3e3e3] placeholder-[#9aa0a6] gemini-border focus:outline-none focus:border-[#8ab4f8]"
/>
<button
type="submit"
disabled={!newItemText.trim()}
className="h-12 px-6 rounded-full bg-[#8ab4f8] disabled:bg-[#282a2c] text-[#0b0f19] disabled:text-[#5f6368] font-semibold text-sm flex items-center gap-1.5 transition-colors"
>
<Plus className="w-4 h-4" />
<span>Hinzufügen</span>
</button>
</div>
</div>
{/* Category Quick-Select Pills */}
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs text-[#9aa0a6] mr-1">Kategorie:</span>
{CATEGORIES.map((cat) => (
<button
key={cat}
type="button"
onClick={() => setNewItemCategory((prev) => (prev === cat ? '' : cat))}
className={`px-3 py-1 rounded-full text-xs transition-colors ${
newItemCategory === cat
? 'bg-[#8ab4f8]/20 text-[#8ab4f8] border border-[#8ab4f8]/40'
: 'bg-[#131314] text-[#9aa0a6] hover:text-[#e3e3e3]'
}`}
>
{cat}
</button>
))}
</div>
</form>
{/* Open Items */}
<div className="space-y-2">
{openItems.length === 0 ? (
<div className="py-8 text-center text-[#9aa0a6] text-sm flex flex-col items-center gap-2">
<Sparkles className="w-6 h-6 text-[#81c995]" />
<span>Alles erledigt! Keine offenen Punkte auf dieser Liste.</span>
</div>
) : (
openItems.map((item) => (
<div
key={item.id}
onClick={() => handleToggleItem(item)}
className="p-4 rounded-2xl bg-[#131314] hover:bg-[#242628] gemini-border flex items-center justify-between gap-3 cursor-pointer transition-all active:scale-[0.99]"
>
<div className="flex items-center gap-3.5 min-w-0">
<Circle className="w-5 h-5 text-[#8ab4f8] flex-shrink-0" />
<span className="text-base font-medium text-[#e3e3e3] truncate">
{item.content}
</span>
{item.quantity && (
<span className="px-2.5 py-0.5 rounded-full bg-[#1e1f20] text-[#8ab4f8] text-xs font-medium">
{item.quantity}
</span>
)}
{item.category && (
<span className="hidden sm:inline px-2.5 py-0.5 rounded-full bg-[#1e1f20] text-[#9aa0a6] text-xs">
{item.category}
</span>
)}
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleDeleteItem(item.id);
}}
className="w-8 h-8 rounded-full hover:bg-[#f28b82]/20 text-[#5f6368] hover:text-[#f28b82] flex items-center justify-center"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))
)}
</div>
{/* Completed Items Section */}
{completedItems.length > 0 && (
<div className="mt-8 pt-6 border-t border-white/[0.06]">
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-semibold uppercase tracking-wider text-[#9aa0a6]">
Bereits im Einkaufswagen / Erledigt ({completedItems.length})
</span>
<button
onClick={handleClearCompleted}
className="px-3.5 py-1 rounded-full bg-[#131314] hover:bg-[#f28b82]/20 text-xs text-[#9aa0a6] hover:text-[#f28b82] transition-colors"
>
Erledigte aufräumen
</button>
</div>
<div className="space-y-2 opacity-70">
{completedItems.map((item) => (
<div
key={item.id}
onClick={() => handleToggleItem(item)}
className="p-3.5 rounded-2xl bg-[#131314]/60 gemini-border flex items-center justify-between gap-3 cursor-pointer"
>
<div className="flex items-center gap-3 min-w-0">
<CheckCircle2 className="w-5 h-5 text-[#81c995] flex-shrink-0" />
<span className="text-sm text-[#9aa0a6] line-through truncate">
{item.content}
</span>
{item.quantity && (
<span className="text-xs text-[#5f6368]">({item.quantity})</span>
)}
</div>
{item.completedByName && (
<span className="text-xs text-[#5f6368]">✓ {item.completedByName}</span>
)}
</div>
))}
</div>
</div>
)}
</div>
) : null}
</div>
);
};
@@ -0,0 +1,283 @@
import React, { useEffect, useState } from 'react';
import {
Key,
Plus,
Trash2,
Copy,
Check,
Webhook,
Download,
ShieldCheck,
Zap,
Terminal,
} from 'lucide-react';
import { APIKey, PriorityLevel } from '../../lib/types';
import { api, getToken, triggerHaptic } from '../../lib/api';
interface EcosystemViewProps {
onIngested: () => void;
}
export const EcosystemView: React.FC<EcosystemViewProps> = ({ onIngested }) => {
const [keys, setKeys] = useState<APIKey[]>([]);
const [newKeyName, setNewKeyName] = useState('');
const [newlyCreatedKey, setNewlyCreatedKey] = useState<string | null>(null);
const [copiedKey, setCopiedKey] = useState(false);
// Live Webhook Simulator State
const [simTitle, setSimTitle] = useState('Waschmaschine im Keller ist fertig!');
const [simPriority, setSimPriority] = useState<PriorityLevel>('high');
const [simStatus, setSimStatus] = useState<string | null>(null);
const loadKeys = async () => {
const k = await api.getAPIKeys();
setKeys(k);
};
useEffect(() => {
loadKeys();
}, []);
const handleCreateKey = async (e: React.FormEvent) => {
e.preventDefault();
triggerHaptic(15);
const created = await api.createAPIKey(newKeyName.trim() || 'Home Assistant');
if (created.rawKey) {
setNewlyCreatedKey(created.rawKey);
}
setNewKeyName('');
await loadKeys();
};
const handleDeleteKey = async (id: string) => {
triggerHaptic(20);
await api.deleteAPIKey(id);
await loadKeys();
};
const handleCopy = (text: string) => {
navigator.clipboard.writeText(text);
setCopiedKey(true);
setTimeout(() => setCopiedKey(false), 2000);
};
const handleSimulateWebhook = async () => {
triggerHaptic(20);
const tokenToUse = newlyCreatedKey || getToken() || '';
try {
await api.testWebhookIngest(tokenToUse, simTitle, simPriority, ['smarthome', 'automation']);
setSimStatus('✅ Webhook erfolgreich ausgelöst! Neue Karte liegt in deiner Inbox.');
onIngested();
setTimeout(() => setSimStatus(null), 4000);
} catch (err: any) {
setSimStatus(`❌ Fehler: ${err.message}`);
}
};
const handleFullExport = async () => {
triggerHaptic(15);
const data = await api.exportAll();
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: 'application/json;charset=utf-8',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `mindsift-backup-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
};
const exampleToken = newlyCreatedKey || 'mindsift_sec_dein_api_key';
const baseUrl = window.location.origin;
return (
<div className="max-w-4xl mx-auto space-y-6">
{/* Section 1: API Keys for Smarthome & Automation */}
<div className="p-6 sm:p-8 rounded-3xl bg-[#1e1f20] gemini-border shadow-card-depth">
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-full bg-[#8ab4f8]/15 flex items-center justify-center">
<Key className="w-5 h-5 text-[#8ab4f8]" />
</div>
<div>
<h3 className="text-lg font-bold text-[#e3e3e3]">
Webhooks & Ingestion API-Schlüssel
</h3>
<p className="text-xs sm:text-sm text-[#9aa0a6]">
Verbinde Home Assistant, Zigbee2MQTT, n8n oder eigene Cronjobs direkt mit deiner MindSift Inbox.
</p>
</div>
</div>
<form onSubmit={handleCreateKey} className="mt-5 flex flex-col sm:flex-row gap-2.5">
<input
type="text"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
placeholder="Bezeichnung (z. B. Home Assistant, Proxmox Backup, Rauchmelder)…"
className="flex-1 h-11 px-4 rounded-full bg-[#131314] text-sm text-[#e3e3e3] placeholder-[#9aa0a6] gemini-border focus:outline-none focus:border-[#8ab4f8]"
/>
<button
type="submit"
className="h-11 px-6 rounded-full bg-[#8ab4f8] text-[#0b0f19] font-semibold text-sm flex items-center justify-center gap-1.5 hover:bg-[#a1c2fa] transition-colors"
>
<Plus className="w-4 h-4" />
<span>API-Key erzeugen</span>
</button>
</form>
{newlyCreatedKey && (
<div className="mt-4 p-4 rounded-2xl bg-[#81c995]/15 border border-[#81c995]/40 flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-semibold text-[#81c995] mb-1">
Neuer API-Schlüssel erstellt (bitte jetzt kopieren):
</p>
<code className="text-xs sm:text-sm font-mono text-[#e3e3e3] break-all">
{newlyCreatedKey}
</code>
</div>
<button
onClick={() => handleCopy(newlyCreatedKey)}
className="h-9 px-4 rounded-full bg-[#81c995] text-[#0b0f19] font-semibold text-xs flex items-center gap-1.5"
>
{copiedKey ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
<span>{copiedKey ? 'Kopiert!' : 'Key kopieren'}</span>
</button>
</div>
)}
{/* Active Keys List */}
<div className="mt-5 space-y-2">
{keys.length === 0 ? (
<p className="text-xs text-[#5f6368] py-2">
Noch keine dedizierten API-Schlüssel hinterlegt.
</p>
) : (
keys.map((k) => (
<div
key={k.id}
className="p-3.5 rounded-2xl bg-[#131314] gemini-border flex items-center justify-between gap-3"
>
<div>
<span className="text-sm font-medium text-[#e3e3e3]">{k.name}</span>
<span className="ml-3 text-xs font-mono text-[#8ab4f8]">{k.keyPrefix}</span>
{k.lastUsedAt && (
<span className="ml-3 text-xs text-[#5f6368]">
Zuletzt genutzt: {new Date(k.lastUsedAt).toLocaleDateString('de-DE')}
</span>
)}
</div>
<button
onClick={() => handleDeleteKey(k.id)}
className="w-8 h-8 rounded-full hover:bg-[#f28b82]/20 text-[#9aa0a6] hover:text-[#f28b82] flex items-center justify-center"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))
)}
</div>
</div>
{/* Section 2: Interactive Smarthome Webhook Simulator */}
<div className="p-6 sm:p-8 rounded-3xl bg-[#1e1f20] gemini-border shadow-card-depth">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-[#c58af9]/15 flex items-center justify-center">
<Webhook className="w-5 h-5 text-[#c58af9]" />
</div>
<div>
<h3 className="text-lg font-bold text-[#e3e3e3]">
Live Smarthome Webhook-Simulator
</h3>
<p className="text-xs sm:text-sm text-[#9aa0a6]">
Teste den Webhook-Endpunkt direkt hier – das Event landet sofort als Karte im Triage-Deck.
</p>
</div>
</div>
<div className="flex flex-wrap gap-2 mb-4">
{[
{ label: '🧺 Waschmaschine fertig', text: 'Waschmaschine im Keller ist fertig – Wäsche aufhängen!', prio: 'high' as PriorityLevel },
{ label: '🚨 Rauchmelder Batterie', text: 'Rauchmelder Dachgeschoss meldet niedrige Batterie (12%)', prio: 'urgent' as PriorityLevel },
{ label: '💧 Luftfeuchtigkeit Keller', text: 'Kellerfeuchtigkeit über 70% – Lüften oder Entfeuchter starten', prio: 'urgent' as PriorityLevel },
].map((preset) => (
<button
key={preset.label}
type="button"
onClick={() => {
setSimTitle(preset.text);
setSimPriority(preset.prio);
}}
className="px-3.5 py-1.5 rounded-full bg-[#131314] hover:bg-[#282a2c] gemini-border text-xs text-[#e3e3e3]"
>
{preset.label}
</button>
))}
</div>
<div className="flex flex-col sm:flex-row gap-2.5">
<input
type="text"
value={simTitle}
onChange={(e) => setSimTitle(e.target.value)}
className="flex-1 h-11 px-4 rounded-full bg-[#131314] text-sm text-[#e3e3e3] gemini-border focus:outline-none"
/>
<button
type="button"
onClick={handleSimulateWebhook}
className="h-11 px-6 rounded-full bg-[#c58af9] text-[#0b0f19] font-semibold text-sm flex items-center justify-center gap-1.5 hover:opacity-90 transition-opacity"
>
<Zap className="w-4 h-4" />
<span>Webhook jetzt abfeuern</span>
</button>
</div>
{simStatus && (
<p className="mt-3 text-xs font-medium text-[#81c995]">{simStatus}</p>
)}
{/* Code Example Snippets */}
<div className="mt-6 space-y-3">
<div className="flex items-center gap-2 text-xs font-semibold text-[#9aa0a6]">
<Terminal className="w-3.5 h-3.5 text-[#8ab4f8]" />
<span>cURL / Home Assistant REST Command:</span>
</div>
<pre className="p-4 rounded-2xl bg-[#0b0f19] gemini-border font-mono text-xs text-[#8ab4f8] overflow-x-auto">
{`curl -X POST "${baseUrl}/api/v1/webhooks/ingest?token=${exampleToken}" \\
-H "Content-Type: application/json" \\
-d '{
"title": "Kellerfeuchtigkeit über 70%",
"priority": "urgent",
"tags": ["smarthome", "keller"]
}'`}
</pre>
</div>
</div>
{/* Section 3: Data Sovereignty & Export */}
<div className="p-6 sm:p-8 rounded-3xl bg-[#1e1f20] gemini-border flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-[#81c995]/15 flex items-center justify-center flex-shrink-0">
<ShieldCheck className="w-5 h-5 text-[#81c995]" />
</div>
<div>
<h3 className="text-base font-bold text-[#e3e3e3]">
100 % Datensouveränität & Komplett-Export
</h3>
<p className="text-xs sm:text-sm text-[#9aa0a6]">
Exportiere jederzeit alle Gedanken, Aufgaben, Dokumente und geteilten Listen als portables JSON-Archiv.
</p>
</div>
</div>
<button
onClick={handleFullExport}
className="h-11 px-6 rounded-full bg-[#81c995] text-[#0b0f19] font-semibold text-sm flex items-center gap-2 hover:opacity-90 flex-shrink-0"
>
<Download className="w-4 h-4" />
<span>Komplett-Backup (.json)</span>
</button>
</div>
</div>
);
};
+272
View File
@@ -0,0 +1,272 @@
import React, { useEffect, useState } from 'react';
import {
CheckCircle2,
Circle,
Calendar,
Trash2,
RotateCcw,
FileText,
Tag,
AlertCircle,
} from 'lucide-react';
import { Thought } from '../../lib/types';
import { api, triggerHaptic } from '../../lib/api';
interface TasksViewProps {
onRefreshStats: () => void;
onOpenDocument: (docId: string) => void;
}
export const TasksView: React.FC<TasksViewProps> = ({ onRefreshStats, onOpenDocument }) => {
const [filter, setFilter] = useState<'open' | 'done' | 'discarded'>('open');
const [tasks, setTasks] = useState<Thought[]>([]);
const [discarded, setDiscarded] = useState<Thought[]>([]);
const [loading, setLoading] = useState(true);
const loadData = async () => {
setLoading(true);
try {
const [allTasks, allDiscarded] = await Promise.all([
api.getTasks(),
api.getThoughts('discarded'),
]);
setTasks(allTasks);
setDiscarded(allDiscarded);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const handleToggleDone = async (task: Thought) => {
triggerHaptic(15);
const nextState = !task.isCompleted;
setTasks((prev) =>
prev.map((t) => (t.id === task.id ? { ...t, isCompleted: nextState } : t))
);
await api.updateThought(task.id, { isCompleted: nextState });
onRefreshStats();
};
const handleDueDateChange = async (task: Thought, newDate: string) => {
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, dueDate: newDate } : t)));
await api.updateThought(task.id, { dueDate: newDate });
};
const handleConvertToDoc = async (task: Thought) => {
triggerHaptic(20);
const updated = await api.triageAction(task.id, 'keep', { convertTo: 'document' });
onRefreshStats();
if (updated.convertedRefId) {
onOpenDocument(updated.convertedRefId);
} else {
loadData();
}
};
const handleRestore = async (item: Thought) => {
triggerHaptic(15);
await api.triageAction(item.id, 'restore');
await loadData();
onRefreshStats();
};
const handleDeletePermanent = async (id: string) => {
triggerHaptic(20);
await api.deleteThought(id);
await loadData();
onRefreshStats();
};
const handlePurgeAllDiscarded = async () => {
await api.purgeDiscarded();
await loadData();
onRefreshStats();
};
const openTasks = tasks.filter((t) => !t.isCompleted);
const doneTasks = tasks.filter((t) => t.isCompleted);
return (
<div className="max-w-3xl mx-auto">
{/* Filter Pills */}
<div className="flex flex-wrap items-center justify-between gap-3 mb-6">
<div className="flex items-center gap-2">
<button
onClick={() => setFilter('open')}
className={`h-10 px-4 rounded-full text-xs sm:text-sm font-medium transition-colors ${
filter === 'open'
? 'bg-[#8ab4f8] text-[#0b0f19] font-semibold'
: 'bg-[#1e1f20] text-[#9aa0a6] hover:text-[#e3e3e3] gemini-border'
}`}
>
Offene Aufgaben ({openTasks.length})
</button>
<button
onClick={() => setFilter('done')}
className={`h-10 px-4 rounded-full text-xs sm:text-sm font-medium transition-colors ${
filter === 'done'
? 'bg-[#81c995] text-[#0b0f19] font-semibold'
: 'bg-[#1e1f20] text-[#9aa0a6] hover:text-[#e3e3e3] gemini-border'
}`}
>
Erledigt ({doneTasks.length})
</button>
<button
onClick={() => setFilter('discarded')}
className={`h-10 px-4 rounded-full text-xs sm:text-sm font-medium transition-colors ${
filter === 'discarded'
? 'bg-[#f28b82] text-[#0b0f19] font-semibold'
: 'bg-[#1e1f20] text-[#9aa0a6] hover:text-[#e3e3e3] gemini-border'
}`}
>
Verworfen ({discarded.length})
</button>
</div>
{filter === 'discarded' && discarded.length > 0 && (
<button
onClick={handlePurgeAllDiscarded}
className="h-9 px-4 rounded-full bg-[#f28b82]/15 hover:bg-[#f28b82]/25 text-[#f28b82] text-xs font-medium flex items-center gap-1.5 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
Papierkorb leeren
</button>
)}
</div>
{loading ? (
<div className="p-8 text-center text-[#9aa0a6] text-sm">Lade Aufgaben…</div>
) : filter === 'discarded' ? (
<div className="space-y-2.5">
{discarded.length === 0 ? (
<div className="p-10 rounded-3xl bg-[#1e1f20] gemini-border text-center text-[#9aa0a6] text-sm">
Keine verworfenen Gedanken im Papierkorb.
</div>
) : (
discarded.map((item) => (
<div
key={item.id}
className="p-4 rounded-3xl bg-[#1e1f20] gemini-border flex items-center justify-between gap-4"
>
<div className="min-w-0 flex-1">
<p className="text-sm text-[#9aa0a6] line-through truncate">{item.content}</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button
onClick={() => handleRestore(item)}
className="h-8 px-3 rounded-full bg-[#282a2c] hover:bg-[#323639] text-xs text-[#8ab4f8] flex items-center gap-1"
>
<RotateCcw className="w-3.5 h-3.5" /> Zurück in Inbox
</button>
<button
onClick={() => handleDeletePermanent(item.id)}
className="w-8 h-8 rounded-full bg-[#282a2c] hover:bg-[#f28b82]/20 text-[#f28b82] flex items-center justify-center"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
))
)}
</div>
) : (
<div className="space-y-2.5">
{(filter === 'open' ? openTasks : doneTasks).length === 0 ? (
<div className="p-10 rounded-3xl bg-[#1e1f20] gemini-border text-center text-[#9aa0a6] text-sm">
{filter === 'open'
? 'Keine offenen Aufgaben. Wische Gedanken in der Triage nach rechts, um Aufgaben anzulegen!'
: 'Noch keine erledigten Aufgaben.'}
</div>
) : (
(filter === 'open' ? openTasks : doneTasks).map((task) => (
<div
key={task.id}
className="p-4 sm:p-5 rounded-3xl bg-[#1e1f20] hover:bg-[#242628] gemini-border flex flex-col sm:flex-row sm:items-center justify-between gap-3 transition-colors"
>
<div className="flex items-start gap-3.5 min-w-0 flex-1">
<button
onClick={() => handleToggleDone(task)}
className="mt-0.5 flex-shrink-0 text-[#8ab4f8] hover:scale-110 transition-transform"
>
{task.isCompleted ? (
<CheckCircle2 className="w-5 h-5 text-[#81c995]" />
) : (
<Circle className="w-5 h-5 text-[#9aa0a6] hover:text-[#8ab4f8]" />
)}
</button>
<div className="min-w-0 flex-1">
<p
className={`text-sm sm:text-base font-medium break-words ${
task.isCompleted ? 'line-through text-[#5f6368]' : 'text-[#e3e3e3]'
}`}
>
{task.content}
</p>
<div className="flex flex-wrap items-center gap-2 mt-2">
{task.priority === 'urgent' && (
<span className="px-2.5 py-0.5 rounded-full bg-[#f28b82]/20 text-[#f28b82] text-xs flex items-center gap-1">
<AlertCircle className="w-3 h-3" /> Dringend
</span>
)}
{task.priority === 'high' && (
<span className="px-2.5 py-0.5 rounded-full bg-[#8ab4f8]/20 text-[#8ab4f8] text-xs">
Hoch
</span>
)}
{task.tags?.map((tag) => (
<span
key={tag}
className="px-2.5 py-0.5 rounded-full bg-[#131314] text-[#8ab4f8] text-xs flex items-center gap-1"
>
<Tag className="w-2.5 h-2.5" />#{tag}
</span>
))}
</div>
</div>
</div>
{/* Action Controls */}
<div className="flex items-center gap-2 self-end sm:self-center flex-shrink-0">
<div className="relative flex items-center">
<Calendar className="w-3.5 h-3.5 text-[#8ab4f8] absolute left-3 pointer-events-none" />
<input
type="date"
value={task.dueDate || ''}
onChange={(e) => handleDueDateChange(task, e.target.value)}
className="pl-8 pr-3 py-1 rounded-full bg-[#131314] text-[#e3e3e3] text-xs gemini-border focus:outline-none focus:border-[#8ab4f8]"
/>
</div>
<button
onClick={() => handleConvertToDoc(task)}
title="Im Workspace als Projekt-Dokument vertiefen"
className="h-8 px-3 rounded-full bg-[#282a2c] hover:bg-[#323538] text-xs text-[#c58af9] flex items-center gap-1"
>
<FileText className="w-3.5 h-3.5" />
<span className="hidden md:inline">Vertiefen</span>
</button>
<button
onClick={() => handleDeletePermanent(task.id)}
title="Löschen"
className="w-8 h-8 rounded-full bg-[#282a2c] hover:bg-[#f28b82]/20 text-[#9aa0a6] hover:text-[#f28b82] flex items-center justify-center"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
))
)}
</div>
)}
</div>
);
};
+457
View File
@@ -0,0 +1,457 @@
import React, { useEffect, useState } from 'react';
import { motion, useMotionValue, useTransform, AnimatePresence } from 'framer-motion';
import {
X,
Check,
Calendar,
FileText,
ShoppingCart,
RotateCcw,
Sparkles,
Mic,
Webhook,
Keyboard,
Tag,
Clock,
} from 'lucide-react';
import { SharedList, Thought } from '../../lib/types';
import { api, triggerHaptic } from '../../lib/api';
interface TriageDeckProps {
deck: Thought[];
lists: SharedList[];
onDeckChange: () => void;
onOpenDocument: (docId: string) => void;
onFocusCapture: () => void;
}
export const TriageDeck: React.FC<TriageDeckProps> = ({
deck,
lists,
onDeckChange,
onOpenDocument,
onFocusCapture,
}) => {
const [cards, setCards] = useState<Thought[]>(deck);
const [lastTriaged, setLastTriaged] = useState<Thought | null>(null);
const [showDateModal, setShowDateModal] = useState(false);
const [taskDueDate, setTaskDueDate] = useState(() => {
const tomorrow = new Date(Date.now() + 86400000);
return tomorrow.toISOString().slice(0, 10);
});
const [showListSelector, setShowListSelector] = useState(false);
useEffect(() => {
setCards(deck);
}, [deck]);
const activeCard = cards[0];
const nextCard = cards[1];
const x = useMotionValue(0);
const rotate = useTransform(x, [-220, 0, 220], [-14, 0, 14]);
const keepOpacity = useTransform(x, [25, 130], [0, 1]);
const discardOpacity = useTransform(x, [-130, -25], [1, 0]);
const cardBorderColor = useTransform(
x,
[-140, 0, 140],
['rgba(242, 139, 130, 0.65)', 'rgba(255, 255, 255, 0.08)', 'rgba(129, 201, 149, 0.65)']
);
const handleAction = async (
action: 'keep' | 'discard',
options?: {
convertTo?: 'task' | 'document' | 'list_item';
targetListId?: string;
dueDate?: string;
}
) => {
if (!activeCard) return;
const current = activeCard;
triggerHaptic(action === 'keep' ? [12, 25] : 18);
// Optimistic removal from deck
setCards((prev) => prev.slice(1));
setLastTriaged(current);
setShowDateModal(false);
setShowListSelector(false);
x.set(0);
try {
const updated = await api.triageAction(current.id, action, options);
onDeckChange();
if (options?.convertTo === 'document' && updated.convertedRefId) {
onOpenDocument(updated.convertedRefId);
}
} catch {
onDeckChange();
}
};
const handleUndo = async () => {
if (!lastTriaged) return;
triggerHaptic(15);
const toRestore = lastTriaged;
setLastTriaged(null);
await api.triageAction(toRestore.id, 'restore');
onDeckChange();
};
// Keyboard shortcuts for desktop power users (BRAIN.md Section 5)
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
if (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable ||
!activeCard
) {
return;
}
if (e.key === 'ArrowLeft' || e.key === 'h' || e.key === 'H') {
e.preventDefault();
handleAction('discard');
} else if (e.key === 'ArrowRight' || e.key === 'l' || e.key === 'L') {
e.preventDefault();
handleAction('keep');
} else if (e.key === 't' || e.key === 'T') {
e.preventDefault();
setShowDateModal((prev) => !prev);
} else if (e.key === 'd' || e.key === 'D') {
e.preventDefault();
handleAction('keep', { convertTo: 'document' });
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [activeCard]);
const priorityBadge = (priority: string) => {
switch (priority) {
case 'urgent':
return (
<span className="px-3 py-1 rounded-full text-xs font-semibold bg-[#f28b82]/20 text-[#f28b82] border border-[#f28b82]/40">
🚨 Dringend
</span>
);
case 'high':
return (
<span className="px-3 py-1 rounded-full text-xs font-semibold bg-[#8ab4f8]/20 text-[#8ab4f8] border border-[#8ab4f8]/40">
⚡ Priorität Hoch
</span>
);
case 'low':
return (
<span className="px-3 py-1 rounded-full text-xs font-medium bg-[#282a2c] text-[#9aa0a6]">
Später / Niedrig
</span>
);
default:
return (
<span className="px-3 py-1 rounded-full text-xs font-medium bg-[#282a2c] text-[#9aa0a6]">
Gedanke
</span>
);
}
};
const sourceBadge = (source: string) => {
if (source === 'voice') {
return (
<span className="px-3 py-1 rounded-full text-xs bg-[#c58af9]/15 text-[#c58af9] flex items-center gap-1">
<Mic className="w-3 h-3" /> Sprachnotiz
</span>
);
}
if (source === 'webhook') {
return (
<span className="px-3 py-1 rounded-full text-xs bg-[#81c995]/15 text-[#81c995] flex items-center gap-1">
<Webhook className="w-3 h-3" /> Smarthome Webhook
</span>
);
}
return null;
};
if (!activeCard) {
return (
<div className="max-w-xl mx-auto mt-8 p-8 sm:p-10 rounded-3xl bg-[#1e1f20] gemini-border text-center shadow-card-depth">
<div className="w-16 h-16 rounded-full bg-[#81c995]/15 border border-[#81c995]/30 flex items-center justify-center mx-auto mb-5">
<Sparkles className="w-8 h-8 text-[#81c995]" />
</div>
<h2 className="text-2xl font-bold text-[#e3e3e3] mb-2">Inbox Zero erreicht!</h2>
<p className="text-[#9aa0a6] text-sm sm:text-base max-w-md mx-auto mb-6">
Dein Kopf ist komplett frei. Alle eingefrorenen Gedanken wurden gesichtet, verworfen oder in echte Projekte und Aufgaben verwandelt.
</p>
<div className="flex flex-wrap items-center justify-center gap-3">
<button
onClick={onFocusCapture}
className="h-11 px-6 rounded-full bg-[#8ab4f8] text-[#0b0f19] font-semibold text-sm hover:bg-[#a1c2fa] transition-colors"
>
+ Neuen Gedanken einfrieren
</button>
{lastTriaged && (
<button
onClick={handleUndo}
className="h-11 px-5 rounded-full bg-[#282a2c] text-[#e3e3e3] hover:bg-[#333639] text-sm flex items-center gap-2 transition-colors"
>
<RotateCcw className="w-4 h-4 text-[#8ab4f8]" />
Letzten Swipe rückgängig machen
</button>
)}
</div>
</div>
);
}
return (
<div className="max-w-xl mx-auto select-none">
{/* Top Deck Status & Undo Bar */}
<div className="flex items-center justify-between mb-4 px-2">
<div className="flex items-center gap-2">
<span className="px-3.5 py-1 rounded-full bg-[#1e1f20] gemini-border text-xs font-medium text-[#8ab4f8]">
🃏 Noch {cards.length} {cards.length === 1 ? 'Gedanke' : 'Gedanken'} im Deck
</span>
<span className="hidden sm:flex items-center gap-1 text-xs text-[#5f6368]">
<Keyboard className="w-3.5 h-3.5" /> Pfeiltasten ← / → oder H / L
</span>
</div>
{lastTriaged && (
<button
onClick={handleUndo}
className="px-3.5 py-1 rounded-full bg-[#1e1f20] hover:bg-[#282a2c] gemini-border text-xs text-[#9aa0a6] hover:text-[#e3e3e3] flex items-center gap-1.5 transition-colors"
>
<RotateCcw className="w-3.5 h-3.5 text-[#8ab4f8]" />
Rückgängig
</button>
)}
</div>
{/* Card Stack Container */}
<div className="relative h-[340px] sm:h-[360px] w-full flex items-center justify-center">
{/* Background Preview Card */}
{nextCard && (
<div
key={nextCard.id + '-preview'}
className="absolute inset-x-4 top-5 bottom-0 rounded-3xl bg-[#18191b] gemini-border p-6 opacity-50 scale-[0.95] pointer-events-none flex flex-col justify-between"
>
<div className="flex items-center gap-2">{priorityBadge(nextCard.priority)}</div>
<p className="text-lg text-[#9aa0a6] line-clamp-3">{nextCard.content}</p>
<div className="text-xs text-[#5f6368]">Nächste Karte…</div>
</div>
)}
{/* Active Draggable Card */}
<AnimatePresence mode="popLayout">
<motion.div
key={activeCard.id}
style={{ x, rotate, borderColor: cardBorderColor }}
drag="x"
dragConstraints={{ left: 0, right: 0 }}
dragElastic={0.85}
transition={{ type: 'spring', stiffness: 350, damping: 25 }}
onDragEnd={(_, info) => {
if (info.offset.x > 110 || info.velocity.x > 550) {
handleAction('keep');
} else if (info.offset.x < -110 || info.velocity.x < -550) {
handleAction('discard');
}
}}
initial={{ scale: 0.94, opacity: 0, y: 16 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.9, opacity: 0 }}
className="relative w-full h-full rounded-3xl bg-[#1e1f20] border p-6 sm:p-8 shadow-card-depth cursor-grab active:cursor-grabbing flex flex-col justify-between overflow-hidden"
>
{/* Dynamic Swipe Overlays */}
<motion.div
style={{ opacity: keepOpacity }}
className="pointer-events-none absolute top-6 right-6 px-4 py-1.5 rounded-full bg-[#81c995]/20 border-2 border-[#81c995] text-[#81c995] font-bold text-xs tracking-wider uppercase"
>
Behalten 👉
</motion.div>
<motion.div
style={{ opacity: discardOpacity }}
className="pointer-events-none absolute top-6 left-6 px-4 py-1.5 rounded-full bg-[#f28b82]/20 border-2 border-[#f28b82] text-[#f28b82] font-bold text-xs tracking-wider uppercase"
>
👈 Weg damit
</motion.div>
{/* Card Header */}
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
{priorityBadge(activeCard.priority)}
{sourceBadge(activeCard.source)}
</div>
<span className="text-xs text-[#5f6368] flex items-center gap-1">
<Clock className="w-3.5 h-3.5" />
{new Date(activeCard.createdAt).toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
})}
</span>
</div>
{/* Card Body: The Thought */}
<div className="my-auto py-4">
<p className="text-xl sm:text-2xl font-medium text-[#e3e3e3] leading-relaxed break-words">
{activeCard.content}
</p>
</div>
{/* Card Footer: Tags & Due Date */}
<div className="flex flex-wrap items-center justify-between gap-2 pt-3 border-t border-white/[0.06]">
<div className="flex flex-wrap items-center gap-1.5">
{activeCard.tags && activeCard.tags.length > 0 ? (
activeCard.tags.map((tag) => (
<span
key={tag}
className="px-3 py-0.5 rounded-full bg-[#131314] text-[#8ab4f8] text-xs flex items-center gap-1"
>
<Tag className="w-3 h-3" />#{tag}
</span>
))
) : (
<span className="text-xs text-[#5f6368]">Wische links zum Verwerfen, rechts zum Behalten</span>
)}
</div>
{activeCard.dueDate && (
<span className="px-3 py-0.5 rounded-full bg-[#8ab4f8]/15 text-[#8ab4f8] text-xs">
📅 {activeCard.dueDate}
</span>
)}
</div>
</motion.div>
</AnimatePresence>
</div>
{/* Convert to Task Date Picker Popover */}
{showDateModal && (
<div className="mt-4 p-4 rounded-3xl bg-[#131314] gemini-border flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm text-[#e3e3e3]">
<Calendar className="w-4 h-4 text-[#8ab4f8]" />
<span>Als To-Do mit Fälligkeitsdatum anlegen:</span>
</div>
<div className="flex items-center gap-2">
<input
type="date"
value={taskDueDate}
onChange={(e) => setTaskDueDate(e.target.value)}
className="bg-[#1e1f20] text-[#e3e3e3] px-3.5 py-1.5 rounded-full gemini-border text-xs focus:outline-none"
/>
<button
onClick={() => handleAction('keep', { convertTo: 'task', dueDate: taskDueDate })}
className="px-4 py-1.5 rounded-full bg-[#81c995] text-[#0b0f19] font-semibold text-xs hover:opacity-90"
>
Aufgabe speichern
</button>
</div>
</div>
)}
{/* Convert to Shopping List Item Popover */}
{showListSelector && (
<div className="mt-4 p-4 rounded-3xl bg-[#131314] gemini-border">
<p className="text-xs text-[#9aa0a6] mb-2.5">
In welche geteilte Liste soll dieser Gedanke verschoben werden?
</p>
<div className="flex flex-wrap gap-2">
{lists.length > 0 ? (
lists.map((lst) => (
<button
key={lst.id}
onClick={() =>
handleAction('keep', { convertTo: 'list_item', targetListId: lst.id })
}
className="px-4 py-2 rounded-full bg-[#1e1f20] hover:bg-[#282a2c] gemini-border text-xs text-[#e3e3e3] flex items-center gap-1.5 transition-colors"
>
<span>{lst.icon}</span>
<span>{lst.title}</span>
</button>
))
) : (
<span className="text-xs text-[#5f6368]">Noch keine Listen vorhanden.</span>
)}
</div>
</div>
)}
{/* Primary Triage Action Pills */}
<div className="mt-6 flex flex-wrap items-center justify-center gap-2.5 sm:gap-3">
{/* Swipe Left: Discard */}
<button
onClick={() => handleAction('discard')}
title="Verwerfen (Taste H oder Pfeil links)"
className="h-12 px-5 rounded-full bg-[#1e1f20] hover:bg-[#f28b82]/20 border border-[#f28b82]/30 text-[#f28b82] font-medium text-sm flex items-center gap-2 transition-all active:scale-95"
>
<X className="w-4 h-4" />
<span>Weg damit</span>
</button>
{/* Convert to Task with Due Date */}
<button
onClick={() => {
setShowListSelector(false);
setShowDateModal((prev) => !prev);
}}
title="Als Aufgabe mit Datum (Taste T)"
className={`h-12 px-4 rounded-full gemini-border text-xs sm:text-sm flex items-center gap-1.5 transition-all ${
showDateModal
? 'bg-[#8ab4f8] text-[#0b0f19] font-semibold'
: 'bg-[#1e1f20] hover:bg-[#282a2c] text-[#e3e3e3]'
}`}
>
<Calendar className="w-4 h-4 text-[#8ab4f8]" />
<span>Termin-To-Do</span>
</button>
{/* Convert to Workspace Document */}
<button
onClick={() => handleAction('keep', { convertTo: 'document' })}
title="In ein Workspace-Dokument verwandeln (Taste D)"
className="h-12 px-4 rounded-full bg-[#1e1f20] hover:bg-[#282a2c] gemini-border text-[#e3e3e3] text-xs sm:text-sm flex items-center gap-1.5 transition-all"
>
<FileText className="w-4 h-4 text-[#c58af9]" />
<span>Als Projekt-Notiz</span>
</button>
{/* Convert to Shared Shopping List Item */}
<button
onClick={() => {
setShowDateModal(false);
setShowListSelector((prev) => !prev);
}}
title="Auf eine geteilte Einkaufsliste setzen"
className={`h-12 px-4 rounded-full gemini-border text-xs sm:text-sm flex items-center gap-1.5 transition-all ${
showListSelector
? 'bg-[#8ab4f8] text-[#0b0f19] font-semibold'
: 'bg-[#1e1f20] hover:bg-[#282a2c] text-[#e3e3e3]'
}`}
>
<ShoppingCart className="w-4 h-4 text-[#81c995]" />
<span>Auf Liste</span>
</button>
{/* Swipe Right: Keep */}
<button
onClick={() => handleAction('keep')}
title="Behalten & als Aufgabe sichern (Taste L oder Pfeil rechts)"
className="h-12 px-5 rounded-full bg-[#81c995]/20 hover:bg-[#81c995]/30 border border-[#81c995]/40 text-[#81c995] font-semibold text-sm flex items-center gap-2 transition-all active:scale-95"
>
<Check className="w-4 h-4" />
<span>Behalten</span>
</button>
</div>
</div>
);
};
@@ -0,0 +1,539 @@
import React, { useEffect, useState } from 'react';
import {
Plus,
Pin,
Trash2,
Download,
CheckSquare,
Heading1,
Heading2,
List,
Code,
Sparkles,
AlignLeft,
Check,
Search,
ArrowUp,
ArrowDown,
} from 'lucide-react';
import { BlockType, Document, DocumentBlock } from '../../lib/types';
import { api, triggerHaptic } from '../../lib/api';
interface WorkspaceViewProps {
initialDocId?: string | null;
onStatsChange: () => void;
}
const ICONS = ['🚀', '💡', '📄', '🏡', '⚡', '🎯', '🍕', '🛠️', '🧠', '🔥', '🏔️', '📦'];
export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
initialDocId,
onStatsChange,
}) => {
const [docs, setDocs] = useState<Document[]>([]);
const [selectedDoc, setSelectedDoc] = useState<Document | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [saveState, setSaveState] = useState<'saved' | 'saving'>('saved');
const [showIconPicker, setShowIconPicker] = useState(false);
const loadDocs = async (selectId?: string | null) => {
const list = await api.getDocuments();
setDocs(list);
if (selectId) {
const found = list.find((d) => d.id === selectId);
if (found) {
setSelectedDoc(found);
return;
}
}
if (!selectedDoc && list.length > 0) {
setSelectedDoc(list[0]);
} else if (selectedDoc) {
const updated = list.find((d) => d.id === selectedDoc.id);
if (updated) setSelectedDoc(updated);
}
};
useEffect(() => {
loadDocs(initialDocId);
}, [initialDocId]);
const persistDocument = async (updatedDoc: Document) => {
setSelectedDoc(updatedDoc);
setDocs((prev) => prev.map((d) => (d.id === updatedDoc.id ? updatedDoc : d)));
setSaveState('saving');
try {
await api.updateDocument(updatedDoc.id, {
title: updatedDoc.title,
icon: updatedDoc.icon,
blocks: updatedDoc.blocks,
tags: updatedDoc.tags,
isPinned: updatedDoc.isPinned,
});
} finally {
setSaveState('saved');
}
};
const handleCreateDoc = async () => {
triggerHaptic(15);
const created = await api.createDocument({
title: 'Neues Projekt / Notiz',
icon: '💡',
blocks: [
{
id: `blk_${Date.now()}_1`,
type: 'callout',
content: 'Kernidee oder Ziel dieses Projekts kurz zusammenfassen…',
},
{
id: `blk_${Date.now()}_2`,
type: 'h2',
content: 'Nächste Schritte',
},
{
id: `blk_${Date.now()}_3`,
type: 'todo',
content: 'Ersten Punkt abhaken',
checked: false,
},
],
tags: ['projekt'],
});
await loadDocs(created.id);
onStatsChange();
};
const handleDeleteDoc = async (docId: string) => {
triggerHaptic(20);
await api.deleteDocument(docId);
const remaining = docs.filter((d) => d.id !== docId);
setDocs(remaining);
setSelectedDoc(remaining[0] || null);
onStatsChange();
};
const addBlock = (type: BlockType, afterIndex?: number) => {
if (!selectedDoc) return;
const newBlock: DocumentBlock = {
id: `blk_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
type,
content: '',
checked: false,
lang: type === 'code' ? 'bash' : undefined,
};
const nextBlocks = [...selectedDoc.blocks];
if (afterIndex !== undefined && afterIndex >= 0) {
nextBlocks.splice(afterIndex + 1, 0, newBlock);
} else {
nextBlocks.push(newBlock);
}
persistDocument({ ...selectedDoc, blocks: nextBlocks });
};
const updateBlock = (blockId: string, patch: Partial<DocumentBlock>) => {
if (!selectedDoc) return;
const nextBlocks = selectedDoc.blocks.map((b) =>
b.id === blockId ? { ...b, ...patch } : b
);
persistDocument({ ...selectedDoc, blocks: nextBlocks });
};
const removeBlock = (blockId: string) => {
if (!selectedDoc || selectedDoc.blocks.length <= 1) return;
const nextBlocks = selectedDoc.blocks.filter((b) => b.id !== blockId);
persistDocument({ ...selectedDoc, blocks: nextBlocks });
};
const moveBlock = (index: number, direction: -1 | 1) => {
if (!selectedDoc) return;
const targetIdx = index + direction;
if (targetIdx < 0 || targetIdx >= selectedDoc.blocks.length) return;
const nextBlocks = [...selectedDoc.blocks];
const [moved] = nextBlocks.splice(index, 1);
nextBlocks.splice(targetIdx, 0, moved);
persistDocument({ ...selectedDoc, blocks: nextBlocks });
};
const handleExportMarkdown = () => {
if (!selectedDoc) return;
const lines: string[] = [`# ${selectedDoc.icon} ${selectedDoc.title}`, ''];
for (const b of selectedDoc.blocks) {
switch (b.type) {
case 'h1':
lines.push(`# ${b.content}`, '');
break;
case 'h2':
lines.push(`## ${b.content}`, '');
break;
case 'h3':
lines.push(`### ${b.content}`, '');
break;
case 'todo':
lines.push(`- [${b.checked ? 'x' : ' '}] ${b.content}`);
break;
case 'bullet':
lines.push(`- ${b.content}`);
break;
case 'callout':
lines.push(`> 💡 ${b.content}`, '');
break;
case 'code':
lines.push('```' + (b.lang || ''), b.content, '```', '');
break;
default:
lines.push(b.content, '');
}
}
const blob = new Blob([lines.join('\n')], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${selectedDoc.title.toLowerCase().replace(/[^a-z0-9äöüß]+/g, '-')}.md`;
a.click();
URL.revokeObjectURL(url);
};
const filteredDocs = docs.filter(
(d) =>
d.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
d.content.toLowerCase().includes(searchQuery.toLowerCase())
);
return (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 max-w-6xl mx-auto">
{/* Left Document List Sidebar */}
<div className="lg:col-span-4 space-y-3">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="w-4 h-4 text-[#9aa0a6] absolute left-3.5 top-1/2 -translate-y-1/2" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Notizen durchsuchen…"
className="w-full h-10 pl-9 pr-4 rounded-full bg-[#1e1f20] text-xs sm:text-sm text-[#e3e3e3] placeholder-[#9aa0a6] gemini-border focus:outline-none focus:border-[#8ab4f8]"
/>
</div>
<button
onClick={handleCreateDoc}
className="h-10 px-4 rounded-full bg-[#8ab4f8] text-[#0b0f19] font-semibold text-xs sm:text-sm flex items-center gap-1.5 hover:bg-[#a1c2fa] transition-colors flex-shrink-0"
>
<Plus className="w-4 h-4" />
<span>Neu</span>
</button>
</div>
<div className="space-y-2 max-h-[540px] overflow-y-auto pr-1">
{filteredDocs.map((doc) => {
const active = selectedDoc?.id === doc.id;
return (
<button
key={doc.id}
onClick={() => setSelectedDoc(doc)}
className={`w-full text-left p-4 rounded-3xl transition-all flex items-start justify-between gap-3 ${
active
? 'bg-[#1e1f20] border border-[#8ab4f8]/50 shadow-gemini-glow'
: 'bg-[#131314] hover:bg-[#1e1f20] gemini-border'
}`}
>
<div className="flex items-start gap-3 min-w-0">
<span className="text-2xl flex-shrink-0">{doc.icon}</span>
<div className="min-w-0">
<h4 className="font-semibold text-sm text-[#e3e3e3] truncate">{doc.title}</h4>
<p className="text-xs text-[#9aa0a6] truncate mt-0.5">
{doc.blocks?.[0]?.content || 'Leeres Dokument'}
</p>
</div>
</div>
{doc.isPinned && <Pin className="w-3.5 h-3.5 text-[#8ab4f8] flex-shrink-0 mt-1" />}
</button>
);
})}
</div>
</div>
{/* Right Notion-Flair Block Editor */}
<div className="lg:col-span-8">
{selectedDoc ? (
<div className="p-6 sm:p-8 rounded-3xl bg-[#1e1f20] gemini-border shadow-card-depth">
{/* Document Top Action Bar */}
<div className="flex flex-wrap items-center justify-between gap-3 pb-4 mb-6 border-b border-white/[0.06]">
<div className="flex items-center gap-2 text-xs text-[#9aa0a6]">
<span className="px-3 py-1 rounded-full bg-[#131314] text-[#81c995] flex items-center gap-1">
<Check className="w-3 h-3" />
{saveState === 'saving' ? 'Speichert…' : 'Automatisch gespeichert'}
</span>
<span>• {selectedDoc.blocks.length} Blöcke</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={() =>
persistDocument({ ...selectedDoc, isPinned: !selectedDoc.isPinned })
}
className={`h-8 px-3 rounded-full text-xs flex items-center gap-1.5 transition-colors ${
selectedDoc.isPinned
? 'bg-[#8ab4f8]/20 text-[#8ab4f8] border border-[#8ab4f8]/40'
: 'bg-[#282a2c] text-[#9aa0a6] hover:text-[#e3e3e3]'
}`}
>
<Pin className="w-3.5 h-3.5" />
<span>{selectedDoc.isPinned ? 'Angepinnt' : 'Anpinnen'}</span>
</button>
<button
onClick={handleExportMarkdown}
className="h-8 px-3 rounded-full bg-[#282a2c] hover:bg-[#323639] text-xs text-[#e3e3e3] flex items-center gap-1.5 transition-colors"
>
<Download className="w-3.5 h-3.5 text-[#8ab4f8]" />
<span>Markdown (.md)</span>
</button>
<button
onClick={() => handleDeleteDoc(selectedDoc.id)}
className="w-8 h-8 rounded-full bg-[#282a2c] hover:bg-[#f28b82]/20 text-[#9aa0a6] hover:text-[#f28b82] flex items-center justify-center transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* Document Icon & Title */}
<div className="flex items-center gap-3 mb-6 relative">
<button
type="button"
onClick={() => setShowIconPicker((prev) => !prev)}
className="w-12 h-12 rounded-2xl bg-[#131314] hover:bg-[#282a2c] gemini-border text-2xl flex items-center justify-center flex-shrink-0"
title="Icon ändern"
>
{selectedDoc.icon}
</button>
{showIconPicker && (
<div className="absolute top-14 left-0 z-20 p-2.5 rounded-3xl bg-[#131314] gemini-border shadow-card-depth flex flex-wrap gap-1.5 w-64">
{ICONS.map((ic) => (
<button
key={ic}
type="button"
onClick={() => {
persistDocument({ ...selectedDoc, icon: ic });
setShowIconPicker(false);
}}
className="w-9 h-9 rounded-full hover:bg-[#282a2c] text-lg flex items-center justify-center"
>
{ic}
</button>
))}
</div>
)}
<input
type="text"
value={selectedDoc.title}
onChange={(e) => persistDocument({ ...selectedDoc, title: e.target.value })}
placeholder="Dokument-Titel…"
className="w-full bg-transparent text-2xl sm:text-3xl font-bold text-[#e3e3e3] focus:outline-none"
/>
</div>
{/* Blocks Stack */}
<div className="space-y-3">
{selectedDoc.blocks.map((block, idx) => (
<div key={block.id} className="group flex items-start gap-2">
{/* Block Renderer */}
<div className="flex-1 min-w-0">
{block.type === 'h1' && (
<input
type="text"
value={block.content}
onChange={(e) => updateBlock(block.id, { content: e.target.value })}
placeholder="Überschrift 1…"
className="w-full bg-transparent text-2xl font-bold text-[#e3e3e3] focus:outline-none py-1"
/>
)}
{block.type === 'h2' && (
<input
type="text"
value={block.content}
onChange={(e) => updateBlock(block.id, { content: e.target.value })}
placeholder="Abschnitts-Überschrift…"
className="w-full bg-transparent text-lg font-semibold text-[#8ab4f8] focus:outline-none pt-2 pb-1"
/>
)}
{block.type === 'paragraph' && (
<textarea
rows={Math.max(1, Math.ceil(block.content.length / 75))}
value={block.content}
onChange={(e) => updateBlock(block.id, { content: e.target.value })}
placeholder="Text schreiben…"
className="w-full bg-transparent text-sm sm:text-base text-[#e3e3e3] leading-relaxed focus:outline-none resize-none"
/>
)}
{block.type === 'todo' && (
<div className="flex items-center gap-3 py-1">
<input
type="checkbox"
checked={!!block.checked}
onChange={(e) => updateBlock(block.id, { checked: e.target.checked })}
className="w-4 h-4 rounded accent-[#81c995] cursor-pointer"
/>
<input
type="text"
value={block.content}
onChange={(e) => updateBlock(block.id, { content: e.target.value })}
placeholder="To-Do Punkt…"
className={`w-full bg-transparent text-sm sm:text-base focus:outline-none ${
block.checked ? 'line-through text-[#5f6368]' : 'text-[#e3e3e3]'
}`}
/>
</div>
)}
{block.type === 'bullet' && (
<div className="flex items-center gap-3 py-0.5">
<span className="w-1.5 h-1.5 rounded-full bg-[#8ab4f8] flex-shrink-0" />
<input
type="text"
value={block.content}
onChange={(e) => updateBlock(block.id, { content: e.target.value })}
placeholder="Listenpunkt…"
className="w-full bg-transparent text-sm sm:text-base text-[#e3e3e3] focus:outline-none"
/>
</div>
)}
{block.type === 'callout' && (
<div className="p-4 rounded-2xl bg-[#131314] border border-[#8ab4f8]/30 flex items-start gap-3">
<Sparkles className="w-4 h-4 text-[#8ab4f8] flex-shrink-0 mt-1" />
<textarea
rows={2}
value={block.content}
onChange={(e) => updateBlock(block.id, { content: e.target.value })}
placeholder="Wichtiger Hinweis oder Zusammenfassung…"
className="w-full bg-transparent text-sm text-[#e3e3e3] focus:outline-none resize-none"
/>
</div>
)}
{block.type === 'code' && (
<div className="p-4 rounded-2xl bg-[#0b0f19] gemini-border font-mono text-xs sm:text-sm text-[#81c995]">
<textarea
rows={Math.max(2, block.content.split('\n').length)}
value={block.content}
onChange={(e) => updateBlock(block.id, { content: e.target.value })}
placeholder="// Code oder Shell-Kommando…"
className="w-full bg-transparent focus:outline-none resize-none"
/>
</div>
)}
</div>
{/* Block Reorder & Delete Controls */}
<div className="opacity-0 group-hover:opacity-100 flex items-center gap-1 transition-opacity pt-1">
<button
type="button"
onClick={() => moveBlock(idx, -1)}
className="p-1 rounded-full hover:bg-[#282a2c] text-[#9aa0a6]"
title="Nach oben"
>
<ArrowUp className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => moveBlock(idx, 1)}
className="p-1 rounded-full hover:bg-[#282a2c] text-[#9aa0a6]"
title="Nach unten"
>
<ArrowDown className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => removeBlock(block.id)}
className="p-1 rounded-full hover:bg-[#f28b82]/20 text-[#9aa0a6] hover:text-[#f28b82]"
title="Block entfernen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
))}
</div>
{/* Add Block Pill Toolbar */}
<div className="mt-8 pt-4 border-t border-white/[0.06] flex flex-wrap items-center gap-2">
<span className="text-xs text-[#9aa0a6] mr-1">+ Block einfügen:</span>
<button
type="button"
onClick={() => addBlock('paragraph')}
className="h-8 px-3 rounded-full bg-[#131314] hover:bg-[#282a2c] gemini-border text-xs text-[#e3e3e3] flex items-center gap-1.5"
>
<AlignLeft className="w-3.5 h-3.5 text-[#8ab4f8]" /> Text
</button>
<button
type="button"
onClick={() => addBlock('h2')}
className="h-8 px-3 rounded-full bg-[#131314] hover:bg-[#282a2c] gemini-border text-xs text-[#e3e3e3] flex items-center gap-1.5"
>
<Heading2 className="w-3.5 h-3.5 text-[#8ab4f8]" /> Überschrift
</button>
<button
type="button"
onClick={() => addBlock('todo')}
className="h-8 px-3 rounded-full bg-[#131314] hover:bg-[#282a2c] gemini-border text-xs text-[#e3e3e3] flex items-center gap-1.5"
>
<CheckSquare className="w-3.5 h-3.5 text-[#81c995]" /> Checkliste
</button>
<button
type="button"
onClick={() => addBlock('bullet')}
className="h-8 px-3 rounded-full bg-[#131314] hover:bg-[#282a2c] gemini-border text-xs text-[#e3e3e3] flex items-center gap-1.5"
>
<List className="w-3.5 h-3.5 text-[#8ab4f8]" /> Aufzählung
</button>
<button
type="button"
onClick={() => addBlock('callout')}
className="h-8 px-3 rounded-full bg-[#131314] hover:bg-[#282a2c] gemini-border text-xs text-[#e3e3e3] flex items-center gap-1.5"
>
<Sparkles className="w-3.5 h-3.5 text-[#c58af9]" /> Callout
</button>
<button
type="button"
onClick={() => addBlock('code')}
className="h-8 px-3 rounded-full bg-[#131314] hover:bg-[#282a2c] gemini-border text-xs text-[#e3e3e3] flex items-center gap-1.5"
>
<Code className="w-3.5 h-3.5 text-[#81c995]" /> Codeblock
</button>
<button
type="button"
onClick={() => addBlock('h1')}
className="h-8 px-3 rounded-full bg-[#131314] hover:bg-[#282a2c] gemini-border text-xs text-[#e3e3e3] flex items-center gap-1.5"
>
<Heading1 className="w-3.5 h-3.5 text-[#8ab4f8]" /> H1
</button>
</div>
</div>
) : (
<div className="p-12 rounded-3xl bg-[#1e1f20] gemini-border text-center text-[#9aa0a6]">
Wähle links ein Dokument aus oder erstelle ein neues Projekt.
</div>
)}
</div>
</div>
);
};
+69
View File
@@ -0,0 +1,69 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
/* BRAIN.md Strict Gemini Dark Theme Tokens */
--bg-app: #0b0f19;
--bg-surface: #131314;
--bg-card: #1e1f20;
--bg-card-hover: #282a2c;
--border-subtle: rgba(255, 255, 255, 0.08);
--border-focus: rgba(138, 180, 248, 0.40);
--accent-blue: #8ab4f8;
--accent-purple: #c58af9;
--swipe-keep: #81c995;
--swipe-discard: #f28b82;
--text-primary: #e3e3e3;
--text-muted: #9aa0a6;
--text-subtle: #5f6368;
}
* {
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
body {
background-color: var(--bg-app);
color: var(--text-primary);
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
overflow-x: hidden;
}
/* Subtle custom dark scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.12);
border-radius: 9999px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(138, 180, 248, 0.35);
}
.gemini-border {
border: 1px solid var(--border-subtle);
}
.gemini-input-glow:focus-within {
border-color: rgba(138, 180, 248, 0.55);
box-shadow: 0 0 32px -4px rgba(138, 180, 248, 0.25), 0 0 12px -2px rgba(197, 138, 249, 0.2);
}
.gemini-gradient-text {
background: linear-gradient(135deg, #8ab4f8 0%, #c58af9 55%, #81c995 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
+368
View File
@@ -0,0 +1,368 @@
import {
APIKey,
DashboardStats,
Document,
DocumentBlock,
ListItem,
PriorityLevel,
SharedList,
Space,
Thought,
User,
} from './types';
const TOKEN_STORAGE_KEY = 'mindsift_jwt_token';
const OFFLINE_QUEUE_KEY = 'mindsift_offline_capture_queue';
export function getToken(): string | null {
return localStorage.getItem(TOKEN_STORAGE_KEY);
}
export function setToken(token: string): void {
localStorage.setItem(TOKEN_STORAGE_KEY, token);
}
export function clearToken(): void {
localStorage.removeItem(TOKEN_STORAGE_KEY);
}
export function triggerHaptic(pattern: number | number[] = 15): void {
try {
if (typeof navigator !== 'undefined' && 'vibrate' in navigator) {
navigator.vibrate(pattern);
}
} catch {
// Ignore on desktop browsers
}
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = getToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const res = await fetch(path, {
...options,
headers,
});
if (!res.ok) {
let errMsg = `HTTP ${res.status}`;
try {
const errData = await res.json();
if (errData.error) errMsg = errData.error;
} catch {
// ignore
}
throw new Error(errMsg);
}
return res.json();
}
export const api = {
// --- AUTH ---
async quickStart(): Promise<{ token: string; user: User }> {
const res = await request<{ token: string; user: User }>('/api/v1/auth/quickstart', {
method: 'POST',
});
setToken(res.token);
return res;
},
async login(email: string, password: string): Promise<{ token: string; user: User }> {
const res = await request<{ token: string; user: User }>('/api/v1/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
setToken(res.token);
return res;
},
async register(email: string, password: string, displayName: string): Promise<{ token: string; user: User }> {
const res = await request<{ token: string; user: User }>('/api/v1/auth/register', {
method: 'POST',
body: JSON.stringify({ email, password, displayName }),
});
setToken(res.token);
return res;
},
async getMe(): Promise<{ user: User; stats: DashboardStats }> {
return request<{ user: User; stats: DashboardStats }>('/api/v1/auth/me');
},
async getStats(): Promise<DashboardStats> {
return request<DashboardStats>('/api/v1/stats');
},
// --- INSTANT CAPTURE & OFFLINE SYNC ---
async captureThought(
content: string,
priority: PriorityLevel = 'normal',
source: 'manual' | 'voice' = 'manual',
dueDate = ''
): Promise<Thought> {
try {
const thought = await request<Thought>('/api/v1/inbox', {
method: 'POST',
body: JSON.stringify({ content, priority, source, dueDate }),
});
return thought;
} catch (err) {
// Queue locally if offline
const queue = getOfflineQueue();
const offlineThought: Thought = {
id: `th_offline_${Date.now()}`,
userId: 'offline',
content,
tags: (content.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g) || []).map((t) => t.slice(1).toLowerCase()),
priority,
source,
status: 'inbox',
isCompleted: false,
dueDate,
createdAt: new Date().toISOString(),
};
queue.push(offlineThought);
localStorage.setItem(OFFLINE_QUEUE_KEY, JSON.stringify(queue));
return offlineThought;
}
},
async flushOfflineQueue(): Promise<number> {
const queue = getOfflineQueue();
if (queue.length === 0) return 0;
let synced = 0;
const remaining: Thought[] = [];
for (const item of queue) {
try {
await request<Thought>('/api/v1/inbox', {
method: 'POST',
body: JSON.stringify({
content: item.content,
priority: item.priority,
source: item.source,
dueDate: item.dueDate || '',
}),
});
synced++;
} catch {
remaining.push(item);
}
}
localStorage.setItem(OFFLINE_QUEUE_KEY, JSON.stringify(remaining));
return synced;
},
// --- THOUGHTS, TRIAGE & TASKS ---
async getTriageDeck(): Promise<Thought[]> {
return request<Thought[]>('/api/v1/triage/deck');
},
async getThoughts(status = 'all'): Promise<Thought[]> {
return request<Thought[]>(`/api/v1/thoughts?status=${encodeURIComponent(status)}`);
},
async triageAction(
thoughtId: string,
action: 'keep' | 'discard' | 'restore',
options?: {
convertTo?: 'task' | 'document' | 'list_item';
targetListId?: string;
dueDate?: string;
}
): Promise<Thought> {
return request<Thought>(`/api/v1/triage/${thoughtId}`, {
method: 'PATCH',
body: JSON.stringify({
action,
convertTo: options?.convertTo || '',
targetListId: options?.targetListId || '',
dueDate: options?.dueDate || '',
}),
});
},
async getTasks(): Promise<Thought[]> {
return request<Thought[]>('/api/v1/tasks');
},
async updateThought(
thoughtId: string,
patch: { content?: string; isCompleted?: boolean; dueDate?: string; priority?: PriorityLevel }
): Promise<Thought> {
return request<Thought>(`/api/v1/thoughts/${thoughtId}`, {
method: 'PATCH',
body: JSON.stringify(patch),
});
},
async deleteThought(thoughtId: string): Promise<void> {
await request(`/api/v1/thoughts/${thoughtId}`, { method: 'DELETE' });
},
async purgeDiscarded(): Promise<{ purged: number }> {
return request<{ purged: number }>('/api/v1/thoughts/discarded/purge', { method: 'DELETE' });
},
// --- WORKSPACE DOCUMENTS ---
async getDocuments(): Promise<Document[]> {
return request<Document[]>('/api/v1/documents');
},
async getDocument(id: string): Promise<Document> {
return request<Document>(`/api/v1/documents/${id}`);
},
async createDocument(payload: {
title: string;
icon?: string;
blocks?: DocumentBlock[];
tags?: string[];
isPinned?: boolean;
}): Promise<Document> {
return request<Document>('/api/v1/documents', {
method: 'POST',
body: JSON.stringify(payload),
});
},
async updateDocument(
id: string,
patch: {
title?: string;
icon?: string;
blocks?: DocumentBlock[];
tags?: string[];
isPinned?: boolean;
}
): Promise<Document> {
return request<Document>(`/api/v1/documents/${id}`, {
method: 'PUT',
body: JSON.stringify(patch),
});
},
async deleteDocument(id: string): Promise<void> {
await request(`/api/v1/documents/${id}`, { method: 'DELETE' });
},
// --- SPACES & REALTIME LISTS ---
async getSpaces(): Promise<Space[]> {
return request<Space[]>('/api/v1/spaces');
},
async createSpace(name: string, icon: string, description: string): Promise<Space> {
return request<Space>('/api/v1/spaces', {
method: 'POST',
body: JSON.stringify({ name, icon, description }),
});
},
async joinSpace(inviteCode: string): Promise<Space> {
return request<Space>('/api/v1/spaces/join', {
method: 'POST',
body: JSON.stringify({ inviteCode }),
});
},
async getSpaceLists(spaceId: string): Promise<SharedList[]> {
return request<SharedList[]>(`/api/v1/spaces/${spaceId}/lists`);
},
async getAllLists(): Promise<SharedList[]> {
return request<SharedList[]>('/api/v1/lists');
},
async createList(spaceId: string, title: string, icon: string, listType: 'shopping' | 'checklist' | 'vacation'): Promise<SharedList> {
return request<SharedList>(`/api/v1/spaces/${spaceId}/lists`, {
method: 'POST',
body: JSON.stringify({ title, icon, listType }),
});
},
async deleteList(listId: string): Promise<void> {
await request(`/api/v1/lists/${listId}`, { method: 'DELETE' });
},
async createListItem(listId: string, content: string, category = '', quantity = ''): Promise<ListItem> {
return request<ListItem>(`/api/v1/lists/${listId}/items`, {
method: 'POST',
body: JSON.stringify({ content, category, quantity }),
});
},
async toggleListItem(listId: string, itemId: string, isCompleted: boolean): Promise<ListItem> {
return request<ListItem>(`/api/v1/lists/${listId}/items/${itemId}`, {
method: 'PATCH',
body: JSON.stringify({ isCompleted }),
});
},
async deleteListItem(listId: string, itemId: string): Promise<void> {
await request(`/api/v1/lists/${listId}/items/${itemId}`, { method: 'DELETE' });
},
async clearCompletedItems(listId: string): Promise<{ removed: number }> {
return request<{ removed: number }>(`/api/v1/lists/${listId}/clear-completed`, {
method: 'POST',
});
},
// --- API KEYS, WEBHOOKS & EXPORT ---
async getAPIKeys(): Promise<APIKey[]> {
return request<APIKey[]>('/api/v1/keys');
},
async createAPIKey(name: string): Promise<APIKey> {
return request<APIKey>('/api/v1/keys', {
method: 'POST',
body: JSON.stringify({ name }),
});
},
async deleteAPIKey(id: string): Promise<void> {
await request(`/api/v1/keys/${id}`, { method: 'DELETE' });
},
async testWebhookIngest(token: string, title: string, priority: PriorityLevel, tags: string[]): Promise<{ status: string; thought: Thought }> {
return request<{ status: string; thought: Thought }>(`/api/v1/webhooks/ingest?token=${encodeURIComponent(token)}`, {
method: 'POST',
body: JSON.stringify({ title, priority, tags }),
});
},
async searchAll(q: string): Promise<{ thoughts: Thought[]; documents: Document[] }> {
return request<{ thoughts: Thought[]; documents: Document[] }>(`/api/v1/search?q=${encodeURIComponent(q)}`);
},
async exportAll(): Promise<any> {
return request<any>('/api/v1/export');
},
};
export function getOfflineQueue(): Thought[] {
try {
const raw = localStorage.getItem(OFFLINE_QUEUE_KEY);
if (!raw) return [];
return JSON.parse(raw);
} catch {
return [];
}
}
export function buildWebSocketURL(spaceId: string): string {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const token = getToken() || '';
return `${protocol}//${window.location.host}/api/v1/ws/spaces/${encodeURIComponent(spaceId)}?token=${encodeURIComponent(token)}`;
}
+125
View File
@@ -0,0 +1,125 @@
export type ThoughtStatus = 'inbox' | 'kept' | 'discarded' | 'converted';
export type PriorityLevel = 'low' | 'normal' | 'high' | 'urgent';
export interface User {
id: string;
email: string;
displayName: string;
createdAt: string;
}
export interface Thought {
id: string;
userId: string;
content: string;
tags: string[];
priority: PriorityLevel;
source: 'manual' | 'voice' | 'webhook';
status: ThoughtStatus;
isCompleted: boolean;
convertedType?: 'task' | 'document' | 'list_item';
convertedRefId?: string;
dueDate?: string;
createdAt: string;
triagedAt?: string;
}
export type BlockType = 'h1' | 'h2' | 'h3' | 'paragraph' | 'todo' | 'bullet' | 'code' | 'callout' | 'divider';
export interface DocumentBlock {
id: string;
type: BlockType;
content: string;
checked?: boolean;
lang?: string;
}
export interface Document {
id: string;
userId: string;
spaceId?: string;
title: string;
icon: string;
content: string;
blocks: DocumentBlock[];
tags: string[];
isPinned: boolean;
isArchived: boolean;
createdAt: string;
updatedAt: string;
}
export interface SpaceMember {
spaceId: string;
userId: string;
displayName: string;
email: string;
role: 'owner' | 'editor' | 'viewer';
joinedAt: string;
}
export interface Space {
id: string;
name: string;
icon: string;
description: string;
inviteCode: string;
isPersonal: boolean;
ownerId: string;
members?: SpaceMember[];
listsCount: number;
createdAt: string;
}
export interface ListItem {
id: string;
listId: string;
content: string;
category?: string;
quantity?: string;
isCompleted: boolean;
completedBy?: string;
completedByName?: string;
sortOrder: number;
createdAt: string;
completedAt?: string;
}
export interface SharedList {
id: string;
spaceId: string;
title: string;
icon: string;
listType: 'shopping' | 'checklist' | 'vacation';
items: ListItem[];
updatedAt: string;
createdAt: string;
}
export interface APIKey {
id: string;
userId: string;
name: string;
keyPrefix: string;
rawKey?: string;
scopes: string;
lastUsedAt?: string;
createdAt: string;
}
export interface DashboardStats {
inboxCount: number;
tasksOpenCount: number;
tasksDoneCount: number;
docsCount: number;
listsCount: number;
discardedCount: number;
}
export interface WSMessage<T = any> {
event: 'ITEM_CREATED' | 'ITEM_UPDATED' | 'ITEM_TOGGLED' | 'ITEM_DELETED' | 'LIST_CLEARED' | 'LIST_CREATED' | 'LIST_DELETED' | 'USER_PRESENCE';
space_id: string;
list_id?: string;
payload: T;
timestamp: string;
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+34
View File
@@ -0,0 +1,34 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
darkMode: 'class',
theme: {
extend: {
colors: {
gemini: {
bg: '#0b0f19',
surface: '#131314',
card: '#1e1f20',
hover: '#282a2c',
blue: '#8ab4f8',
purple: '#c58af9',
keep: '#81c995',
discard: '#f28b82',
text: '#e3e3e3',
muted: '#9aa0a6',
subtle: '#5f6368',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'],
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
},
boxShadow: {
'gemini-glow': '0 0 28px -4px rgba(138, 180, 248, 0.22)',
'purple-glow': '0 0 32px -4px rgba(197, 138, 249, 0.35)',
'card-depth': '0 20px 50px -12px rgba(0, 0, 0, 0.7)',
},
},
},
plugins: [],
};
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": false
},
"include": ["src"]
}
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
ws: true,
},
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
sourcemap: false,
},
});