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

@ -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;
},
};
}