mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
feat(web): PillNav bar mode, fullscreen, local STT + mic button
PillNav overhaul: - Dropdown-as-bar: theme/AI/sync/user menus render as horizontal bars in the bottom stack (PillDropdownBar) instead of floating popovers. New onOpenBar/activeBarId props on PillNavigation. - iconOnly pills: tags/search/workbench-tabs pills show only icons. Home pill removed. New iconOnly flag on PillNavItem. - Segmented toggle groups: items sharing a `group` id render as a single segmented pill (e.g. Light/Dark/System triple). - Fullscreen mode: press "f" to hide all bottom chrome, Esc to exit. - QuickInputBar + bottom bar visibility toggles via new pills. - Progress ring on AI trigger pill during model download (conic-gradient ::after, follows pill border-radius). @mana/local-stt — new package for browser-local speech-to-text: - Whisper models via transformers.js v4 (WebGPU + WASM fallback) - Same Web Worker architecture as @mana/local-llm - Two models: Whisper Tiny (150 MB) and Whisper Small (950 MB) - Reactive Svelte 5 bindings (getLocalSttStatus, loadLocalStt, transcribe) Voice-to-text integration: - useLocalStt() composable: mic capture via AudioContext + ScriptProcessor, resample to 16kHz mono, feed into Whisper worker - Mic button in QuickInputBar (leftAction slot) with recording/loading/transcribing states + pulse animation - Transcribed text injected into InputBar via new injectedText prop - STT model selector in AI bar alongside LLM tier controls Also: vite.config.ts server.fs.allow expanded to monorepo root so workspace package workers resolve in dev. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8c2f9306e9
commit
3deee755b3
24 changed files with 2145 additions and 28 deletions
|
|
@ -100,6 +100,7 @@ export {
|
|||
SidebarSection,
|
||||
PillNavigation,
|
||||
PillDropdown,
|
||||
PillDropdownBar,
|
||||
AppDrawer,
|
||||
GlobalSpotlight,
|
||||
createGlobalSpotlightState,
|
||||
|
|
@ -129,6 +130,7 @@ export type {
|
|||
PillNavItem,
|
||||
PillDropdownItem,
|
||||
PillNavElement,
|
||||
PillBarConfig,
|
||||
PillNavigationProps,
|
||||
PillTabOption,
|
||||
PillTabGroupConfig,
|
||||
|
|
|
|||
510
packages/shared-ui/src/navigation/PillDropdownBar.svelte
Normal file
510
packages/shared-ui/src/navigation/PillDropdownBar.svelte
Normal file
|
|
@ -0,0 +1,510 @@
|
|||
<script lang="ts">
|
||||
import type { PillDropdownItem } from './types';
|
||||
import {
|
||||
Archive,
|
||||
Bell,
|
||||
Buildings,
|
||||
CalendarBlank,
|
||||
CaretDown,
|
||||
ChartBar,
|
||||
ChatCircle,
|
||||
Check,
|
||||
CheckCircle,
|
||||
CheckSquare,
|
||||
Clock,
|
||||
Cloud,
|
||||
Columns,
|
||||
Compass,
|
||||
CreditCard,
|
||||
File,
|
||||
FileText,
|
||||
Fire,
|
||||
Folder,
|
||||
Gear,
|
||||
Gift,
|
||||
Globe,
|
||||
GridFour,
|
||||
Heart,
|
||||
House,
|
||||
Key,
|
||||
List,
|
||||
MagnifyingGlass,
|
||||
Microphone,
|
||||
Moon,
|
||||
MusicNote,
|
||||
MusicNotes,
|
||||
Palette,
|
||||
Playlist,
|
||||
Plus,
|
||||
Question,
|
||||
Robot,
|
||||
Scales,
|
||||
ShareFat,
|
||||
ShareNetwork,
|
||||
Shield,
|
||||
SignOut,
|
||||
Sparkle,
|
||||
Spiral,
|
||||
Sun,
|
||||
Tag,
|
||||
Target,
|
||||
Timer,
|
||||
Trash,
|
||||
Tray,
|
||||
Upload,
|
||||
User,
|
||||
Users,
|
||||
Waveform,
|
||||
} from '@mana/shared-icons';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const phosphorIcons: Record<string, any> = {
|
||||
home: House,
|
||||
users: Users,
|
||||
user: User,
|
||||
tag: Tag,
|
||||
heart: Heart,
|
||||
settings: Gear,
|
||||
chat: ChatCircle,
|
||||
'help-circle': Question,
|
||||
help: Question,
|
||||
'share-2': ShareNetwork,
|
||||
bell: Bell,
|
||||
clock: Clock,
|
||||
timer: Timer,
|
||||
target: Target,
|
||||
globe: Globe,
|
||||
inbox: Tray,
|
||||
check: Check,
|
||||
checkCircle: CheckCircle,
|
||||
'check-square': CheckSquare,
|
||||
plus: Plus,
|
||||
columns: Columns,
|
||||
kanban: Columns,
|
||||
mic: Microphone,
|
||||
calendar: CalendarBlank,
|
||||
folder: Folder,
|
||||
archive: Archive,
|
||||
upload: Upload,
|
||||
music: MusicNote,
|
||||
document: File,
|
||||
chart: ChartBar,
|
||||
'bar-chart-3': ChartBar,
|
||||
search: MagnifyingGlass,
|
||||
list: List,
|
||||
compass: Compass,
|
||||
moon: Moon,
|
||||
sun: Sun,
|
||||
logout: SignOut,
|
||||
chevronDown: CaretDown,
|
||||
menu: List,
|
||||
fire: Fire,
|
||||
grid: GridFour,
|
||||
gridSmall: GridFour,
|
||||
palette: Palette,
|
||||
creditCard: CreditCard,
|
||||
building: Buildings,
|
||||
scale: Scales,
|
||||
robot: Robot,
|
||||
key: Key,
|
||||
shield: Shield,
|
||||
gift: Gift,
|
||||
'music-notes': MusicNotes,
|
||||
playlist: Playlist,
|
||||
waveform: Waveform,
|
||||
'file-text': FileText,
|
||||
sparkle: Sparkle,
|
||||
sparkles: Sparkle,
|
||||
spiral: Spiral,
|
||||
share: ShareFat,
|
||||
trash: Trash,
|
||||
cloud: Cloud,
|
||||
};
|
||||
|
||||
interface Props {
|
||||
/** Items to render as pills in the bar */
|
||||
items: PillDropdownItem[];
|
||||
/** Label shown at the start of the bar (title of the opened dropdown) */
|
||||
label?: string;
|
||||
/** Icon rendered next to the label */
|
||||
icon?: string;
|
||||
/** Use 'static' when inside a flex container (bottom-stack pattern). */
|
||||
positioning?: 'fixed' | 'static';
|
||||
}
|
||||
|
||||
let { items, label, icon, positioning = 'static' }: Props = $props();
|
||||
|
||||
// A render element is either a single item, a divider/section-label, or a
|
||||
// group of items that share the same `group` id (rendered as a segmented
|
||||
// toggle pill).
|
||||
type RenderElement =
|
||||
| { kind: 'item'; id: string; item: PillDropdownItem }
|
||||
| { kind: 'divider'; id: string }
|
||||
| { kind: 'section-label'; id: string; label: string }
|
||||
| { kind: 'group'; id: string; items: PillDropdownItem[] };
|
||||
|
||||
const renderElements = $derived.by<RenderElement[]>(() => {
|
||||
const out: RenderElement[] = [];
|
||||
// Track groups already emitted so we only render each once.
|
||||
const emittedGroups = new Set<string>();
|
||||
|
||||
// First flatten submenus, then collect groups.
|
||||
const flat: PillDropdownItem[] = [];
|
||||
for (const item of items) {
|
||||
if (item.submenu && item.submenu.length > 0) {
|
||||
flat.push({ id: `${item.id}-section`, label: item.label, divider: true });
|
||||
for (const child of item.submenu) flat.push(child);
|
||||
} else {
|
||||
flat.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of flat) {
|
||||
if (item.divider) {
|
||||
const hasLabel = !!item.label;
|
||||
out.push(
|
||||
hasLabel
|
||||
? { kind: 'section-label', id: item.id, label: item.label }
|
||||
: { kind: 'divider', id: item.id }
|
||||
);
|
||||
} else if (item.group) {
|
||||
if (!emittedGroups.has(item.group)) {
|
||||
emittedGroups.add(item.group);
|
||||
const grouped = flat.filter((i) => i.group === item.group);
|
||||
out.push({ kind: 'group', id: `group-${item.group}`, items: grouped });
|
||||
}
|
||||
} else {
|
||||
out.push({ kind: 'item', id: item.id, item });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
function handleClick(item: PillDropdownItem, event: MouseEvent) {
|
||||
if (item.disabled || item.divider) return;
|
||||
item.onClick?.(event);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="dropdown-bar-wrapper" class:static={positioning === 'static'}>
|
||||
<div class="dropdown-bar-container">
|
||||
{#if label}
|
||||
<div class="bar-label glass-pill">
|
||||
{#if icon && phosphorIcons[icon]}
|
||||
{@const IconComponent = phosphorIcons[icon]}
|
||||
<IconComponent size={16} />
|
||||
{/if}
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each renderElements as el (el.id)}
|
||||
{#if el.kind === 'divider'}
|
||||
<div class="bar-divider"></div>
|
||||
{:else if el.kind === 'section-label'}
|
||||
<div class="bar-section-label">{el.label}</div>
|
||||
{:else if el.kind === 'group'}
|
||||
<!-- Segmented toggle pill. If any label in the group is longer
|
||||
than 10 chars the group shows icon+label; otherwise icon-only
|
||||
(e.g. the Light/Dark/System triple). -->
|
||||
{@const showLabels = el.items.some((i) => (i.label?.length ?? 0) > 10)}
|
||||
<div class="segmented-toggle glass-pill" class:with-labels={showLabels}>
|
||||
{#each el.items as gi (gi.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="segmented-btn"
|
||||
class:active={gi.active}
|
||||
class:has-progress={gi.progress != null}
|
||||
disabled={gi.disabled}
|
||||
onclick={(e) => handleClick(gi, e)}
|
||||
title={gi.label}
|
||||
>
|
||||
{#if gi.progress != null}
|
||||
<svg class="progress-ring-inline" 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 - gi.progress)}
|
||||
/>
|
||||
</svg>
|
||||
{:else if gi.icon && phosphorIcons[gi.icon]}
|
||||
{@const GIcon = phosphorIcons[gi.icon]}
|
||||
<GIcon size={16} class="segmented-icon" />
|
||||
{/if}
|
||||
{#if showLabels}
|
||||
<span class="segmented-label">{gi.label}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
{@const item = el.item}
|
||||
<button
|
||||
type="button"
|
||||
class="bar-pill glass-pill"
|
||||
class:active={item.active}
|
||||
class:primary={item.primary}
|
||||
class:danger={item.danger}
|
||||
disabled={item.disabled}
|
||||
onclick={(e) => handleClick(item, e)}
|
||||
title={item.label}
|
||||
>
|
||||
{#if item.imageUrl}
|
||||
<img src={item.imageUrl} alt="" class="bar-img" />
|
||||
{:else if item.icon && phosphorIcons[item.icon]}
|
||||
{@const IconComponent = phosphorIcons[item.icon]}
|
||||
<IconComponent size={16} />
|
||||
{/if}
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.dropdown-bar-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dropdown-bar-wrapper.static {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dropdown-bar-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
pointer-events: auto;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding: 0.5rem 2rem;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
transparent 0%,
|
||||
black 2rem,
|
||||
black calc(100% - 2rem),
|
||||
transparent 100%
|
||||
);
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to right,
|
||||
transparent 0%,
|
||||
black 2rem,
|
||||
black calc(100% - 2rem),
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
.dropdown-bar-container::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.bar-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.875rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
background: hsl(var(--color-card));
|
||||
color: hsl(var(--color-foreground));
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s ease;
|
||||
box-shadow:
|
||||
0 1px 2px hsl(0 0% 0% / 0.05),
|
||||
0 2px 6px hsl(0 0% 0% / 0.04);
|
||||
}
|
||||
|
||||
.bar-pill:hover:not(:disabled) {
|
||||
background: hsl(var(--color-surface-hover, var(--color-card)));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.bar-pill:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.bar-pill.active {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--pill-primary-color, var(--color-primary-500, #f8d62b)) 20%,
|
||||
white 80%
|
||||
);
|
||||
border-color: var(--pill-primary-color, var(--color-primary-500, rgba(248, 214, 43, 0.5)));
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
:global(.dark) .bar-pill.active {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--pill-primary-color, var(--color-primary-500, #f8d62b)) 30%,
|
||||
transparent 70%
|
||||
);
|
||||
color: var(--pill-primary-color, var(--color-primary-500, #f8d62b));
|
||||
}
|
||||
|
||||
.bar-pill.primary {
|
||||
background: var(--pill-primary-color, var(--color-primary-500, #6366f1));
|
||||
border-color: transparent;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.bar-pill.danger {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
:global(.dark) .bar-pill.danger {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.bar-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.875rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
background: hsl(var(--color-muted, var(--color-card)));
|
||||
color: hsl(var(--color-foreground));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bar-divider {
|
||||
width: 1px;
|
||||
height: 1.5rem;
|
||||
background: hsl(var(--color-border));
|
||||
flex-shrink: 0;
|
||||
margin: 0 0.125rem;
|
||||
}
|
||||
|
||||
.bar-section-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 0.5rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bar-img {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* Segmented toggle pill (e.g. Light / Dark / System three-way) */
|
||||
.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);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.segmented-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.375rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 9999px;
|
||||
cursor: pointer;
|
||||
color: hsl(var(--color-foreground));
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.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 :global(.segmented-icon) {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
/* When the group shows labels, give the buttons more padding */
|
||||
.segmented-toggle.with-labels .segmented-btn {
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
}
|
||||
|
||||
.segmented-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Inline progress ring (replaces icon when downloading) */
|
||||
.progress-ring-inline {
|
||||
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;
|
||||
}
|
||||
|
||||
.segmented-btn.has-progress {
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
PillTabGroupConfig,
|
||||
PillTagSelectorConfig,
|
||||
PillAppItem,
|
||||
PillBarConfig,
|
||||
SpotlightAction,
|
||||
ContentSearcher,
|
||||
} from './types';
|
||||
|
|
@ -32,6 +33,7 @@
|
|||
CheckCircle,
|
||||
CheckSquare,
|
||||
Clock,
|
||||
Cloud,
|
||||
Columns,
|
||||
Compass,
|
||||
CreditCard,
|
||||
|
|
@ -140,6 +142,7 @@
|
|||
share: ShareFat,
|
||||
trash: Trash,
|
||||
filter: Funnel,
|
||||
cloud: Cloud,
|
||||
};
|
||||
|
||||
// Convert app items to dropdown items (will be computed as derived)
|
||||
|
|
@ -326,6 +329,14 @@
|
|||
helpHref?: string;
|
||||
/** Bottom offset from viewport bottom (default: '0px'). Use to position above other fixed bars. */
|
||||
bottomOffset?: string;
|
||||
/** When provided, dropdown triggers (theme, AI tier, sync, user menu) render
|
||||
* as plain pills that call this callback with a bar config instead of
|
||||
* opening their in-place PillDropdown popover. The host is expected to
|
||||
* render the returned items in its own bar (e.g. bottom-stack). Pass null
|
||||
* to request closing the active bar. */
|
||||
onOpenBar?: (config: PillBarConfig | null) => void;
|
||||
/** Id of the bar currently open in the host. Used to highlight the trigger pill. */
|
||||
activeBarId?: string | null;
|
||||
}
|
||||
|
||||
let {
|
||||
|
|
@ -386,8 +397,192 @@
|
|||
guestMenuLabel = 'Menü',
|
||||
helpHref,
|
||||
bottomOffset = '0px',
|
||||
onOpenBar,
|
||||
activeBarId = null,
|
||||
}: Props = $props();
|
||||
|
||||
// Whether this nav should surface dropdowns as bars instead of popovers.
|
||||
const barMode = $derived(!!onOpenBar);
|
||||
|
||||
// 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;
|
||||
if (activeBarId === config.id) {
|
||||
onOpenBar(null);
|
||||
} else {
|
||||
onOpenBar(config);
|
||||
}
|
||||
}
|
||||
|
||||
// Type guards for elements
|
||||
function isTabGroup(element: PillNavElement): element is PillTabGroupConfig {
|
||||
return 'type' in element && element.type === 'tabs';
|
||||
|
|
@ -506,6 +701,9 @@
|
|||
oncontextmenu={item.onContextMenu}
|
||||
class="pill glass-pill"
|
||||
class:active={item.active}
|
||||
class:icon-only={item.iconOnly}
|
||||
aria-label={item.iconOnly ? item.label : undefined}
|
||||
title={item.iconOnly ? item.label : undefined}
|
||||
>
|
||||
{#if item.icon}
|
||||
{#if item.icon === 'mana'}
|
||||
|
|
@ -521,7 +719,9 @@
|
|||
<IconComponent size={18} class="pill-icon" />
|
||||
{/if}
|
||||
{/if}
|
||||
<span class="pill-label">{item.label}</span>
|
||||
{#if !item.iconOnly}
|
||||
<span class="pill-label">{item.label}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<a
|
||||
|
|
@ -529,6 +729,9 @@
|
|||
oncontextmenu={item.onContextMenu}
|
||||
class="pill glass-pill"
|
||||
class:active={isActive(item.href)}
|
||||
class:icon-only={item.iconOnly}
|
||||
aria-label={item.iconOnly ? item.label : undefined}
|
||||
title={item.iconOnly ? item.label : undefined}
|
||||
>
|
||||
{#if item.icon}
|
||||
{#if item.icon === 'mana'}
|
||||
|
|
@ -544,7 +747,9 @@
|
|||
<IconComponent size={18} class="pill-icon" />
|
||||
{/if}
|
||||
{/if}
|
||||
<span class="pill-label">{item.label}</span>
|
||||
{#if !item.iconOnly}
|
||||
<span class="pill-label">{item.label}</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
|
|
@ -587,7 +792,24 @@
|
|||
{/each}
|
||||
|
||||
<!-- Theme Variant Selector -->
|
||||
{#if showThemeVariants && themeVariantItems.length > 0}
|
||||
{#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}
|
||||
|
|
@ -679,7 +901,31 @@
|
|||
{/if}
|
||||
|
||||
<!-- AI Tier Selector -->
|
||||
{#if showAiTierSelector && aiTierItems.length > 0}
|
||||
{#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}
|
||||
|
|
@ -689,7 +935,24 @@
|
|||
{/if}
|
||||
|
||||
<!-- Sync Status -->
|
||||
{#if showSyncStatus && syncStatusItems.length > 0}
|
||||
{#if showSyncStatus && syncStatusItems.length > 0 && barMode}
|
||||
{@const syncConfig = {
|
||||
id: 'sync',
|
||||
label: currentSyncLabel,
|
||||
icon: 'cloud',
|
||||
items: syncStatusItems,
|
||||
}}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggleBar(syncConfig)}
|
||||
class="pill glass-pill"
|
||||
class:active={activeBarId === 'sync'}
|
||||
title={currentSyncLabel}
|
||||
>
|
||||
<Cloud size={18} class="pill-icon" />
|
||||
<span class="pill-label">{currentSyncLabel}</span>
|
||||
</button>
|
||||
{:else if showSyncStatus && syncStatusItems.length > 0}
|
||||
<PillDropdown
|
||||
items={syncStatusItems}
|
||||
direction={dropdownDirection}
|
||||
|
|
@ -718,7 +981,25 @@
|
|||
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}
|
||||
{#if (userEmail || loginHref) && barMode}
|
||||
{@const userLabel = userEmail ? truncateEmail(userEmail) : guestMenuLabel}
|
||||
{@const userConfig = {
|
||||
id: 'user',
|
||||
label: userLabel,
|
||||
icon: 'user',
|
||||
items: userBarItems,
|
||||
}}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggleBar(userConfig)}
|
||||
class="pill glass-pill"
|
||||
class:active={activeBarId === 'user'}
|
||||
title={userLabel}
|
||||
>
|
||||
<User size={18} class="pill-icon" />
|
||||
<span class="pill-label">{userLabel}</span>
|
||||
</button>
|
||||
{:else if userEmail || loginHref}
|
||||
<PillDropdown
|
||||
items={[
|
||||
...(userEmail && profileHref
|
||||
|
|
@ -1038,6 +1319,47 @@
|
|||
display: inline;
|
||||
}
|
||||
|
||||
/* Icon-only pill: square-ish shape, no label gap */
|
||||
.pill.icon-only {
|
||||
gap: 0;
|
||||
padding: 0.5rem 0.625rem;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ 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 PillDropdownBar } from './PillDropdownBar.svelte';
|
||||
export { default as AppDrawer } from './AppDrawer.svelte';
|
||||
export { default as GlobalSpotlight } from './GlobalSpotlight.svelte';
|
||||
export { createGlobalSpotlightState } from './useGlobalSpotlight.svelte';
|
||||
|
|
@ -43,6 +44,7 @@ export type {
|
|||
PillTagSelectorConfig,
|
||||
PillDivider,
|
||||
PillNavElement,
|
||||
PillBarConfig,
|
||||
SpotlightAction,
|
||||
ContentSearchResult,
|
||||
ContentSearchGroup,
|
||||
|
|
|
|||
|
|
@ -26,6 +26,23 @@ export interface PillNavItem {
|
|||
active?: boolean;
|
||||
/** Right-click handler for context menu */
|
||||
onContextMenu?: (e: MouseEvent) => void;
|
||||
/** Show only the icon (hide the label). Label is still used for aria-label/title. */
|
||||
iconOnly?: boolean;
|
||||
}
|
||||
|
||||
/** Config passed when a PillNavigation dropdown should surface as a bar
|
||||
* in the host's bottom stack instead of an in-place popover. */
|
||||
export interface PillBarConfig {
|
||||
/** Stable id (e.g. 'theme', 'ai', 'sync', 'user') */
|
||||
id: string;
|
||||
/** Title shown at the start of the bar */
|
||||
label: string;
|
||||
/** Icon name shown next to the title */
|
||||
icon?: string;
|
||||
/** Items to render as pills */
|
||||
items: PillDropdownItem[];
|
||||
/** Progress value 0–1. When set, a progress ring is shown on the trigger pill. */
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
export interface PillDropdownItem {
|
||||
|
|
@ -51,6 +68,10 @@ export interface PillDropdownItem {
|
|||
divider?: boolean;
|
||||
/** Nested submenu items */
|
||||
submenu?: PillDropdownItem[];
|
||||
/** Group id — items sharing the same group are rendered as a segmented toggle pill */
|
||||
group?: string;
|
||||
/** Progress value 0–1. When set, a circular progress ring is rendered around the icon. */
|
||||
progress?: number;
|
||||
/** Whether to show a split button for opening in panel */
|
||||
showSplitButton?: boolean;
|
||||
/** Click handler for split button */
|
||||
|
|
|
|||
|
|
@ -76,6 +76,9 @@
|
|||
locale?: string;
|
||||
/** Use 'static' when inside a flex container (bottom-stack pattern). Default: 'fixed'. */
|
||||
positioning?: 'fixed' | 'static';
|
||||
/** Externally injected text (e.g. from voice input). When this changes
|
||||
* to a non-empty string, the input bar's query is set and focused. */
|
||||
injectedText?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
|
|
@ -106,6 +109,7 @@
|
|||
highlightPatterns,
|
||||
locale = 'de',
|
||||
positioning = 'fixed',
|
||||
injectedText,
|
||||
}: Props = $props();
|
||||
|
||||
// Use settings for autoFocus
|
||||
|
|
@ -125,6 +129,18 @@
|
|||
// Whether search has been explicitly triggered in deferred mode
|
||||
let searchTriggered = $state(false);
|
||||
|
||||
// External text injection (e.g. from voice-to-text). When the prop
|
||||
// changes to a new non-empty value, set the search query and focus.
|
||||
let lastInjected = '';
|
||||
$effect(() => {
|
||||
if (injectedText && injectedText !== lastInjected) {
|
||||
lastInjected = injectedText;
|
||||
searchQuery = injectedText;
|
||||
// Focus the input so the user sees and can edit the text
|
||||
requestAnimationFrame(() => inputElement?.focus());
|
||||
}
|
||||
});
|
||||
|
||||
// Context menu state
|
||||
let contextMenuVisible = $state(false);
|
||||
let contextMenuX = $state(0);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue