mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-22 08:26:41 +02:00
feat(todo): add quick task creation via CommandBar
- Add natural language parser for task input (date, time, priority, project, labels) - Extend CommandBar with onCreate/onParseCreate callbacks - Show create preview with parsed attributes as first option - Support Cmd/Ctrl+Enter to create directly - Fix service worker to not intercept Vite dev server requests - Update deprecated apple-mobile-web-app-capable meta tag Example: "Meeting morgen 14 Uhr !hoch @Arbeit #wichtig" → Creates task with due date, time, priority, project and label 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
d8f1bbbbce
commit
89a2b3da9e
7 changed files with 460 additions and 25 deletions
|
|
@ -15,8 +15,8 @@
|
||||||
<meta name="theme-color" content="#8b5cf6" />
|
<meta name="theme-color" content="#8b5cf6" />
|
||||||
<meta name="msapplication-TileColor" content="#8b5cf6" />
|
<meta name="msapplication-TileColor" content="#8b5cf6" />
|
||||||
|
|
||||||
<!-- Apple iOS PWA -->
|
<!-- PWA -->
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Todo" />
|
<meta name="apple-mobile-web-app-title" content="Todo" />
|
||||||
<link rel="apple-touch-icon" href="/icons/icon.svg" />
|
<link rel="apple-touch-icon" href="/icons/icon.svg" />
|
||||||
|
|
|
||||||
260
apps/todo/apps/web/src/lib/utils/task-parser.ts
Normal file
260
apps/todo/apps/web/src/lib/utils/task-parser.ts
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
import {
|
||||||
|
addDays,
|
||||||
|
nextMonday,
|
||||||
|
nextTuesday,
|
||||||
|
nextWednesday,
|
||||||
|
nextThursday,
|
||||||
|
nextFriday,
|
||||||
|
nextSaturday,
|
||||||
|
nextSunday,
|
||||||
|
setHours,
|
||||||
|
setMinutes,
|
||||||
|
parse,
|
||||||
|
} from 'date-fns';
|
||||||
|
import { de } from 'date-fns/locale';
|
||||||
|
import type { TaskPriority } from '@todo/shared';
|
||||||
|
|
||||||
|
export interface ParsedTask {
|
||||||
|
title: string;
|
||||||
|
dueDate?: Date;
|
||||||
|
priority?: TaskPriority;
|
||||||
|
projectName?: string;
|
||||||
|
labelNames: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Project {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Label {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedTaskWithIds {
|
||||||
|
title: string;
|
||||||
|
dueDate?: string;
|
||||||
|
priority?: TaskPriority;
|
||||||
|
projectId?: string;
|
||||||
|
labelIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority patterns
|
||||||
|
const PRIORITY_PATTERNS: { pattern: RegExp; priority: TaskPriority }[] = [
|
||||||
|
{ pattern: /!{3,}|!dringend|!urgent/i, priority: 'urgent' },
|
||||||
|
{ pattern: /!{2}|!hoch|!high/i, priority: 'high' },
|
||||||
|
{ pattern: /!mittel|!medium/i, priority: 'medium' },
|
||||||
|
{ pattern: /!niedrig|!low/i, priority: 'low' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Date patterns (German)
|
||||||
|
const DATE_PATTERNS: { pattern: RegExp; getDate: () => Date }[] = [
|
||||||
|
{ pattern: /\bheute\b/i, getDate: () => new Date() },
|
||||||
|
{ pattern: /\bmorgen\b/i, getDate: () => addDays(new Date(), 1) },
|
||||||
|
{ pattern: /\bübermorgen\b/i, getDate: () => addDays(new Date(), 2) },
|
||||||
|
{ pattern: /\bin\s*(\d+)\s*tage?n?\b/i, getDate: () => new Date() }, // Handled specially
|
||||||
|
{ pattern: /\bnächste[nr]?\s*woche\b/i, getDate: () => addDays(new Date(), 7) },
|
||||||
|
{ pattern: /\bnächste[nr]?\s*montag\b/i, getDate: () => nextMonday(new Date()) },
|
||||||
|
{ pattern: /\bnächste[nr]?\s*dienstag\b/i, getDate: () => nextTuesday(new Date()) },
|
||||||
|
{ pattern: /\bnächste[nr]?\s*mittwoch\b/i, getDate: () => nextWednesday(new Date()) },
|
||||||
|
{ pattern: /\bnächste[nr]?\s*donnerstag\b/i, getDate: () => nextThursday(new Date()) },
|
||||||
|
{ pattern: /\bnächste[nr]?\s*freitag\b/i, getDate: () => nextFriday(new Date()) },
|
||||||
|
{ pattern: /\bnächste[nr]?\s*samstag\b/i, getDate: () => nextSaturday(new Date()) },
|
||||||
|
{ pattern: /\bnächste[nr]?\s*sonntag\b/i, getDate: () => nextSunday(new Date()) },
|
||||||
|
{ pattern: /\bmontag\b/i, getDate: () => nextMonday(new Date()) },
|
||||||
|
{ pattern: /\bdienstag\b/i, getDate: () => nextTuesday(new Date()) },
|
||||||
|
{ pattern: /\bmittwoch\b/i, getDate: () => nextWednesday(new Date()) },
|
||||||
|
{ pattern: /\bdonnerstag\b/i, getDate: () => nextThursday(new Date()) },
|
||||||
|
{ pattern: /\bfreitag\b/i, getDate: () => nextFriday(new Date()) },
|
||||||
|
{ pattern: /\bsamstag\b/i, getDate: () => nextSaturday(new Date()) },
|
||||||
|
{ pattern: /\bsonntag\b/i, getDate: () => nextSunday(new Date()) },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Time pattern
|
||||||
|
const TIME_PATTERN = /\b(?:um\s*)?(\d{1,2})(?::(\d{2}))?\s*(?:uhr)?\b/i;
|
||||||
|
|
||||||
|
// Specific date pattern (DD.MM. or DD.MM.YYYY)
|
||||||
|
const SPECIFIC_DATE_PATTERN = /\b(\d{1,2})\.(\d{1,2})\.?(\d{2,4})?\b/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse natural language task input
|
||||||
|
*/
|
||||||
|
export function parseTaskInput(input: string): ParsedTask {
|
||||||
|
let text = input.trim();
|
||||||
|
let dueDate: Date | undefined;
|
||||||
|
let priority: TaskPriority | undefined;
|
||||||
|
let projectName: string | undefined;
|
||||||
|
const labelNames: string[] = [];
|
||||||
|
|
||||||
|
// Extract priority (!hoch, !!, etc.)
|
||||||
|
for (const { pattern, priority: p } of PRIORITY_PATTERNS) {
|
||||||
|
if (pattern.test(text)) {
|
||||||
|
priority = p;
|
||||||
|
text = text.replace(pattern, '').trim();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract project (@ProjectName)
|
||||||
|
const projectMatch = text.match(/@(\S+)/);
|
||||||
|
if (projectMatch) {
|
||||||
|
projectName = projectMatch[1];
|
||||||
|
text = text.replace(/@\S+/, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract labels (#label1 #label2)
|
||||||
|
const labelRegex = /#(\S+)/g;
|
||||||
|
let labelMatch;
|
||||||
|
while ((labelMatch = labelRegex.exec(text)) !== null) {
|
||||||
|
labelNames.push(labelMatch[1]);
|
||||||
|
}
|
||||||
|
text = text.replace(/#\S+/g, '').trim();
|
||||||
|
|
||||||
|
// Extract specific date (DD.MM. or DD.MM.YYYY)
|
||||||
|
const specificDateMatch = text.match(SPECIFIC_DATE_PATTERN);
|
||||||
|
if (specificDateMatch) {
|
||||||
|
const day = parseInt(specificDateMatch[1], 10);
|
||||||
|
const month = parseInt(specificDateMatch[2], 10) - 1;
|
||||||
|
const year = specificDateMatch[3]
|
||||||
|
? parseInt(specificDateMatch[3], 10) < 100
|
||||||
|
? 2000 + parseInt(specificDateMatch[3], 10)
|
||||||
|
: parseInt(specificDateMatch[3], 10)
|
||||||
|
: new Date().getFullYear();
|
||||||
|
|
||||||
|
dueDate = new Date(year, month, day);
|
||||||
|
text = text.replace(SPECIFIC_DATE_PATTERN, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract relative date (heute, morgen, nächsten Montag, etc.)
|
||||||
|
if (!dueDate) {
|
||||||
|
// Special handling for "in X Tagen"
|
||||||
|
const inDaysMatch = text.match(/\bin\s*(\d+)\s*tage?n?\b/i);
|
||||||
|
if (inDaysMatch) {
|
||||||
|
const days = parseInt(inDaysMatch[1], 10);
|
||||||
|
dueDate = addDays(new Date(), days);
|
||||||
|
text = text.replace(/\bin\s*\d+\s*tage?n?\b/i, '').trim();
|
||||||
|
} else {
|
||||||
|
for (const { pattern, getDate } of DATE_PATTERNS) {
|
||||||
|
if (pattern.test(text)) {
|
||||||
|
dueDate = getDate();
|
||||||
|
text = text.replace(pattern, '').trim();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract time (um 14 Uhr, 14:00, etc.)
|
||||||
|
const timeMatch = text.match(TIME_PATTERN);
|
||||||
|
if (timeMatch && dueDate) {
|
||||||
|
const hours = parseInt(timeMatch[1], 10);
|
||||||
|
const minutes = timeMatch[2] ? parseInt(timeMatch[2], 10) : 0;
|
||||||
|
dueDate = setHours(setMinutes(dueDate, minutes), hours);
|
||||||
|
text = text.replace(TIME_PATTERN, '').trim();
|
||||||
|
} else if (timeMatch && !dueDate) {
|
||||||
|
// Time without date = today
|
||||||
|
dueDate = new Date();
|
||||||
|
const hours = parseInt(timeMatch[1], 10);
|
||||||
|
const minutes = timeMatch[2] ? parseInt(timeMatch[2], 10) : 0;
|
||||||
|
dueDate = setHours(setMinutes(dueDate, minutes), hours);
|
||||||
|
text = text.replace(TIME_PATTERN, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up multiple spaces
|
||||||
|
const title = text.replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
dueDate,
|
||||||
|
priority,
|
||||||
|
projectName,
|
||||||
|
labelNames,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve project and label names to IDs
|
||||||
|
*/
|
||||||
|
export function resolveTaskIds(
|
||||||
|
parsed: ParsedTask,
|
||||||
|
projects: Project[],
|
||||||
|
labels: Label[]
|
||||||
|
): ParsedTaskWithIds {
|
||||||
|
let projectId: string | undefined;
|
||||||
|
const labelIds: string[] = [];
|
||||||
|
|
||||||
|
// Find project by name (case-insensitive)
|
||||||
|
if (parsed.projectName) {
|
||||||
|
const project = projects.find(
|
||||||
|
(p) => p.name.toLowerCase() === parsed.projectName!.toLowerCase()
|
||||||
|
);
|
||||||
|
if (project) {
|
||||||
|
projectId = project.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find labels by name (case-insensitive)
|
||||||
|
for (const labelName of parsed.labelNames) {
|
||||||
|
const label = labels.find((l) => l.name.toLowerCase() === labelName.toLowerCase());
|
||||||
|
if (label) {
|
||||||
|
labelIds.push(label.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: parsed.title,
|
||||||
|
dueDate: parsed.dueDate?.toISOString(),
|
||||||
|
priority: parsed.priority,
|
||||||
|
projectId,
|
||||||
|
labelIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format parsed task for preview display
|
||||||
|
*/
|
||||||
|
export function formatParsedTaskPreview(parsed: ParsedTask): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
if (parsed.dueDate) {
|
||||||
|
const now = new Date();
|
||||||
|
const tomorrow = addDays(now, 1);
|
||||||
|
|
||||||
|
if (parsed.dueDate.toDateString() === now.toDateString()) {
|
||||||
|
parts.push('📅 Heute');
|
||||||
|
} else if (parsed.dueDate.toDateString() === tomorrow.toDateString()) {
|
||||||
|
parts.push('📅 Morgen');
|
||||||
|
} else {
|
||||||
|
parts.push(
|
||||||
|
`📅 ${parsed.dueDate.toLocaleDateString('de-DE', { weekday: 'short', day: 'numeric', month: 'short' })}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add time if not midnight
|
||||||
|
if (parsed.dueDate.getHours() !== 0 || parsed.dueDate.getMinutes() !== 0) {
|
||||||
|
parts[parts.length - 1] +=
|
||||||
|
` ${parsed.dueDate.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.priority) {
|
||||||
|
const priorityLabels: Record<TaskPriority, string> = {
|
||||||
|
low: '🟢 Niedrig',
|
||||||
|
medium: '🟡 Mittel',
|
||||||
|
high: '🟠 Hoch',
|
||||||
|
urgent: '🔴 Dringend',
|
||||||
|
};
|
||||||
|
parts.push(priorityLabels[parsed.priority]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.projectName) {
|
||||||
|
parts.push(`📁 ${parsed.projectName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.labelNames.length > 0) {
|
||||||
|
parts.push(`🏷️ ${parsed.labelNames.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(' · ');
|
||||||
|
}
|
||||||
|
|
@ -9,11 +9,13 @@
|
||||||
PillDropdownItem,
|
PillDropdownItem,
|
||||||
CommandBarItem,
|
CommandBarItem,
|
||||||
QuickAction,
|
QuickAction,
|
||||||
|
CreatePreview,
|
||||||
} from '@manacore/shared-ui';
|
} from '@manacore/shared-ui';
|
||||||
import { authStore } from '$lib/stores/auth.svelte';
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
import { userSettings } from '$lib/stores/user-settings.svelte';
|
import { userSettings } from '$lib/stores/user-settings.svelte';
|
||||||
import { projectsStore } from '$lib/stores/projects.svelte';
|
import { projectsStore } from '$lib/stores/projects.svelte';
|
||||||
import { labelsStore } from '$lib/stores/labels.svelte';
|
import { labelsStore } from '$lib/stores/labels.svelte';
|
||||||
|
import { tasksStore } from '$lib/stores/tasks.svelte';
|
||||||
import { theme } from '$lib/stores/theme';
|
import { theme } from '$lib/stores/theme';
|
||||||
import {
|
import {
|
||||||
isSidebarMode as sidebarModeStore,
|
isSidebarMode as sidebarModeStore,
|
||||||
|
|
@ -28,6 +30,7 @@
|
||||||
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
|
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
|
||||||
import { getPillAppItems } from '@manacore/shared-branding';
|
import { getPillAppItems } from '@manacore/shared-branding';
|
||||||
import { getTasks } from '$lib/api/tasks';
|
import { getTasks } from '$lib/api/tasks';
|
||||||
|
import { parseTaskInput, resolveTaskIds, formatParsedTaskPreview } from '$lib/utils/task-parser';
|
||||||
|
|
||||||
// App switcher items
|
// App switcher items
|
||||||
const appItems = getPillAppItems('todo');
|
const appItems = getPillAppItems('todo');
|
||||||
|
|
@ -69,6 +72,35 @@
|
||||||
goto(`/task/${item.id}`);
|
goto(`/task/${item.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CommandBar create - parse input and show preview
|
||||||
|
function handleCommandBarParseCreate(query: string): CreatePreview | null {
|
||||||
|
if (!query.trim()) return null;
|
||||||
|
|
||||||
|
const parsed = parseTaskInput(query);
|
||||||
|
const preview = formatParsedTaskPreview(parsed);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `"${parsed.title}" als Aufgabe erstellen`,
|
||||||
|
subtitle: preview || 'Neue Aufgabe',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommandBar create - actually create the task
|
||||||
|
async function handleCommandBarCreate(query: string): Promise<void> {
|
||||||
|
if (!query.trim()) return;
|
||||||
|
|
||||||
|
const parsed = parseTaskInput(query);
|
||||||
|
const resolved = resolveTaskIds(parsed, projectsStore.projects, labelsStore.labels);
|
||||||
|
|
||||||
|
await tasksStore.createTask({
|
||||||
|
title: resolved.title,
|
||||||
|
dueDate: resolved.dueDate,
|
||||||
|
priority: resolved.priority,
|
||||||
|
projectId: resolved.projectId,
|
||||||
|
labelIds: resolved.labelIds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let isSidebarMode = $state(false);
|
let isSidebarMode = $state(false);
|
||||||
let isCollapsed = $state(false);
|
let isCollapsed = $state(false);
|
||||||
|
|
||||||
|
|
@ -326,9 +358,13 @@
|
||||||
onSearch={handleCommandBarSearch}
|
onSearch={handleCommandBarSearch}
|
||||||
onSelect={handleCommandBarSelect}
|
onSelect={handleCommandBarSelect}
|
||||||
quickActions={commandBarQuickActions}
|
quickActions={commandBarQuickActions}
|
||||||
placeholder="Aufgabe suchen..."
|
placeholder="Aufgabe suchen oder erstellen..."
|
||||||
emptyText="Keine Aufgaben gefunden"
|
emptyText="Keine Aufgaben gefunden"
|
||||||
searchingText="Suche..."
|
searchingText="Suche..."
|
||||||
|
onCreate={handleCommandBarCreate}
|
||||||
|
onParseCreate={handleCommandBarParseCreate}
|
||||||
|
createText="Als Aufgabe erstellen"
|
||||||
|
createShortcut="⌘↵"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
const CACHE_NAME = 'todo-v1';
|
const CACHE_NAME = 'todo-v2';
|
||||||
const OFFLINE_URL = '/offline.html';
|
const OFFLINE_URL = '/offline.html';
|
||||||
|
|
||||||
// Assets, die immer gecacht werden sollen
|
// Assets, die immer gecacht werden sollen
|
||||||
|
|
@ -8,23 +8,16 @@ const STATIC_CACHE_URLS = ['/', '/offline.html', '/icons/icon.svg', '/manifest.j
|
||||||
const CACHE_STRATEGIES = {
|
const CACHE_STRATEGIES = {
|
||||||
// Netzwerk zuerst, dann Cache (für HTML/Navigation)
|
// Netzwerk zuerst, dann Cache (für HTML/Navigation)
|
||||||
networkFirst: [/\/$/, /\.html$/, /^\/kanban/, /^\/settings/, /^\/mana/, /^\/feedback/],
|
networkFirst: [/\/$/, /\.html$/, /^\/kanban/, /^\/settings/, /^\/mana/, /^\/feedback/],
|
||||||
// Cache zuerst, dann Netzwerk (für Assets)
|
// Cache zuerst, dann Netzwerk (für Assets) - nur für gebaute Assets, nicht /src/
|
||||||
cacheFirst: [
|
cacheFirst: [
|
||||||
/\.css$/,
|
/\/_app\//, // SvelteKit gebaute Assets
|
||||||
/\.js$/,
|
|
||||||
/\.woff2?$/,
|
/\.woff2?$/,
|
||||||
/\.ttf$/,
|
/\.ttf$/,
|
||||||
/\.otf$/,
|
/\.otf$/,
|
||||||
/\.svg$/,
|
|
||||||
/\.png$/,
|
|
||||||
/\.jpg$/,
|
|
||||||
/\.jpeg$/,
|
|
||||||
/\.webp$/,
|
|
||||||
/\.ico$/,
|
/\.ico$/,
|
||||||
/\/_app\//,
|
|
||||||
],
|
],
|
||||||
// Nur Netzwerk (für API-Calls)
|
// Nur Netzwerk (für API-Calls und Dev-Server)
|
||||||
networkOnly: [/\/api\//, /localhost:3018/],
|
networkOnly: [/\/api\//, /localhost:3018/, /^\/src\//, /^\/@/, /^\/node_modules\//],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Service Worker Installation
|
// Service Worker Installation
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,11 @@
|
||||||
onclick?: () => void;
|
onclick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreatePreview {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
|
@ -28,6 +33,11 @@
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
emptyText?: string;
|
emptyText?: string;
|
||||||
searchingText?: string;
|
searchingText?: string;
|
||||||
|
// New: Task creation support
|
||||||
|
onCreate?: (query: string) => Promise<void>;
|
||||||
|
onParseCreate?: (query: string) => CreatePreview | null;
|
||||||
|
createText?: string;
|
||||||
|
createShortcut?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
|
@ -39,21 +49,35 @@
|
||||||
placeholder = 'Suchen...',
|
placeholder = 'Suchen...',
|
||||||
emptyText = 'Keine Ergebnisse gefunden',
|
emptyText = 'Keine Ergebnisse gefunden',
|
||||||
searchingText = 'Suche...',
|
searchingText = 'Suche...',
|
||||||
|
onCreate,
|
||||||
|
onParseCreate,
|
||||||
|
createText = 'Als Eintrag erstellen',
|
||||||
|
createShortcut = '⌘↵',
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let searchQuery = $state('');
|
let searchQuery = $state('');
|
||||||
let results = $state<CommandBarItem[]>([]);
|
let results = $state<CommandBarItem[]>([]);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
|
let creating = $state(false);
|
||||||
let selectedIndex = $state(0);
|
let selectedIndex = $state(0);
|
||||||
let searchTimeout: ReturnType<typeof setTimeout>;
|
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||||
let inputElement: HTMLInputElement;
|
let inputElement: HTMLInputElement;
|
||||||
|
|
||||||
|
// Computed create preview
|
||||||
|
let createPreview = $derived(
|
||||||
|
searchQuery.trim() && onParseCreate ? onParseCreate(searchQuery) : null
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if create option is selected (it's always first when available)
|
||||||
|
let isCreateSelected = $derived(selectedIndex === 0 && createPreview !== null);
|
||||||
|
|
||||||
// Reset state when modal opens
|
// Reset state when modal opens
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
searchQuery = '';
|
searchQuery = '';
|
||||||
results = [];
|
results = [];
|
||||||
selectedIndex = 0;
|
selectedIndex = 0;
|
||||||
|
creating = false;
|
||||||
setTimeout(() => inputElement?.focus(), 50);
|
setTimeout(() => inputElement?.focus(), 50);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -82,6 +106,20 @@
|
||||||
}, 150);
|
}, 150);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
if (!onCreate || !searchQuery.trim() || creating) return;
|
||||||
|
|
||||||
|
creating = true;
|
||||||
|
try {
|
||||||
|
await onCreate(searchQuery);
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Create error:', error);
|
||||||
|
} finally {
|
||||||
|
creating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleKeydown(event: KeyboardEvent) {
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
@ -89,10 +127,23 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cmd/Ctrl+Enter to create directly
|
||||||
|
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (onCreate && searchQuery.trim()) {
|
||||||
|
handleCreate();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (event.key === 'ArrowDown') {
|
if (event.key === 'ArrowDown') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const maxIndex = searchQuery.trim() ? results.length - 1 : quickActions.length - 1;
|
// Calculate max index including create option
|
||||||
selectedIndex = Math.min(selectedIndex + 1, maxIndex);
|
const hasCreate = createPreview !== null;
|
||||||
|
const maxIndex = searchQuery.trim()
|
||||||
|
? (hasCreate ? 1 : 0) + results.length - 1
|
||||||
|
: quickActions.length - 1;
|
||||||
|
selectedIndex = Math.min(selectedIndex + 1, Math.max(0, maxIndex));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -104,8 +155,17 @@
|
||||||
|
|
||||||
if (event.key === 'Enter') {
|
if (event.key === 'Enter') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (searchQuery.trim() && results.length > 0) {
|
if (searchQuery.trim()) {
|
||||||
selectItem(results[selectedIndex]);
|
// If create option is selected
|
||||||
|
if (isCreateSelected && onCreate) {
|
||||||
|
handleCreate();
|
||||||
|
} else if (results.length > 0) {
|
||||||
|
// Adjust index for results (subtract 1 if create option exists)
|
||||||
|
const resultIndex = createPreview !== null ? selectedIndex - 1 : selectedIndex;
|
||||||
|
if (resultIndex >= 0 && resultIndex < results.length) {
|
||||||
|
selectItem(results[resultIndex]);
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if (!searchQuery.trim() && quickActions.length > 0) {
|
} else if (!searchQuery.trim() && quickActions.length > 0) {
|
||||||
const action = quickActions[selectedIndex];
|
const action = quickActions[selectedIndex];
|
||||||
if (action.href) {
|
if (action.href) {
|
||||||
|
|
@ -184,23 +244,63 @@
|
||||||
<!-- Results -->
|
<!-- Results -->
|
||||||
{#if searchQuery.trim()}
|
{#if searchQuery.trim()}
|
||||||
<div class="command-results">
|
<div class="command-results">
|
||||||
|
<!-- Create option (always first when available) -->
|
||||||
|
{#if createPreview && onCreate}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="command-result create-option"
|
||||||
|
class:selected={selectedIndex === 0}
|
||||||
|
onclick={handleCreate}
|
||||||
|
onmouseenter={() => (selectedIndex = 0)}
|
||||||
|
disabled={creating}
|
||||||
|
>
|
||||||
|
<div class="result-avatar create-avatar">
|
||||||
|
{#if creating}
|
||||||
|
<div class="loading-spinner-small"></div>
|
||||||
|
{:else}
|
||||||
|
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 4v16m8-8H4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="result-info">
|
||||||
|
<div class="result-name">{createPreview.title}</div>
|
||||||
|
{#if createPreview.subtitle}
|
||||||
|
<div class="result-details">
|
||||||
|
<span>{createPreview.subtitle}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<kbd class="create-shortcut">{createShortcut}</kbd>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="command-loading">
|
<div class="command-loading">
|
||||||
<div class="loading-spinner"></div>
|
<div class="loading-spinner"></div>
|
||||||
<span>{searchingText}</span>
|
<span>{searchingText}</span>
|
||||||
</div>
|
</div>
|
||||||
{:else if results.length === 0}
|
{:else if results.length === 0 && !createPreview}
|
||||||
<div class="command-empty">
|
<div class="command-empty">
|
||||||
<span>{emptyText}</span>
|
<span>{emptyText}</span>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else if results.length > 0}
|
||||||
|
<div class="results-divider">
|
||||||
|
<span>Suchergebnisse</span>
|
||||||
|
</div>
|
||||||
{#each results as item, index (item.id)}
|
{#each results as item, index (item.id)}
|
||||||
|
{@const adjustedIndex = createPreview ? index + 1 : index}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="command-result"
|
class="command-result"
|
||||||
class:selected={index === selectedIndex}
|
class:selected={adjustedIndex === selectedIndex}
|
||||||
onclick={() => selectItem(item)}
|
onclick={() => selectItem(item)}
|
||||||
onmouseenter={() => (selectedIndex = index)}
|
onmouseenter={() => (selectedIndex = adjustedIndex)}
|
||||||
>
|
>
|
||||||
<div class="result-avatar">
|
<div class="result-avatar">
|
||||||
{#if item.imageUrl}
|
{#if item.imageUrl}
|
||||||
|
|
@ -329,6 +429,9 @@
|
||||||
<div class="footer-hints">
|
<div class="footer-hints">
|
||||||
<span><kbd>↑↓</kbd> Navigation</span>
|
<span><kbd>↑↓</kbd> Navigation</span>
|
||||||
<span><kbd>↵</kbd> Öffnen</span>
|
<span><kbd>↵</kbd> Öffnen</span>
|
||||||
|
{#if onCreate}
|
||||||
|
<span><kbd>{createShortcut}</kbd> Erstellen</span>
|
||||||
|
{/if}
|
||||||
<span><kbd>ESC</kbd> Schließen</span>
|
<span><kbd>ESC</kbd> Schließen</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -449,12 +552,55 @@
|
||||||
animation: spin 0.8s linear infinite;
|
animation: spin 0.8s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.loading-spinner-small {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
border: 2px solid #444;
|
||||||
|
border-top-color: #10b981;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
to {
|
to {
|
||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Create option styles */
|
||||||
|
.create-option {
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-option.selected,
|
||||||
|
.create-option:hover {
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-avatar {
|
||||||
|
background: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-shortcut {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-family: inherit;
|
||||||
|
background: #2a2a2a;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #888;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-divider {
|
||||||
|
padding: 0.5rem 1.25rem 0.25rem;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
.command-result {
|
.command-result {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
export { default as CommandBar } from './CommandBar.svelte';
|
export { default as CommandBar } from './CommandBar.svelte';
|
||||||
export type { CommandBarItem, QuickAction } from './CommandBar.svelte';
|
export type { CommandBarItem, QuickAction, CreatePreview } from './CommandBar.svelte';
|
||||||
|
|
|
||||||
|
|
@ -106,7 +106,7 @@ export {
|
||||||
|
|
||||||
// Command Bar
|
// Command Bar
|
||||||
export { CommandBar } from './command-bar';
|
export { CommandBar } from './command-bar';
|
||||||
export type { CommandBarItem, QuickAction } from './command-bar';
|
export type { CommandBarItem, QuickAction, CreatePreview } from './command-bar';
|
||||||
|
|
||||||
// Pages
|
// Pages
|
||||||
export { default as AppsPage } from './pages/AppsPage.svelte';
|
export { default as AppsPage } from './pages/AppsPage.svelte';
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue