feat: multi-entity picker for status widget app selection
- Create MultiEntityPicker component with search, checkboxes, keyboard nav - Replace plain checkbox list in status widget config with MultiEntityPicker - Render app icons properly by type (lucide, simple, url, emoji)
This commit is contained in:
@@ -0,0 +1,253 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
import { Search } from 'lucide-svelte';
|
||||||
|
import DynamicIcon from '$lib/components/ui/DynamicIcon.svelte';
|
||||||
|
|
||||||
|
export interface MultiEntityPickerItem {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
icon?: string | null;
|
||||||
|
iconType?: string | null;
|
||||||
|
desc?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
items: MultiEntityPickerItem[];
|
||||||
|
values: string[];
|
||||||
|
onchange?: (values: string[]) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
searchPlaceholder?: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
items,
|
||||||
|
values = $bindable(),
|
||||||
|
onchange,
|
||||||
|
placeholder = '',
|
||||||
|
searchPlaceholder = '',
|
||||||
|
name
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
let query = $state('');
|
||||||
|
let highlightIdx = $state(0);
|
||||||
|
let listEl = $state<HTMLDivElement>();
|
||||||
|
let inputEl = $state<HTMLInputElement>();
|
||||||
|
|
||||||
|
const selectedCount = $derived(values.length);
|
||||||
|
const selectedLabels = $derived(
|
||||||
|
values
|
||||||
|
.map((v) => items.find((i) => i.value === v)?.label)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ')
|
||||||
|
);
|
||||||
|
|
||||||
|
const filtered = $derived.by(() => {
|
||||||
|
if (!query.trim()) return items;
|
||||||
|
const q = query.toLowerCase().trim();
|
||||||
|
return items.filter(
|
||||||
|
(i) =>
|
||||||
|
i.label.toLowerCase().includes(q) || (i.desc && i.desc.toLowerCase().includes(q))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (open) {
|
||||||
|
highlightIdx = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function openPicker() {
|
||||||
|
query = '';
|
||||||
|
highlightIdx = 0;
|
||||||
|
open = true;
|
||||||
|
requestAnimationFrame(() => inputEl?.focus());
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePicker() {
|
||||||
|
open = false;
|
||||||
|
query = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleItem(item: MultiEntityPickerItem) {
|
||||||
|
const idx = values.indexOf(item.value);
|
||||||
|
const next = idx >= 0
|
||||||
|
? values.filter((v) => v !== item.value)
|
||||||
|
: [...values, item.value];
|
||||||
|
values = next;
|
||||||
|
onchange?.(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
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]) {
|
||||||
|
toggleItem(filtered[highlightIdx]);
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
closePicker();
|
||||||
|
} else if (e.key === 'Tab') {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollHighlightIntoView() {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const el = listEl?.querySelector('.mep-highlight');
|
||||||
|
el?.scrollIntoView({ block: 'nearest' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBackdropClick(e: MouseEvent) {
|
||||||
|
if (e.target === e.currentTarget) closePicker();
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||||
|
query;
|
||||||
|
highlightIdx = 0;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if name}
|
||||||
|
{#each values as v}
|
||||||
|
<input type="hidden" {name} value={v} />
|
||||||
|
{/each}
|
||||||
|
{/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 selectedCount > 0}
|
||||||
|
<span class="flex-1 truncate text-left">{selectedLabels}</span>
|
||||||
|
<span class="shrink-0 rounded-full bg-primary/15 px-1.5 py-0.5 text-[10px] font-medium text-primary">{selectedCount}</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">▾</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: mepFadeIn 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: mepSlideDown 0.2s cubic-bezier(0.16, 1, 0.3, 1)"
|
||||||
|
role="dialog"
|
||||||
|
aria-label={searchPlaceholder || 'Select items'}
|
||||||
|
>
|
||||||
|
<!-- 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('common.search') || 'Search...'}
|
||||||
|
class="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
/>
|
||||||
|
{#if selectedCount > 0}
|
||||||
|
<span class="shrink-0 text-xs text-muted-foreground">{selectedCount} selected</span>
|
||||||
|
{/if}
|
||||||
|
</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)}
|
||||||
|
{@const isSelected = values.includes(item.value)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center gap-3 px-3 py-2 text-left text-sm transition-colors
|
||||||
|
{i === highlightIdx ? 'mep-highlight bg-accent' : 'hover:bg-accent/50'}"
|
||||||
|
onclick={() => toggleItem(item)}
|
||||||
|
onmouseenter={() => (highlightIdx = i)}
|
||||||
|
>
|
||||||
|
<!-- Checkbox -->
|
||||||
|
<span
|
||||||
|
class="flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors
|
||||||
|
{isSelected
|
||||||
|
? 'border-primary bg-primary text-primary-foreground'
|
||||||
|
: 'border-input bg-background'}"
|
||||||
|
>
|
||||||
|
{#if isSelected}
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M20 6 9 17l-5-5" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{#if item.icon}
|
||||||
|
<span class="flex h-5 w-5 shrink-0 items-center justify-center text-muted-foreground">
|
||||||
|
{#if item.iconType === 'lucide'}
|
||||||
|
<DynamicIcon name={item.icon} size={16} />
|
||||||
|
{:else if item.iconType === 'url'}
|
||||||
|
<img src={item.icon} alt="" class="h-4 w-4 rounded object-contain" />
|
||||||
|
{:else if item.iconType === 'simple'}
|
||||||
|
<img src="https://cdn.simpleicons.org/{item.icon.toLowerCase()}" alt="" class="h-4 w-4" />
|
||||||
|
{:else if item.iconType === 'emoji'}
|
||||||
|
<span class="text-sm leading-none">{item.icon}</span>
|
||||||
|
{:else}
|
||||||
|
<span class="text-sm leading-none">{item.icon}</span>
|
||||||
|
{/if}
|
||||||
|
</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="flex items-center justify-between border-t border-border px-3 py-1.5">
|
||||||
|
<span class="text-[11px] text-muted-foreground">
|
||||||
|
↑↓ navigate · Enter toggle · Esc close
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={closePicker}
|
||||||
|
class="rounded px-2 py-0.5 text-xs font-medium text-primary transition-colors hover:bg-primary/10"
|
||||||
|
>
|
||||||
|
{$t('common.done') ?? 'Done'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@keyframes mepFadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes mepSlideDown {
|
||||||
|
from { opacity: 0; transform: translateY(-12px) scale(0.98); }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import { tick } from 'svelte';
|
import { tick } from 'svelte';
|
||||||
import DynamicIcon from '$lib/components/ui/DynamicIcon.svelte';
|
import DynamicIcon from '$lib/components/ui/DynamicIcon.svelte';
|
||||||
|
import MultiEntityPicker from '$lib/components/ui/MultiEntityPicker.svelte';
|
||||||
|
|
||||||
interface AppInfo {
|
interface AppInfo {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -174,13 +175,14 @@
|
|||||||
transition:fade={{ duration: 100 }}
|
transition:fade={{ duration: 100 }}
|
||||||
onkeydown={handleKeydown}
|
onkeydown={handleKeydown}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
|
tabindex="-1"
|
||||||
>
|
>
|
||||||
<div class="mb-3 flex items-center justify-between">
|
<div class="mb-3 flex items-center justify-between">
|
||||||
<h3 class="text-sm font-semibold text-foreground">
|
<h3 class="text-sm font-semibold text-foreground">
|
||||||
{mode === 'create' ? ($t('widget.add_widget') ?? 'Add Widget') : ($t('widget.edit_widget') ?? 'Edit Widget')}
|
{mode === 'create' ? ($t('widget.add_widget') ?? 'Add Widget') : ($t('widget.edit_widget') ?? 'Edit Widget')}
|
||||||
<span class="ml-1 text-xs font-normal text-muted-foreground">({widgetType})</span>
|
<span class="ml-1 text-xs font-normal text-muted-foreground">({widgetType})</span>
|
||||||
</h3>
|
</h3>
|
||||||
<button type="button" onclick={onCancel} class="rounded p-0.5 text-muted-foreground hover:text-foreground">
|
<button type="button" onclick={onCancel} aria-label="Close" class="rounded p-0.5 text-muted-foreground hover:text-foreground">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -188,6 +190,7 @@
|
|||||||
<div class="max-h-80 space-y-3 overflow-y-auto">
|
<div class="max-h-80 space-y-3 overflow-y-auto">
|
||||||
{#if widgetType === 'app'}
|
{#if widgetType === 'app'}
|
||||||
<div>
|
<div>
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
<label class={labelClass}>{$t('widget.app') ?? 'App'}</label>
|
<label class={labelClass}>{$t('widget.app') ?? 'App'}</label>
|
||||||
<!-- Search -->
|
<!-- Search -->
|
||||||
<div class="relative mb-2">
|
<div class="relative mb-2">
|
||||||
@@ -240,90 +243,90 @@
|
|||||||
|
|
||||||
{:else if widgetType === 'bookmark'}
|
{:else if widgetType === 'bookmark'}
|
||||||
<div>
|
<div>
|
||||||
<label class={labelClass}>URL</label>
|
<label class={labelClass}>URL
|
||||||
<input type="url" bind:value={bookmarkUrl} placeholder="https://..." class={inputClass} bind:this={firstInput} />
|
<input type="url" bind:value={bookmarkUrl} placeholder="https://..." class={inputClass} bind:this={firstInput} />
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class={labelClass}>{$t('common.label') ?? 'Label'}</label>
|
<label class={labelClass}>{$t('common.label') ?? 'Label'}
|
||||||
<input type="text" bind:value={bookmarkLabel} class={inputClass} />
|
<input type="text" bind:value={bookmarkLabel} class={inputClass} />
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class={labelClass}>{$t('app.icon') ?? 'Icon'}</label>
|
<label class={labelClass}>{$t('app.icon') ?? 'Icon'}
|
||||||
<input type="text" bind:value={bookmarkIcon} placeholder="e.g. globe" class={inputClass} />
|
<input type="text" bind:value={bookmarkIcon} placeholder="e.g. globe" class={inputClass} />
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class={labelClass}>{$t('common.description') ?? 'Description'}</label>
|
<label class={labelClass}>{$t('common.description') ?? 'Description'}
|
||||||
<input type="text" bind:value={bookmarkDescription} class={inputClass} />
|
<input type="text" bind:value={bookmarkDescription} class={inputClass} />
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else if widgetType === 'note'}
|
{:else if widgetType === 'note'}
|
||||||
<div>
|
<div>
|
||||||
<label class={labelClass}>{$t('widget.content') ?? 'Content'}</label>
|
<label class={labelClass}>{$t('widget.content') ?? 'Content'}
|
||||||
<textarea bind:value={noteContent} rows="4" class={inputClass} bind:this={firstInput}></textarea>
|
<textarea bind:value={noteContent} rows="4" class={inputClass} bind:this={firstInput}></textarea>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class={labelClass}>{$t('widget.format') ?? 'Format'}</label>
|
<label class={labelClass}>{$t('widget.format') ?? 'Format'}
|
||||||
<select bind:value={noteFormat} class={inputClass}>
|
<select bind:value={noteFormat} class={inputClass}>
|
||||||
<option value="markdown">Markdown</option>
|
<option value="markdown">Markdown</option>
|
||||||
<option value="text">Plain Text</option>
|
<option value="text">Plain Text</option>
|
||||||
<option value="html">HTML</option>
|
<option value="html">HTML</option>
|
||||||
</select>
|
</select>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else if widgetType === 'embed'}
|
{:else if widgetType === 'embed'}
|
||||||
<div>
|
<div>
|
||||||
<label class={labelClass}>URL</label>
|
<label class={labelClass}>URL
|
||||||
<input type="url" bind:value={embedUrl} placeholder="https://..." class={inputClass} bind:this={firstInput} />
|
<input type="url" bind:value={embedUrl} placeholder="https://..." class={inputClass} bind:this={firstInput} />
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class={labelClass}>{$t('widget.height') ?? 'Height'} ({embedHeight}px)</label>
|
<label class={labelClass}>{$t('widget.height') ?? 'Height'} ({embedHeight}px)
|
||||||
<input type="range" min="100" max="800" bind:value={embedHeight} class="w-full accent-primary" />
|
<input type="range" min="100" max="800" bind:value={embedHeight} class="w-full accent-primary" />
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class={labelClass}>Sandbox</label>
|
<label class={labelClass}>Sandbox
|
||||||
<input type="text" bind:value={embedSandbox} placeholder="allow-scripts allow-same-origin" class={inputClass} />
|
<input type="text" bind:value={embedSandbox} placeholder="allow-scripts allow-same-origin" class={inputClass} />
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else if widgetType === 'status'}
|
{:else if widgetType === 'status'}
|
||||||
<div>
|
<div>
|
||||||
<label class={labelClass}>{$t('common.label') ?? 'Label'}</label>
|
<label class={labelClass}>{$t('common.label') ?? 'Label'}
|
||||||
<input type="text" bind:value={statusLabel} class={inputClass} bind:this={firstInput} />
|
<input type="text" bind:value={statusLabel} class={inputClass} bind:this={firstInput} />
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
<label class={labelClass}>{$t('widget.apps') ?? 'Apps'}</label>
|
<label class={labelClass}>{$t('widget.apps') ?? 'Apps'}</label>
|
||||||
<div class="space-y-1 rounded-lg border border-input bg-background p-2">
|
<MultiEntityPicker
|
||||||
{#each apps as app}
|
items={apps.map((a) => ({ value: a.id, label: a.name, icon: a.icon, iconType: a.iconType }))}
|
||||||
<label class="flex items-center gap-2 text-sm">
|
bind:values={statusAppIds}
|
||||||
<input
|
placeholder={$t('widget.select_apps') ?? 'Select apps...'}
|
||||||
type="checkbox"
|
searchPlaceholder={$t('common.search') ?? 'Search...'}
|
||||||
checked={statusAppIds.includes(app.id)}
|
|
||||||
onchange={() => {
|
|
||||||
if (statusAppIds.includes(app.id)) {
|
|
||||||
statusAppIds = statusAppIds.filter((id) => id !== app.id);
|
|
||||||
} else {
|
|
||||||
statusAppIds = [...statusAppIds, app.id];
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
class="h-3.5 w-3.5 rounded border-input accent-primary"
|
|
||||||
/>
|
/>
|
||||||
{app.name}
|
|
||||||
</label>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else if widgetType === 'clock'}
|
{:else if widgetType === 'clock'}
|
||||||
<div>
|
<div>
|
||||||
<label class={labelClass}>{$t('widget.timezone') ?? 'Timezone'}</label>
|
<label class={labelClass}>{$t('widget.timezone') ?? 'Timezone'}
|
||||||
<input type="text" bind:value={clockTimezone} placeholder="America/New_York" class={inputClass} bind:this={firstInput} />
|
<input type="text" bind:value={clockTimezone} placeholder="America/New_York" class={inputClass} bind:this={firstInput} />
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class={labelClass}>{$t('widget.style') ?? 'Style'}</label>
|
<label class={labelClass}>{$t('widget.style') ?? 'Style'}
|
||||||
<select bind:value={clockStyle} class={inputClass}>
|
<select bind:value={clockStyle} class={inputClass}>
|
||||||
<option value="digital">Digital</option>
|
<option value="digital">Digital</option>
|
||||||
<option value="analog">Analog</option>
|
<option value="analog">Analog</option>
|
||||||
<option value="24h">24h</option>
|
<option value="24h">24h</option>
|
||||||
</select>
|
</select>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<label class="flex items-center gap-2 text-sm text-foreground">
|
<label class="flex items-center gap-2 text-sm text-foreground">
|
||||||
<input type="checkbox" bind:checked={clockShowWeather} class="h-3.5 w-3.5 rounded border-input accent-primary" />
|
<input type="checkbox" bind:checked={clockShowWeather} class="h-3.5 w-3.5 rounded border-input accent-primary" />
|
||||||
@@ -332,42 +335,54 @@
|
|||||||
{#if clockShowWeather}
|
{#if clockShowWeather}
|
||||||
<div class="grid grid-cols-2 gap-2">
|
<div class="grid grid-cols-2 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<label class={labelClass}>Latitude</label>
|
<label class={labelClass}>Latitude
|
||||||
<input type="text" bind:value={clockLatitude} class={inputClass} />
|
<input type="text" bind:value={clockLatitude} class={inputClass} />
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class={labelClass}>Longitude</label>
|
<label class={labelClass}>Longitude
|
||||||
<input type="text" bind:value={clockLongitude} class={inputClass} />
|
<input type="text" bind:value={clockLongitude} class={inputClass} />
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{:else if widgetType === 'system_stats'}
|
{:else if widgetType === 'system_stats'}
|
||||||
<div>
|
<div>
|
||||||
<label class={labelClass}>Source URL</label>
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
|
<label class={labelClass}>Source URL
|
||||||
<input type="url" bind:value={sysStatsSourceUrl} placeholder="http://localhost:61208/api/3" class={inputClass} bind:this={firstInput} />
|
<input type="url" bind:value={sysStatsSourceUrl} placeholder="http://localhost:61208/api/3" class={inputClass} bind:this={firstInput} />
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class={labelClass}>Source Type</label>
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
|
<label class={labelClass}>Source Type
|
||||||
<select bind:value={sysStatsSourceType} class={inputClass}>
|
<select bind:value={sysStatsSourceType} class={inputClass}>
|
||||||
<option value="glances">Glances</option>
|
<option value="glances">Glances</option>
|
||||||
<option value="prometheus">Prometheus</option>
|
<option value="prometheus">Prometheus</option>
|
||||||
<option value="custom">Custom</option>
|
<option value="custom">Custom</option>
|
||||||
</select>
|
</select>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class={labelClass}>Refresh ({sysStatsRefreshInterval}s)</label>
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
|
<label class={labelClass}>Refresh ({sysStatsRefreshInterval}s)
|
||||||
<input type="range" min="5" max="300" bind:value={sysStatsRefreshInterval} class="w-full accent-primary" />
|
<input type="range" min="5" max="300" bind:value={sysStatsRefreshInterval} class="w-full accent-primary" />
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else if widgetType === 'rss'}
|
{:else if widgetType === 'rss'}
|
||||||
<div>
|
<div>
|
||||||
<label class={labelClass}>Feed URL</label>
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
|
<label class={labelClass}>Feed URL
|
||||||
<input type="url" bind:value={rssFeedUrl} placeholder="https://..." class={inputClass} bind:this={firstInput} />
|
<input type="url" bind:value={rssFeedUrl} placeholder="https://..." class={inputClass} bind:this={firstInput} />
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class={labelClass}>Max Items ({rssMaxItems})</label>
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
|
<label class={labelClass}>Max Items ({rssMaxItems})
|
||||||
<input type="range" min="1" max="50" bind:value={rssMaxItems} class="w-full accent-primary" />
|
<input type="range" min="1" max="50" bind:value={rssMaxItems} class="w-full accent-primary" />
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<label class="flex items-center gap-2 text-sm text-foreground">
|
<label class="flex items-center gap-2 text-sm text-foreground">
|
||||||
<input type="checkbox" bind:checked={rssShowSummary} class="h-3.5 w-3.5 rounded border-input accent-primary" />
|
<input type="checkbox" bind:checked={rssShowSummary} class="h-3.5 w-3.5 rounded border-input accent-primary" />
|
||||||
@@ -376,6 +391,7 @@
|
|||||||
|
|
||||||
{:else if widgetType === 'calendar'}
|
{:else if widgetType === 'calendar'}
|
||||||
<div>
|
<div>
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
<label class={labelClass}>iCal URLs</label>
|
<label class={labelClass}>iCal URLs</label>
|
||||||
{#each calendarUrlsRaw as cal, i}
|
{#each calendarUrlsRaw as cal, i}
|
||||||
<div class="mb-1 flex items-center gap-1">
|
<div class="mb-1 flex items-center gap-1">
|
||||||
@@ -390,66 +406,78 @@
|
|||||||
<button type="button" onclick={addCalendarUrl} class="text-xs text-primary hover:underline">+ Add URL</button>
|
<button type="button" onclick={addCalendarUrl} class="text-xs text-primary hover:underline">+ Add URL</button>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class={labelClass}>Days Ahead ({calendarDaysAhead})</label>
|
<label class={labelClass}>Days Ahead ({calendarDaysAhead})
|
||||||
<input type="range" min="1" max="30" bind:value={calendarDaysAhead} class="w-full accent-primary" />
|
<input type="range" min="1" max="30" bind:value={calendarDaysAhead} class="w-full accent-primary" />
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else if widgetType === 'markdown'}
|
{:else if widgetType === 'markdown'}
|
||||||
<div>
|
<div>
|
||||||
<label class={labelClass}>{$t('widget.content') ?? 'Content'}</label>
|
<label class={labelClass}>{$t('widget.content') ?? 'Content'}
|
||||||
<textarea bind:value={markdownContent} rows="6" class="{inputClass} font-mono text-xs" bind:this={firstInput}></textarea>
|
<textarea bind:value={markdownContent} rows="6" class="{inputClass} font-mono text-xs" bind:this={firstInput}></textarea>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else if widgetType === 'metric'}
|
{:else if widgetType === 'metric'}
|
||||||
<div>
|
<div>
|
||||||
<label class={labelClass}>{$t('common.label') ?? 'Label'}</label>
|
<label class={labelClass}>{$t('common.label') ?? 'Label'}
|
||||||
<input type="text" bind:value={metricLabel} class={inputClass} bind:this={firstInput} />
|
<input type="text" bind:value={metricLabel} class={inputClass} bind:this={firstInput} />
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class={labelClass}>Source</label>
|
<label class={labelClass}>Source
|
||||||
<select bind:value={metricSource} class={inputClass}>
|
<select bind:value={metricSource} class={inputClass}>
|
||||||
<option value="static">Static</option>
|
<option value="static">Static</option>
|
||||||
<option value="json">JSON Endpoint</option>
|
<option value="json">JSON Endpoint</option>
|
||||||
<option value="prometheus">Prometheus</option>
|
<option value="prometheus">Prometheus</option>
|
||||||
</select>
|
</select>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{#if metricSource === 'static'}
|
{#if metricSource === 'static'}
|
||||||
<div>
|
<div>
|
||||||
<label class={labelClass}>Value</label>
|
<label class={labelClass}>Value
|
||||||
<input type="text" bind:value={metricValue} class={inputClass} />
|
<input type="text" bind:value={metricValue} class={inputClass} />
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{:else if metricSource === 'json'}
|
{:else if metricSource === 'json'}
|
||||||
<div>
|
<div>
|
||||||
<label class={labelClass}>URL</label>
|
<label class={labelClass}>URL
|
||||||
<input type="url" bind:value={metricUrl} class={inputClass} />
|
<input type="url" bind:value={metricUrl} class={inputClass} />
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class={labelClass}>JSON Path</label>
|
<label class={labelClass}>JSON Path
|
||||||
<input type="text" bind:value={metricJsonPath} placeholder="$.data.value" class={inputClass} />
|
<input type="text" bind:value={metricJsonPath} placeholder="$.data.value" class={inputClass} />
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{:else if metricSource === 'prometheus'}
|
{:else if metricSource === 'prometheus'}
|
||||||
<div>
|
<div>
|
||||||
<label class={labelClass}>URL</label>
|
<label class={labelClass}>URL
|
||||||
<input type="url" bind:value={metricUrl} class={inputClass} />
|
<input type="url" bind:value={metricUrl} class={inputClass} />
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class={labelClass}>PromQL Query</label>
|
<label class={labelClass}>PromQL Query
|
||||||
<input type="text" bind:value={metricQuery} class={inputClass} />
|
<input type="text" bind:value={metricQuery} class={inputClass} />
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="grid grid-cols-2 gap-2">
|
<div class="grid grid-cols-2 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<label class={labelClass}>Unit</label>
|
<label class={labelClass}>Unit
|
||||||
<input type="text" bind:value={metricUnit} placeholder="%" class={inputClass} />
|
<input type="text" bind:value={metricUnit} placeholder="%" class={inputClass} />
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class={labelClass}>Refresh ({metricRefreshInterval}s)</label>
|
<label class={labelClass}>Refresh ({metricRefreshInterval}s)
|
||||||
<input type="range" min="5" max="300" bind:value={metricRefreshInterval} class="w-full accent-primary" />
|
<input type="range" min="5" max="300" bind:value={metricRefreshInterval} class="w-full accent-primary" />
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else if widgetType === 'link_group'}
|
{:else if widgetType === 'link_group'}
|
||||||
<div>
|
<div>
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
<label class={labelClass}>Links</label>
|
<label class={labelClass}>Links</label>
|
||||||
{#each linkGroupLinks as link, i}
|
{#each linkGroupLinks as link, i}
|
||||||
<div class="mb-1 flex items-center gap-1">
|
<div class="mb-1 flex items-center gap-1">
|
||||||
@@ -470,47 +498,54 @@
|
|||||||
|
|
||||||
{:else if widgetType === 'camera'}
|
{:else if widgetType === 'camera'}
|
||||||
<div>
|
<div>
|
||||||
<label class={labelClass}>Stream URL</label>
|
<label class={labelClass}>Stream URL
|
||||||
<input type="url" bind:value={cameraStreamUrl} placeholder="https://..." class={inputClass} bind:this={firstInput} />
|
<input type="url" bind:value={cameraStreamUrl} placeholder="https://..." class={inputClass} bind:this={firstInput} />
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class={labelClass}>Type</label>
|
<label class={labelClass}>Type
|
||||||
<select bind:value={cameraType} class={inputClass}>
|
<select bind:value={cameraType} class={inputClass}>
|
||||||
<option value="image">Image</option>
|
<option value="image">Image</option>
|
||||||
<option value="mjpeg">MJPEG</option>
|
<option value="mjpeg">MJPEG</option>
|
||||||
<option value="hls">HLS</option>
|
<option value="hls">HLS</option>
|
||||||
</select>
|
</select>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class={labelClass}>Refresh ({cameraRefreshInterval}s)</label>
|
<label class={labelClass}>Refresh ({cameraRefreshInterval}s)
|
||||||
<input type="range" min="1" max="60" bind:value={cameraRefreshInterval} class="w-full accent-primary" />
|
<input type="range" min="1" max="60" bind:value={cameraRefreshInterval} class="w-full accent-primary" />
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class={labelClass}>Aspect Ratio</label>
|
<label class={labelClass}>Aspect Ratio
|
||||||
<select bind:value={cameraAspectRatio} class={inputClass}>
|
<select bind:value={cameraAspectRatio} class={inputClass}>
|
||||||
<option value="16/9">16:9</option>
|
<option value="16/9">16:9</option>
|
||||||
<option value="4/3">4:3</option>
|
<option value="4/3">4:3</option>
|
||||||
<option value="1/1">1:1</option>
|
<option value="1/1">1:1</option>
|
||||||
</select>
|
</select>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else if widgetType === 'integration'}
|
{:else if widgetType === 'integration'}
|
||||||
<div>
|
<div>
|
||||||
<label class={labelClass}>{$t('widget.app') ?? 'App'}</label>
|
<label class={labelClass}>{$t('widget.app') ?? 'App'}
|
||||||
<select bind:value={integrationAppId} class={inputClass} bind:this={firstInput}>
|
<select bind:value={integrationAppId} class={inputClass} bind:this={firstInput}>
|
||||||
<option value="">Select app...</option>
|
<option value="">Select app...</option>
|
||||||
{#each apps as app}
|
{#each apps as app}
|
||||||
<option value={app.id}>{app.name}</option>
|
<option value={app.id}>{app.name}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class={labelClass}>Endpoint ID</label>
|
<label class={labelClass}>Endpoint ID
|
||||||
<input type="text" bind:value={integrationEndpointId} class={inputClass} />
|
<input type="text" bind:value={integrationEndpointId} class={inputClass} />
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class={labelClass}>Refresh ({integrationRefreshInterval}s)</label>
|
<label class={labelClass}>Refresh ({integrationRefreshInterval}s)
|
||||||
<input type="range" min="10" max="600" bind:value={integrationRefreshInterval} class="w-full accent-primary" />
|
<input type="range" min="10" max="600" bind:value={integrationRefreshInterval} class="w-full accent-primary" />
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user