mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:21:10 +02:00
feat(pillnav): compact nav with user menu overlay panel
Replace inline PillDropdownBar for user menu with a centered overlay panel (UserMenuPanel). Move AI tier, theme, and language selectors into the panel. Make app switcher and user pill icon-only. AI section split into "Textgenerierung" and "Spracherkennung" subsections. - AppDrawer trigger: icon-only (no label/chevron) - User pill: icon-only, opens overlay panel instead of bar - Theme + AI pills removed from nav bar (now in user panel) - UserMenuPanel: centered on desktop, bottom-sheet on mobile - Login button in footer, structured sections with subsection headers Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f47ea966df
commit
55b7a8a2ef
4 changed files with 887 additions and 535 deletions
|
|
@ -99,25 +99,16 @@
|
|||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div class="app-drawer" onkeydown={handleKeydown} role="navigation">
|
||||
<!-- Trigger Button -->
|
||||
<button bind:this={triggerButton} onclick={toggle} class="pill glass-pill trigger-button">
|
||||
<button
|
||||
bind:this={triggerButton}
|
||||
onclick={toggle}
|
||||
class="pill glass-pill trigger-button icon-only"
|
||||
aria-label={triggerLabel}
|
||||
title={triggerLabel}
|
||||
>
|
||||
<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}
|
||||
|
|
@ -303,19 +294,9 @@
|
|||
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);
|
||||
.trigger-button.icon-only {
|
||||
gap: 0;
|
||||
padding: 0.5rem 0.625rem;
|
||||
}
|
||||
|
||||
/* Solid theme-tokened pill (formerly the "glass" frosted pill). */
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
import PillTabGroup from './PillTabGroup.svelte';
|
||||
import PillTagSelector from './PillTagSelector.svelte';
|
||||
import AppDrawer from './AppDrawer.svelte';
|
||||
import UserMenuPanel from './UserMenuPanel.svelte';
|
||||
import GlobalSpotlight from './GlobalSpotlight.svelte';
|
||||
import { createGlobalSpotlightState } from './useGlobalSpotlight.svelte';
|
||||
// Phosphor Icons (via shared-icons)
|
||||
|
|
@ -406,173 +407,6 @@
|
|||
|
||||
// Build the flat PillDropdownItem list for each bar, matching what the
|
||||
// equivalent PillDropdown would render. Mode toggles + variants + a11y
|
||||
// toggles for theme; tier/sync items pass through; user menu is assembled
|
||||
// from the same rules as the PillDropdown below.
|
||||
const themeBarItems = $derived.by<PillDropdownItem[]>(() => {
|
||||
const out: PillDropdownItem[] = [];
|
||||
if (onThemeModeChange) {
|
||||
out.push(
|
||||
{
|
||||
id: 'theme-mode-light',
|
||||
label: 'Light',
|
||||
icon: 'sun',
|
||||
group: 'theme-mode',
|
||||
onClick: () => onThemeModeChange('light'),
|
||||
active: themeMode === 'light',
|
||||
},
|
||||
{
|
||||
id: 'theme-mode-dark',
|
||||
label: 'Dark',
|
||||
icon: 'moon',
|
||||
group: 'theme-mode',
|
||||
onClick: () => onThemeModeChange('dark'),
|
||||
active: themeMode === 'dark',
|
||||
},
|
||||
{
|
||||
id: 'theme-mode-system',
|
||||
label: 'System',
|
||||
icon: 'settings',
|
||||
group: 'theme-mode',
|
||||
onClick: () => onThemeModeChange('system'),
|
||||
active: themeMode === 'system',
|
||||
}
|
||||
);
|
||||
}
|
||||
if (themeVariantItems.length > 0) {
|
||||
if (out.length > 0) out.push({ id: 'theme-variants-div', label: '', divider: true });
|
||||
for (const v of themeVariantItems) out.push(v);
|
||||
}
|
||||
if (showA11yQuickToggles) {
|
||||
out.push({ id: 'a11y-div', label: '', divider: true });
|
||||
if (onA11yContrastChange) {
|
||||
out.push({
|
||||
id: 'a11y-contrast',
|
||||
label: 'Hoher Kontrast',
|
||||
icon: 'sun',
|
||||
onClick: () => onA11yContrastChange(a11yContrast === 'high' ? 'normal' : 'high'),
|
||||
active: a11yContrast === 'high',
|
||||
});
|
||||
}
|
||||
if (onA11yReduceMotionChange) {
|
||||
out.push({
|
||||
id: 'a11y-reduce-motion',
|
||||
label: 'Animationen reduzieren',
|
||||
icon: 'check',
|
||||
onClick: () => onA11yReduceMotionChange(!a11yReduceMotion),
|
||||
active: a11yReduceMotion,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
const userBarItems = $derived.by<PillDropdownItem[]>(() => {
|
||||
const out: PillDropdownItem[] = [];
|
||||
if (userEmail && profileHref) {
|
||||
out.push({
|
||||
id: 'profile',
|
||||
label: 'Profil',
|
||||
icon: 'user',
|
||||
onClick: () => {
|
||||
window.location.href = profileHref!;
|
||||
},
|
||||
active: currentPath === profileHref,
|
||||
});
|
||||
}
|
||||
out.push({
|
||||
id: 'settings',
|
||||
label: 'Einstellungen',
|
||||
icon: 'settings',
|
||||
onClick: () => {
|
||||
window.location.href = settingsHref;
|
||||
},
|
||||
active: currentPath === settingsHref,
|
||||
});
|
||||
if (userEmail && manaHref) {
|
||||
out.push({
|
||||
id: 'mana',
|
||||
label: 'Mana',
|
||||
icon: 'sparkle',
|
||||
onClick: () => {
|
||||
window.location.href = manaHref!;
|
||||
},
|
||||
active: currentPath === manaHref,
|
||||
});
|
||||
}
|
||||
if (spiralHref) {
|
||||
out.push({
|
||||
id: 'spiral',
|
||||
label: 'Spiral',
|
||||
icon: 'spiral',
|
||||
onClick: () => {
|
||||
window.location.href = spiralHref!;
|
||||
},
|
||||
active: currentPath === spiralHref,
|
||||
});
|
||||
}
|
||||
if (creditsHref) {
|
||||
out.push({
|
||||
id: 'credits',
|
||||
label: 'Credits',
|
||||
icon: 'creditCard',
|
||||
onClick: () => {
|
||||
window.location.href = creditsHref!;
|
||||
},
|
||||
active: currentPath === creditsHref,
|
||||
});
|
||||
}
|
||||
if (userEmail && feedbackHref) {
|
||||
out.push({
|
||||
id: 'feedback',
|
||||
label: 'Feedback',
|
||||
icon: 'chat',
|
||||
onClick: () => {
|
||||
window.location.href = feedbackHref!;
|
||||
},
|
||||
active: currentPath === feedbackHref,
|
||||
});
|
||||
}
|
||||
if (helpHref) {
|
||||
out.push({
|
||||
id: 'help',
|
||||
label: 'Hilfe',
|
||||
icon: 'help',
|
||||
onClick: () => {
|
||||
window.location.href = helpHref!;
|
||||
},
|
||||
active: currentPath === helpHref,
|
||||
});
|
||||
}
|
||||
if (showLanguageSwitcher && languageItems.length > 0) {
|
||||
out.push({ id: 'language-div', label: '', divider: true });
|
||||
out.push({
|
||||
id: 'language',
|
||||
label: currentLanguageLabel,
|
||||
submenu: languageItems.map((item) => ({ ...item, id: `lang-${item.id}` })),
|
||||
});
|
||||
}
|
||||
out.push({ id: 'auth-div', label: '', divider: true });
|
||||
if (userEmail && showLogout && onLogout) {
|
||||
out.push({
|
||||
id: 'logout',
|
||||
label: 'Logout',
|
||||
icon: 'logout',
|
||||
onClick: () => onLogout!(),
|
||||
danger: true,
|
||||
});
|
||||
} else if (!userEmail && loginHref) {
|
||||
out.push({
|
||||
id: 'login',
|
||||
label: 'Anmelden',
|
||||
icon: 'user',
|
||||
primary: true,
|
||||
onClick: () => {
|
||||
window.location.href = loginHref!;
|
||||
},
|
||||
});
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
function toggleBar(config: PillBarConfig) {
|
||||
if (!onOpenBar) return;
|
||||
|
|
@ -616,6 +450,92 @@
|
|||
// App drawer state
|
||||
let appDrawerOpen = $state(false);
|
||||
|
||||
// User menu panel state
|
||||
let userMenuOpen = $state(false);
|
||||
let userMenuTrigger = $state<HTMLButtonElement | undefined>(undefined);
|
||||
|
||||
// Close user menu on navigation
|
||||
$effect(() => {
|
||||
currentPath;
|
||||
userMenuOpen = false;
|
||||
});
|
||||
|
||||
// Account links for UserMenuPanel
|
||||
const accountLinks = $derived.by(() => {
|
||||
const links: { id: string; label: string; icon: string; href: string; active?: boolean }[] = [];
|
||||
if (userEmail && profileHref) {
|
||||
links.push({
|
||||
id: 'profile',
|
||||
label: 'Profil',
|
||||
icon: 'user',
|
||||
href: profileHref,
|
||||
active: currentPath === profileHref,
|
||||
});
|
||||
}
|
||||
links.push({
|
||||
id: 'settings',
|
||||
label: 'Einstellungen',
|
||||
icon: 'settings',
|
||||
href: settingsHref,
|
||||
active: currentPath === settingsHref,
|
||||
});
|
||||
if (userEmail && manaHref) {
|
||||
links.push({
|
||||
id: 'mana',
|
||||
label: 'Mana',
|
||||
icon: 'sparkle',
|
||||
href: manaHref,
|
||||
active: currentPath === manaHref,
|
||||
});
|
||||
}
|
||||
if (spiralHref) {
|
||||
links.push({
|
||||
id: 'spiral',
|
||||
label: 'Spiral',
|
||||
icon: 'spiral',
|
||||
href: spiralHref,
|
||||
active: currentPath === spiralHref,
|
||||
});
|
||||
}
|
||||
if (creditsHref) {
|
||||
links.push({
|
||||
id: 'credits',
|
||||
label: 'Credits',
|
||||
icon: 'creditCard',
|
||||
href: creditsHref,
|
||||
active: currentPath === creditsHref,
|
||||
});
|
||||
}
|
||||
if (userEmail && feedbackHref) {
|
||||
links.push({
|
||||
id: 'feedback',
|
||||
label: 'Feedback',
|
||||
icon: 'chat',
|
||||
href: feedbackHref,
|
||||
active: currentPath === feedbackHref,
|
||||
});
|
||||
}
|
||||
if (helpHref) {
|
||||
links.push({
|
||||
id: 'help',
|
||||
label: 'Hilfe',
|
||||
icon: 'help',
|
||||
href: helpHref,
|
||||
active: currentPath === helpHref,
|
||||
});
|
||||
}
|
||||
if (themesHref) {
|
||||
links.push({
|
||||
id: 'themes',
|
||||
label: 'Themes',
|
||||
icon: 'palette',
|
||||
href: themesHref,
|
||||
active: currentPath === themesHref,
|
||||
});
|
||||
}
|
||||
return links;
|
||||
});
|
||||
|
||||
// Global spotlight (Cmd+K) — only active when spotlightActions are provided
|
||||
// svelte-ignore state_referenced_locally
|
||||
const spotlight = spotlightActions ? createGlobalSpotlightState() : null;
|
||||
|
|
@ -791,149 +711,6 @@
|
|||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Theme Variant Selector -->
|
||||
{#if showThemeVariants && themeVariantItems.length > 0 && barMode}
|
||||
{@const themeConfig = {
|
||||
id: 'theme',
|
||||
label: '',
|
||||
icon: undefined,
|
||||
items: themeBarItems,
|
||||
}}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggleBar(themeConfig)}
|
||||
class="pill glass-pill icon-only"
|
||||
class:active={activeBarId === 'theme'}
|
||||
title={currentThemeVariantLabel}
|
||||
aria-label={currentThemeVariantLabel}
|
||||
>
|
||||
<Palette size={18} class="pill-icon" />
|
||||
</button>
|
||||
{:else if showThemeVariants && themeVariantItems.length > 0}
|
||||
<PillDropdown
|
||||
items={themeVariantItems}
|
||||
direction={dropdownDirection}
|
||||
label={currentThemeVariantLabel}
|
||||
icon="palette"
|
||||
>
|
||||
{#snippet header()}
|
||||
<div class="theme-mode-selector glass-pill">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onThemeModeChange?.('light')}
|
||||
class="mode-btn"
|
||||
class:active={themeMode === 'light'}
|
||||
title="Light mode"
|
||||
>
|
||||
<Sun size={16} class="mode-icon" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onThemeModeChange?.('dark')}
|
||||
class="mode-btn"
|
||||
class:active={themeMode === 'dark'}
|
||||
title="Dark mode"
|
||||
>
|
||||
<Moon size={16} class="mode-icon" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onThemeModeChange?.('system')}
|
||||
class="mode-btn"
|
||||
class:active={themeMode === 'system'}
|
||||
title="System mode"
|
||||
>
|
||||
<svg class="mode-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" stroke-width="2" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 21h8M12 17v4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet footer()}
|
||||
{#if showA11yQuickToggles}
|
||||
<div class="a11y-quick-toggles glass-pill">
|
||||
<!-- Contrast Toggle -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() =>
|
||||
onA11yContrastChange?.(a11yContrast === 'high' ? 'normal' : 'high')}
|
||||
class="a11y-btn"
|
||||
class:active={a11yContrast === 'high'}
|
||||
title="Hoher Kontrast"
|
||||
aria-pressed={a11yContrast === 'high'}
|
||||
>
|
||||
<Sun size={20} class="a11y-icon" />
|
||||
</button>
|
||||
<!-- Reduce Motion Toggle -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onA11yReduceMotionChange?.(!a11yReduceMotion)}
|
||||
class="a11y-btn"
|
||||
class:active={a11yReduceMotion}
|
||||
title="Animationen reduzieren"
|
||||
aria-pressed={a11yReduceMotion}
|
||||
>
|
||||
<svg
|
||||
class="a11y-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
{#if a11yReduceMotion}
|
||||
<rect x="6" y="4" width="4" height="16" rx="1" />
|
||||
<rect x="14" y="4" width="4" height="16" rx="1" />
|
||||
{:else}
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
{/if}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</PillDropdown>
|
||||
{/if}
|
||||
|
||||
<!-- AI Tier Selector -->
|
||||
{#if showAiTierSelector && aiTierItems.length > 0 && barMode}
|
||||
{@const aiProgress = aiTierItems.find((i) => i.progress != null)?.progress}
|
||||
{@const aiConfig = {
|
||||
id: 'ai',
|
||||
label: '',
|
||||
icon: undefined,
|
||||
items: aiTierItems,
|
||||
progress: aiProgress,
|
||||
}}
|
||||
{@const AiIcon = phosphorIcons[currentAiTierIcon]}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggleBar(aiConfig)}
|
||||
class="pill glass-pill icon-only"
|
||||
class:active={activeBarId === 'ai'}
|
||||
class:downloading={aiProgress != null}
|
||||
title={currentAiTierLabel}
|
||||
aria-label={currentAiTierLabel}
|
||||
style={aiProgress != null ? `--progress: ${aiProgress}` : ''}
|
||||
>
|
||||
{#if AiIcon}
|
||||
<AiIcon size={18} class="pill-icon" />
|
||||
{/if}
|
||||
</button>
|
||||
{:else if showAiTierSelector && aiTierItems.length > 0}
|
||||
<PillDropdown
|
||||
items={aiTierItems}
|
||||
direction={dropdownDirection}
|
||||
label={currentAiTierLabel}
|
||||
icon={currentAiTierIcon}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Sync Status -->
|
||||
{#if showSyncStatus && syncStatusItems.length > 0 && barMode}
|
||||
{@const syncConfig = {
|
||||
|
|
@ -961,178 +738,21 @@
|
|||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Theme Toggle (only show when not using theme variants dropdown) -->
|
||||
{#if showThemeToggle && onToggleTheme && !showThemeVariants}
|
||||
<button
|
||||
onclick={onToggleTheme}
|
||||
class="pill glass-pill"
|
||||
title={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
>
|
||||
{#if !isDark}
|
||||
<Moon size={18} class="pill-icon" />
|
||||
{:else}
|
||||
<Sun size={18} class="pill-icon" />
|
||||
{/if}
|
||||
<span class="pill-label">{isDark ? 'Light' : 'Dark'}</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- User Menu Dropdown — rendered for both authenticated users and
|
||||
guests. Auth-only items (profile/settings/logout) are filtered
|
||||
out when userEmail is empty; spiral/credits/themes/help stay
|
||||
available either way so guests can still navigate. -->
|
||||
{#if (userEmail || loginHref) && barMode}
|
||||
<!-- User Menu -->
|
||||
{#if userEmail || loginHref}
|
||||
{@const userLabel = userEmail ? truncateEmail(userEmail) : guestMenuLabel}
|
||||
{@const userConfig = {
|
||||
id: 'user',
|
||||
label: userLabel,
|
||||
icon: 'user',
|
||||
items: userBarItems,
|
||||
}}
|
||||
<button
|
||||
bind:this={userMenuTrigger}
|
||||
type="button"
|
||||
onclick={() => toggleBar(userConfig)}
|
||||
class="pill glass-pill"
|
||||
class:active={activeBarId === 'user'}
|
||||
onclick={() => (userMenuOpen = !userMenuOpen)}
|
||||
class="pill glass-pill icon-only"
|
||||
class:active={userMenuOpen}
|
||||
aria-label={userLabel}
|
||||
title={userLabel}
|
||||
>
|
||||
<User size={18} class="pill-icon" />
|
||||
<span class="pill-label">{userLabel}</span>
|
||||
</button>
|
||||
{:else if userEmail || loginHref}
|
||||
<PillDropdown
|
||||
items={[
|
||||
...(userEmail && profileHref
|
||||
? [
|
||||
{
|
||||
id: 'profile',
|
||||
label: 'Profil',
|
||||
icon: 'user',
|
||||
onClick: () => {
|
||||
window.location.href = profileHref;
|
||||
},
|
||||
active: currentPath === profileHref,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: 'settings',
|
||||
label: 'Einstellungen',
|
||||
icon: 'settings',
|
||||
onClick: () => {
|
||||
window.location.href = settingsHref;
|
||||
},
|
||||
active: currentPath === settingsHref,
|
||||
},
|
||||
...(userEmail && manaHref
|
||||
? [
|
||||
{
|
||||
id: 'mana',
|
||||
label: 'Mana',
|
||||
icon: 'sparkle',
|
||||
onClick: () => {
|
||||
window.location.href = manaHref;
|
||||
},
|
||||
active: currentPath === manaHref,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(spiralHref
|
||||
? [
|
||||
{
|
||||
id: 'spiral',
|
||||
label: 'Spiral',
|
||||
icon: 'spiral',
|
||||
onClick: () => {
|
||||
window.location.href = spiralHref;
|
||||
},
|
||||
active: currentPath === spiralHref,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(creditsHref
|
||||
? [
|
||||
{
|
||||
id: 'credits',
|
||||
label: 'Credits',
|
||||
icon: 'creditCard',
|
||||
onClick: () => {
|
||||
window.location.href = creditsHref;
|
||||
},
|
||||
active: currentPath === creditsHref,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(userEmail && feedbackHref
|
||||
? [
|
||||
{
|
||||
id: 'feedback',
|
||||
label: 'Feedback',
|
||||
icon: 'chat',
|
||||
onClick: () => {
|
||||
window.location.href = feedbackHref;
|
||||
},
|
||||
active: currentPath === feedbackHref,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(helpHref
|
||||
? [
|
||||
{
|
||||
id: 'help',
|
||||
label: 'Hilfe',
|
||||
icon: 'help',
|
||||
onClick: () => {
|
||||
window.location.href = helpHref;
|
||||
},
|
||||
active: currentPath === helpHref,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(showLanguageSwitcher && languageItems.length > 0
|
||||
? [
|
||||
{ id: 'language-divider', label: '', divider: true },
|
||||
{
|
||||
id: 'language',
|
||||
label: currentLanguageLabel,
|
||||
submenu: languageItems.map((item) => ({
|
||||
...item,
|
||||
id: `lang-${item.id}`,
|
||||
})),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ id: 'auth-divider', label: '', divider: true },
|
||||
...(userEmail && showLogout && onLogout
|
||||
? [
|
||||
{
|
||||
id: 'logout',
|
||||
label: 'Logout',
|
||||
icon: 'logout',
|
||||
onClick: () => onLogout?.(),
|
||||
danger: true,
|
||||
},
|
||||
]
|
||||
: !userEmail && loginHref
|
||||
? [
|
||||
{
|
||||
id: 'login',
|
||||
label: 'Anmelden',
|
||||
icon: 'user',
|
||||
primary: true,
|
||||
onClick: () => {
|
||||
window.location.href = loginHref;
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
direction={dropdownDirection}
|
||||
label={userEmail ? truncateEmail(userEmail) : guestMenuLabel}
|
||||
icon="user"
|
||||
/>
|
||||
{:else if onLogout && showLogout}
|
||||
<!-- Fallback to standalone logout if no user email and no loginHref -->
|
||||
<button onclick={onLogout} class="pill glass-pill logout-pill" title="Logout">
|
||||
<SignOut size={18} class="pill-icon" />
|
||||
<span class="pill-label">Logout</span>
|
||||
|
|
@ -1142,6 +762,31 @@
|
|||
</nav>
|
||||
{/if}
|
||||
|
||||
<!-- User Menu Panel (overlay) -->
|
||||
{#if userMenuOpen}
|
||||
<UserMenuPanel
|
||||
{userEmail}
|
||||
{loginHref}
|
||||
{accountLinks}
|
||||
showLogout={showLogout && !!userEmail}
|
||||
{onLogout}
|
||||
showAiTier={showAiTierSelector && aiTierItems.length > 0}
|
||||
{aiTierItems}
|
||||
{themeMode}
|
||||
{onThemeModeChange}
|
||||
{themeVariantItems}
|
||||
{showA11yQuickToggles}
|
||||
{a11yContrast}
|
||||
{onA11yContrastChange}
|
||||
{a11yReduceMotion}
|
||||
{onA11yReduceMotionChange}
|
||||
showLanguageSwitcher={showLanguageSwitcher && languageItems.length > 0}
|
||||
{languageItems}
|
||||
onClose={() => (userMenuOpen = false)}
|
||||
triggerElement={userMenuTrigger}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Global Spotlight (Cmd+K) -->
|
||||
{#if spotlight && spotlightActions}
|
||||
<GlobalSpotlight
|
||||
|
|
@ -1328,38 +973,6 @@
|
|||
/* Progress ring on pill (used for download indicator).
|
||||
Uses a conic-gradient border trick so it follows the pill's
|
||||
own border-radius regardless of shape. */
|
||||
.pill.downloading {
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.pill.downloading::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -3px;
|
||||
border-radius: inherit;
|
||||
border: 2.5px solid transparent;
|
||||
background:
|
||||
conic-gradient(
|
||||
from 0deg,
|
||||
var(--pill-primary-color, var(--color-primary-500, #6366f1))
|
||||
calc(var(--progress) * 360deg),
|
||||
transparent calc(var(--progress) * 360deg)
|
||||
)
|
||||
border-box,
|
||||
linear-gradient(hsl(var(--color-card)), hsl(var(--color-card))) padding-box;
|
||||
mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
mask-composite: exclude;
|
||||
-webkit-mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
pointer-events: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/* Transitions */
|
||||
.pill-nav {
|
||||
transition: all 0.3s ease;
|
||||
|
|
|
|||
757
packages/shared-ui/src/navigation/UserMenuPanel.svelte
Normal file
757
packages/shared-ui/src/navigation/UserMenuPanel.svelte
Normal file
|
|
@ -0,0 +1,757 @@
|
|||
<script lang="ts">
|
||||
import type { PillDropdownItem } from './types';
|
||||
import {
|
||||
ChatCircle,
|
||||
Clock,
|
||||
Cloud,
|
||||
CreditCard,
|
||||
Gear,
|
||||
Globe,
|
||||
Heart,
|
||||
Moon,
|
||||
Palette,
|
||||
Question,
|
||||
Robot,
|
||||
SignOut,
|
||||
Sparkle,
|
||||
Spiral,
|
||||
Sun,
|
||||
User,
|
||||
} from '@mana/shared-icons';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const icons: Record<string, any> = {
|
||||
user: User,
|
||||
settings: Gear,
|
||||
sparkle: Sparkle,
|
||||
spiral: Spiral,
|
||||
creditCard: CreditCard,
|
||||
chat: ChatCircle,
|
||||
help: Question,
|
||||
heart: Heart,
|
||||
clock: Clock,
|
||||
globe: Globe,
|
||||
cloud: Cloud,
|
||||
moon: Moon,
|
||||
sun: Sun,
|
||||
palette: Palette,
|
||||
robot: Robot,
|
||||
logout: SignOut,
|
||||
};
|
||||
|
||||
interface AccountLink {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
href: string;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
// Account
|
||||
userEmail?: string;
|
||||
loginHref?: string;
|
||||
accountLinks?: AccountLink[];
|
||||
showLogout?: boolean;
|
||||
onLogout?: () => void;
|
||||
|
||||
// AI tier
|
||||
showAiTier?: boolean;
|
||||
aiTierItems?: PillDropdownItem[];
|
||||
|
||||
// Theme
|
||||
themeMode?: 'light' | 'dark' | 'system';
|
||||
onThemeModeChange?: (mode: 'light' | 'dark' | 'system') => void;
|
||||
themeVariantItems?: PillDropdownItem[];
|
||||
|
||||
// A11y
|
||||
showA11yQuickToggles?: boolean;
|
||||
a11yContrast?: 'normal' | 'high';
|
||||
onA11yContrastChange?: (v: 'normal' | 'high') => void;
|
||||
a11yReduceMotion?: boolean;
|
||||
onA11yReduceMotionChange?: (v: boolean) => void;
|
||||
|
||||
// Language
|
||||
showLanguageSwitcher?: boolean;
|
||||
languageItems?: PillDropdownItem[];
|
||||
|
||||
// Panel
|
||||
onClose: () => void;
|
||||
triggerElement?: HTMLElement;
|
||||
}
|
||||
|
||||
let {
|
||||
userEmail,
|
||||
loginHref,
|
||||
accountLinks = [],
|
||||
showLogout = false,
|
||||
onLogout,
|
||||
showAiTier = false,
|
||||
aiTierItems = [],
|
||||
themeMode = 'system',
|
||||
onThemeModeChange,
|
||||
themeVariantItems = [],
|
||||
showA11yQuickToggles = false,
|
||||
a11yContrast = 'normal',
|
||||
onA11yContrastChange,
|
||||
a11yReduceMotion = false,
|
||||
onA11yReduceMotionChange,
|
||||
showLanguageSwitcher = false,
|
||||
languageItems = [],
|
||||
onClose,
|
||||
triggerElement,
|
||||
}: Props = $props();
|
||||
|
||||
let panelBottom = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
if (triggerElement) {
|
||||
const rect = triggerElement.getBoundingClientRect();
|
||||
panelBottom = window.innerHeight - rect.top + 8;
|
||||
}
|
||||
});
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleItemClick(item: PillDropdownItem, event: MouseEvent) {
|
||||
if (item.disabled) return;
|
||||
item.onClick?.(event);
|
||||
}
|
||||
|
||||
function navigateTo(href: string) {
|
||||
window.location.href = href;
|
||||
onClose();
|
||||
}
|
||||
|
||||
// Split AI items into LLM, STT, and extra sections by dividers
|
||||
const aiSections = $derived.by(() => {
|
||||
const llm: PillDropdownItem[] = [];
|
||||
const stt: PillDropdownItem[] = [];
|
||||
const extra: PillDropdownItem[] = [];
|
||||
let section: 'llm' | 'stt' | 'extra' = 'llm';
|
||||
for (const item of aiTierItems) {
|
||||
if (item.divider && item.id === 'stt-divider') {
|
||||
section = 'stt';
|
||||
continue;
|
||||
}
|
||||
if (item.divider && item.id === 'ai-divider') {
|
||||
section = 'extra';
|
||||
continue;
|
||||
}
|
||||
if (item.divider) continue;
|
||||
if (section === 'llm') llm.push(item);
|
||||
else if (section === 'stt') stt.push(item);
|
||||
else extra.push(item);
|
||||
}
|
||||
return { llm, stt, extra };
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions a11y_interactive_supports_focus -->
|
||||
<div
|
||||
class="user-menu-panel-root"
|
||||
onkeydown={handleKeydown}
|
||||
role="dialog"
|
||||
aria-label="Menu"
|
||||
tabindex="-1"
|
||||
>
|
||||
<!-- Backdrop -->
|
||||
<button class="panel-backdrop" onclick={onClose} aria-label="Menü schließen"></button>
|
||||
|
||||
<!-- Panel -->
|
||||
<div class="panel" style="bottom: {panelBottom}px;">
|
||||
<!-- Header -->
|
||||
{#if userEmail}
|
||||
<div class="panel-header">
|
||||
<User size={18} />
|
||||
<span class="header-email">{userEmail}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="panel-content">
|
||||
<!-- Account Links -->
|
||||
{#if accountLinks.length > 0}
|
||||
<div class="panel-section">
|
||||
<div class="section-header">Account</div>
|
||||
<div class="chip-grid">
|
||||
{#each accountLinks as link (link.id)}
|
||||
<button
|
||||
class="chip"
|
||||
class:active={link.active}
|
||||
onclick={() => navigateTo(link.href)}
|
||||
title={link.label}
|
||||
>
|
||||
{#if icons[link.icon]}
|
||||
{@const Icon = icons[link.icon]}
|
||||
<Icon size={16} />
|
||||
{/if}
|
||||
<span>{link.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- AI Tier -->
|
||||
{#if showAiTier && aiTierItems.length > 0}
|
||||
<div class="panel-section">
|
||||
<div class="section-header">Künstliche Intelligenz</div>
|
||||
|
||||
<!-- LLM subsection -->
|
||||
{#if aiSections.llm.length > 0}
|
||||
<div class="subsection">
|
||||
<div class="subsection-header">Textgenerierung</div>
|
||||
<div class="chip-grid">
|
||||
{#each aiSections.llm as item (item.id)}
|
||||
<button
|
||||
class="chip"
|
||||
class:active={item.active}
|
||||
disabled={item.disabled}
|
||||
onclick={(e) => handleItemClick(item, e)}
|
||||
title={item.label}
|
||||
>
|
||||
{#if item.progress != null}
|
||||
<svg class="progress-ring" viewBox="0 0 20 20">
|
||||
<circle class="progress-bg" cx="10" cy="10" r="8" />
|
||||
<circle
|
||||
class="progress-fill"
|
||||
cx="10"
|
||||
cy="10"
|
||||
r="8"
|
||||
stroke-dasharray={8 * 2 * Math.PI}
|
||||
stroke-dashoffset={8 * 2 * Math.PI * (1 - item.progress)}
|
||||
/>
|
||||
</svg>
|
||||
{:else if item.icon && icons[item.icon]}
|
||||
{@const Icon = icons[item.icon]}
|
||||
<Icon size={16} />
|
||||
{/if}
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Whisper subsection -->
|
||||
{#if aiSections.stt.length > 0}
|
||||
<div class="subsection">
|
||||
<div class="subsection-header">Spracherkennung</div>
|
||||
<div class="chip-grid">
|
||||
{#each aiSections.stt as item (item.id)}
|
||||
<button
|
||||
class="chip"
|
||||
class:active={item.active}
|
||||
disabled={item.disabled}
|
||||
onclick={(e) => handleItemClick(item, e)}
|
||||
title={item.label}
|
||||
>
|
||||
{#if item.progress != null}
|
||||
<svg class="progress-ring" viewBox="0 0 20 20">
|
||||
<circle class="progress-bg" cx="10" cy="10" r="8" />
|
||||
<circle
|
||||
class="progress-fill"
|
||||
cx="10"
|
||||
cy="10"
|
||||
r="8"
|
||||
stroke-dasharray={8 * 2 * Math.PI}
|
||||
stroke-dashoffset={8 * 2 * Math.PI * (1 - item.progress)}
|
||||
/>
|
||||
</svg>
|
||||
{:else if item.icon && icons[item.icon]}
|
||||
{@const Icon = icons[item.icon]}
|
||||
<Icon size={16} />
|
||||
{/if}
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Extra (e.g. KI-Einstellungen link) -->
|
||||
{#if aiSections.extra.length > 0}
|
||||
<div class="chip-grid" style="margin-top: 0.5rem;">
|
||||
{#each aiSections.extra as item (item.id)}
|
||||
<button
|
||||
class="chip"
|
||||
class:active={item.active}
|
||||
disabled={item.disabled}
|
||||
onclick={(e) => handleItemClick(item, e)}
|
||||
title={item.label}
|
||||
>
|
||||
{#if item.icon && icons[item.icon]}
|
||||
{@const Icon = icons[item.icon]}
|
||||
<Icon size={16} />
|
||||
{/if}
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Theme -->
|
||||
{#if onThemeModeChange || themeVariantItems.length > 0}
|
||||
<div class="panel-section">
|
||||
<div class="section-header">Theme</div>
|
||||
{#if onThemeModeChange}
|
||||
<div class="segmented-toggle">
|
||||
<button
|
||||
class="segmented-btn"
|
||||
class:active={themeMode === 'light'}
|
||||
onclick={() => onThemeModeChange('light')}
|
||||
title="Light"
|
||||
>
|
||||
<Sun size={16} />
|
||||
<span>Light</span>
|
||||
</button>
|
||||
<button
|
||||
class="segmented-btn"
|
||||
class:active={themeMode === 'dark'}
|
||||
onclick={() => onThemeModeChange('dark')}
|
||||
title="Dark"
|
||||
>
|
||||
<Moon size={16} />
|
||||
<span>Dark</span>
|
||||
</button>
|
||||
<button
|
||||
class="segmented-btn"
|
||||
class:active={themeMode === 'system'}
|
||||
onclick={() => onThemeModeChange('system')}
|
||||
title="System"
|
||||
>
|
||||
<Gear size={16} />
|
||||
<span>Auto</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if themeVariantItems.length > 0}
|
||||
<div class="chip-grid" style="margin-top: 0.5rem;">
|
||||
{#each themeVariantItems as item (item.id)}
|
||||
<button
|
||||
class="chip"
|
||||
class:active={item.active}
|
||||
disabled={item.disabled}
|
||||
onclick={(e) => handleItemClick(item, e)}
|
||||
title={item.label}
|
||||
>
|
||||
{#if item.imageUrl}
|
||||
<img src={item.imageUrl} alt="" class="chip-img" />
|
||||
{:else if item.icon && icons[item.icon]}
|
||||
{@const Icon = icons[item.icon]}
|
||||
<Icon size={16} />
|
||||
{/if}
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if showA11yQuickToggles}
|
||||
<div class="a11y-row" style="margin-top: 0.5rem;">
|
||||
{#if onA11yContrastChange}
|
||||
<button
|
||||
class="chip"
|
||||
class:active={a11yContrast === 'high'}
|
||||
onclick={() => onA11yContrastChange(a11yContrast === 'high' ? 'normal' : 'high')}
|
||||
>
|
||||
<Sun size={16} />
|
||||
<span>Kontrast</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if onA11yReduceMotionChange}
|
||||
<button
|
||||
class="chip"
|
||||
class:active={a11yReduceMotion}
|
||||
onclick={() => onA11yReduceMotionChange(!a11yReduceMotion)}
|
||||
>
|
||||
<Gear size={16} />
|
||||
<span>Weniger Animationen</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Language -->
|
||||
{#if showLanguageSwitcher && languageItems.length > 0}
|
||||
<div class="panel-section">
|
||||
<div class="section-header">Sprache</div>
|
||||
<div class="segmented-toggle">
|
||||
{#each languageItems as item (item.id)}
|
||||
<button
|
||||
class="segmented-btn"
|
||||
class:active={item.active}
|
||||
onclick={(e) => handleItemClick(item, e)}
|
||||
title={item.label}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer: Login / Logout -->
|
||||
{#if !userEmail && loginHref}
|
||||
<div class="panel-footer">
|
||||
<button class="login-btn" onclick={() => navigateTo(loginHref)}>
|
||||
<User size={16} />
|
||||
<span>Anmelden</span>
|
||||
</button>
|
||||
</div>
|
||||
{:else if userEmail && showLogout && onLogout}
|
||||
<div class="panel-footer">
|
||||
<button
|
||||
class="logout-btn"
|
||||
onclick={() => {
|
||||
onLogout();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<SignOut size={16} />
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.user-menu-panel-root {
|
||||
position: relative;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
/* Backdrop */
|
||||
.panel-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9998;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
:global(.dark) .panel-backdrop {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Panel */
|
||||
.panel {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
width: 520px;
|
||||
max-height: 85vh;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: hsl(var(--color-card));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
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;
|
||||
}
|
||||
|
||||
@keyframes panelIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid hsl(var(--color-border));
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.header-email {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.panel-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
.panel-section {
|
||||
padding: 0.375rem 0.5rem;
|
||||
}
|
||||
|
||||
.panel-section + .panel-section {
|
||||
border-top: 1px solid hsl(var(--color-border));
|
||||
margin-top: 0.25rem;
|
||||
padding-top: 0.625rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
padding-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.subsection {
|
||||
margin-top: 0.375rem;
|
||||
}
|
||||
|
||||
.subsection:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.subsection-header {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
opacity: 0.7;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
/* Chip grid */
|
||||
.chip-grid,
|
||||
.a11y-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
background: hsl(var(--color-card));
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.chip:hover:not(:disabled) {
|
||||
background: hsl(var(--color-surface-hover, var(--color-muted)));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.chip:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.chip.active {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--pill-primary-color, var(--color-primary-500, #6366f1)) 20%,
|
||||
white 80%
|
||||
);
|
||||
border-color: var(--pill-primary-color, var(--color-primary-500, rgba(99, 102, 241, 0.4)));
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
:global(.dark) .chip.active {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--pill-primary-color, var(--color-primary-500, #6366f1)) 30%,
|
||||
transparent 70%
|
||||
);
|
||||
color: var(--pill-primary-color, var(--color-primary-500, #6366f1));
|
||||
}
|
||||
|
||||
.chip-img {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* Segmented toggle */
|
||||
.segmented-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
background: hsl(var(--color-card));
|
||||
box-shadow:
|
||||
0 1px 2px hsl(0 0% 0% / 0.05),
|
||||
0 2px 6px hsl(0 0% 0% / 0.04);
|
||||
}
|
||||
|
||||
.segmented-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 9999px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-foreground));
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.segmented-btn:hover:not(.active):not(:disabled) {
|
||||
background: hsl(var(--color-surface-hover, var(--color-muted)));
|
||||
}
|
||||
|
||||
.segmented-btn.active {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--pill-primary-color, var(--color-primary-500, #6366f1)) 20%,
|
||||
white 80%
|
||||
);
|
||||
}
|
||||
|
||||
:global(.dark) .segmented-btn.active {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--pill-primary-color, var(--color-primary-500, #6366f1)) 30%,
|
||||
transparent 70%
|
||||
);
|
||||
}
|
||||
|
||||
.segmented-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Progress ring */
|
||||
.progress-ring {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
transform: rotate(-90deg);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.progress-bg {
|
||||
fill: none;
|
||||
stroke: hsl(var(--color-border));
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
fill: none;
|
||||
stroke: var(--pill-primary-color, var(--color-primary-500, #6366f1));
|
||||
stroke-width: 2.5;
|
||||
stroke-linecap: round;
|
||||
transition: stroke-dashoffset 0.3s ease;
|
||||
}
|
||||
|
||||
/* Login button */
|
||||
.login-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border-radius: 9999px;
|
||||
border: none;
|
||||
background: var(--pill-primary-color, var(--color-primary-500, #6366f1));
|
||||
color: white;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.login-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.panel-footer {
|
||||
border-top: 1px solid hsl(var(--color-border));
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.875rem;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
background: hsl(var(--color-card));
|
||||
color: #dc2626;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
:global(.dark) .logout-btn {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
background: hsl(var(--color-surface-hover, var(--color-muted)));
|
||||
}
|
||||
|
||||
/* Mobile: bottom sheet */
|
||||
@media (max-width: 640px) {
|
||||
.panel {
|
||||
position: fixed;
|
||||
top: auto !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
bottom: 0 !important;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
.panel-backdrop {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -6,6 +6,7 @@ export { default as PillNavigation } from './PillNavigation.svelte';
|
|||
export { default as PillDropdown } from './PillDropdown.svelte';
|
||||
export { default as PillDropdownBar } from './PillDropdownBar.svelte';
|
||||
export { default as AppDrawer } from './AppDrawer.svelte';
|
||||
export { default as UserMenuPanel } from './UserMenuPanel.svelte';
|
||||
export { default as GlobalSpotlight } from './GlobalSpotlight.svelte';
|
||||
export { createGlobalSpotlightState } from './useGlobalSpotlight.svelte';
|
||||
export {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue