feat(widget-config): visual app selector grid with search and icons
Replace plain select dropdown with a searchable 2-column grid showing app icons for the app widget type in the inline config panel.
This commit is contained in:
@@ -2,11 +2,19 @@
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { tick } from 'svelte';
|
||||
import DynamicIcon from '$lib/components/ui/DynamicIcon.svelte';
|
||||
|
||||
interface AppInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
icon?: string | null;
|
||||
iconType?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
widgetType: string;
|
||||
initialConfig?: Record<string, unknown>;
|
||||
apps?: Array<{ id: string; name: string }>;
|
||||
apps?: AppInfo[];
|
||||
mode: 'create' | 'edit';
|
||||
onSave: (config: Record<string, unknown>) => void;
|
||||
onCancel: () => void;
|
||||
@@ -14,6 +22,14 @@
|
||||
|
||||
let { widgetType, initialConfig = {}, apps = [], mode, onSave, onCancel }: Props = $props();
|
||||
|
||||
// App search
|
||||
let appSearchQuery = $state('');
|
||||
const filteredApps = $derived(
|
||||
appSearchQuery.trim()
|
||||
? apps.filter((a) => a.name.toLowerCase().includes(appSearchQuery.toLowerCase()))
|
||||
: apps
|
||||
);
|
||||
|
||||
// -- Form fields initialised from config --
|
||||
// App
|
||||
let appId = $state((initialConfig.appId as string) ?? '');
|
||||
@@ -173,12 +189,53 @@
|
||||
{#if widgetType === 'app'}
|
||||
<div>
|
||||
<label class={labelClass}>{$t('widget.app') ?? 'App'}</label>
|
||||
<select bind:value={appId} class={inputClass} bind:this={firstInput}>
|
||||
<option value="">Select an app...</option>
|
||||
{#each apps as app}
|
||||
<option value={app.id}>{app.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<!-- Search -->
|
||||
<div class="relative mb-2">
|
||||
<svg class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" 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">
|
||||
<circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={appSearchQuery}
|
||||
bind:this={firstInput}
|
||||
placeholder={$t('common.search') ?? 'Search apps...'}
|
||||
class="w-full rounded-lg border border-input bg-background py-1.5 pl-8 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-ring/30"
|
||||
/>
|
||||
</div>
|
||||
<!-- App grid -->
|
||||
<div class="max-h-48 overflow-y-auto rounded-lg border border-input bg-background p-1">
|
||||
{#if filteredApps.length === 0}
|
||||
<p class="py-4 text-center text-xs text-muted-foreground">{$t('common.no_results') ?? 'No apps found'}</p>
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 gap-1">
|
||||
{#each filteredApps as app}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { appId = app.id; }}
|
||||
class="flex items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm transition-colors
|
||||
{appId === app.id
|
||||
? 'bg-primary/10 text-primary ring-1 ring-primary/30'
|
||||
: 'text-foreground hover:bg-accent'}"
|
||||
>
|
||||
{#if app.icon && app.iconType === 'lucide'}
|
||||
<DynamicIcon name={app.icon} size={18} />
|
||||
{:else if app.icon && app.iconType === 'url'}
|
||||
<img src={app.icon} alt="" class="h-[18px] w-[18px] rounded object-contain" />
|
||||
{:else if app.icon && app.iconType === 'simple'}
|
||||
<img src="https://cdn.simpleicons.org/{app.icon.toLowerCase()}" alt="" class="h-[18px] w-[18px]" />
|
||||
{:else if app.icon && app.iconType === 'emoji'}
|
||||
<span class="text-base leading-none">{app.icon}</span>
|
||||
{:else}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" /><line x1="8" y1="21" x2="16" y2="21" /><line x1="12" y1="17" x2="12" y2="21" />
|
||||
</svg>
|
||||
{/if}
|
||||
<span class="truncate">{app.name}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if widgetType === 'bookmark'}
|
||||
|
||||
@@ -109,7 +109,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
const appsForPicker = $derived(allApps.map((a) => ({ id: a.id, name: a.name })));
|
||||
const appsForPicker = $derived(allApps.map((a) => ({ id: a.id, name: a.name, icon: a.icon, iconType: a.iconType })));
|
||||
</script>
|
||||
|
||||
{#if widgets.length === 0 && !editMode.active}
|
||||
|
||||
Reference in New Issue
Block a user