mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:21:10 +02:00
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:
parent
4dfa2cc899
commit
ffd608cbb9
10 changed files with 1985 additions and 133 deletions
225
apps/manacore/apps/web/src/lib/components/ActivityFeed.svelte
Normal file
225
apps/manacore/apps/web/src/lib/components/ActivityFeed.svelte
Normal 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>
|
||||
167
apps/manacore/apps/web/src/lib/components/AppRow.svelte
Normal file
167
apps/manacore/apps/web/src/lib/components/AppRow.svelte
Normal 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>
|
||||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
697
packages/shared-ui/src/navigation/AppDrawer.svelte
Normal file
697
packages/shared-ui/src/navigation/AppDrawer.svelte
Normal 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>
|
||||
556
packages/shared-ui/src/navigation/GlobalSpotlight.svelte
Normal file
556
packages/shared-ui/src/navigation/GlobalSpotlight.svelte
Normal 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>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
142
packages/shared-ui/src/navigation/appNavigationStore.svelte.ts
Normal file
142
packages/shared-ui/src/navigation/appNavigationStore.svelte.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue