feat(shared-ui): add global CommandBar component with search across apps

- Add reusable CommandBar component to shared-ui package with dark theme
- Integrate CommandBar (Cmd/K) in contacts, calendar, todo, and clock apps
- Implement search functionality for each app:
  - Contacts: search by name, company, email with relevance-based sorting
  - Calendar: search events by title/description within next year
  - Todo: search tasks by title/description
  - Clock: search alarms and timers by label
- Add quick actions for common operations in each app

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-09 20:35:58 +01:00
parent 2b3f92ff36
commit a4846aea06
8 changed files with 899 additions and 17 deletions

View file

@ -9,6 +9,7 @@ export interface QueryEventsParams {
startDate: string;
endDate: string;
calendarIds?: string[];
search?: string;
}
export async function getEvents(params: QueryEventsParams) {
@ -19,9 +20,25 @@ export async function getEvents(params: QueryEventsParams) {
if (params.calendarIds?.length) {
searchParams.set('calendarIds', params.calendarIds.join(','));
}
if (params.search) {
searchParams.set('search', params.search);
}
return fetchApi<CalendarEvent[]>(`/events?${searchParams.toString()}`);
}
export async function searchEvents(query: string, limit: number = 10) {
// Search events within the next year
const now = new Date();
const oneYearFromNow = new Date();
oneYearFromNow.setFullYear(oneYearFromNow.getFullYear() + 1);
return getEvents({
startDate: now.toISOString(),
endDate: oneYearFromNow.toISOString(),
search: query,
});
}
export async function getEvent(id: string) {
const result = await fetchApi<{ event: CalendarEvent }>(`/events/${id}`);
if (result.error || !result.data) {

View file

@ -3,8 +3,13 @@
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { locale } from 'svelte-i18n';
import { PillNavigation } from '@manacore/shared-ui';
import type { PillNavItem, PillDropdownItem } from '@manacore/shared-ui';
import { PillNavigation, CommandBar } from '@manacore/shared-ui';
import type {
PillNavItem,
PillDropdownItem,
CommandBarItem,
QuickAction,
} from '@manacore/shared-ui';
import { theme } from '$lib/stores/theme';
import { authStore } from '$lib/stores/auth.svelte';
import { userSettings } from '$lib/stores/user-settings.svelte';
@ -19,12 +24,49 @@
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
import { getPillAppItems } from '@manacore/shared-branding';
import { setLocale, supportedLocales } from '$lib/i18n';
import { searchEvents } from '$lib/api/events';
import { format } from 'date-fns';
import { de } from 'date-fns/locale';
// App switcher items
const appItems = getPillAppItems('calendar');
let { children } = $props();
// CommandBar state
let commandBarOpen = $state(false);
// CommandBar quick actions (no search for calendar yet)
const commandBarQuickActions: QuickAction[] = [
{ id: 'new', label: 'Neuen Termin erstellen', icon: 'plus', href: '/event/new', shortcut: 'N' },
{
id: 'today',
label: 'Zu Heute springen',
icon: 'calendar',
onclick: () => viewStore.goToToday(),
},
{ id: 'agenda', label: 'Agenda anzeigen', icon: 'list', href: '/agenda' },
{ id: 'settings', label: 'Einstellungen', icon: 'settings', href: '/settings' },
];
// CommandBar search - search events
async function handleCommandBarSearch(query: string): Promise<CommandBarItem[]> {
if (!query.trim()) return [];
const result = await searchEvents(query);
if (result.error || !result.data) return [];
return result.data.slice(0, 10).map((event) => ({
id: event.id,
title: event.title,
subtitle: format(new Date(event.startTime), 'dd. MMM yyyy, HH:mm', { locale: de }),
}));
}
function handleCommandBarSelect(item: CommandBarItem) {
goto(`/event/${item.id}`);
}
let isSidebarMode = $state(false);
let isCollapsed = $state(false);
@ -79,6 +121,13 @@
function handleKeydown(event: KeyboardEvent) {
const target = event.target as HTMLElement;
// Cmd/Ctrl+K to open command bar (works even in inputs)
if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
event.preventDefault();
commandBarOpen = true;
return;
}
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return;
}
@ -209,6 +258,18 @@
{@render children()}
</div>
</main>
<!-- Global Command Bar (Cmd/K) -->
<CommandBar
bind:open={commandBarOpen}
onClose={() => (commandBarOpen = false)}
onSearch={handleCommandBarSearch}
onSelect={handleCommandBarSelect}
quickActions={commandBarQuickActions}
placeholder="Termin suchen..."
emptyText="Keine Termine gefunden"
searchingText="Suche..."
/>
</div>
<style>

View file

@ -3,8 +3,13 @@
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { locale } from 'svelte-i18n';
import { PillNavigation } from '@manacore/shared-ui';
import type { PillNavItem, PillDropdownItem } from '@manacore/shared-ui';
import { PillNavigation, CommandBar } from '@manacore/shared-ui';
import type {
PillNavItem,
PillDropdownItem,
CommandBarItem,
QuickAction,
} from '@manacore/shared-ui';
import { theme } from '$lib/stores/theme.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { userSettings } from '$lib/stores/user-settings.svelte';
@ -16,12 +21,89 @@
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
import { getPillAppItems } from '@manacore/shared-branding';
import { setLocale, supportedLocales } from '$lib/i18n';
import { alarmsApi } from '$lib/api/alarms';
import { timersApi } from '$lib/api/timers';
// App switcher items
const appItems = getPillAppItems('clock');
let { children } = $props();
// CommandBar state
let commandBarOpen = $state(false);
// CommandBar quick actions
const commandBarQuickActions: QuickAction[] = [
{
id: 'alarm',
label: 'Neuen Wecker erstellen',
icon: 'bell',
href: '/alarms?new=true',
shortcut: 'A',
},
{
id: 'timer',
label: 'Neuen Timer starten',
icon: 'timer',
href: '/timers?new=true',
shortcut: 'T',
},
{ id: 'stopwatch', label: 'Stoppuhr', icon: 'stopwatch', href: '/stopwatch' },
{ id: 'pomodoro', label: 'Pomodoro starten', icon: 'target', href: '/pomodoro' },
{ id: 'worldclock', label: 'Weltzeituhr', icon: 'globe', href: '/world-clock' },
{ id: 'settings', label: 'Einstellungen', icon: 'settings', href: '/settings' },
];
// CommandBar search - search alarms and timers
async function handleCommandBarSearch(query: string): Promise<CommandBarItem[]> {
if (!query.trim()) return [];
const queryLower = query.toLowerCase();
const results: CommandBarItem[] = [];
try {
// Search alarms
const alarms = await alarmsApi.getAll();
const matchingAlarms = alarms
.filter((alarm) => alarm.label?.toLowerCase().includes(queryLower))
.slice(0, 5)
.map((alarm) => ({
id: `alarm-${alarm.id}`,
title: alarm.label || 'Wecker',
subtitle: `⏰ ${alarm.time} ${alarm.enabled ? '(aktiv)' : '(inaktiv)'}`,
}));
results.push(...matchingAlarms);
// Search timers
const timers = await timersApi.getAll();
const matchingTimers = timers
.filter((timer) => timer.label?.toLowerCase().includes(queryLower))
.slice(0, 5)
.map((timer) => {
const mins = Math.floor(timer.durationSeconds / 60);
const secs = timer.durationSeconds % 60;
return {
id: `timer-${timer.id}`,
title: timer.label || 'Timer',
subtitle: `⏱️ ${mins}:${secs.toString().padStart(2, '0')} ${timer.status === 'running' ? '(läuft)' : ''}`,
};
});
results.push(...matchingTimers);
} catch {
// Ignore errors
}
return results.slice(0, 10);
}
function handleCommandBarSelect(item: CommandBarItem) {
if (item.id.startsWith('alarm-')) {
goto('/alarms');
} else if (item.id.startsWith('timer-')) {
goto('/timers');
}
}
let isSidebarMode = $state(false);
let isCollapsed = $state(false);
@ -83,6 +165,13 @@
function handleKeydown(event: KeyboardEvent) {
const target = event.target as HTMLElement;
// Cmd/Ctrl+K to open command bar (works even in inputs)
if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
event.preventDefault();
commandBarOpen = true;
return;
}
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return;
}
@ -206,6 +295,18 @@
{@render children()}
</div>
</main>
<!-- Global Command Bar (Cmd/K) -->
<CommandBar
bind:open={commandBarOpen}
onClose={() => (commandBarOpen = false)}
onSearch={handleCommandBarSearch}
onSelect={handleCommandBarSelect}
quickActions={commandBarQuickActions}
placeholder="Schnellzugriff..."
emptyText="Keine Ergebnisse"
searchingText="Suche..."
/>
</div>
<style>

View file

@ -21,23 +21,59 @@ export class ContactService {
async findByUserId(userId: string, filters: ContactFilters = {}): Promise<Contact[]> {
const { search, isFavorite, isArchived = false, limit = 50, offset = 0 } = filters;
let query = this.db
// When searching, use relevance-based sorting (name matches first, then company/email)
if (search) {
const searchLower = search.toLowerCase();
const query = this.db
.select({
contact: contacts,
// Relevance score: name matches get higher priority than company/email
relevance: sql<number>`
CASE
WHEN LOWER(${contacts.firstName}) LIKE ${`${searchLower}%`} THEN 100
WHEN LOWER(${contacts.lastName}) LIKE ${`${searchLower}%`} THEN 100
WHEN LOWER(${contacts.displayName}) LIKE ${`${searchLower}%`} THEN 90
WHEN LOWER(${contacts.firstName}) LIKE ${`%${searchLower}%`} THEN 80
WHEN LOWER(${contacts.lastName}) LIKE ${`%${searchLower}%`} THEN 80
WHEN LOWER(${contacts.displayName}) LIKE ${`%${searchLower}%`} THEN 70
WHEN LOWER(${contacts.email}) LIKE ${`%${searchLower}%`} THEN 50
WHEN LOWER(${contacts.company}) LIKE ${`%${searchLower}%`} THEN 40
ELSE 0
END
`.as('relevance'),
})
.from(contacts)
.where(
and(
eq(contacts.userId, userId),
eq(contacts.isArchived, isArchived),
isFavorite !== undefined ? eq(contacts.isFavorite, isFavorite) : undefined,
or(
ilike(contacts.firstName, `%${search}%`),
ilike(contacts.lastName, `%${search}%`),
ilike(contacts.displayName, `%${search}%`),
ilike(contacts.email, `%${search}%`),
ilike(contacts.company, `%${search}%`)
)
)
)
.orderBy(sql`relevance DESC`, desc(contacts.updatedAt))
.limit(limit)
.offset(offset);
const results = await query;
return results.map((r) => r.contact);
}
// Without search, just order by updatedAt
const query = this.db
.select()
.from(contacts)
.where(
and(
eq(contacts.userId, userId),
eq(contacts.isArchived, isArchived),
isFavorite !== undefined ? eq(contacts.isFavorite, isFavorite) : undefined,
search
? or(
ilike(contacts.firstName, `%${search}%`),
ilike(contacts.lastName, `%${search}%`),
ilike(contacts.displayName, `%${search}%`),
ilike(contacts.email, `%${search}%`),
ilike(contacts.company, `%${search}%`)
)
: undefined
isFavorite !== undefined ? eq(contacts.isFavorite, isFavorite) : undefined
)
)
.orderBy(desc(contacts.updatedAt))

View file

@ -3,8 +3,13 @@
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { locale } from 'svelte-i18n';
import { PillNavigation } from '@manacore/shared-ui';
import type { PillNavItem, PillDropdownItem } from '@manacore/shared-ui';
import { PillNavigation, CommandBar } from '@manacore/shared-ui';
import type {
PillNavItem,
PillDropdownItem,
CommandBarItem,
QuickAction,
} from '@manacore/shared-ui';
import { authStore } from '$lib/stores/auth.svelte';
import { userSettings } from '$lib/stores/user-settings.svelte';
import { projectsStore } from '$lib/stores/projects.svelte';
@ -17,12 +22,48 @@
import { THEME_DEFINITIONS } from '@manacore/shared-theme';
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
import { getPillAppItems } from '@manacore/shared-branding';
import { getTasks } from '$lib/api/tasks';
// App switcher items
const appItems = getPillAppItems('todo');
let { children } = $props();
// CommandBar state
let commandBarOpen = $state(false);
// CommandBar quick actions
const commandBarQuickActions: QuickAction[] = [
{ id: 'new', label: 'Neue Aufgabe erstellen', icon: 'plus', href: '/task/new', shortcut: 'N' },
{ id: 'kanban', label: 'Kanban-Board', icon: 'list', href: '/kanban' },
{ id: 'stats', label: 'Statistiken', icon: 'chart', href: '/statistics' },
{ id: 'settings', label: 'Einstellungen', icon: 'settings', href: '/settings' },
];
// CommandBar search - search tasks
async function handleCommandBarSearch(query: string): Promise<CommandBarItem[]> {
if (!query.trim()) return [];
try {
const tasks = await getTasks({ search: query });
return tasks.slice(0, 10).map((task) => ({
id: task.id,
title: task.title,
subtitle: task.isCompleted
? '✓ Erledigt'
: task.dueDate
? new Date(task.dueDate).toLocaleDateString('de-DE')
: 'Kein Datum',
}));
} catch {
return [];
}
}
function handleCommandBarSelect(item: CommandBarItem) {
goto(`/task/${item.id}`);
}
let isSidebarMode = $state(false);
let isCollapsed = $state(false);
@ -78,6 +119,13 @@
function handleKeydown(event: KeyboardEvent) {
const target = event.target as HTMLElement;
// Cmd/Ctrl+K to open command bar (works even in inputs)
if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
event.preventDefault();
commandBarOpen = true;
return;
}
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return;
}
@ -232,6 +280,18 @@
{@render children()}
</div>
</main>
<!-- Global Command Bar (Cmd/K) -->
<CommandBar
bind:open={commandBarOpen}
onClose={() => (commandBarOpen = false)}
onSearch={handleCommandBarSearch}
onSelect={handleCommandBarSelect}
quickActions={commandBarQuickActions}
placeholder="Aufgabe suchen..."
emptyText="Keine Aufgaben gefunden"
searchingText="Suche..."
/>
</div>
<style>