Merge branch 'feature/search-emoji-demo' into dev
Gemini: full emoji picker (desktop + mobile bottom sheet), global search with SQLite FTS5 and Cmd+K palette (access-checked), demo mode resets the database on every start. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
a044620cec
22 files changed
+1518
-203
No files matched your search
+78
-7
@@ -23,6 +23,7 @@ import {
|
||||
Flame,
|
||||
Mic,
|
||||
MicOff,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { AuthStatus, DashboardStats, PriorityLevel, SharedList, Thought, User, UserTheme } from './lib/types';
|
||||
import {
|
||||
@@ -56,6 +57,7 @@ import { GroupsView } from './features/teams/GroupsView';
|
||||
import { useMobileActions } from './context/MobileActionContext';
|
||||
import { MobileHeaderMenu } from './components/MobileHeaderMenu';
|
||||
import { UserAvatar } from './components/UserAvatar';
|
||||
import { CommandPalette } from './components/CommandPalette';
|
||||
import { APP_VERSION } from './version';
|
||||
|
||||
type ActiveTab =
|
||||
@@ -136,6 +138,8 @@ export const App: React.FC = () => {
|
||||
const [selectedDocId, setSelectedDocId] = useState<string | null>(null);
|
||||
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||
const [selectedListId, setSelectedListId] = useState<string | null>(null);
|
||||
const [showCommandPalette, setShowCommandPalette] = useState(false);
|
||||
const [captureFocusCount, setCaptureFocusCount] = useState(0);
|
||||
const [refreshRevision, setRefreshRevision] = useState(0);
|
||||
|
||||
@@ -594,6 +598,36 @@ export const App: React.FC = () => {
|
||||
setActiveTab('tasks');
|
||||
}, []);
|
||||
|
||||
const handleOpenList = (listId: string) => {
|
||||
setSelectedListId(listId);
|
||||
setActiveTab('lists');
|
||||
};
|
||||
|
||||
// Global Command Palette shortcuts: Cmd+K / Ctrl+K, and '/' when not in input
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const el = e.target as HTMLElement;
|
||||
const isEditing =
|
||||
el.tagName === 'INPUT' ||
|
||||
el.tagName === 'TEXTAREA' ||
|
||||
el.isContentEditable;
|
||||
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault();
|
||||
setShowCommandPalette((prev) => !prev);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isEditing && e.key === '/') {
|
||||
e.preventDefault();
|
||||
setShowCommandPalette(true);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
const handleTriggerAddButton = () => {
|
||||
triggerHaptic(20);
|
||||
if (window.innerWidth < 640) {
|
||||
@@ -710,6 +744,19 @@ export const App: React.FC = () => {
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
setShowCommandPalette(true);
|
||||
}}
|
||||
className="w-11 h-11 rounded-full flex items-center justify-center text-muted hover:text-main bg-panel border border-border transition-transform active:scale-95 cursor-pointer"
|
||||
title="Globale Suche"
|
||||
aria-label="Globale Suche öffnen"
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -750,12 +797,23 @@ export const App: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Quick-Capture Input Bubble directly in Header (above Tabs) */}
|
||||
<div className="flex-1 max-w-xl mx-auto">
|
||||
<InstantCaptureBar
|
||||
onCaptured={handleCaptured}
|
||||
lists={lists}
|
||||
autoFocusTrigger={captureFocusCount}
|
||||
/>
|
||||
<div className="flex-1 max-w-xl mx-auto flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<InstantCaptureBar
|
||||
onCaptured={handleCaptured}
|
||||
lists={lists}
|
||||
autoFocusTrigger={captureFocusCount}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCommandPalette(true)}
|
||||
className="h-8 px-2.5 rounded-btn bg-panel border border-border text-muted hover:text-main flex items-center gap-1.5 text-xs transition-colors cursor-pointer shrink-0"
|
||||
title="Globale Suche öffnen (Cmd+K oder /)"
|
||||
>
|
||||
<Search className="w-3.5 h-3.5" />
|
||||
<span className="hidden lg:inline font-mono text-[10px] text-accent font-semibold">⌘K</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* User Profile Pill & Dropdown Menu */}
|
||||
@@ -1010,7 +1068,11 @@ export const App: React.FC = () => {
|
||||
)}
|
||||
|
||||
{activeTab === 'lists' && (
|
||||
<SharedListsView onRefreshGlobal={refreshAll} currentUser={user} />
|
||||
<SharedListsView
|
||||
onRefreshGlobal={refreshAll}
|
||||
currentUser={user}
|
||||
initialListId={selectedListId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'groups' && (
|
||||
@@ -1533,6 +1595,15 @@ export const App: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Global Command Palette (Cmd+K / Ctrl+K / '/') */}
|
||||
<CommandPalette
|
||||
isOpen={showCommandPalette}
|
||||
onClose={() => setShowCommandPalette(false)}
|
||||
onOpenDocument={handleOpenDocument}
|
||||
onOpenTask={handleOpenTask}
|
||||
onOpenList={handleOpenList}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Search,
|
||||
FileText,
|
||||
CheckSquare,
|
||||
ShoppingCart,
|
||||
X,
|
||||
Loader2,
|
||||
ArrowRight,
|
||||
CornerDownLeft,
|
||||
} from 'lucide-react';
|
||||
import { SearchHit } from '../lib/types';
|
||||
import { api } from '../lib/api';
|
||||
|
||||
interface CommandPaletteProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onOpenDocument: (docId: string, blockId?: string) => void;
|
||||
onOpenTask: (taskId: string) => void;
|
||||
onOpenList: (listId: string) => void;
|
||||
}
|
||||
|
||||
// Safely render snippet containing <mark>...</mark> without dangerouslySetInnerHTML
|
||||
const SnippetRenderer: React.FC<{ snippet: string }> = ({ snippet }) => {
|
||||
const parts = useMemo(() => {
|
||||
if (!snippet) return [];
|
||||
return snippet.split(/(<mark>[\s\S]*?<\/mark>)/g);
|
||||
}, [snippet]);
|
||||
|
||||
return (
|
||||
<span className="text-muted text-xs line-clamp-2 leading-relaxed">
|
||||
{parts.map((part, i) => {
|
||||
if (part.startsWith('<mark>') && part.endsWith('</mark>')) {
|
||||
const text = part.slice(6, -7);
|
||||
return (
|
||||
<mark
|
||||
key={i}
|
||||
className="bg-accent/25 text-accent font-semibold px-0.5 rounded"
|
||||
>
|
||||
{text}
|
||||
</mark>
|
||||
);
|
||||
}
|
||||
return <span key={i}>{part}</span>;
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export const CommandPalette: React.FC<CommandPaletteProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onOpenDocument,
|
||||
onOpenTask,
|
||||
onOpenList,
|
||||
}) => {
|
||||
const [query, setQuery] = useState('');
|
||||
const [hits, setHits] = useState<SearchHit[]>([]);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const debounceTimerRef = useRef<number | null>(null);
|
||||
|
||||
// Group hits by category: Notizen, Aufgaben, Listen
|
||||
const groupedHits = useMemo(() => {
|
||||
const docs: SearchHit[] = [];
|
||||
const tasks: SearchHit[] = [];
|
||||
const lists: SearchHit[] = [];
|
||||
|
||||
for (const h of hits) {
|
||||
if (h.type === 'document') {
|
||||
docs.push(h);
|
||||
} else if (h.type === 'thought') {
|
||||
tasks.push(h);
|
||||
} else if (h.type === 'list_item') {
|
||||
lists.push(h);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
{ key: 'documents', label: 'Notizen', icon: FileText, items: docs },
|
||||
{ key: 'tasks', label: 'Aufgaben', icon: CheckSquare, items: tasks },
|
||||
{ key: 'lists', label: 'Listen', icon: ShoppingCart, items: lists },
|
||||
].filter((g) => g.items.length > 0);
|
||||
}, [hits]);
|
||||
|
||||
// Flattened array for simple up/down index navigation
|
||||
const flatHits = useMemo(() => {
|
||||
return groupedHits.flatMap((g) => g.items);
|
||||
}, [groupedHits]);
|
||||
|
||||
// Focus input and reset on open
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setQuery('');
|
||||
setHits([]);
|
||||
setSelectedIndex(0);
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// 150ms debounce search
|
||||
useEffect(() => {
|
||||
if (debounceTimerRef.current !== null) {
|
||||
window.clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) {
|
||||
setHits([]);
|
||||
setLoading(false);
|
||||
setSelectedIndex(0);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
debounceTimerRef.current = window.setTimeout(async () => {
|
||||
try {
|
||||
const res = await api.searchAll(trimmed);
|
||||
setHits(res.hits || []);
|
||||
setSelectedIndex(0);
|
||||
} catch {
|
||||
setHits([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, 150);
|
||||
|
||||
return () => {
|
||||
if (debounceTimerRef.current !== null) {
|
||||
window.clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
// Keyboard navigation: ↑, ↓, Enter, Escape
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (flatHits.length === 0) return;
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev + 1) % flatHits.length);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev - 1 + flatHits.length) % flatHits.length);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const selected = flatHits[selectedIndex];
|
||||
if (selected) {
|
||||
executeSelection(selected);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, flatHits, selectedIndex]);
|
||||
|
||||
const executeSelection = (hit: SearchHit) => {
|
||||
onClose();
|
||||
if (hit.type === 'document') {
|
||||
onOpenDocument(hit.docId || hit.id, hit.blockId);
|
||||
} else if (hit.type === 'thought') {
|
||||
onOpenTask(hit.id);
|
||||
} else if (hit.type === 'list_item') {
|
||||
if (hit.listId) {
|
||||
onOpenList(hit.listId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center p-0 sm:p-4 sm:pt-16 bg-black/60 backdrop-blur-xs animate-in fade-in duration-150"
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* Search Modal Container */}
|
||||
<div
|
||||
className="
|
||||
w-full h-full bg-panel flex flex-col overflow-hidden shadow-2xl
|
||||
sm:h-auto sm:max-h-[80vh] sm:max-w-2xl sm:rounded-card sm:border sm:border-border
|
||||
"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Top Input Bar */}
|
||||
<div className="flex items-center gap-2.5 px-4 py-3 border-b border-border bg-canvas/40 shrink-0">
|
||||
<Search className="w-5 h-5 text-accent shrink-0" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Globale Suche in Notizen, Aufgaben und Listen…"
|
||||
className="flex-1 bg-transparent border-0 outline-none text-main placeholder-muted text-base sm:text-sm focus:ring-0 p-0"
|
||||
/>
|
||||
{loading && <Loader2 className="w-4 h-4 animate-spin text-accent shrink-0" />}
|
||||
{query && !loading && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setQuery('');
|
||||
setHits([]);
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
className="text-muted hover:text-main p-1"
|
||||
title="Eingabe leeren"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="sm:hidden px-2 py-1 text-xs font-semibold text-accent"
|
||||
>
|
||||
Fertig
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Results Container */}
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-4 min-h-[160px]">
|
||||
{query.trim() === '' ? (
|
||||
<div className="py-12 px-4 text-center text-xs text-muted space-y-1">
|
||||
<p className="font-semibold text-main">MindSift Globale Suche</p>
|
||||
<p>Tippe ein Stichwort ein, um alle Inhalte in Sekundenschnelle zu durchsuchen.</p>
|
||||
</div>
|
||||
) : flatHits.length === 0 && !loading ? (
|
||||
<div className="py-12 px-4 text-center text-xs text-muted space-y-1">
|
||||
<p className="font-semibold text-main">Keine Treffer gefunden</p>
|
||||
<p>Keine Notizen, Aufgaben oder Listen-Einträge stimmen mit „{query}“ überein.</p>
|
||||
</div>
|
||||
) : (
|
||||
groupedHits.map((group) => {
|
||||
const GroupIcon = group.icon;
|
||||
return (
|
||||
<div key={group.key} className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-bold text-accent uppercase tracking-wider">
|
||||
<GroupIcon className="w-3.5 h-3.5" />
|
||||
<span>{group.label}</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{group.items.map((item) => {
|
||||
const overallIndex = flatHits.indexOf(item);
|
||||
const isSelected = overallIndex === selectedIndex;
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => executeSelection(item)}
|
||||
onMouseEnter={() => setSelectedIndex(overallIndex)}
|
||||
className={`
|
||||
px-3 py-2.5 rounded-btn flex items-start gap-2.5 transition-colors cursor-pointer border
|
||||
${
|
||||
isSelected
|
||||
? 'bg-panel-elevated border-accent text-main'
|
||||
: 'bg-canvas/50 border-transparent text-main hover:bg-panel-elevated'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="mt-0.5 shrink-0 text-base leading-none">
|
||||
{item.icon || (item.type === 'document' ? '📝' : item.type === 'thought' ? '✅' : '🛒')}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 space-y-0.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-semibold text-xs truncate">
|
||||
{item.title || 'Unbenannt'}
|
||||
</span>
|
||||
{isSelected && (
|
||||
<span className="hidden sm:inline-flex items-center text-[10px] text-accent gap-0.5 shrink-0">
|
||||
<span>Öffnen</span>
|
||||
<CornerDownLeft className="w-3 h-3" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{item.snippet && (
|
||||
<div>
|
||||
<SnippetRenderer snippet={item.snippet} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Shortcut Bar (Desktop) */}
|
||||
<div className="hidden sm:flex items-center justify-between px-4 py-2 bg-canvas/40 border-t border-border text-[11px] text-muted shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<span>
|
||||
<kbd className="px-1 py-0.5 bg-panel border border-border rounded font-mono text-[10px] text-accent">↑</kbd>{' '}
|
||||
<kbd className="px-1 py-0.5 bg-panel border border-border rounded font-mono text-[10px] text-accent">↓</kbd> Navigieren
|
||||
</span>
|
||||
<span>
|
||||
<kbd className="px-1 py-0.5 bg-panel border border-border rounded font-mono text-[10px] text-accent">↵</kbd> Auswählen
|
||||
</span>
|
||||
<span>
|
||||
<kbd className="px-1 py-0.5 bg-panel border border-border rounded font-mono text-[10px] text-accent">Esc</kbd> Schließen
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span>{flatHits.length} Treffer</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -103,7 +103,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
el.tagName === 'TEXTAREA' ||
|
||||
el.isContentEditable;
|
||||
|
||||
if (!isEditing && (e.key === 'c' || e.key === 'C' || e.key === '/')) {
|
||||
if (!isEditing && (e.key === 'c' || e.key === 'C')) {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
} else if (e.key === 'Escape' && document.activeElement === inputRef.current) {
|
||||
|
||||
@@ -343,6 +343,42 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'workspace-search-cmdk',
|
||||
category: 'workspace',
|
||||
title: 'Globale Suche & Command Palette (Cmd+K)',
|
||||
subtitle: 'FTS5-Volltextsuche in Notizen, Blöcken, Aufgaben und Listen',
|
||||
icon: Command,
|
||||
keywords: [
|
||||
'suche',
|
||||
'search',
|
||||
'command',
|
||||
'palette',
|
||||
'cmdk',
|
||||
'find',
|
||||
'fts',
|
||||
'volltext',
|
||||
'tastatur',
|
||||
],
|
||||
content: (
|
||||
<div className="space-y-2 text-muted leading-relaxed">
|
||||
<p>
|
||||
Drücke <kbd className="px-1.5 py-0.5 bg-panel border border-border rounded font-mono text-[11px] text-accent">Cmd + K</kbd> / <kbd className="px-1.5 py-0.5 bg-panel border border-border rounded font-mono text-[11px] text-accent">Strg + K</kbd> oder <kbd className="px-1.5 py-0.5 bg-panel border border-border rounded font-mono text-[11px] text-accent">/</kbd> (wenn kein Textfeld aktiv ist), um die globale Suche zu öffnen:
|
||||
</p>
|
||||
<ul className="space-y-1.5 list-disc list-inside">
|
||||
<li>
|
||||
<strong className="text-main">Volltextsuche (FTS5):</strong> Durchsucht Notiz-Titel und Blöcke, Aufgaben und Listeneinträge blitzschnell mit Relevanz-Ranking (BM25) und Präfixsuche.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-main">Sicherheit:</strong> Zeigt nur Inhalte, auf die du tatsächlich Zugriff hast (eigene sowie freigegebene Notizen, Aufgabenlisten und Listen).
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-main">Tastaturnavigation:</strong> Mit <kbd className="px-1.5 py-0.5 bg-panel border border-border rounded font-mono text-[11px] text-accent">↑</kbd> und <kbd className="px-1.5 py-0.5 bg-panel border border-border rounded font-mono text-[11px] text-accent">↓</kbd> blättern, mit <kbd className="px-1.5 py-0.5 bg-panel border border-border rounded font-mono text-[11px] text-accent">Enter</kbd> Treffer direkt öffnen (Notizen direkt am gefundenen Block).
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
// TRIAGE & QUICK DUMP
|
||||
{
|
||||
@@ -658,7 +694,8 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
|
||||
// Keyboard shortcuts table data
|
||||
const shortcuts = useMemo(
|
||||
() => [
|
||||
{ key: 'C oder /', scope: 'Global', desc: 'Fokus sofort in die Schnellerfassungsleiste setzen' },
|
||||
{ key: 'Cmd + K / Strg + K oder /', scope: 'Global', desc: 'Globale Volltextsuche (Command Palette) öffnen' },
|
||||
{ key: 'C', scope: 'Global', desc: 'Fokus sofort in die Schnellerfassungsleiste setzen' },
|
||||
{ key: 'Enter', scope: 'Schnellerfassung', desc: 'Gedanken blitzschnell speichern' },
|
||||
{ key: 'Esc', scope: 'Schnellerfassung', desc: 'Eingabe leeren & Optionen schließen' },
|
||||
{ key: 'Enter', scope: 'Workspace Outliner', desc: 'Neuen Bullet-Point anlegen (oder leeren Block ausrücken)' },
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useMobileActions } from '../../context/MobileActionContext';
|
||||
interface SharedListsViewProps {
|
||||
onRefreshGlobal: () => void;
|
||||
currentUser?: User | null;
|
||||
initialListId?: string | null;
|
||||
}
|
||||
|
||||
export function parseListItemInput(rawText: string, defaultQty?: string): {
|
||||
@@ -79,10 +80,16 @@ export function deduplicateItems(items: ListItem[]): ListItem[] {
|
||||
return result;
|
||||
}
|
||||
|
||||
export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGlobal, currentUser }) => {
|
||||
export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGlobal, currentUser, initialListId }) => {
|
||||
const { setGroups, setPrimaryAction } = useMobileActions();
|
||||
const [lists, setLists] = useState<SharedList[]>([]);
|
||||
const [activeListId, setActiveListId] = useState<string>('');
|
||||
const [activeListId, setActiveListId] = useState<string>(initialListId || '');
|
||||
|
||||
useEffect(() => {
|
||||
if (initialListId) {
|
||||
setActiveListId(initialListId);
|
||||
}
|
||||
}, [initialListId]);
|
||||
|
||||
const [newItemText, setNewItemText] = useState('');
|
||||
const [newItemQty, setNewItemQty] = useState('');
|
||||
@@ -128,7 +135,9 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
items: deduplicateItems(l.items || []),
|
||||
}));
|
||||
setLists(deduplicated);
|
||||
if (deduplicated.length > 0 && (!activeListId || !deduplicated.some((l) => l.id === activeListId))) {
|
||||
if (initialListId && deduplicated.some((l) => l.id === initialListId)) {
|
||||
setActiveListId(initialListId);
|
||||
} else if (deduplicated.length > 0 && (!activeListId || !deduplicated.some((l) => l.id === activeListId))) {
|
||||
setActiveListId(deduplicated[0].id);
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -44,63 +44,7 @@ export interface DocumentEditorProps {
|
||||
onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void;
|
||||
}
|
||||
|
||||
interface EmojiItem {
|
||||
emoji: string;
|
||||
category: string;
|
||||
keywords: string;
|
||||
}
|
||||
|
||||
const EMOJI_CATALOG: EmojiItem[] = [
|
||||
// Notizen & Dokumente
|
||||
{ emoji: '📝', category: 'Notizen', keywords: 'notiz schreiben memo text note' },
|
||||
{ emoji: '📄', category: 'Notizen', keywords: 'dokument seite page doc' },
|
||||
{ emoji: '📋', category: 'Notizen', keywords: 'clipboard klemmbrett liste' },
|
||||
{ emoji: '📂', category: 'Notizen', keywords: 'ordner mappe folder datei' },
|
||||
{ emoji: '📁', category: 'Notizen', keywords: 'ordner folder directory' },
|
||||
{ emoji: '📌', category: 'Notizen', keywords: 'pin anheften bookmark wichtig' },
|
||||
{ emoji: '📍', category: 'Notizen', keywords: 'standort ort pin location' },
|
||||
{ emoji: '🏷️', category: 'Notizen', keywords: 'tag etikett label markierung' },
|
||||
{ emoji: '📚', category: 'Notizen', keywords: 'buch bibliothek lesen studium' },
|
||||
{ emoji: '📖', category: 'Notizen', keywords: 'buch open book wissen' },
|
||||
|
||||
// Ideen & Fokus
|
||||
{ emoji: '🧠', category: 'Ideen', keywords: 'gehirn brain denken wissen thought mind' },
|
||||
{ emoji: '💡', category: 'Ideen', keywords: 'idee inspiration lampe glühbirne idea' },
|
||||
{ emoji: '🎯', category: 'Ideen', keywords: 'ziel target fokus mission okr' },
|
||||
{ emoji: '🚀', category: 'Ideen', keywords: 'rakete start launch projekt rocket' },
|
||||
{ emoji: '⚡', category: 'Ideen', keywords: 'blitz energie schnell quick power' },
|
||||
{ emoji: '🔥', category: 'Ideen', keywords: 'feuer flame priorität hot wichtig' },
|
||||
{ emoji: '⭐', category: 'Ideen', keywords: 'stern favoriten star top' },
|
||||
{ emoji: '✨', category: 'Ideen', keywords: 'funkeln magie neu neuigkeit sparkles' },
|
||||
{ emoji: '🧩', category: 'Ideen', keywords: 'puzzle teil lösung problem' },
|
||||
{ emoji: '🔬', category: 'Ideen', keywords: 'mikroskop forschung analyse test' },
|
||||
|
||||
// Tech & Dev
|
||||
{ emoji: '💻', category: 'Tech', keywords: 'laptop computer dev code programming' },
|
||||
{ emoji: '🖥️', category: 'Tech', keywords: 'desktop monitor bildschirm workstation' },
|
||||
{ emoji: '🛠️', category: 'Tech', keywords: 'werkzeug tools optionen settings einstellungen' },
|
||||
{ emoji: '⚙️', category: 'Tech', keywords: 'zahnrad config settings system' },
|
||||
{ emoji: '📊', category: 'Tech', keywords: 'diagramm chart daten statistik dashboard' },
|
||||
{ emoji: '📈', category: 'Tech', keywords: 'wachstum trend kurve business' },
|
||||
{ emoji: '🔒', category: 'Tech', keywords: 'schloss sicherheit geheim privat secure' },
|
||||
{ emoji: '🔑', category: 'Tech', keywords: 'schlüssel key passwort access' },
|
||||
{ emoji: '🌐', category: 'Tech', keywords: 'web internet network welt globus' },
|
||||
{ emoji: '📦', category: 'Tech', keywords: 'paket box release lieferung package' },
|
||||
|
||||
// Alltag & Organisation
|
||||
{ emoji: '💬', category: 'Alltag', keywords: 'chat nachricht kommentar call sprechen' },
|
||||
{ emoji: '✉️', category: 'Alltag', keywords: 'mail email brief post nachricht' },
|
||||
{ emoji: '📅', category: 'Alltag', keywords: 'kalender datum termin plan event' },
|
||||
{ emoji: '⏰', category: 'Alltag', keywords: 'wecker uhr zeit deadline alarm' },
|
||||
{ emoji: '🛒', category: 'Alltag', keywords: 'warenkorb einkauf shopping liste' },
|
||||
{ emoji: '☕', category: 'Alltag', keywords: 'kaffee pause bistro morning' },
|
||||
{ emoji: '🏠', category: 'Alltag', keywords: 'haus heim home wohnung' },
|
||||
{ emoji: '🏢', category: 'Alltag', keywords: 'büro firma arbeit office work' },
|
||||
{ emoji: '✈️', category: 'Alltag', keywords: 'flugzeug reise urlaub trip travel' },
|
||||
{ emoji: '🎨', category: 'Alltag', keywords: 'kunst farbe design creative' },
|
||||
{ emoji: '🏆', category: 'Alltag', keywords: 'pokal erfolg win trophy award' },
|
||||
{ emoji: '✅', category: 'Alltag', keywords: 'haken check done erledigt' },
|
||||
];
|
||||
import { EmojiPickerModal } from './EmojiPickerModal';
|
||||
|
||||
function ensureNonEmptyBlocks(docId: string, blocks?: DocumentBlock[]): DocumentBlock[] {
|
||||
if (!blocks || blocks.length === 0) {
|
||||
@@ -250,21 +194,12 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
const [loading, setLoading] = useState<boolean>(!initialDoc);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false);
|
||||
const [emojiSearch, setEmojiSearch] = useState<string>('');
|
||||
const [zoomedBlockId, setZoomedBlockId] = useState<string | null>(null);
|
||||
|
||||
// Sync engine (operation log): stores every change locally and syncs it with the server
|
||||
const engineRef = useRef<DocSyncEngine | null>(null);
|
||||
const [engineState, setEngineState] = useState<EngineState | null>(null);
|
||||
|
||||
const filteredEmojis = useMemo(() => {
|
||||
const q = emojiSearch.trim().toLowerCase();
|
||||
if (!q) return EMOJI_CATALOG;
|
||||
return EMOJI_CATALOG.filter(
|
||||
(item) => item.keywords.includes(q) || item.emoji.includes(q) || item.category.toLowerCase().includes(q)
|
||||
);
|
||||
}, [emojiSearch]);
|
||||
|
||||
|
||||
const titleInputRef = useRef<HTMLTextAreaElement | HTMLInputElement | null>(null);
|
||||
const outlinerTreeRef = useRef<OutlinerTreeHandle | null>(null);
|
||||
@@ -1044,72 +979,11 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
{doc?.icon || '📝'}
|
||||
</button>
|
||||
{showEmojiPicker && canEdit && (
|
||||
<div
|
||||
className="absolute left-0 mt-1 p-2 panel-surface shadow-lg border border-border rounded-card z-30 w-72 space-y-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="relative">
|
||||
<Search className="w-3.5 h-3.5 text-faint absolute left-2 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={emojiSearch}
|
||||
onChange={(e) => setEmojiSearch(e.target.value)}
|
||||
placeholder="Icon suchen oder Emoji eingeben…"
|
||||
className="input-field w-full pl-7 pr-2 py-1 text-xs"
|
||||
/>
|
||||
</div>
|
||||
{doc?.icon && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
handleSelectIcon('');
|
||||
setShowEmojiPicker(false);
|
||||
setEmojiSearch('');
|
||||
}}
|
||||
className="w-full text-left px-2 py-1 text-status-warn/90 hover:bg-status-warn/10 rounded text-xs font-medium flex items-center gap-1.5 cursor-pointer"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
<span>Icon entfernen</span>
|
||||
</button>
|
||||
)}
|
||||
{emojiSearch.trim() && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
handleSelectIcon(emojiSearch.trim());
|
||||
setShowEmojiPicker(false);
|
||||
setEmojiSearch('');
|
||||
}}
|
||||
className="w-full text-left px-2 py-1 bg-accent/10 hover:bg-accent/20 text-accent rounded text-xs font-medium flex items-center justify-between cursor-pointer"
|
||||
>
|
||||
<span>Eingabe als Icon nutzen</span>
|
||||
<span className="text-base">{emojiSearch.trim()}</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="max-h-48 overflow-y-auto grid grid-cols-6 gap-1 pr-1">
|
||||
{filteredEmojis.map((item) => (
|
||||
<button
|
||||
key={item.emoji}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
handleSelectIcon(item.emoji);
|
||||
setShowEmojiPicker(false);
|
||||
setEmojiSearch('');
|
||||
}}
|
||||
className="p-1.5 rounded-btn hover:bg-panel-elevated text-lg flex items-center justify-center cursor-pointer transition-colors"
|
||||
title={item.keywords}
|
||||
>
|
||||
{item.emoji}
|
||||
</button>
|
||||
))}
|
||||
{filteredEmojis.length === 0 && (
|
||||
<div className="col-span-6 text-center text-xs text-faint py-3">
|
||||
Kein passendes Standard-Icon
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<EmojiPickerModal
|
||||
currentIcon={doc?.icon}
|
||||
onSelectEmoji={handleSelectIcon}
|
||||
onClose={() => setShowEmojiPicker(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import React, { Suspense, useEffect, useRef, useState } from 'react';
|
||||
import { X, Trash2, Loader2, Clock } from 'lucide-react';
|
||||
import type { EmojiClickData } from 'emoji-picker-react';
|
||||
import { EmojiStyle, Theme, SuggestionMode } from 'emoji-picker-react';
|
||||
|
||||
// Lazy-load emoji-picker-react so the main bundle size stays small
|
||||
const EmojiPicker = React.lazy(() => import('emoji-picker-react'));
|
||||
|
||||
const RECENT_EMOJIS_STORAGE_KEY = 'mindsift_recent_emojis';
|
||||
const MAX_RECENT_EMOJIS = 10;
|
||||
|
||||
function getStoredRecentEmojis(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(RECENT_EMOJIS_STORAGE_KEY);
|
||||
if (!raw) return ['📝', '💡', '🚀', '⭐', '🔥', '🎯', '📚', '💻'];
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
return parsed.slice(0, MAX_RECENT_EMOJIS);
|
||||
}
|
||||
} catch {
|
||||
// fallback
|
||||
}
|
||||
return ['📝', '💡', '🚀', '⭐', '🔥', '🎯', '📚', '💻'];
|
||||
}
|
||||
|
||||
function saveRecentEmoji(emoji: string): void {
|
||||
try {
|
||||
const current = getStoredRecentEmojis();
|
||||
const updated = [emoji, ...current.filter((e) => e !== emoji)].slice(0, MAX_RECENT_EMOJIS);
|
||||
localStorage.setItem(RECENT_EMOJIS_STORAGE_KEY, JSON.stringify(updated));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
interface EmojiPickerModalProps {
|
||||
currentIcon?: string;
|
||||
onSelectEmoji: (emoji: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
||||
currentIcon,
|
||||
onSelectEmoji,
|
||||
onClose,
|
||||
}) => {
|
||||
const [recentEmojis, setRecentEmojis] = useState<string[]>(getStoredRecentEmojis);
|
||||
const [isDark, setIsDark] = useState<boolean>(() =>
|
||||
typeof document !== 'undefined'
|
||||
? document.documentElement.classList.contains('dark')
|
||||
: true
|
||||
);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Sync theme changes
|
||||
useEffect(() => {
|
||||
const observer = new MutationObserver(() => {
|
||||
setIsDark(document.documentElement.classList.contains('dark'));
|
||||
});
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'data-theme'],
|
||||
});
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Close on Escape or click outside (desktop popover)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(e.target as Node)
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const handlePick = (emoji: string) => {
|
||||
saveRecentEmoji(emoji);
|
||||
setRecentEmojis(getStoredRecentEmojis());
|
||||
onSelectEmoji(emoji);
|
||||
};
|
||||
|
||||
const handlePickerEmojiClick = (emojiData: EmojiClickData) => {
|
||||
handlePick(emojiData.emoji);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile Backdrop (< 640px) */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 z-40 sm:hidden backdrop-blur-xs"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Main Container: Mobile Bottom-Sheet (<640px) & Desktop Popover (>=640px) */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="
|
||||
fixed inset-x-0 bottom-0 z-50 rounded-t-2xl max-h-[85vh] bg-panel border-t border-border shadow-2xl flex flex-col p-3 space-y-3
|
||||
sm:absolute sm:inset-auto sm:left-0 sm:top-full sm:mt-1 sm:z-40 sm:w-[360px] sm:max-h-[520px] sm:rounded-card sm:border sm:border-border sm:shadow-xl
|
||||
"
|
||||
style={
|
||||
{
|
||||
// Inject MindSift CSS variables into emoji-picker-react for seamless theming
|
||||
'--epr-bg-color': 'var(--bg-panel)',
|
||||
'--epr-category-label-bg-color': 'var(--bg-panel)',
|
||||
'--epr-picker-border-color': 'var(--border-color)',
|
||||
'--epr-text-color': 'var(--text-main)',
|
||||
'--epr-search-input-bg-color': 'var(--bg-app)',
|
||||
'--epr-search-input-bg-color-active': 'var(--bg-panel-elevated)',
|
||||
'--epr-hover-bg-color': 'var(--bg-panel-elevated)',
|
||||
'--epr-focus-bg-color': 'var(--bg-panel-elevated)',
|
||||
'--epr-highlight-color': 'var(--accent-primary)',
|
||||
'--epr-category-icon-active-color': 'var(--accent-primary)',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{/* Mobile handle indicator */}
|
||||
<div className="w-10 h-1 bg-border rounded-full mx-auto sm:hidden shrink-0" />
|
||||
|
||||
{/* Top Header: Title, Actions & Close */}
|
||||
<div className="flex items-center justify-between gap-2 shrink-0 border-b border-border pb-2">
|
||||
<div className="flex items-center gap-1.5 text-xs font-semibold text-main">
|
||||
<span>Icon auswählen</span>
|
||||
{currentIcon && (
|
||||
<span className="text-base leading-none ml-1">{currentIcon}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{currentIcon && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectEmoji('')}
|
||||
className="h-11 sm:h-7 px-3 sm:px-2 rounded text-xs font-medium text-status-err hover:bg-status-err/10 flex items-center gap-1 transition-colors cursor-pointer"
|
||||
title="Icon entfernen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">Entfernen</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="w-11 h-11 sm:w-7 sm:h-7 rounded flex items-center justify-center text-muted hover:text-main hover:bg-panel-elevated transition-colors cursor-pointer"
|
||||
title="Schließen"
|
||||
aria-label="Schließen"
|
||||
>
|
||||
<X className="w-4 h-4 sm:w-3.5 sm:h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Emojis Bar */}
|
||||
{recentEmojis.length > 0 && (
|
||||
<div className="shrink-0 space-y-1.5">
|
||||
<div className="flex items-center gap-1 text-[11px] text-muted">
|
||||
<Clock className="w-3 h-3 text-accent" />
|
||||
<span>Zuletzt verwendet:</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 no-scrollbar">
|
||||
{recentEmojis.map((emoji) => (
|
||||
<button
|
||||
key={emoji}
|
||||
type="button"
|
||||
onClick={() => handlePick(emoji)}
|
||||
className="min-w-[44px] min-h-[44px] sm:min-w-[32px] sm:min-h-[32px] rounded-btn bg-panel-elevated hover:bg-canvas border border-border flex items-center justify-center text-xl sm:text-base cursor-pointer transition-transform active:scale-95 shrink-0"
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Full Emoji Catalog via emoji-picker-react */}
|
||||
<div className="flex-1 min-h-[300px] overflow-hidden rounded mindsift-emoji-picker">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="h-64 flex flex-col items-center justify-center gap-2 text-xs text-muted">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-accent" />
|
||||
<span>Emoji-Katalog wird geladen…</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<EmojiPicker
|
||||
onEmojiClick={handlePickerEmojiClick}
|
||||
autoFocusSearch={true}
|
||||
theme={isDark ? Theme.DARK : Theme.LIGHT}
|
||||
emojiStyle={EmojiStyle.NATIVE}
|
||||
suggestedEmojisMode={SuggestionMode.RECENT}
|
||||
searchPlaceholder="Emoji suchen…"
|
||||
searchPlaceHolder="Emoji suchen…"
|
||||
width="100%"
|
||||
height="350px"
|
||||
previewConfig={{
|
||||
showPreview: false,
|
||||
}}
|
||||
skinTonesDisabled={false}
|
||||
lazyLoadEmojis={true}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -461,3 +461,30 @@ body {
|
||||
color: #f97316;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
EMOJI PICKER (emoji-picker-react Integration & Mobile Touch Tuning)
|
||||
========================================================================== */
|
||||
|
||||
.mindsift-emoji-picker .EmojiPickerReact {
|
||||
border: none !important;
|
||||
font-family: inherit !important;
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
/* Prevent iOS auto-zoom on input focus */
|
||||
.mindsift-emoji-picker .EmojiPickerReact input.epr-search-input {
|
||||
font-size: 1rem !important;
|
||||
min-height: 44px !important;
|
||||
}
|
||||
|
||||
/* Min 44px touch targets on mobile */
|
||||
.mindsift-emoji-picker .EmojiPickerReact button.epr-emoji {
|
||||
min-width: 44px !important;
|
||||
min-height: 44px !important;
|
||||
}
|
||||
|
||||
.mindsift-emoji-picker .EmojiPickerReact .epr-category-nav button {
|
||||
min-height: 44px !important;
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
TaskListShare,
|
||||
Thought,
|
||||
SubTask,
|
||||
SearchResults,
|
||||
TagDetails,
|
||||
TagSummary,
|
||||
User,
|
||||
@@ -1065,8 +1066,8 @@ export const api = {
|
||||
);
|
||||
},
|
||||
|
||||
async searchAll(q: string): Promise<{ thoughts: Thought[]; documents: Document[] }> {
|
||||
return request<{ thoughts: Thought[]; documents: Document[] }>(
|
||||
async searchAll(q: string): Promise<SearchResults> {
|
||||
return request<SearchResults>(
|
||||
`/api/v1/search?q=${encodeURIComponent(q)}`
|
||||
);
|
||||
},
|
||||
|
||||
@@ -304,6 +304,25 @@ export interface TagDetails {
|
||||
totalMatches: number;
|
||||
}
|
||||
|
||||
export interface SearchHit {
|
||||
type: 'document' | 'thought' | 'list_item';
|
||||
id: string;
|
||||
title: string;
|
||||
snippet: string;
|
||||
score?: number;
|
||||
docId?: string;
|
||||
blockId?: string;
|
||||
listId?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface SearchResults {
|
||||
thoughts: Thought[];
|
||||
documents: Document[];
|
||||
listItems: ListItem[];
|
||||
hits: SearchHit[];
|
||||
}
|
||||
|
||||
export interface SpaceMember {
|
||||
spaceId: string;
|
||||
userId: string;
|
||||
|
||||
Reference in new issue
Block a user