Files
web-app-launcher/src/lib/components/ui/EntityPicker.svelte
T
alexei.dolgolyov 1c0a7cb850 feat: Phases 4-7 — Full Feature Expansion (26 features)
Phase 4 — New Widget Types:
- Clock/Weather, System Stats, RSS/Feed, Calendar, Markdown,
  Metric/Counter, Link Group, Camera/Stream widgets
- Backend services with caching for each data source
- Full creation form with dynamic config fields per type

Phase 5 — Visual & Styling Enhancements:
- Glassmorphism card style (solid/glass/outline)
- Board-level themes with per-board hue/saturation
- Animated SVG status rings replacing static dots
- Card size options (compact/medium/large)
- Custom CSS injection (admin + per-board, sanitized)
- Wallpaper backgrounds with blur/overlay/parallax

Phase 6 — Functional Features:
- Favorites bar with drag-and-drop reordering
- Recent apps tracking with privacy toggle
- Uptime dashboard page (/status, guest-accessible)
- Notifications system (Discord/Slack/Telegram/HTTP webhooks)
- App tags with filtering in board view
- Multi-URL app cards with expandable sub-links
- Personal API tokens with scoped permissions
- Audit log with retention and admin viewer

Phase 7 — Quality of Life:
- Onboarding wizard (5-step first-launch setup)
- App URL health preview with favicon/title detection
- Board templates (4 built-in + custom import/export)
- Keyboard shortcut overlay (j/k nav, 1-9 boards, ? help)

212 files changed, 15641 insertions, 980 deletions.
Build, lint, type check, and 222 tests all pass.
2026-03-25 14:18:10 +03:00

242 lines
6.3 KiB
Svelte

<script lang="ts">
import { t } from 'svelte-i18n';
import { Search } from 'lucide-svelte';
export interface EntityPickerItem {
value: string;
label: string;
icon?: string | null;
desc?: string | null;
}
interface Props {
items: EntityPickerItem[];
value: string;
onchange?: (value: string) => void;
placeholder?: string;
searchPlaceholder?: string;
allowNone?: boolean;
noneLabel?: string;
name?: string;
}
let {
items,
value = $bindable(),
onchange,
placeholder = '',
searchPlaceholder = '',
allowNone = false,
noneLabel = '—',
name
}: Props = $props();
let open = $state(false);
let query = $state('');
let highlightIdx = $state(0);
let listEl: HTMLDivElement;
let inputEl: HTMLInputElement;
const selectedItem = $derived(items.find((i) => i.value === value));
const allItems = $derived.by(() => {
const result: (EntityPickerItem & { _isNone?: boolean })[] = [];
if (allowNone) {
result.push({ value: '', label: noneLabel, icon: null, desc: null, _isNone: true });
}
result.push(...items);
return result;
});
const filtered = $derived.by(() => {
if (!query.trim()) return allItems;
const q = query.toLowerCase().trim();
return allItems.filter(
(i) =>
i.label.toLowerCase().includes(q) || (i.desc && i.desc.toLowerCase().includes(q))
);
});
$effect(() => {
if (open) {
// Reset highlight to current value when opened
const idx = filtered.findIndex((i) => i.value === value);
highlightIdx = idx >= 0 ? idx : 0;
}
});
function openPicker() {
query = '';
highlightIdx = 0;
open = true;
requestAnimationFrame(() => inputEl?.focus());
}
function closePicker() {
open = false;
query = '';
}
function selectItem(item: EntityPickerItem) {
value = item.value;
closePicker();
onchange?.(item.value);
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowDown') {
e.preventDefault();
highlightIdx = Math.min(highlightIdx + 1, filtered.length - 1);
scrollHighlightIntoView();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
highlightIdx = Math.max(highlightIdx - 1, 0);
scrollHighlightIntoView();
} else if (e.key === 'Enter') {
e.preventDefault();
if (filtered[highlightIdx]) {
selectItem(filtered[highlightIdx]);
}
} else if (e.key === 'Escape') {
e.preventDefault();
closePicker();
} else if (e.key === 'Tab') {
e.preventDefault();
}
}
function scrollHighlightIntoView() {
requestAnimationFrame(() => {
const el = listEl?.querySelector('.ep-highlight');
el?.scrollIntoView({ block: 'nearest' });
});
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) closePicker();
}
// Reset highlight when query changes
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
query; // track
highlightIdx = 0;
});
</script>
{#if name}
<input type="hidden" {name} {value} />
{/if}
<button
type="button"
class="flex w-full items-center gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors hover:border-primary focus:outline-none focus:ring-2 focus:ring-ring"
onclick={openPicker}
>
{#if selectedItem}
{#if selectedItem.icon}
<span class="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground">
<span>{selectedItem.icon}</span>
</span>
{/if}
<span class="flex-1 truncate text-left">{selectedItem.label}</span>
{:else if allowNone && !value}
<span class="flex-1 text-left text-muted-foreground">{noneLabel}</span>
{:else if placeholder}
<span class="flex-1 text-left text-muted-foreground">{placeholder}</span>
{/if}
<span class="shrink-0 text-xs text-muted-foreground">&#x25BE;</span>
</button>
{#if open}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-start justify-center bg-black/50 pt-[15vh] backdrop-blur-sm"
onclick={handleBackdropClick}
onkeydown={() => {}}
style="animation: epFadeIn 0.15s ease-out"
>
<div
class="flex w-full max-w-md flex-col rounded-lg border border-border bg-popover shadow-2xl"
style="max-height: 60vh; animation: epSlideDown 0.2s cubic-bezier(0.16, 1, 0.3, 1)"
role="dialog"
aria-label={searchPlaceholder || 'Select entity'}
>
<!-- Search row -->
<div class="flex items-center gap-2 border-b border-border px-3 py-2.5">
<Search size={16} class="shrink-0 text-muted-foreground" />
<input
bind:this={inputEl}
bind:value={query}
type="text"
placeholder={searchPlaceholder || $t('admin.perm_search_placeholder') || 'Search...'}
class="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
autocomplete="off"
spellcheck="false"
onkeydown={handleKeydown}
/>
</div>
<!-- List -->
<div class="overflow-y-auto py-1" bind:this={listEl}>
{#if filtered.length === 0}
<div class="py-6 text-center text-sm text-muted-foreground"></div>
{:else}
{#each filtered as item, i (item.value + (item._isNone ? '_none' : ''))}
<button
type="button"
class="flex w-full items-center gap-3 px-3 py-2 text-left text-sm transition-colors
{i === highlightIdx ? 'ep-highlight bg-accent' : 'hover:bg-accent/50'}
{item.value === value && !item._isNone ? 'border-l-[3px] border-l-primary' : 'border-l-[3px] border-l-transparent'}"
onclick={() => selectItem(item)}
onmouseenter={() => (highlightIdx = i)}
>
{#if item.icon}
<span
class="flex h-5 w-5 shrink-0 items-center justify-center text-muted-foreground"
>
<span>{item.icon}</span>
</span>
{/if}
<span class="flex-1 truncate text-foreground">{item.label}</span>
{#if item.desc}
<span class="shrink-0 truncate text-xs text-muted-foreground" style="max-width: 40%">
{item.desc}
</span>
{/if}
</button>
{/each}
{/if}
</div>
<!-- Footer -->
<div
class="border-t border-border px-3 py-1.5 text-[11px] text-muted-foreground"
>
↑↓ navigate · Enter select · Esc close
</div>
</div>
</div>
{/if}
<style>
@keyframes epFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes epSlideDown {
from {
opacity: 0;
transform: translateY(-12px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
</style>