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:
2026-04-10 19:05:03 +03:00
parent f559c93e19
commit 5af670fa3c
2 changed files with 345 additions and 57 deletions
@@ -3,6 +3,7 @@
import { fade } from 'svelte/transition';
import { tick } from 'svelte';
import DynamicIcon from '$lib/components/ui/DynamicIcon.svelte';
import MultiEntityPicker from '$lib/components/ui/MultiEntityPicker.svelte';
interface AppInfo {
id: string;
@@ -174,13 +175,14 @@
transition:fade={{ duration: 100 }}
onkeydown={handleKeydown}
role="dialog"
tabindex="-1"
>
<div class="mb-3 flex items-center justify-between">
<h3 class="text-sm font-semibold text-foreground">
{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>
</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>
</button>
</div>
@@ -188,6 +190,7 @@
<div class="max-h-80 space-y-3 overflow-y-auto">
{#if widgetType === 'app'}
<div>
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class={labelClass}>{$t('widget.app') ?? 'App'}</label>
<!-- Search -->
<div class="relative mb-2">
@@ -240,90 +243,90 @@
{:else if widgetType === 'bookmark'}
<div>
<label class={labelClass}>URL</label>
<label class={labelClass}>URL
<input type="url" bind:value={bookmarkUrl} placeholder="https://..." class={inputClass} bind:this={firstInput} />
</label>
</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} />
</label>
</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} />
</label>
</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} />
</label>
</div>
{:else if widgetType === 'note'}
<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>
</label>
</div>
<div>
<label class={labelClass}>{$t('widget.format') ?? 'Format'}</label>
<label class={labelClass}>{$t('widget.format') ?? 'Format'}
<select bind:value={noteFormat} class={inputClass}>
<option value="markdown">Markdown</option>
<option value="text">Plain Text</option>
<option value="html">HTML</option>
</select>
</label>
</div>
{:else if widgetType === 'embed'}
<div>
<label class={labelClass}>URL</label>
<label class={labelClass}>URL
<input type="url" bind:value={embedUrl} placeholder="https://..." class={inputClass} bind:this={firstInput} />
</label>
</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" />
</label>
</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} />
</label>
</div>
{:else if widgetType === 'status'}
<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} />
</label>
</div>
<div>
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class={labelClass}>{$t('widget.apps') ?? 'Apps'}</label>
<div class="space-y-1 rounded-lg border border-input bg-background p-2">
{#each apps as app}
<label class="flex items-center gap-2 text-sm">
<input
type="checkbox"
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>
<MultiEntityPicker
items={apps.map((a) => ({ value: a.id, label: a.name, icon: a.icon, iconType: a.iconType }))}
bind:values={statusAppIds}
placeholder={$t('widget.select_apps') ?? 'Select apps...'}
searchPlaceholder={$t('common.search') ?? 'Search...'}
/>
</div>
{:else if widgetType === 'clock'}
<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} />
</label>
</div>
<div>
<label class={labelClass}>{$t('widget.style') ?? 'Style'}</label>
<label class={labelClass}>{$t('widget.style') ?? 'Style'}
<select bind:value={clockStyle} class={inputClass}>
<option value="digital">Digital</option>
<option value="analog">Analog</option>
<option value="24h">24h</option>
</select>
</label>
</div>
<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" />
@@ -332,42 +335,54 @@
{#if clockShowWeather}
<div class="grid grid-cols-2 gap-2">
<div>
<label class={labelClass}>Latitude</label>
<label class={labelClass}>Latitude
<input type="text" bind:value={clockLatitude} class={inputClass} />
</label>
</div>
<div>
<label class={labelClass}>Longitude</label>
<label class={labelClass}>Longitude
<input type="text" bind:value={clockLongitude} class={inputClass} />
</label>
</div>
</div>
{/if}
{:else if widgetType === 'system_stats'}
<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} />
</label>
</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}>
<option value="glances">Glances</option>
<option value="prometheus">Prometheus</option>
<option value="custom">Custom</option>
</select>
</label>
</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" />
</label>
</div>
{:else if widgetType === 'rss'}
<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} />
</label>
</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" />
</label>
</div>
<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" />
@@ -376,6 +391,7 @@
{:else if widgetType === 'calendar'}
<div>
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class={labelClass}>iCal URLs</label>
{#each calendarUrlsRaw as cal, i}
<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>
</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" />
</label>
</div>
{:else if widgetType === 'markdown'}
<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>
</label>
</div>
{:else if widgetType === 'metric'}
<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} />
</label>
</div>
<div>
<label class={labelClass}>Source</label>
<label class={labelClass}>Source
<select bind:value={metricSource} class={inputClass}>
<option value="static">Static</option>
<option value="json">JSON Endpoint</option>
<option value="prometheus">Prometheus</option>
</select>
</label>
</div>
{#if metricSource === 'static'}
<div>
<label class={labelClass}>Value</label>
<label class={labelClass}>Value
<input type="text" bind:value={metricValue} class={inputClass} />
</label>
</div>
{:else if metricSource === 'json'}
<div>
<label class={labelClass}>URL</label>
<label class={labelClass}>URL
<input type="url" bind:value={metricUrl} class={inputClass} />
</label>
</div>
<div>
<label class={labelClass}>JSON Path</label>
<label class={labelClass}>JSON Path
<input type="text" bind:value={metricJsonPath} placeholder="$.data.value" class={inputClass} />
</label>
</div>
{:else if metricSource === 'prometheus'}
<div>
<label class={labelClass}>URL</label>
<label class={labelClass}>URL
<input type="url" bind:value={metricUrl} class={inputClass} />
</label>
</div>
<div>
<label class={labelClass}>PromQL Query</label>
<label class={labelClass}>PromQL Query
<input type="text" bind:value={metricQuery} class={inputClass} />
</label>
</div>
{/if}
<div class="grid grid-cols-2 gap-2">
<div>
<label class={labelClass}>Unit</label>
<label class={labelClass}>Unit
<input type="text" bind:value={metricUnit} placeholder="%" class={inputClass} />
</label>
</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" />
</label>
</div>
</div>
{:else if widgetType === 'link_group'}
<div>
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class={labelClass}>Links</label>
{#each linkGroupLinks as link, i}
<div class="mb-1 flex items-center gap-1">
@@ -470,47 +498,54 @@
{:else if widgetType === 'camera'}
<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} />
</label>
</div>
<div>
<label class={labelClass}>Type</label>
<label class={labelClass}>Type
<select bind:value={cameraType} class={inputClass}>
<option value="image">Image</option>
<option value="mjpeg">MJPEG</option>
<option value="hls">HLS</option>
</select>
</label>
</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" />
</label>
</div>
<div>
<label class={labelClass}>Aspect Ratio</label>
<label class={labelClass}>Aspect Ratio
<select bind:value={cameraAspectRatio} class={inputClass}>
<option value="16/9">16:9</option>
<option value="4/3">4:3</option>
<option value="1/1">1:1</option>
</select>
</label>
</div>
{:else if widgetType === 'integration'}
<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}>
<option value="">Select app...</option>
{#each apps as app}
<option value={app.id}>{app.name}</option>
{/each}
</select>
</label>
</div>
<div>
<label class={labelClass}>Endpoint ID</label>
<label class={labelClass}>Endpoint ID
<input type="text" bind:value={integrationEndpointId} class={inputClass} />
</label>
</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" />
</label>
</div>
{/if}
</div>