feat(shared-ui, manacore/web): cross-app navigation enhancement (3 phases)

Phase 1: Enhanced App Drawer in PillNavigation
- appNavigationStore: localStorage-backed favorites, recents, usage counts
- AppDrawer: replaces PillDropdown for apps with search, favorites, recents, grid
- All apps using PillNavigation get this automatically

Phase 2: Cmd+K Command Palette (GlobalSpotlight)
- GlobalSpotlight: modal with app search, quick actions, keyboard navigation
- useGlobalSpotlight: Cmd+K / Ctrl+K keyboard listener
- Integrated into PillNavigation via optional spotlightActions prop

Phase 3: Improved /home page
- AppRow: horizontal scrollable app row for favorites/recents with pin toggle
- ActivityFeed: cross-app timeline (completed tasks, upcoming events, contacts)
- Replaced hardcoded 3-category layout with dynamic favorites, recents, activity
  feed, and usage-frequency sorted app grid

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-01 11:59:36 +02:00
parent 4dfa2cc899
commit ffd608cbb9
10 changed files with 1985 additions and 133 deletions

View file

@ -0,0 +1,225 @@
<script lang="ts">
import {
crossTaskCollection,
crossEventCollection,
crossContactCollection,
} from '$lib/data/cross-app-stores';
interface Props {
maxItems?: number;
locale?: 'de' | 'en';
}
let { maxItems = 8, locale = 'de' }: Props = $props();
interface FeedItem {
id: string;
type: 'task' | 'event' | 'contact';
title: string;
subtitle?: string;
timestamp: string;
color: string;
icon: string;
}
// Query recent completed tasks
let recentTasks = $state<FeedItem[]>([]);
let upcomingEvents = $state<FeedItem[]>([]);
let recentContacts = $state<FeedItem[]>([]);
$effect(() => {
loadFeed();
});
async function loadFeed() {
try {
// Completed tasks from today
const today = new Date();
today.setHours(0, 0, 0, 0);
const todayStr = today.toISOString();
const tasks = await crossTaskCollection.getAll();
recentTasks = tasks
.filter((t) => t.isCompleted && t.completedAt && t.completedAt >= todayStr)
.sort((a, b) => (b.completedAt || '').localeCompare(a.completedAt || ''))
.slice(0, 5)
.map((t) => ({
id: `task-${t.id}`,
type: 'task' as const,
title: t.title,
subtitle: locale === 'de' ? 'Erledigt' : 'Completed',
timestamp: t.completedAt || t.updatedAt,
color: '#22c55e',
icon: '\u2705',
}));
} catch {
recentTasks = [];
}
try {
// Upcoming events (next 24h)
const now = new Date().toISOString();
const tomorrow = new Date(Date.now() + 86400000).toISOString();
const events = await crossEventCollection.getAll();
upcomingEvents = events
.filter((e) => e.startDate >= now && e.startDate <= tomorrow)
.sort((a, b) => a.startDate.localeCompare(b.startDate))
.slice(0, 5)
.map((e) => ({
id: `event-${e.id}`,
type: 'event' as const,
title: e.title,
subtitle: formatTime(e.startDate),
timestamp: e.startDate,
color: e.color || '#6366f1',
icon: '\uD83D\uDCC5',
}));
} catch {
upcomingEvents = [];
}
try {
// Recently added contacts
const contacts = await crossContactCollection.getAll();
recentContacts = contacts
.filter((c) => !c.isArchived)
.sort((a, b) => (b.createdAt || '').localeCompare(a.createdAt || ''))
.slice(0, 3)
.map((c) => {
const name = [c.firstName, c.lastName].filter(Boolean).join(' ') || c.email || '?';
return {
id: `contact-${c.id}`,
type: 'contact' as const,
title: name,
subtitle: c.company || undefined,
timestamp: c.createdAt,
color: '#8b5cf6',
icon: '\uD83D\uDC64',
};
});
} catch {
recentContacts = [];
}
}
let feedItems = $derived(
[...recentTasks, ...upcomingEvents, ...recentContacts]
.sort((a, b) => b.timestamp.localeCompare(a.timestamp))
.slice(0, maxItems)
);
function formatTime(isoString: string): string {
try {
const date = new Date(isoString);
return date.toLocaleTimeString(locale === 'de' ? 'de-DE' : 'en-US', {
hour: '2-digit',
minute: '2-digit',
});
} catch {
return '';
}
}
function formatRelative(isoString: string): string {
try {
const diff = Date.now() - new Date(isoString).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return locale === 'de' ? 'gerade eben' : 'just now';
if (mins < 60) return `${mins}m`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h`;
return `${Math.floor(hrs / 24)}d`;
} catch {
return '';
}
}
</script>
{#if feedItems.length > 0}
<section class="feed-section">
<h2 class="feed-title">{locale === 'de' ? 'Aktivität' : 'Activity'}</h2>
<div class="feed-list">
{#each feedItems as item (item.id)}
<div class="feed-item">
<span class="feed-icon">{item.icon}</span>
<div class="feed-content">
<span class="feed-item-title">{item.title}</span>
{#if item.subtitle}
<span class="feed-item-sub">{item.subtitle}</span>
{/if}
</div>
<span class="feed-time">{formatRelative(item.timestamp)}</span>
</div>
{/each}
</div>
</section>
{/if}
<style>
.feed-section {
margin-bottom: 2rem;
}
.feed-title {
font-size: 0.8125rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: hsl(var(--muted-foreground, 0 0% 45%));
margin: 0 0 0.625rem;
}
.feed-list {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.feed-item {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.5rem 0.625rem;
border-radius: 0.5rem;
transition: background 0.1s;
}
.feed-item:hover {
background: hsl(var(--muted, 0 0% 96%) / 0.5);
}
.feed-icon {
font-size: 0.875rem;
flex-shrink: 0;
width: 1.5rem;
text-align: center;
}
.feed-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.feed-item-title {
font-size: 0.8125rem;
font-weight: 500;
color: hsl(var(--foreground, 0 0% 9%));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.feed-item-sub {
font-size: 0.6875rem;
color: hsl(var(--muted-foreground, 0 0% 45%));
}
.feed-time {
font-size: 0.6875rem;
color: hsl(var(--muted-foreground, 0 0% 45%));
flex-shrink: 0;
}
</style>

View file

@ -0,0 +1,167 @@
<script lang="ts">
import type { ManaApp, AppIconId } from '@manacore/shared-branding';
import { APP_URLS } from '@manacore/shared-branding';
interface Props {
apps: ManaApp[];
title: string;
emptyText?: string;
showPin?: boolean;
onAppClick: (app: ManaApp) => void;
onTogglePin?: (appId: string) => void;
pinnedIds?: string[];
}
let {
apps,
title,
emptyText,
showPin = false,
onAppClick,
onTogglePin,
pinnedIds = [],
}: Props = $props();
</script>
{#if apps.length > 0}
<section class="app-row-section">
<h2 class="row-title">{title}</h2>
<div class="row-scroll">
{#each apps as app (app.id)}
<button class="row-card" style="--app-color: {app.color};" onclick={() => onAppClick(app)}>
<div class="row-card-icon">
{#if app.icon}
<img src={app.icon} alt={app.name} class="row-icon" />
{:else}
<span class="row-icon-letter" style="color: {app.color};">{app.name.charAt(0)}</span>
{/if}
</div>
<span class="row-card-name">{app.name}</span>
{#if showPin && onTogglePin}
<button
class="pin-btn"
class:pinned={pinnedIds.includes(app.id)}
onclick={(e) => {
e.stopPropagation();
onTogglePin(app.id);
}}
title={pinnedIds.includes(app.id)
? 'Aus Favoriten entfernen'
: 'Zu Favoriten hinzufügen'}
>
{pinnedIds.includes(app.id) ? '\u2605' : '\u2606'}
</button>
{/if}
</button>
{/each}
</div>
</section>
{:else if emptyText}
<section class="app-row-section">
<h2 class="row-title">{title}</h2>
<p class="row-empty">{emptyText}</p>
</section>
{/if}
<style>
.app-row-section {
margin-bottom: 1.5rem;
}
.row-title {
font-size: 0.8125rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: hsl(var(--muted-foreground, 0 0% 45%));
margin: 0 0 0.625rem;
}
.row-scroll {
display: flex;
gap: 0.625rem;
overflow-x: auto;
padding-bottom: 0.25rem;
scrollbar-width: none;
}
.row-scroll::-webkit-scrollbar {
display: none;
}
.row-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.375rem;
padding: 0.75rem 1rem;
border-radius: 0.75rem;
border: 1px solid hsl(var(--border, 0 0% 90%));
background: hsl(var(--card, 0 0% 100%));
cursor: pointer;
transition: all 0.15s;
min-width: 5.5rem;
position: relative;
}
.row-card:hover {
border-color: color-mix(in srgb, var(--app-color) 40%, hsl(var(--border, 0 0% 90%)));
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.row-card-icon {
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
}
.row-icon {
width: 100%;
height: 100%;
object-fit: contain;
}
.row-icon-letter {
font-weight: 700;
font-size: 1rem;
}
.row-card-name {
font-size: 0.75rem;
font-weight: 500;
color: hsl(var(--foreground, 0 0% 9%));
white-space: nowrap;
}
.pin-btn {
position: absolute;
top: 0.25rem;
right: 0.25rem;
background: none;
border: none;
cursor: pointer;
font-size: 0.75rem;
opacity: 0.3;
transition: opacity 0.15s;
padding: 0.125rem;
line-height: 1;
}
.pin-btn:hover,
.pin-btn.pinned {
opacity: 1;
}
.pin-btn.pinned {
color: #eab308;
}
.row-empty {
font-size: 0.8125rem;
color: hsl(var(--muted-foreground, 0 0% 45%));
margin: 0;
}
</style>

View file

@ -1,16 +1,19 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { locale } from 'svelte-i18n';
import {
MANA_APPS,
APP_URLS,
APP_STATUS_LABELS,
getAccessibleManaApps,
type ManaApp,
type AppIconId,
} from '@manacore/shared-branding';
import { createAppNavigationStore } from '@manacore/shared-ui';
import { authStore } from '$lib/stores/auth.svelte';
import { ManaCoreEvents } from '@manacore/shared-utils/analytics';
import AppRow from '$lib/components/AppRow.svelte';
import ActivityFeed from '$lib/components/ActivityFeed.svelte';
const store = createAppNavigationStore();
// Detect dev mode
const isDev =
@ -25,56 +28,29 @@
let userTier = $derived(authStore.user?.tier || 'public');
let activeApps = $derived(getAccessibleManaApps(userTier));
// Group apps by category
interface AppCategory {
id: string;
titleDe: string;
titleEn: string;
descDe: string;
descEn: string;
icon: string;
apps: ManaApp[];
}
// Favorites resolved to ManaApp objects
let favoriteApps = $derived(
store.favorites
.map((id) => activeApps.find((a) => a.id === id))
.filter((a): a is ManaApp => !!a)
);
const aiAppIds: AppIconId[] = ['chat', 'picture', 'questions', 'context', 'presi', 'mail'];
const productivityIds: AppIconId[] = ['todo', 'calendar', 'contacts', 'cards', 'inventory'];
const utilityIds: AppIconId[] = ['clock', 'zitare', 'storage', 'moodlit', 'matrix'];
// Recently used resolved to ManaApp objects
let recentApps = $derived(
store.recentApps
.map((r) => activeApps.find((a) => a.id === r.id))
.filter((a): a is ManaApp => !!a)
.slice(0, 5)
);
function getAppsForCategory(ids: AppIconId[], apps: ManaApp[]): ManaApp[] {
return ids
.map((id) => apps.find((app) => app.id === id))
.filter((app): app is ManaApp => !!app);
}
let categories = $derived([
{
id: 'ai',
titleDe: 'KI & Kreativ',
titleEn: 'AI & Creative',
descDe: 'Intelligente Assistenten und kreative Werkzeuge',
descEn: 'Intelligent assistants and creative tools',
icon: '🤖',
apps: getAppsForCategory(aiAppIds, activeApps),
},
{
id: 'productivity',
titleDe: 'Produktivität',
titleEn: 'Productivity',
descDe: 'Organisiere deinen Alltag',
descEn: 'Organize your daily life',
icon: '📋',
apps: getAppsForCategory(productivityIds, activeApps),
},
{
id: 'utility',
titleDe: 'Tools & Utilities',
titleEn: 'Tools & Utilities',
descDe: 'Praktische Helferlein',
descEn: 'Handy helpers',
icon: '🔧',
apps: getAppsForCategory(utilityIds, activeApps),
},
] satisfies AppCategory[]);
// All apps sorted by usage frequency
let sortedApps = $derived(
[...activeApps].sort((a, b) => {
const countA = store.usageCounts[a.id] || 0;
const countB = store.usageCounts[b.id] || 0;
return countB - countA;
})
);
function getStatusColor(status: ManaApp['status']): string {
const colors = {
@ -103,6 +79,7 @@
}
function handleAppClick(app: ManaApp) {
store.recordAppVisit(app.id);
ManaCoreEvents.appOpened(app.id);
const url = getAppUrl(app.id);
if (url) {
@ -164,68 +141,78 @@
</div>
</div>
<!-- App Categories -->
{#each categories as category}
<section class="category">
<div class="category-header">
<span class="category-icon">{category.icon}</span>
<div>
<h2 class="category-title">
{currentLocale === 'en' ? category.titleEn : category.titleDe}
</h2>
<p class="category-desc">
{currentLocale === 'en' ? category.descEn : category.descDe}
</p>
</div>
</div>
<!-- Favorites -->
<AppRow
apps={favoriteApps}
title={currentLocale === 'en' ? 'Favorites' : 'Favoriten'}
showPin={true}
onAppClick={handleAppClick}
onTogglePin={(id) => store.toggleFavorite(id)}
pinnedIds={store.favorites}
/>
<div class="app-grid">
{#each category.apps as app}
<button
class="app-card"
style="--app-color: {app.color};"
onclick={() => handleAppClick(app)}
>
<div class="app-card-top">
<div class="app-icon-wrap">
{#if app.icon}
<img src={app.icon} alt={app.name} class="app-icon" />
{:else}
<div class="app-icon-fallback" style="color: {app.color};">
{app.name.charAt(0)}
</div>
{/if}
</div>
<div
class="status-badge"
style="color: {getStatusColor(app.status)}; background: {getStatusBgColor(
app.status
)};"
>
<span class="status-dot" style="background: {getStatusColor(app.status)};"></span>
{statusLabels[app.status]}
</div>
</div>
<!-- Recently Used -->
<AppRow
apps={recentApps}
title={currentLocale === 'en' ? 'Recently Used' : 'Zuletzt verwendet'}
onAppClick={handleAppClick}
/>
<h3 class="app-name">{app.name}</h3>
<p class="app-tagline">{app.description[currentLocale] || app.description.de}</p>
<!-- Activity Feed -->
<ActivityFeed locale={currentLocale} />
<div class="app-card-footer">
{#if app.comingSoon}
<span class="coming-soon-label">
{currentLocale === 'en' ? 'Coming Soon' : 'Demnächst'}
</span>
<!-- All Apps (sorted by usage) -->
<section class="category">
<h2 class="section-title">
{currentLocale === 'en' ? 'All Apps' : 'Alle Apps'}
</h2>
<div class="app-grid">
{#each sortedApps as app (app.id)}
<button
class="app-card"
style="--app-color: {app.color};"
onclick={() => handleAppClick(app)}
>
<div class="app-card-top">
<div class="app-icon-wrap">
{#if app.icon}
<img src={app.icon} alt={app.name} class="app-icon" />
{:else}
<span class="open-label" style="color: {app.color};">
{currentLocale === 'en' ? 'Open' : 'Öffnen'}
</span>
<div class="app-icon-fallback" style="color: {app.color};">
{app.name.charAt(0)}
</div>
{/if}
</div>
</button>
{/each}
</div>
</section>
{/each}
<div
class="status-badge"
style="color: {getStatusColor(app.status)}; background: {getStatusBgColor(
app.status
)};"
>
<span class="status-dot" style="background: {getStatusColor(app.status)};"></span>
{statusLabels[app.status]}
</div>
</div>
<h3 class="app-name">{app.name}</h3>
<p class="app-tagline">{app.description[currentLocale] || app.description.de}</p>
<div class="app-card-footer">
{#if app.comingSoon}
<span class="coming-soon-label">
{currentLocale === 'en' ? 'Coming Soon' : 'Demnächst'}
</span>
{:else}
<span class="open-label" style="color: {app.color};">
{currentLocale === 'en' ? 'Open' : 'Öffnen'}
</span>
{/if}
</div>
</button>
{/each}
</div>
</section>
<!-- Legend -->
<div class="legend">
@ -318,33 +305,18 @@
color: hsl(var(--muted-foreground, 0 0% 45%));
}
/* Categories */
/* Sections */
.category {
margin-bottom: 2rem;
}
.category-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
}
.category-icon {
font-size: 1.5rem;
}
.category-title {
font-size: 1.125rem;
font-weight: 600;
color: hsl(var(--foreground, 0 0% 9%));
margin: 0;
}
.category-desc {
.section-title {
font-size: 0.8125rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: hsl(var(--muted-foreground, 0 0% 45%));
margin: 0;
margin: 0 0 0.625rem;
}
/* App Grid */

View file

@ -84,6 +84,9 @@ export {
SidebarSection,
PillNavigation,
PillDropdown,
AppDrawer,
GlobalSpotlight,
createGlobalSpotlightState,
PillTabGroup,
PillTagSelector,
PillTimeRangeSelector,
@ -93,6 +96,13 @@ export {
PillToolbarDivider,
TagStrip,
ExpandableToolbar,
createAppNavigationStore,
getFavoriteApps,
getRecentApps,
getUsageCounts,
toggleFavoriteApp,
recordAppVisit,
clearRecentApps,
} from './navigation';
export type {
NavItem,
@ -109,6 +119,8 @@ export type {
PillTagItem,
PillTagSelectorConfig,
ExpandableToolbarProps,
RecentAppEntry,
SpotlightAction,
} from './navigation';
// Settings

View file

@ -0,0 +1,697 @@
<script lang="ts">
import type { PillAppItem } from './types';
import { createAppNavigationStore } from './appNavigationStore.svelte';
interface Props {
apps: PillAppItem[];
isOpen: boolean;
onToggle: (open: boolean) => void;
onAppClick?: (app: PillAppItem, event: MouseEvent) => void;
onOpenInPanel?: (appId: string, url: string) => void;
allAppsHref?: string;
allAppsLabel?: string;
triggerLabel: string;
}
let {
apps,
isOpen,
onToggle,
onAppClick,
onOpenInPanel,
allAppsHref,
allAppsLabel = 'Alle Apps',
triggerLabel,
}: Props = $props();
const store = createAppNavigationStore();
let triggerButton: HTMLButtonElement;
let searchInput = $state<HTMLInputElement | undefined>(undefined);
let panelPosition = $state({ top: 0, left: 0 });
let searchQuery = $state('');
// Filter apps by search
const filteredApps = $derived(
searchQuery
? apps.filter((a) => a.name.toLowerCase().includes(searchQuery.toLowerCase()))
: apps
);
// Resolve favorites and recents to actual PillAppItem objects
const favoriteApps = $derived(
store.favorites.map((id) => apps.find((a) => a.id === id)).filter(Boolean) as PillAppItem[]
);
const recentApps = $derived(
store.recentApps.map((r) => apps.find((a) => a.id === r.id)).filter(Boolean) as PillAppItem[]
);
function toggle() {
if (triggerButton) {
const rect = triggerButton.getBoundingClientRect();
panelPosition = { top: rect.bottom + 8, left: rect.left };
}
onToggle(!isOpen);
}
function close() {
searchQuery = '';
onToggle(false);
}
function handleAppClick(app: PillAppItem, event: MouseEvent) {
store.recordAppVisit(app.id);
if (onAppClick) {
onAppClick(app, event);
} else if (
event &&
(event.ctrlKey || event.metaKey) &&
onOpenInPanel &&
app.url &&
!app.isCurrent
) {
onOpenInPanel(app.id, app.url);
} else if (app.isCurrent) {
window.location.href = '/';
} else if (app.url) {
window.open(app.url, '_blank', 'noopener,noreferrer');
}
close();
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
close();
}
}
$effect(() => {
if (isOpen && searchInput) {
// Focus search input after panel opens
requestAnimationFrame(() => searchInput?.focus());
}
});
const iconPaths = {
grid: 'M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z',
chevronDown: 'M19 9l-7 7-7-7',
search: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z',
star: 'M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.562.562 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.562.562 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z',
starFilled:
'M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.006 5.404.433c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.433 2.082-5.006z',
};
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="app-drawer" onkeydown={handleKeydown}>
<!-- Trigger Button -->
<button bind:this={triggerButton} onclick={toggle} class="pill glass-pill trigger-button">
<svg class="pill-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={iconPaths.grid} />
</svg>
<span class="pill-label">{triggerLabel}</span>
<svg
class="chevron-icon"
class:rotated={isOpen}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d={iconPaths.chevronDown}
/>
</svg>
</button>
{#if isOpen}
<!-- Backdrop -->
<button class="drawer-backdrop" onclick={close} aria-label="Close app drawer"></button>
<!-- Panel -->
<div
class="drawer-panel"
style="top: {panelPosition.top}px; left: {panelPosition.left}px;"
role="dialog"
aria-label="App switcher"
>
<!-- Search -->
<div class="drawer-search">
<svg class="search-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d={iconPaths.search}
/>
</svg>
<input
bind:this={searchInput}
bind:value={searchQuery}
type="text"
placeholder="App suchen..."
class="search-input"
/>
</div>
<div class="drawer-content">
<!-- Favorites section -->
{#if !searchQuery && favoriteApps.length > 0}
<div class="drawer-section">
<div class="section-header">Favoriten</div>
<div class="app-chips">
{#each favoriteApps as app (app.id)}
<button
class="app-chip"
class:current={app.isCurrent}
onclick={(e) => handleAppClick(app, e)}
title={app.name}
>
{#if app.icon}
<img src={app.icon} alt="" class="app-chip-icon" />
{:else}
<span class="app-chip-letter" style="color: {app.color}"
>{app.name.charAt(0)}</span
>
{/if}
<span class="app-chip-name">{app.name}</span>
</button>
{/each}
</div>
</div>
{/if}
<!-- Recents section -->
{#if !searchQuery && recentApps.length > 0}
<div class="drawer-section">
<div class="section-header">Zuletzt verwendet</div>
<div class="app-chips">
{#each recentApps.slice(0, 5) as app (app.id)}
<button
class="app-chip"
class:current={app.isCurrent}
onclick={(e) => handleAppClick(app, e)}
title={app.name}
>
{#if app.icon}
<img src={app.icon} alt="" class="app-chip-icon" />
{:else}
<span class="app-chip-letter" style="color: {app.color}"
>{app.name.charAt(0)}</span
>
{/if}
<span class="app-chip-name">{app.name}</span>
</button>
{/each}
</div>
</div>
{/if}
<!-- All apps grid -->
<div class="drawer-section">
{#if !searchQuery}
<div class="section-header">Alle Apps</div>
{/if}
<div class="app-grid">
{#each filteredApps as app (app.id)}
<div class="app-grid-item">
<button
class="app-grid-button"
class:current={app.isCurrent}
onclick={(e) => handleAppClick(app, e)}
title="{app.name}{app.isCurrent ? ' (aktuelle App)' : ''}"
>
{#if app.icon}
<img src={app.icon} alt="" class="app-grid-icon" />
{:else}
<span class="app-grid-letter" style="color: {app.color}"
>{app.name.charAt(0)}</span
>
{/if}
<span class="app-grid-name">{app.name}</span>
</button>
<button
class="fav-toggle"
onclick={() => store.toggleFavorite(app.id)}
title={store.isFavorite(app.id)
? 'Aus Favoriten entfernen'
: 'Zu Favoriten hinzufügen'}
>
<svg viewBox="0 0 24 24" class="fav-icon" class:is-fav={store.isFavorite(app.id)}>
<path
d={store.isFavorite(app.id) ? iconPaths.starFilled : iconPaths.star}
fill={store.isFavorite(app.id) ? 'currentColor' : 'none'}
stroke={store.isFavorite(app.id) ? 'none' : 'currentColor'}
stroke-width="1.5"
/>
</svg>
</button>
</div>
{/each}
</div>
{#if filteredApps.length === 0}
<div class="empty-state">Keine Apps gefunden</div>
{/if}
</div>
</div>
<!-- Footer -->
{#if allAppsHref}
<a href={allAppsHref} class="drawer-footer" onclick={close}>
{allAppsLabel}
<svg class="arrow-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</a>
{/if}
</div>
{/if}
</div>
<style>
.app-drawer {
position: relative;
z-index: 1;
}
.app-drawer:has(.drawer-panel) {
z-index: 10000;
}
/* Trigger - matches PillDropdown */
.trigger-button {
position: relative;
z-index: 10;
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 500;
white-space: nowrap;
border: none;
cursor: pointer;
transition: all 0.2s;
}
.pill-icon {
width: 1rem;
height: 1rem;
flex-shrink: 0;
}
.pill-label {
display: inline;
}
.chevron-icon {
width: 0.75rem;
height: 0.75rem;
transition: transform 0.2s;
margin-left: 0.25rem;
}
.chevron-icon.rotated {
transform: rotate(180deg);
}
/* Glass pill - matches PillDropdown exactly */
.glass-pill {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
color: #374151;
}
:global(.dark) .glass-pill {
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.15);
color: #f3f4f6;
}
.glass-pill:hover {
background: rgba(255, 255, 255, 0.95);
border-color: rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
:global(.dark) .glass-pill:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.25);
}
/* Backdrop */
.drawer-backdrop {
position: fixed;
inset: 0;
z-index: 9998;
background: rgba(0, 0, 0, 0.1);
border: none;
cursor: default;
}
:global(.dark) .drawer-backdrop {
background: rgba(0, 0, 0, 0.3);
}
/* Panel */
.drawer-panel {
position: fixed;
z-index: 9999;
width: 320px;
max-height: 70vh;
display: flex;
flex-direction: column;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 1rem;
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.15),
0 8px 10px -6px rgba(0, 0, 0, 0.1);
animation: panelIn 0.15s ease-out;
overflow: hidden;
}
:global(.dark) .drawer-panel {
background: rgba(30, 30, 35, 0.95);
border-color: rgba(255, 255, 255, 0.12);
}
@keyframes panelIn {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Search */
.drawer-search {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
:global(.dark) .drawer-search {
border-bottom-color: rgba(255, 255, 255, 0.1);
}
.search-icon {
width: 1rem;
height: 1rem;
flex-shrink: 0;
opacity: 0.4;
}
.search-input {
flex: 1;
background: none;
border: none;
outline: none;
font-size: 0.875rem;
color: inherit;
}
.search-input::placeholder {
color: rgba(0, 0, 0, 0.35);
}
:global(.dark) .search-input::placeholder {
color: rgba(255, 255, 255, 0.35);
}
/* Content */
.drawer-content {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
/* Sections */
.drawer-section {
margin-bottom: 0.25rem;
}
.section-header {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.375rem 0.5rem 0.25rem;
opacity: 0.5;
}
/* App chips (favorites, recents) */
.app-chips {
display: flex;
gap: 0.375rem;
padding: 0.25rem 0.25rem 0.5rem;
overflow-x: auto;
scrollbar-width: none;
}
.app-chips::-webkit-scrollbar {
display: none;
}
.app-chip {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.625rem;
border-radius: 9999px;
border: 1px solid rgba(0, 0, 0, 0.08);
background: rgba(0, 0, 0, 0.03);
font-size: 0.8125rem;
white-space: nowrap;
cursor: pointer;
transition: all 0.15s;
}
:global(.dark) .app-chip {
border-color: rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.06);
}
.app-chip:hover {
background: rgba(0, 0, 0, 0.08);
border-color: rgba(0, 0, 0, 0.15);
}
:global(.dark) .app-chip:hover {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.2);
}
.app-chip.current {
border-color: var(--pill-primary-color, rgba(99, 102, 241, 0.4));
background: var(--pill-primary-color, rgba(99, 102, 241, 0.1));
}
.app-chip-icon {
width: 1rem;
height: 1rem;
border-radius: 0.1875rem;
}
.app-chip-letter {
font-weight: 600;
font-size: 0.75rem;
}
.app-chip-name {
font-weight: 500;
}
/* App grid */
.app-grid {
display: grid;
grid-template-columns: 1fr;
gap: 1px;
}
.app-grid-item {
display: flex;
align-items: center;
}
.app-grid-button {
flex: 1;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
border-radius: 0.5rem;
border: none;
background: none;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: background 0.1s;
color: inherit;
}
.app-grid-button:hover {
background: rgba(0, 0, 0, 0.06);
}
:global(.dark) .app-grid-button:hover {
background: rgba(255, 255, 255, 0.08);
}
.app-grid-button.current {
background: rgba(0, 0, 0, 0.04);
}
:global(.dark) .app-grid-button.current {
background: rgba(255, 255, 255, 0.06);
}
.app-grid-icon {
width: 1.25rem;
height: 1.25rem;
border-radius: 0.25rem;
flex-shrink: 0;
}
.app-grid-letter {
width: 1.25rem;
height: 1.25rem;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 0.75rem;
flex-shrink: 0;
}
.app-grid-name {
flex: 1;
text-align: left;
}
/* Favorite toggle */
.fav-toggle {
padding: 0.375rem;
border: none;
background: none;
cursor: pointer;
border-radius: 0.375rem;
transition: background 0.1s;
flex-shrink: 0;
}
.fav-toggle:hover {
background: rgba(0, 0, 0, 0.06);
}
:global(.dark) .fav-toggle:hover {
background: rgba(255, 255, 255, 0.08);
}
.fav-icon {
width: 0.875rem;
height: 0.875rem;
color: rgba(0, 0, 0, 0.25);
transition: color 0.15s;
}
:global(.dark) .fav-icon {
color: rgba(255, 255, 255, 0.25);
}
.fav-icon.is-fav {
color: #eab308;
}
/* Empty state */
.empty-state {
padding: 1.5rem;
text-align: center;
font-size: 0.8125rem;
opacity: 0.5;
}
/* Footer */
.drawer-footer {
display: flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
padding: 0.625rem;
border-top: 1px solid rgba(0, 0, 0, 0.08);
font-size: 0.8125rem;
font-weight: 500;
color: inherit;
text-decoration: none;
opacity: 0.6;
transition: opacity 0.15s;
}
:global(.dark) .drawer-footer {
border-top-color: rgba(255, 255, 255, 0.1);
}
.drawer-footer:hover {
opacity: 1;
}
.arrow-icon {
width: 0.75rem;
height: 0.75rem;
}
/* Mobile: bottom sheet */
@media (max-width: 640px) {
.drawer-panel {
position: fixed;
top: auto !important;
left: 0 !important;
right: 0;
bottom: 0;
width: 100%;
max-height: 80vh;
border-radius: 1rem 1rem 0 0;
animation: slideUp 0.2s ease-out;
}
@keyframes slideUp {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
.drawer-backdrop {
background: rgba(0, 0, 0, 0.3);
}
}
</style>

View file

@ -0,0 +1,556 @@
<script lang="ts">
import type { PillAppItem } from './types';
import { createAppNavigationStore } from './appNavigationStore.svelte';
export interface SpotlightAction {
id: string;
label: string;
description?: string;
icon?: string;
shortcut?: string;
category?: string;
onExecute: () => void;
}
interface Props {
open: boolean;
onClose: () => void;
apps: PillAppItem[];
quickActions?: SpotlightAction[];
placeholder?: string;
}
let {
open,
onClose,
apps,
quickActions = [],
placeholder = 'Was möchtest du tun?',
}: Props = $props();
const store = createAppNavigationStore();
let searchQuery = $state('');
let selectedIndex = $state(0);
let inputEl = $state<HTMLInputElement | undefined>(undefined);
// Build combined results list
interface SpotlightResult {
type: 'app' | 'action';
id: string;
label: string;
description?: string;
icon?: string;
imageUrl?: string;
shortcut?: string;
category?: string;
}
const results = $derived.by(() => {
const items: SpotlightResult[] = [];
const q = searchQuery.toLowerCase();
if (!q) {
// No query: show recents then actions
const recentIds = store.recentApps.map((r) => r.id);
const recentApps = recentIds
.map((id) => apps.find((a) => a.id === id))
.filter(Boolean) as PillAppItem[];
for (const app of recentApps.slice(0, 5)) {
items.push({
type: 'app',
id: app.id,
label: app.name,
imageUrl: app.icon,
category: 'Zuletzt verwendet',
});
}
for (const action of quickActions) {
items.push({
type: 'action',
id: action.id,
label: action.label,
description: action.description,
icon: action.icon,
shortcut: action.shortcut,
category: action.category || 'Aktionen',
});
}
} else {
// Filter apps
const matchedApps = apps.filter((a) => a.name.toLowerCase().includes(q));
for (const app of matchedApps) {
items.push({
type: 'app',
id: app.id,
label: app.name,
imageUrl: app.icon,
category: 'Apps',
});
}
// Filter actions
const matchedActions = quickActions.filter(
(a) => a.label.toLowerCase().includes(q) || a.description?.toLowerCase().includes(q)
);
for (const action of matchedActions) {
items.push({
type: 'action',
id: action.id,
label: action.label,
description: action.description,
icon: action.icon,
shortcut: action.shortcut,
category: action.category || 'Aktionen',
});
}
}
return items;
});
// Group results by category for display
const groupedResults = $derived.by(() => {
const groups: { category: string; items: SpotlightResult[] }[] = [];
const seen = new Set<string>();
for (const item of results) {
const cat = item.category || '';
if (!seen.has(cat)) {
seen.add(cat);
groups.push({ category: cat, items: [] });
}
groups.find((g) => g.category === cat)!.items.push(item);
}
return groups;
});
// Clamp selected index
$effect(() => {
if (selectedIndex >= results.length) {
selectedIndex = Math.max(0, results.length - 1);
}
});
// Focus input on open
$effect(() => {
if (open) {
searchQuery = '';
selectedIndex = 0;
requestAnimationFrame(() => inputEl?.focus());
}
});
function handleSelect(item: SpotlightResult) {
if (item.type === 'app') {
const app = apps.find((a) => a.id === item.id);
if (app) {
store.recordAppVisit(app.id);
if (app.isCurrent) {
window.location.href = '/';
} else if (app.url) {
window.open(app.url, '_blank', 'noopener,noreferrer');
}
}
} else {
const action = quickActions.find((a) => a.id === item.id);
action?.onExecute();
}
onClose();
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'ArrowDown') {
event.preventDefault();
selectedIndex = (selectedIndex + 1) % Math.max(1, results.length);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
selectedIndex = (selectedIndex - 1 + results.length) % Math.max(1, results.length);
} else if (event.key === 'Enter' && results[selectedIndex]) {
event.preventDefault();
handleSelect(results[selectedIndex]);
} else if (event.key === 'Escape') {
onClose();
}
}
// Track the flat index for highlighting
function getFlatIndex(groupIndex: number, itemIndex: number): number {
let idx = 0;
for (let g = 0; g < groupIndex; g++) {
idx += groupedResults[g].items.length;
}
return idx + itemIndex;
}
const iconPaths: Record<string, string> = {
search: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z',
plus: 'M12 4v16m8-8H4',
arrow: 'M13 7l5 5m0 0l-5 5m5-5H6',
};
</script>
{#if open}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="spotlight-overlay" onclick={onClose} onkeydown={handleKeydown}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="spotlight-modal" onclick={(e) => e.stopPropagation()}>
<!-- Search input -->
<div class="spotlight-input-wrapper">
<svg class="spotlight-search-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d={iconPaths.search}
/>
</svg>
<input
bind:this={inputEl}
bind:value={searchQuery}
type="text"
{placeholder}
class="spotlight-input"
onkeydown={handleKeydown}
/>
<kbd class="spotlight-kbd">Esc</kbd>
</div>
<!-- Results -->
{#if results.length > 0}
<div class="spotlight-results">
{#each groupedResults as group, gi}
{#if group.category}
<div class="spotlight-category">{group.category}</div>
{/if}
{#each group.items as item, ii}
{@const flatIdx = getFlatIndex(gi, ii)}
<button
class="spotlight-item"
class:selected={flatIdx === selectedIndex}
onclick={() => handleSelect(item)}
onmouseenter={() => (selectedIndex = flatIdx)}
>
<div class="spotlight-item-left">
{#if item.imageUrl}
<img src={item.imageUrl} alt="" class="spotlight-item-icon" />
{:else if item.icon && iconPaths[item.icon]}
<svg
class="spotlight-item-svg"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d={iconPaths[item.icon]}
/>
</svg>
{:else}
<span class="spotlight-item-dot">
{item.type === 'app' ? '\u{1F4F1}' : '\u{26A1}'}
</span>
{/if}
<div class="spotlight-item-text">
<span class="spotlight-item-label">{item.label}</span>
{#if item.description}
<span class="spotlight-item-desc">{item.description}</span>
{/if}
</div>
</div>
{#if item.shortcut}
<kbd class="spotlight-shortcut">{item.shortcut}</kbd>
{/if}
</button>
{/each}
{/each}
</div>
{:else if searchQuery}
<div class="spotlight-empty">Keine Ergebnisse</div>
{/if}
<!-- Footer hints -->
<div class="spotlight-footer">
<span class="spotlight-hint"><kbd>↑↓</kbd> Navigation</span>
<span class="spotlight-hint"><kbd></kbd> Auswählen</span>
<span class="spotlight-hint"><kbd>Esc</kbd> Schließen</span>
</div>
</div>
</div>
{/if}
<style>
.spotlight-overlay {
position: fixed;
inset: 0;
z-index: 99999;
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: min(20vh, 160px);
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4px);
animation: fadeIn 0.1s ease-out;
}
:global(.dark) .spotlight-overlay {
background: rgba(0, 0, 0, 0.6);
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.spotlight-modal {
width: 100%;
max-width: 560px;
margin: 0 1rem;
background: rgba(255, 255, 255, 0.98);
backdrop-filter: blur(20px);
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 1rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
overflow: hidden;
animation: slideDown 0.15s ease-out;
}
:global(.dark) .spotlight-modal {
background: rgba(30, 30, 35, 0.98);
border-color: rgba(255, 255, 255, 0.12);
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-16px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* Input */
.spotlight-input-wrapper {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.25rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
:global(.dark) .spotlight-input-wrapper {
border-bottom-color: rgba(255, 255, 255, 0.1);
}
.spotlight-search-icon {
width: 1.25rem;
height: 1.25rem;
flex-shrink: 0;
opacity: 0.4;
}
.spotlight-input {
flex: 1;
background: none;
border: none;
outline: none;
font-size: 1rem;
color: inherit;
}
.spotlight-input::placeholder {
color: rgba(0, 0, 0, 0.35);
}
:global(.dark) .spotlight-input::placeholder {
color: rgba(255, 255, 255, 0.35);
}
.spotlight-kbd {
font-size: 0.6875rem;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
background: rgba(0, 0, 0, 0.06);
border: 1px solid rgba(0, 0, 0, 0.1);
color: rgba(0, 0, 0, 0.4);
font-family: inherit;
}
:global(.dark) .spotlight-kbd {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.4);
}
/* Results */
.spotlight-results {
max-height: 360px;
overflow-y: auto;
padding: 0.5rem;
}
.spotlight-category {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.5rem 0.5rem 0.25rem;
opacity: 0.45;
}
.spotlight-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.625rem 0.75rem;
border: none;
background: none;
border-radius: 0.5rem;
cursor: pointer;
color: inherit;
text-align: left;
transition: background 0.08s;
}
.spotlight-item:hover,
.spotlight-item.selected {
background: rgba(0, 0, 0, 0.06);
}
:global(.dark) .spotlight-item:hover,
:global(.dark) .spotlight-item.selected {
background: rgba(255, 255, 255, 0.08);
}
.spotlight-item-left {
display: flex;
align-items: center;
gap: 0.625rem;
min-width: 0;
}
.spotlight-item-icon {
width: 1.5rem;
height: 1.5rem;
border-radius: 0.375rem;
flex-shrink: 0;
}
.spotlight-item-svg {
width: 1.25rem;
height: 1.25rem;
flex-shrink: 0;
opacity: 0.5;
}
.spotlight-item-dot {
font-size: 1rem;
width: 1.5rem;
text-align: center;
flex-shrink: 0;
}
.spotlight-item-text {
display: flex;
flex-direction: column;
min-width: 0;
}
.spotlight-item-label {
font-size: 0.875rem;
font-weight: 500;
}
.spotlight-item-desc {
font-size: 0.75rem;
opacity: 0.5;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.spotlight-shortcut {
font-size: 0.6875rem;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
background: rgba(0, 0, 0, 0.06);
border: 1px solid rgba(0, 0, 0, 0.1);
color: rgba(0, 0, 0, 0.4);
font-family: inherit;
flex-shrink: 0;
}
:global(.dark) .spotlight-shortcut {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.4);
}
/* Empty */
.spotlight-empty {
padding: 2rem;
text-align: center;
font-size: 0.875rem;
opacity: 0.5;
}
/* Footer */
.spotlight-footer {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 0.5rem 1rem;
border-top: 1px solid rgba(0, 0, 0, 0.08);
}
:global(.dark) .spotlight-footer {
border-top-color: rgba(255, 255, 255, 0.1);
}
.spotlight-hint {
font-size: 0.6875rem;
opacity: 0.4;
display: flex;
align-items: center;
gap: 0.25rem;
}
.spotlight-hint kbd {
font-size: 0.625rem;
padding: 0.0625rem 0.25rem;
border-radius: 0.1875rem;
background: rgba(0, 0, 0, 0.06);
border: 1px solid rgba(0, 0, 0, 0.08);
font-family: inherit;
}
:global(.dark) .spotlight-hint kbd {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.1);
}
/* Mobile */
@media (max-width: 640px) {
.spotlight-overlay {
padding-top: 1rem;
}
.spotlight-modal {
max-width: none;
margin: 0 0.5rem;
}
}
</style>

View file

@ -11,6 +11,9 @@
import PillDropdown from './PillDropdown.svelte';
import PillTabGroup from './PillTabGroup.svelte';
import PillTagSelector from './PillTagSelector.svelte';
import AppDrawer from './AppDrawer.svelte';
import GlobalSpotlight, { type SpotlightAction } from './GlobalSpotlight.svelte';
import { createGlobalSpotlightState } from './useGlobalSpotlight.svelte';
// Phosphor Icons (via shared-icons)
import {
Archive,
@ -275,6 +278,10 @@
showA11yQuickToggles?: boolean;
/** Called when an app should be opened in a split panel */
onOpenInPanel?: (appId: string, url: string) => void;
/** Quick actions for Cmd+K spotlight (pass to enable spotlight) */
spotlightActions?: SpotlightAction[];
/** Placeholder text for spotlight search */
spotlightPlaceholder?: string;
/** Accessible label for the nav element */
ariaLabel?: string;
/** Feedback page href (shown in user dropdown). Set to empty string to hide. */
@ -326,6 +333,8 @@
onA11yReduceMotionChange,
showA11yQuickToggles = false,
onOpenInPanel,
spotlightActions,
spotlightPlaceholder,
ariaLabel,
feedbackHref = '/feedback',
themesHref,
@ -371,6 +380,12 @@
// Dropdown direction: always up since nav is always at bottom
const dropdownDirection = 'up' as const;
// App drawer state
let appDrawerOpen = $state(false);
// Global spotlight (Cmd+K) — only active when spotlightActions are provided
const spotlight = spotlightActions ? createGlobalSpotlightState() : null;
function collapseNav() {
if (onCollapsedChange) {
onCollapsedChange(true);
@ -401,12 +416,14 @@
<div class="pill-nav-container">
<!-- Logo pill / App Switcher -->
{#if showAppSwitcher && appItems.length > 0}
<PillDropdown
items={createAppDropdownItems(appItems, allAppsHref, allAppsLabel, onOpenInPanel)}
direction={dropdownDirection}
label={appName}
icon="grid"
iconOnly={false}
<AppDrawer
apps={appItems}
isOpen={appDrawerOpen}
onToggle={(open) => (appDrawerOpen = open)}
{onOpenInPanel}
{allAppsHref}
{allAppsLabel}
triggerLabel={appName}
/>
{:else}
<a href={homeRoute} class="pill glass-pill logo-pill">
@ -802,6 +819,17 @@
</button>
{/if}
<!-- Global Spotlight (Cmd+K) -->
{#if spotlight && spotlightActions}
<GlobalSpotlight
open={spotlight.isOpen}
onClose={spotlight.close}
apps={appItems}
quickActions={spotlightActions}
placeholder={spotlightPlaceholder}
/>
{/if}
<style>
.pill-nav {
position: fixed;

View file

@ -0,0 +1,142 @@
/**
* App Navigation Store
*
* Tracks favorite apps, recently visited apps, and usage counts.
* Persists to localStorage for cross-session retention.
* Pattern follows recentInputHistory.ts
*/
const STORAGE_KEY_FAVORITES = 'mana-app-favorites';
const STORAGE_KEY_RECENT = 'mana-app-recent';
const STORAGE_KEY_USAGE = 'mana-app-usage-counts';
const MAX_RECENT = 8;
export interface RecentAppEntry {
id: string;
timestamp: number;
}
// --- Standalone functions (non-reactive) ---
export function getFavoriteApps(): string[] {
if (typeof window === 'undefined') return [];
try {
const stored = localStorage.getItem(STORAGE_KEY_FAVORITES);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
}
export function getRecentApps(): RecentAppEntry[] {
if (typeof window === 'undefined') return [];
try {
const stored = localStorage.getItem(STORAGE_KEY_RECENT);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
}
export function getUsageCounts(): Record<string, number> {
if (typeof window === 'undefined') return {};
try {
const stored = localStorage.getItem(STORAGE_KEY_USAGE);
return stored ? JSON.parse(stored) : {};
} catch {
return {};
}
}
export function toggleFavoriteApp(appId: string): void {
if (typeof window === 'undefined') return;
try {
const current = getFavoriteApps();
const index = current.indexOf(appId);
if (index >= 0) {
current.splice(index, 1);
} else {
current.push(appId);
}
localStorage.setItem(STORAGE_KEY_FAVORITES, JSON.stringify(current));
} catch {
// Ignore storage errors
}
}
export function recordAppVisit(appId: string): void {
if (typeof window === 'undefined') return;
try {
// Update recent apps
const recent = getRecentApps();
const filtered = recent.filter((r) => r.id !== appId);
const updated = [{ id: appId, timestamp: Date.now() }, ...filtered].slice(0, MAX_RECENT);
localStorage.setItem(STORAGE_KEY_RECENT, JSON.stringify(updated));
// Update usage counts
const counts = getUsageCounts();
counts[appId] = (counts[appId] || 0) + 1;
localStorage.setItem(STORAGE_KEY_USAGE, JSON.stringify(counts));
} catch {
// Ignore storage errors
}
}
export function clearRecentApps(): void {
if (typeof window === 'undefined') return;
try {
localStorage.removeItem(STORAGE_KEY_RECENT);
} catch {
// Ignore storage errors
}
}
// --- Reactive Svelte 5 store ---
export function createAppNavigationStore() {
let favorites = $state<string[]>(getFavoriteApps());
let recentApps = $state<RecentAppEntry[]>(getRecentApps());
let usageCounts = $state<Record<string, number>>(getUsageCounts());
function refresh() {
favorites = getFavoriteApps();
recentApps = getRecentApps();
usageCounts = getUsageCounts();
}
function toggleFavorite(appId: string) {
toggleFavoriteApp(appId);
refresh();
}
function isFavorite(appId: string): boolean {
return favorites.includes(appId);
}
function visit(appId: string) {
recordAppVisit(appId);
refresh();
}
function clearRecent() {
clearRecentApps();
refresh();
}
return {
get favorites() {
return favorites;
},
get recentApps() {
return recentApps;
},
get usageCounts() {
return usageCounts;
},
toggleFavorite,
isFavorite,
recordAppVisit: visit,
clearRecent,
refresh,
};
}

View file

@ -4,6 +4,20 @@ export { default as Sidebar } from './Sidebar.svelte';
export { default as SidebarSection } from './SidebarSection.svelte';
export { default as PillNavigation } from './PillNavigation.svelte';
export { default as PillDropdown } from './PillDropdown.svelte';
export { default as AppDrawer } from './AppDrawer.svelte';
export { default as GlobalSpotlight } from './GlobalSpotlight.svelte';
export type { SpotlightAction } from './GlobalSpotlight.svelte';
export { createGlobalSpotlightState } from './useGlobalSpotlight.svelte';
export {
createAppNavigationStore,
getFavoriteApps,
getRecentApps,
getUsageCounts,
toggleFavoriteApp,
recordAppVisit,
clearRecentApps,
} from './appNavigationStore.svelte';
export type { RecentAppEntry } from './appNavigationStore.svelte';
export { default as PillTabGroup } from './PillTabGroup.svelte';
export { default as PillTagSelector } from './PillTagSelector.svelte';
export { default as PillTimeRangeSelector } from './PillTimeRangeSelector.svelte';

View file

@ -0,0 +1,39 @@
/**
* Global Spotlight State
*
* Manages open/close state for the Cmd+K command palette.
* Registers a global keydown listener.
*/
export function createGlobalSpotlightState() {
let isOpen = $state(false);
$effect(() => {
function handleKeydown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
isOpen = !isOpen;
}
if (e.key === 'Escape' && isOpen) {
isOpen = false;
}
}
window.addEventListener('keydown', handleKeydown);
return () => window.removeEventListener('keydown', handleKeydown);
});
return {
get isOpen() {
return isOpen;
},
open() {
isOpen = true;
},
close() {
isOpen = false;
},
toggle() {
isOpen = !isOpen;
},
};
}