a9bb912c30
Shared grid-items.ts with reusable item definitions for: sort by/order, album mode, asset type, memory source, locale, response mode, event type filter, chat action, preview target type, provider type. Replaced selects on: - Dashboard: event type, provider, sort (compact mode — auto-width) - Tracking configs: sort by/order, album modes, asset types, memory source - Command configs: locale, response mode, provider type - Targets: chat action - Template configs: preview target type, provider type - Command template configs: provider type - Providers: type selector (read-only during edit) IconGridSelect: added compact prop for inline filter bars (auto-width, smaller padding, shows icon + label text). Backend: template seed now re-creates deleted system templates on startup using raw SQL to handle legacy NOT NULL columns. Added i18n: trackingConfig.providerType, trackingConfig.sortRandom Added provider_type badge to tracking config cards.
238 lines
15 KiB
Svelte
238 lines
15 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { slide } from 'svelte/transition';
|
|
import { api } from '$lib/api';
|
|
import { t } from '$lib/i18n';
|
|
import { trackingConfigsCache } from '$lib/stores/caches.svelte';
|
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
|
import Card from '$lib/components/Card.svelte';
|
|
import Loading from '$lib/components/Loading.svelte';
|
|
import IconPicker from '$lib/components/IconPicker.svelte';
|
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
|
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
|
import Hint from '$lib/components/Hint.svelte';
|
|
import IconButton from '$lib/components/IconButton.svelte';
|
|
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
|
|
import { providerTypeItems, sortByItems, sortOrderItems, albumModeItems, assetTypeItems, memorySourceItems } from '$lib/grid-items';
|
|
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
|
import { highlightFromUrl } from '$lib/highlight';
|
|
import type { TrackingConfig } from '$lib/types';
|
|
|
|
let configs = $derived(trackingConfigsCache.items);
|
|
let loaded = $state(false);
|
|
let showForm = $state(false);
|
|
let editing = $state<number | null>(null);
|
|
let error = $state('');
|
|
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
|
|
|
|
const defaultForm = () => ({
|
|
provider_type: 'immich', name: '', icon: '',
|
|
track_assets_added: true, track_assets_removed: false,
|
|
track_collection_renamed: true, track_collection_deleted: true, track_sharing_changed: false,
|
|
track_images: true, track_videos: true, notify_favorites_only: false,
|
|
include_tags: true, include_asset_details: false,
|
|
max_assets_to_show: 5, assets_order_by: 'none', assets_order: 'descending',
|
|
periodic_enabled: false, periodic_interval_days: 1, periodic_start_date: '2025-01-01', periodic_times: '12:00',
|
|
scheduled_enabled: false, scheduled_times: '09:00', scheduled_collection_mode: 'per_collection',
|
|
scheduled_limit: 10, scheduled_favorite_only: false, scheduled_asset_type: 'all',
|
|
scheduled_min_rating: 0, scheduled_order_by: 'random', scheduled_order: 'descending',
|
|
memory_enabled: false, memory_source: 'albums', memory_times: '09:00', memory_collection_mode: 'combined',
|
|
memory_limit: 10, memory_favorite_only: false, memory_asset_type: 'all', memory_min_rating: 0,
|
|
});
|
|
let form = $state(defaultForm());
|
|
|
|
onMount(load);
|
|
async function load() {
|
|
try { await trackingConfigsCache.fetch(true); }
|
|
catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
|
finally { loaded = true; highlightFromUrl(); }
|
|
}
|
|
|
|
function openNew() { form = defaultForm(); editing = null; showForm = true; }
|
|
function edit(c: any) {
|
|
form = { ...defaultForm(), ...c };
|
|
editing = c.id; showForm = true;
|
|
}
|
|
|
|
async function save(e: SubmitEvent) {
|
|
e.preventDefault(); error = '';
|
|
try {
|
|
if (editing) await api(`/tracking-configs/${editing}`, { method: 'PUT', body: JSON.stringify(form) });
|
|
else await api('/tracking-configs', { method: 'POST', body: JSON.stringify(form) });
|
|
showForm = false; editing = null; await load();
|
|
snackSuccess(t('snack.trackingConfigSaved'));
|
|
} catch (err: any) { error = err.message; snackError(err.message); }
|
|
}
|
|
|
|
function remove(id: number) {
|
|
confirmDelete = {
|
|
id,
|
|
onconfirm: async () => {
|
|
try { await api(`/tracking-configs/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.trackingConfigDeleted')); }
|
|
catch (err: any) { error = err.message; snackError(err.message); }
|
|
finally { confirmDelete = null; }
|
|
}
|
|
};
|
|
}
|
|
</script>
|
|
|
|
<PageHeader title={t('trackingConfig.title')} description={t('trackingConfig.description')}>
|
|
<button onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}
|
|
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
|
{showForm ? t('common.cancel') : t('trackingConfig.newConfig')}
|
|
</button>
|
|
</PageHeader>
|
|
|
|
{#if !loaded}<Loading />{:else}
|
|
|
|
{#if showForm}
|
|
<div in:slide={{ duration: 200 }}>
|
|
<Card class="mb-6">
|
|
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
|
<form onsubmit={save} class="space-y-5">
|
|
<div>
|
|
<label for="tc-name" class="block text-sm font-medium mb-1">{t('trackingConfig.name')}</label>
|
|
<div class="flex gap-2">
|
|
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
|
|
<input id="tc-name" bind:value={form.name} required placeholder={t('trackingConfig.namePlaceholder')}
|
|
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium mb-1">{t('trackingConfig.providerType')}</label>
|
|
{#if !editing}
|
|
<IconGridSelect items={providerTypeItems()} bind:value={form.provider_type} columns={2} />
|
|
{:else}
|
|
<p class="text-sm text-[var(--color-muted-foreground)]">{form.provider_type}</p>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Event tracking -->
|
|
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
|
<legend class="text-sm font-medium px-1">{t('trackingConfig.eventTracking')}</legend>
|
|
<div class="grid grid-cols-2 gap-2 mt-2">
|
|
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_assets_added} /> {t('trackingConfig.assetsAdded')}</label>
|
|
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_assets_removed} /> {t('trackingConfig.assetsRemoved')}</label>
|
|
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_collection_renamed} /> {t('trackingConfig.albumRenamed')}</label>
|
|
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_collection_deleted} /> {t('trackingConfig.albumDeleted')}</label>
|
|
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_sharing_changed} /> {t('trackingConfig.sharingChanged')}</label>
|
|
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_images} /> {t('trackingConfig.trackImages')}</label>
|
|
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_videos} /> {t('trackingConfig.trackVideos')}</label>
|
|
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.notify_favorites_only} /> {t('trackingConfig.favoritesOnly')}<Hint text={t('hints.favoritesOnly')} /></label>
|
|
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.include_tags} /> {t('trackingConfig.includePeople')}</label>
|
|
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.include_asset_details} /> {t('trackingConfig.includeDetails')}</label>
|
|
</div>
|
|
<div class="grid grid-cols-3 gap-3 mt-3">
|
|
<div>
|
|
<label for="tc-max" class="block text-xs mb-1">{t('trackingConfig.maxAssets')}<Hint text={t('hints.maxAssets')} /></label>
|
|
<input id="tc-max" type="number" bind:value={form.max_assets_to_show} min="0" max="50" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs mb-1">{t('trackingConfig.sortBy')}</label>
|
|
<IconGridSelect items={sortByItems()} bind:value={form.assets_order_by} columns={2} />
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs mb-1">{t('trackingConfig.sortOrder')}</label>
|
|
<IconGridSelect items={sortOrderItems()} bind:value={form.assets_order} columns={2} />
|
|
</div>
|
|
</div>
|
|
</fieldset>
|
|
|
|
<!-- Periodic summary -->
|
|
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
|
<legend class="text-sm font-medium px-1">{t('trackingConfig.periodicSummary')}<Hint text={t('hints.periodicSummary')} /></legend>
|
|
<label class="flex items-center gap-2 text-sm mt-1"><input type="checkbox" bind:checked={form.periodic_enabled} /> {t('trackingConfig.enabled')}</label>
|
|
{#if form.periodic_enabled}
|
|
<div class="grid grid-cols-3 gap-3 mt-3">
|
|
<div><label class="block text-xs mb-1">{t('trackingConfig.intervalDays')}</label><input type="number" bind:value={form.periodic_interval_days} min="1" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /></div>
|
|
<div><label class="block text-xs mb-1">{t('trackingConfig.startDate')}<Hint text={t('hints.periodicStartDate')} /></label><input type="date" bind:value={form.periodic_start_date} class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /></div>
|
|
<div><label class="block text-xs mb-1">{t('trackingConfig.times')}<Hint text={t('hints.times')} /></label><input bind:value={form.periodic_times} placeholder="12:00" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /></div>
|
|
</div>
|
|
{/if}
|
|
</fieldset>
|
|
|
|
<!-- Scheduled assets -->
|
|
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
|
<legend class="text-sm font-medium px-1">{t('trackingConfig.scheduledAssets')}<Hint text={t('hints.scheduledAssets')} /></legend>
|
|
<label class="flex items-center gap-2 text-sm mt-1"><input type="checkbox" bind:checked={form.scheduled_enabled} /> {t('trackingConfig.enabled')}</label>
|
|
{#if form.scheduled_enabled}
|
|
<div class="grid grid-cols-3 gap-3 mt-3">
|
|
<div><label class="block text-xs mb-1">{t('trackingConfig.times')}<Hint text={t('hints.times')} /></label><input bind:value={form.scheduled_times} placeholder="09:00" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /></div>
|
|
<div><label class="block text-xs mb-1">{t('trackingConfig.albumMode')}<Hint text={t('hints.albumMode')} /></label>
|
|
<IconGridSelect items={albumModeItems()} bind:value={form.scheduled_collection_mode} columns={3} /></div>
|
|
<div><label class="block text-xs mb-1">{t('trackingConfig.maxAssets')}<Hint text={t('hints.maxAssets')} /></label><input type="number" bind:value={form.scheduled_limit} min="1" max="100" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /></div>
|
|
<div><label class="block text-xs mb-1">{t('trackingConfig.assetType')}</label>
|
|
<IconGridSelect items={assetTypeItems()} bind:value={form.scheduled_asset_type} columns={3} /></div>
|
|
<div><label class="block text-xs mb-1">{t('trackingConfig.minRating')}<Hint text={t('hints.minRating')} /></label><input type="number" bind:value={form.scheduled_min_rating} min="0" max="5" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /></div>
|
|
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.scheduled_favorite_only} /> {t('trackingConfig.favoritesOnly')}<Hint text={t('hints.favoritesOnly')} /></label>
|
|
</div>
|
|
{/if}
|
|
</fieldset>
|
|
|
|
<!-- Memory mode -->
|
|
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
|
<legend class="text-sm font-medium px-1">{t('trackingConfig.memoryMode')}<Hint text={t('hints.memoryMode')} /></legend>
|
|
<label class="flex items-center gap-2 text-sm mt-1"><input type="checkbox" bind:checked={form.memory_enabled} /> {t('trackingConfig.enabled')}</label>
|
|
{#if form.memory_enabled}
|
|
<div class="grid grid-cols-3 gap-3 mt-3">
|
|
<div><label class="block text-xs mb-1">{t('trackingConfig.memorySource')}<Hint text={t('hints.memorySource')} /></label>
|
|
<IconGridSelect items={memorySourceItems()} bind:value={form.memory_source} columns={2} /></div>
|
|
<div><label class="block text-xs mb-1">{t('trackingConfig.times')}<Hint text={t('hints.times')} /></label><input bind:value={form.memory_times} placeholder="09:00" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /></div>
|
|
<div><label class="block text-xs mb-1">{t('trackingConfig.albumMode')}<Hint text={t('hints.albumMode')} /></label>
|
|
<IconGridSelect items={albumModeItems()} bind:value={form.memory_collection_mode} columns={3} /></div>
|
|
<div><label class="block text-xs mb-1">{t('trackingConfig.maxAssets')}<Hint text={t('hints.maxAssets')} /></label><input type="number" bind:value={form.memory_limit} min="1" max="100" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /></div>
|
|
<div><label class="block text-xs mb-1">{t('trackingConfig.assetType')}</label>
|
|
<IconGridSelect items={assetTypeItems()} bind:value={form.memory_asset_type} columns={3} /></div>
|
|
<div><label class="block text-xs mb-1">{t('trackingConfig.minRating')}<Hint text={t('hints.minRating')} /></label><input type="number" bind:value={form.memory_min_rating} min="0" max="5" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /></div>
|
|
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.memory_favorite_only} /> {t('trackingConfig.favoritesOnly')}<Hint text={t('hints.favoritesOnly')} /></label>
|
|
</div>
|
|
{/if}
|
|
</fieldset>
|
|
|
|
<button type="submit" class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
|
{editing ? t('common.save') : t('common.create')}
|
|
</button>
|
|
</form>
|
|
</Card>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if configs.length === 0 && !showForm}
|
|
<Card>
|
|
<EmptyState icon="mdiCog" message={t('trackingConfig.noConfigs')} />
|
|
</Card>
|
|
{:else}
|
|
<div class="space-y-3 stagger-children">
|
|
{#each configs as config}
|
|
<Card hover entityId={config.id}>
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<div class="flex items-center gap-2">
|
|
{#if config.icon}<MdiIcon name={config.icon} />{/if}
|
|
<p class="font-medium">{config.name}</p>
|
|
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] font-mono">{config.provider_type}</span>
|
|
</div>
|
|
<p class="text-sm text-[var(--color-muted-foreground)]">
|
|
{[config.track_assets_added && t('trackingConfig.added'), config.track_assets_removed && t('trackingConfig.removed'), config.track_collection_renamed && t('trackingConfig.renamed'), config.track_collection_deleted && t('trackingConfig.deleted')].filter(Boolean).join(', ')}
|
|
{config.periodic_enabled ? ` · ${t('trackingConfig.periodic')}` : ''}
|
|
{config.scheduled_enabled ? ` · ${t('trackingConfig.scheduled')}` : ''}
|
|
{config.memory_enabled ? ` · ${t('trackingConfig.memory')}` : ''}
|
|
</p>
|
|
</div>
|
|
<div class="flex items-center gap-1">
|
|
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(config)} />
|
|
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(config.id)} variant="danger" />
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
{/if}
|
|
|
|
<ConfirmModal open={confirmDelete !== null} message={t('trackingConfig.confirmDelete')}
|
|
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|