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 { t } from 'svelte-i18n';
|
||||||
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';
|
||||||
|
|
||||||
|
interface AppInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon?: string | null;
|
||||||
|
iconType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
widgetType: string;
|
widgetType: string;
|
||||||
initialConfig?: Record<string, unknown>;
|
initialConfig?: Record<string, unknown>;
|
||||||
apps?: Array<{ id: string; name: string }>;
|
apps?: AppInfo[];
|
||||||
mode: 'create' | 'edit';
|
mode: 'create' | 'edit';
|
||||||
onSave: (config: Record<string, unknown>) => void;
|
onSave: (config: Record<string, unknown>) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
@@ -14,6 +22,14 @@
|
|||||||
|
|
||||||
let { widgetType, initialConfig = {}, apps = [], mode, onSave, onCancel }: Props = $props();
|
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 --
|
// -- Form fields initialised from config --
|
||||||
// App
|
// App
|
||||||
let appId = $state((initialConfig.appId as string) ?? '');
|
let appId = $state((initialConfig.appId as string) ?? '');
|
||||||
@@ -173,12 +189,53 @@
|
|||||||
{#if widgetType === 'app'}
|
{#if widgetType === 'app'}
|
||||||
<div>
|
<div>
|
||||||
<label class={labelClass}>{$t('widget.app') ?? 'App'}</label>
|
<label class={labelClass}>{$t('widget.app') ?? 'App'}</label>
|
||||||
<select bind:value={appId} class={inputClass} bind:this={firstInput}>
|
<!-- Search -->
|
||||||
<option value="">Select an app...</option>
|
<div class="relative mb-2">
|
||||||
{#each apps as app}
|
<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">
|
||||||
<option value={app.id}>{app.name}</option>
|
<circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||||
{/each}
|
</svg>
|
||||||
</select>
|
<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>
|
</div>
|
||||||
|
|
||||||
{:else if widgetType === 'bookmark'}
|
{: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>
|
</script>
|
||||||
|
|
||||||
{#if widgets.length === 0 && !editMode.active}
|
{#if widgets.length === 0 && !editMode.active}
|
||||||
|
|||||||
Reference in New Issue
Block a user