8651767112
Adds bot commands for the bridge_self provider so operators can inspect and manage bridge health from chat: /status, /thresholds, /reset, /health. Includes Jinja2 templates for both locales, seed data, capability slots, and a handler that exposes pending deferred backlog plus per-counter reset. Also adds .claude/skills/ for project-scoped graph-aware skills.
622 lines
24 KiB
Svelte
622 lines
24 KiB
Svelte
<script lang="ts">
|
||
import { onMount, onDestroy } from 'svelte';
|
||
import { slide } from 'svelte/transition';
|
||
import { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
|
||
import { topbarAction } from '$lib/stores/topbar-action.svelte';
|
||
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 Modal from '$lib/components/Modal.svelte';
|
||
import { sanitizePreview } from '$lib/sanitize';
|
||
import { supportedLocalesCache } from '$lib/stores/caches.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 MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
|
||
|
||
/** Grid-select item source lookup — maps descriptor string name to actual function. */
|
||
const gridItemSources: Record<string, () => any[]> = {
|
||
sortByItems, sortOrderItems, albumModeItems, assetTypeItems, memorySourceItems,
|
||
};
|
||
|
||
/**
|
||
* HH:MM, comma-separated: "09:00" or "09:00, 18:30" — the only format cron
|
||
* dispatch accepts. Matched on blur for time-list fields; invalid values
|
||
* are surfaced inline next to the input.
|
||
*/
|
||
const TIME_LIST_RE = /^\s*(?:[01]\d|2[0-3]):[0-5]\d(?:\s*,\s*(?:[01]\d|2[0-3]):[0-5]\d)*\s*$/;
|
||
|
||
/** Per-field error messages surfaced inline under time-list inputs. */
|
||
let timeListErrors = $state<Record<string, string>>({});
|
||
|
||
/** Normalize "9:0 , 18:30" → "09:00,18:30" on blur, clear error when valid. */
|
||
function normalizeTimeList(key: string) {
|
||
const raw = String(form[key] ?? '').trim();
|
||
if (!raw) { timeListErrors = { ...timeListErrors, [key]: '' }; return; }
|
||
if (!TIME_LIST_RE.test(raw)) {
|
||
// Try a lenient normalization: split on commas, zero-pad each part.
|
||
const parts = raw.split(',').map(p => p.trim()).filter(Boolean);
|
||
const fixed: string[] = [];
|
||
let ok = true;
|
||
for (const p of parts) {
|
||
const m = /^(\d{1,2}):(\d{1,2})$/.exec(p);
|
||
if (!m) { ok = false; break; }
|
||
const hh = Number(m[1]);
|
||
const mm = Number(m[2]);
|
||
if (!Number.isFinite(hh) || !Number.isFinite(mm) || hh < 0 || hh > 23 || mm < 0 || mm > 59) { ok = false; break; }
|
||
fixed.push(`${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}`);
|
||
}
|
||
if (ok) {
|
||
form[key] = fixed.join(',');
|
||
timeListErrors = { ...timeListErrors, [key]: '' };
|
||
return;
|
||
}
|
||
timeListErrors = { ...timeListErrors, [key]: t('trackingConfig.invalidTimeList') };
|
||
return;
|
||
}
|
||
// Canonicalise spacing.
|
||
form[key] = raw.split(',').map(s => s.trim()).join(',');
|
||
timeListErrors = { ...timeListErrors, [key]: '' };
|
||
}
|
||
|
||
/**
|
||
* Quiet-hours preview: "22:00 → 07:00 next day (9h)" or "Quiet period is 0
|
||
* minutes — adjust times" when start equals end. Handles overnight ranges
|
||
* (start > end) correctly.
|
||
*/
|
||
function quietHoursPreview(start: string, end: string): string {
|
||
if (!start || !end) return '';
|
||
const [sh, sm] = start.split(':').map(Number);
|
||
const [eh, em] = end.split(':').map(Number);
|
||
if (![sh, sm, eh, em].every(Number.isFinite)) return '';
|
||
const sMin = sh * 60 + sm;
|
||
const eMin = eh * 60 + em;
|
||
if (sMin === eMin) return t('trackingConfig.quietHoursZero');
|
||
const overnight = sMin > eMin;
|
||
const span = overnight ? (24 * 60 - sMin) + eMin : eMin - sMin;
|
||
const h = Math.floor(span / 60);
|
||
const m = span % 60;
|
||
const dur = m === 0 ? `${h}h` : `${h}h ${m}m`;
|
||
const arrow = overnight
|
||
? `${start} → ${end} ${t('trackingConfig.nextDay')}`
|
||
: `${start} → ${end}`;
|
||
return `${arrow} (${dur})`;
|
||
}
|
||
|
||
function gotoTemplateConfig(slotName: string) {
|
||
// Deep-link to the template configs page: pass the slot as a query
|
||
// param (``edit_slot``) so the destination can auto-open the first
|
||
// matching config in edit mode and expand that slot. Plain hashes
|
||
// like ``#slot-X`` were a no-op because slots don't exist in the DOM
|
||
// until a config is being edited.
|
||
const u = new URL('/template-configs', window.location.origin);
|
||
u.searchParams.set('provider', 'immich');
|
||
u.searchParams.set('edit_slot', slotName);
|
||
window.location.href = u.toString();
|
||
}
|
||
|
||
/**
|
||
* Inline preview of the shipped default template for a scheduled/periodic/
|
||
* memory slot. Using the shipped default (not a tracker's current template)
|
||
* keeps this scoped to the tracking-config page — which has no concept of
|
||
* which TemplateConfig a given tracker uses. Users who want to edit the
|
||
* actual config can click "Edit template" in the modal footer.
|
||
*
|
||
* ``previewLocale`` is modal-scoped so switching tabs only refetches for
|
||
* this preview — the user's UI locale (and other previews) are untouched.
|
||
*/
|
||
let previewModal = $state<{ slotName: string; rendered: string; error: string; locale: string } | null>(null);
|
||
let previewLoading = $state(false);
|
||
let previewLocales = $derived(supportedLocalesCache.items);
|
||
|
||
async function openTemplatePreview(slotName: string) {
|
||
await supportedLocalesCache.fetch();
|
||
const initialLocale = previewLocales.includes('en') ? 'en' : (previewLocales[0] || 'en');
|
||
await renderPreviewFor(slotName, initialLocale);
|
||
}
|
||
|
||
async function renderPreviewFor(slotName: string, locale: string) {
|
||
previewLoading = true;
|
||
try {
|
||
const defaults = await api<Record<string, Record<string, string>>>(
|
||
`/template-configs/defaults?provider_type=immich&slot_name=${encodeURIComponent(slotName)}&locale=${encodeURIComponent(locale)}`,
|
||
);
|
||
const template = defaults?.[slotName]?.[locale];
|
||
if (!template) {
|
||
previewModal = { slotName, rendered: '', error: t('templateConfig.resetNoDefault'), locale };
|
||
return;
|
||
}
|
||
const res = await api<{ rendered?: string; error?: string }>(
|
||
'/template-configs/preview-raw',
|
||
{
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
template,
|
||
target_type: 'telegram',
|
||
date_format: '%d.%m.%Y, %H:%M UTC',
|
||
date_only_format: '%d.%m.%Y',
|
||
}),
|
||
},
|
||
);
|
||
previewModal = {
|
||
slotName,
|
||
rendered: res?.rendered || '',
|
||
error: res?.error || '',
|
||
locale,
|
||
};
|
||
} catch (err: unknown) {
|
||
previewModal = { slotName, rendered: '', error: errMsg(err), locale };
|
||
} finally {
|
||
previewLoading = false;
|
||
}
|
||
}
|
||
|
||
const SLOT_FOR_SECTION: Record<string, string> = {
|
||
periodic: 'periodic_summary_message',
|
||
scheduled: 'scheduled_assets_message',
|
||
memory: 'memory_mode_message',
|
||
};
|
||
|
||
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));
|
||
let nameManuallyEdited = $state(false);
|
||
|
||
$effect(() => {
|
||
if (showForm && !nameManuallyEdited && !editing) {
|
||
const desc = getDescriptor(form.provider_type);
|
||
form.name = desc ? `${desc.defaultName} Tracking` : 'Tracking';
|
||
}
|
||
});
|
||
|
||
onMount(() => {
|
||
topbarAction.set({
|
||
label: t('trackingConfig.newConfig'),
|
||
onclick: () => { showForm ? (showForm = false, editing = null) : openNew(); },
|
||
});
|
||
load();
|
||
});
|
||
onDestroy(() => topbarAction.clear());
|
||
|
||
const headerPills = $derived.by(() => {
|
||
const pills: Array<{ label: string; tone: 'sky' }> = [];
|
||
const types = new Set(configs.map(c => c.provider_type)).size;
|
||
if (types > 0) pills.push({ label: `${types} ${types === 1 ? t('providers.typeSingular') : t('providers.typePlural')}`, tone: 'sky' });
|
||
return pills;
|
||
});
|
||
async function load() {
|
||
try { await trackingConfigsCache.fetch(true); }
|
||
catch (err: unknown) { error = errMsg(err, t('common.loadError')); snackError(error); }
|
||
finally { loaded = true; highlightFromUrl(); _openEditFromUrl(); }
|
||
}
|
||
|
||
// Cross-page deep-link: ``/tracking-configs?edit=<id>`` auto-opens that
|
||
// config in edit mode. Used by the Notification Tracker form's "Open
|
||
// Tracking Config" link so users land directly on the right editor
|
||
// instead of the generic list. Strips the param afterwards so a browser
|
||
// refresh doesn't re-open the modal.
|
||
function _openEditFromUrl() {
|
||
if (typeof window === 'undefined') return;
|
||
const params = new URLSearchParams(window.location.search);
|
||
const editId = params.get('edit');
|
||
if (!editId) return;
|
||
const match = allConfigs.find(c => String(c.id) === editId);
|
||
if (match) edit(match);
|
||
params.delete('edit');
|
||
const qs = params.toString();
|
||
const cleanUrl = window.location.pathname + (qs ? '?' + qs : '');
|
||
window.history.replaceState(null, '', cleanUrl);
|
||
}
|
||
|
||
function trackingConfigTiles(config: Record<string, any>): MetaTile[] {
|
||
const tiles: MetaTile[] = [];
|
||
const desc = getDescriptor(config.provider_type);
|
||
const events = (desc?.eventFields ?? []).filter(f => config[f.key]);
|
||
tiles.push({
|
||
icon: 'mdiPulse',
|
||
value: String(events.length),
|
||
label: t('trackingConfig.eventTracking'),
|
||
hint: events.map(f => t(f.label)).join(', ') || undefined,
|
||
tone: events.length > 0 ? 'lavender' : 'default',
|
||
});
|
||
if (config.periodic_enabled) {
|
||
tiles.push({ icon: 'mdiTimerSyncOutline', label: t('trackingConfig.periodic'), tone: 'mint' });
|
||
}
|
||
if (config.scheduled_enabled) {
|
||
tiles.push({ icon: 'mdiCalendarClock', label: t('trackingConfig.scheduled'), tone: 'sky' });
|
||
}
|
||
if (config.memory_enabled) {
|
||
tiles.push({ icon: 'mdiHistory', label: t('trackingConfig.memory'), tone: 'orchid' });
|
||
}
|
||
if (config.quiet_hours_start && config.quiet_hours_end) {
|
||
tiles.push({
|
||
icon: 'mdiWeatherNight',
|
||
label: `${config.quiet_hours_start}–${config.quiet_hours_end}`,
|
||
hint: t('trackingConfig.quietHoursStart'),
|
||
tone: 'citrus',
|
||
mono: true,
|
||
});
|
||
}
|
||
return tiles;
|
||
}
|
||
|
||
function openNew() { form = defaultForm(); nameManuallyEdited = false; editing = null; showForm = true; }
|
||
function edit(c: any) {
|
||
form = { ...defaultForm(), ...c };
|
||
nameManuallyEdited = true;
|
||
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: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
|
||
}
|
||
|
||
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: unknown) {
|
||
const bb = getBlockedBy(err);
|
||
if (bb) { blockedBy = bb; return; }
|
||
const m = errMsg(err); error = m; snackError(m);
|
||
}
|
||
finally { confirmDelete = null; }
|
||
}
|
||
};
|
||
}
|
||
</script>
|
||
|
||
<PageHeader
|
||
title={t('trackingConfig.title')}
|
||
emphasis={t('trackingConfig.titleEmphasis')}
|
||
description={t('trackingConfig.description')}
|
||
crumb={t('crumbs.routingNotification')}
|
||
count={configs.length}
|
||
countLabel={t('trackingConfig.countLabel')}
|
||
pills={headerPills}
|
||
>
|
||
<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} oninput={() => nameManuallyEdited = true} 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>
|
||
<div class="block text-sm font-medium mb-1">{t('trackingConfig.providerType')}</div>
|
||
{#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>
|
||
<div class="flex items-center justify-between mt-1">
|
||
<label class="flex items-center gap-2 text-sm">
|
||
<input type="checkbox" bind:checked={form[section.enabledField]} />
|
||
{t('trackingConfig.enabled')}
|
||
</label>
|
||
{#if SLOT_FOR_SECTION[section.key]}
|
||
<button type="button" onclick={() => openTemplatePreview(SLOT_FOR_SECTION[section.key])}
|
||
class="text-xs text-[var(--color-primary)] hover:underline inline-flex items-center gap-1"
|
||
disabled={previewLoading}>
|
||
<MdiIcon name="mdiEyeOutline" size={14} />
|
||
{t('trackingConfig.previewTemplate')}
|
||
</button>
|
||
{/if}
|
||
</div>
|
||
{#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}
|
||
{@const inputType = field.type === 'date' ? 'date'
|
||
: field.type === 'time' ? 'time'
|
||
: field.type === 'time-list' ? 'text'
|
||
: 'number'}
|
||
{@const hasError = field.type === 'time-list' && !!timeListErrors[field.key]}
|
||
<input type={inputType}
|
||
bind:value={form[field.key]} min={field.min} max={field.max}
|
||
onblur={field.type === 'time-list' && field.validateFormat ? () => normalizeTimeList(field.key) : undefined}
|
||
placeholder={field.type === 'time-list' || field.type === 'time' ? String(typeof field.defaultValue === 'function' ? field.defaultValue() : (field.defaultValue ?? '')) : ''}
|
||
class="w-full px-2 py-1 border rounded-md text-sm bg-[var(--color-background)] {hasError ? 'border-[var(--color-error-fg)]' : 'border-[var(--color-border)]'}" />
|
||
{#if field.inlineHelp}
|
||
<p class="text-[10px] mt-0.5" style="color: var(--color-muted-foreground);">{t(field.inlineHelp)}</p>
|
||
{/if}
|
||
{#if hasError}
|
||
<p class="text-[10px] mt-0.5" style="color: var(--color-error-fg);">{timeListErrors[field.key]}</p>
|
||
{/if}
|
||
{/if}
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{#if section.key === 'quietHours' && form.quiet_hours_start && form.quiet_hours_end}
|
||
<p class="text-xs mt-2" style="color: var(--color-muted-foreground);">
|
||
<MdiIcon name="mdiWeatherNight" size={12} />
|
||
{quietHoursPreview(String(form.quiet_hours_start), String(form.quiet_hours_end))}
|
||
</p>
|
||
{/if}
|
||
{/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="list-stack stagger-children">
|
||
{#each configs as config}
|
||
{@const desc = getDescriptor(config.provider_type)}
|
||
<Card hover entityId={config.id}>
|
||
<div class="list-row">
|
||
<div class="list-row__identity">
|
||
<div class="flex items-center gap-2 min-w-0">
|
||
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={providerDefaultIcon({ icon: config.icon, type: config.provider_type })} size={20} /></span>
|
||
<p class="font-medium truncate">{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 shrink-0">{config.provider_type}</span>
|
||
</div>
|
||
<p class="text-sm text-[var(--color-muted-foreground)] list-row__secondary">
|
||
{(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>
|
||
<MetaStrip tiles={trackingConfigTiles(config)} />
|
||
<div class="list-row__actions">
|
||
<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} />
|
||
|
||
<Modal open={previewModal !== null}
|
||
title={previewModal ? `${t('trackingConfig.previewTemplate')} — ${previewModal.slotName}` : ''}
|
||
onclose={() => previewModal = null}>
|
||
{#if previewModal}
|
||
{#if previewLocales.length > 1}
|
||
<div class="flex gap-1 mb-3 border-b border-[var(--color-border)]">
|
||
{#each previewLocales as loc}
|
||
<button type="button"
|
||
onclick={() => renderPreviewFor(previewModal!.slotName, loc)}
|
||
disabled={previewLoading}
|
||
class="px-3 py-1.5 text-xs font-medium rounded-t-md transition-colors {previewModal.locale === loc ? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]' : 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]'} disabled:opacity-50">
|
||
{loc.toUpperCase()}
|
||
</button>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
<p class="text-xs mb-3" style="color: var(--color-muted-foreground);">
|
||
{t('trackingConfig.previewSampleNote')}
|
||
</p>
|
||
<!-- Keep the prior rendered/error box mounted while refetching on locale
|
||
switch — just dim it. Unmounting and replacing with a small "…"
|
||
placeholder caused a one-frame layout jump as the modal shrank and
|
||
then re-expanded. -->
|
||
<div class="relative mb-3" style="opacity: {previewLoading ? 0.5 : 1}; transition: opacity 0.15s ease;">
|
||
{#if previewModal.error}
|
||
<div class="p-3 rounded text-xs" style="background: var(--color-error-bg); color: var(--color-error-fg);">
|
||
{previewModal.error}
|
||
</div>
|
||
{:else if previewModal.rendered}
|
||
<div class="p-3 bg-[var(--color-muted)] rounded text-sm preview-html">
|
||
<pre class="whitespace-pre-wrap text-xs">{@html sanitizePreview(previewModal.rendered)}</pre>
|
||
</div>
|
||
{:else}
|
||
<div class="p-3 text-xs" style="color: var(--color-muted-foreground);">…</div>
|
||
{/if}
|
||
</div>
|
||
<div class="flex gap-2 justify-end mt-3">
|
||
<button type="button" onclick={() => { const s = previewModal!.slotName; previewModal = null; gotoTemplateConfig(s); }}
|
||
class="text-xs px-3 py-1.5 rounded-md border border-[var(--color-border)] hover:bg-[var(--color-muted)]">
|
||
{t('trackingConfig.editTemplate')}
|
||
</button>
|
||
<button type="button" onclick={() => previewModal = null}
|
||
class="text-xs px-3 py-1.5 rounded-md bg-[var(--color-primary)] text-[var(--color-primary-foreground)]">
|
||
{t('common.close')}
|
||
</button>
|
||
</div>
|
||
{/if}
|
||
</Modal>
|
||
|
||
<style>
|
||
:global(.preview-html a) {
|
||
color: var(--color-primary);
|
||
text-decoration: underline;
|
||
}
|
||
:global(.preview-html a:hover) {
|
||
opacity: 0.8;
|
||
}
|
||
.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>
|