6c3dd67c1b
Add quiet_hours_enabled/start/end to TrackingConfig (HH:MM strings interpreted in the app-level timezone AppSetting). The dispatch path loads the app timezone once per run and passes it through event_allowed_by_config -> in_quiet_hours, so overnight windows like 22:00-07:00 work correctly in any IANA tz. Frontend exposes a Timezone field under Settings and a Quiet Hours section on the Immich tracking-config form with time-picker inputs.
317 lines
12 KiB
Svelte
317 lines
12 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { slide } from 'svelte/transition';
|
|
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
|
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
|
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, providerTypeFilterItems, sortByItems, sortOrderItems, albumModeItems, assetTypeItems, memorySourceItems, providerDefaultIcon } from '$lib/grid-items';
|
|
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
|
import { highlightFromUrl } from '$lib/highlight';
|
|
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
|
import { getDescriptor, buildTrackingFormDefaults } from '$lib/providers';
|
|
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
|
import Button from '$lib/components/Button.svelte';
|
|
import type { TrackingConfig } from '$lib/types';
|
|
|
|
/** Grid-select item source lookup — maps descriptor string name to actual function. */
|
|
const gridItemSources: Record<string, () => any[]> = {
|
|
sortByItems, sortOrderItems, albumModeItems, assetTypeItems, memorySourceItems,
|
|
};
|
|
|
|
let allConfigs = $derived(trackingConfigsCache.items);
|
|
let filterText = $state('');
|
|
let filterType = $state('');
|
|
let effectiveType = $derived(globalProviderFilter.providerType || filterType);
|
|
let configs = $derived(allConfigs.filter(c =>
|
|
(!filterText || c.name.toLowerCase().includes(filterText.toLowerCase())) &&
|
|
(!effectiveType || c.provider_type === effectiveType)
|
|
));
|
|
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 = (): Record<string, any> => ({
|
|
provider_type: '', name: '', icon: '',
|
|
...buildTrackingFormDefaults(),
|
|
});
|
|
let form: Record<string, any> = $state(defaultForm());
|
|
let descriptor = $derived(getDescriptor(form.provider_type));
|
|
|
|
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); }
|
|
}
|
|
|
|
let blockedBy = $state<BlockedByDetail | null>(null);
|
|
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) {
|
|
const bb = getBlockedBy(err);
|
|
if (bb) { blockedBy = bb; return; }
|
|
error = err.message; snackError(err.message);
|
|
}
|
|
finally { confirmDelete = null; }
|
|
}
|
|
};
|
|
}
|
|
</script>
|
|
|
|
<PageHeader title={t('trackingConfig.title')} description={t('trackingConfig.description')}>
|
|
<Button size="sm" onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}>
|
|
{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}<ErrorBanner message={error} />{/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 — driven by descriptor -->
|
|
{#if descriptor}
|
|
<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">
|
|
{#each descriptor.eventFields as field (field.key)}
|
|
<label class="flex items-center gap-2 text-sm">
|
|
<input type="checkbox" bind:checked={form[field.key]} />
|
|
{t(field.label)}
|
|
{#if field.hint}<Hint text={t(field.hint)} />{/if}
|
|
</label>
|
|
{/each}
|
|
</div>
|
|
{#if descriptor.extraTrackingFields?.length}
|
|
<div class="grid grid-cols-3 gap-3 mt-3">
|
|
{#each descriptor.extraTrackingFields as field (field.key)}
|
|
<div>
|
|
<label class="block text-xs mb-1">
|
|
{t(field.label)}
|
|
{#if field.hint}<Hint text={t(field.hint)} />{/if}
|
|
</label>
|
|
{#if field.type === 'grid-select' && field.gridItems}
|
|
<IconGridSelect items={gridItemSources[field.gridItems]()} bind:value={form[field.key]} columns={field.gridColumns ?? 2} compact />
|
|
{:else}
|
|
<input type="number" bind:value={form[field.key]} min={field.min} max={field.max}
|
|
class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</fieldset>
|
|
|
|
<!-- Feature sections (periodic, scheduled, memory) — driven by descriptor -->
|
|
{#each descriptor.featureSections ?? [] as section (section.key)}
|
|
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
|
<legend class="text-sm font-medium px-1">
|
|
{t(section.legend)}
|
|
{#if section.legendHint}<Hint text={t(section.legendHint)} />{/if}
|
|
</legend>
|
|
<label class="flex items-center gap-2 text-sm mt-1">
|
|
<input type="checkbox" bind:checked={form[section.enabledField]} />
|
|
{t('trackingConfig.enabled')}
|
|
</label>
|
|
{#if form[section.enabledField]}
|
|
<div class="grid grid-cols-3 gap-3 mt-3">
|
|
{#each section.fields as field (field.key)}
|
|
<div>
|
|
<label class="block text-xs mb-1">
|
|
{t(field.label)}
|
|
{#if field.hint}<Hint text={t(field.hint)} />{/if}
|
|
</label>
|
|
{#if field.type === 'toggle'}
|
|
<label class="toggle-switch">
|
|
<input type="checkbox" bind:checked={form[field.key]} />
|
|
<span class="toggle-track"></span>
|
|
</label>
|
|
{:else if field.type === 'grid-select' && field.gridItems}
|
|
<IconGridSelect items={gridItemSources[field.gridItems]()} bind:value={form[field.key]} columns={field.gridColumns ?? 2} compact />
|
|
{:else}
|
|
<input type={field.key.includes('date') ? 'date'
|
|
: field.key.startsWith('quiet_hours_') ? 'time'
|
|
: field.key.includes('times') ? 'text'
|
|
: 'number'}
|
|
bind:value={form[field.key]} min={field.min} max={field.max}
|
|
placeholder={field.key.includes('times') || field.key.startsWith('quiet_hours_') ? String(field.defaultValue ?? '') : ''}
|
|
class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</fieldset>
|
|
{/each}
|
|
{:else if form.provider_type}
|
|
<Card>
|
|
<div class="flex items-center gap-2 text-sm" style="color: var(--color-error-fg);">
|
|
<MdiIcon name="mdiAlertCircle" size={18} />
|
|
{t('trackingConfig.unknownProviderType')}: {form.provider_type}
|
|
</div>
|
|
</Card>
|
|
{/if}
|
|
|
|
<Button type="submit">
|
|
{editing ? t('common.save') : t('common.create')}
|
|
</Button>
|
|
</form>
|
|
</Card>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if !showForm && allConfigs.length > 0}
|
|
<div class="flex items-center gap-2 mb-3">
|
|
<input type="text" bind:value={filterText} placeholder={t('common.filterByName')}
|
|
class="flex-1 px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
|
{#if !globalProviderFilter.id}
|
|
<div class="w-48">
|
|
<IconGridSelect items={providerTypeFilterItems()} bind:value={filterType} columns={2} compact />
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if allConfigs.length === 0 && !showForm}
|
|
<Card>
|
|
<EmptyState icon="mdiCog" message={t('trackingConfig.noConfigs')} />
|
|
</Card>
|
|
{:else if configs.length === 0 && !showForm}
|
|
<Card>
|
|
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
|
|
</Card>
|
|
{:else}
|
|
<div class="space-y-3 stagger-children">
|
|
{#each configs as config}
|
|
{@const desc = getDescriptor(config.provider_type)}
|
|
<Card hover entityId={config.id}>
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<div class="flex items-center gap-2">
|
|
<span style="color: var(--color-primary);"><MdiIcon name={providerDefaultIcon({ icon: config.icon, type: config.provider_type })} size={20} /></span>
|
|
<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)]">
|
|
{(desc?.eventFields ?? []).filter(f => (config as Record<string, any>)[f.key]).map(f => t(f.label)).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} />
|
|
|
|
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
|
|
|
<style>
|
|
.toggle-switch {
|
|
position: relative;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
cursor: pointer;
|
|
height: 1.75rem;
|
|
}
|
|
|
|
.toggle-switch input {
|
|
position: absolute;
|
|
opacity: 0;
|
|
width: 0;
|
|
height: 0;
|
|
}
|
|
|
|
.toggle-track {
|
|
position: relative;
|
|
width: 2.5rem;
|
|
height: 1.375rem;
|
|
background: var(--color-border);
|
|
border-radius: 9999px;
|
|
transition: background 0.2s ease;
|
|
}
|
|
|
|
.toggle-track::after {
|
|
content: '';
|
|
position: absolute;
|
|
top: 0.1875rem;
|
|
left: 0.1875rem;
|
|
width: 1rem;
|
|
height: 1rem;
|
|
background: var(--color-foreground);
|
|
border-radius: 50%;
|
|
transition: transform 0.2s ease;
|
|
}
|
|
|
|
.toggle-switch input:checked + .toggle-track {
|
|
background: var(--color-primary);
|
|
}
|
|
|
|
.toggle-switch input:checked + .toggle-track::after {
|
|
transform: translateX(1.125rem);
|
|
background: var(--color-primary-foreground);
|
|
}
|
|
</style>
|