Add MDI icon picker to all entity types
Some checks failed
Validate / Hassfest (push) Has been cancelled

- Install @mdi/js (~7000 Material Design Icons)
- IconPicker component: dropdown with search, popular icons grid,
  clear option. Stores icon name as string (e.g. "mdiCamera")
- MdiIcon component: renders SVG from icon name
- Backend: add `icon` field to ImmichServer, TelegramBot,
  TrackingConfig, TemplateConfig, NotificationTarget, AlbumTracker
- All 6 entity pages: icon picker next to name input in create/edit
  forms, icon displayed on entity cards

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 18:01:22 +03:00
parent af9bfb7b22
commit 5a0b0b78f6
11 changed files with 182 additions and 24 deletions

View File

@@ -0,0 +1,81 @@
<script lang="ts">
import { t } from '$lib/i18n';
import * as mdi from '@mdi/js';
let { value = '', onselect } = $props<{
value: string;
onselect: (icon: string) => void;
}>();
let open = $state(false);
let search = $state('');
// Build searchable icon list from @mdi/js exports
// Each export is like mdiAccount = "M12 4..." (SVG path)
const allIcons = Object.keys(mdi).filter(k => k.startsWith('mdi') && k !== 'default');
// Popular icons shown first when search is empty
const popular = [
'mdiServer', 'mdiCamera', 'mdiImage', 'mdiVideo', 'mdiBell', 'mdiSend',
'mdiRobot', 'mdiHome', 'mdiStar', 'mdiHeart', 'mdiAccount', 'mdiFolder',
'mdiFolderImage', 'mdiAlbum', 'mdiImageMultiple', 'mdiCloudUpload',
'mdiEye', 'mdiCog', 'mdiTelegram', 'mdiWebhook', 'mdiMessageText',
'mdiCalendar', 'mdiClock', 'mdiMapMarker', 'mdiTag', 'mdiFilter',
'mdiSort', 'mdiMagnify', 'mdiPencil', 'mdiDelete', 'mdiPlus',
'mdiCheck', 'mdiClose', 'mdiAlert', 'mdiInformation', 'mdiShield',
'mdiLink', 'mdiDownload', 'mdiUpload', 'mdiRefresh', 'mdiPlay',
'mdiPause', 'mdiStop', 'mdiSkipNext', 'mdiMusic', 'mdiMovie',
'mdiFileDocument', 'mdiEmail', 'mdiPhone', 'mdiChat', 'mdiShare',
];
function filtered(): string[] {
if (!search) return popular.filter(p => allIcons.includes(p));
const q = search.toLowerCase();
return allIcons.filter(k => k.toLowerCase().includes(q)).slice(0, 60);
}
function getPath(iconName: string): string {
return (mdi as any)[iconName] || '';
}
function select(iconName: string) {
onselect(iconName);
open = false;
search = '';
}
</script>
<div class="inline-block relative">
<button type="button" onclick={() => open = !open}
class="flex items-center gap-1 px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] hover:bg-[var(--color-muted)] transition-colors">
{#if value && getPath(value)}
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d={getPath(value)} /></svg>
{:else}
<span class="text-[var(--color-muted-foreground)]">Icon</span>
{/if}
<span class="text-xs text-[var(--color-muted-foreground)]"></span>
</button>
{#if open}
<div style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 9998;"
onclick={() => { open = false; search = ''; }}></div>
<div style="position: absolute; z-index: 9999; top: 100%; left: 0; margin-top: 0.25rem;"
class="bg-[var(--color-card)] border border-[var(--color-border)] rounded-lg shadow-lg p-3 w-72">
<input type="text" bind:value={search} placeholder="Search icons..."
class="w-full px-2 py-1 mb-2 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" />
<div class="grid grid-cols-8 gap-1 max-h-48 overflow-y-auto">
<!-- Clear option -->
<button type="button" onclick={() => select('')}
class="flex items-center justify-center w-8 h-8 rounded hover:bg-[var(--color-muted)] text-xs text-[var(--color-muted-foreground)]"
title="No icon"></button>
{#each filtered() as iconName}
<button type="button" onclick={() => select(iconName)}
class="flex items-center justify-center w-8 h-8 rounded hover:bg-[var(--color-muted)] {value === iconName ? 'bg-[var(--color-accent)]' : ''}"
title={iconName.replace('mdi', '')}>
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d={getPath(iconName)} /></svg>
</button>
{/each}
</div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,13 @@
<script lang="ts">
import * as mdi from '@mdi/js';
let { name = '', size = 18 } = $props<{ name: string; size?: number }>();
function getPath(iconName: string): string {
return (mdi as any)[iconName] || '';
}
</script>
{#if name && getPath(name)}
<svg viewBox="0 0 24 24" width={size} height={size} fill="currentColor"><path d={getPath(name)} /></svg>
{/if}