Some checks failed
Validate / Hassfest (push) Has been cancelled
JinjaEditor:
- Custom StreamLanguage parser for Jinja2 syntax highlighting:
{{ variables }} in blue, {% statements %} in purple, {# comments #} in gray
- Replaced HTML mode (didn't understand Jinja2 syntax)
- Proper monospace font (Consolas/Monaco)
TemplateConfig:
- Added `description` field to model + seed defaults with descriptions
- Description shown on template cards instead of raw template text
- Description input in create/edit form
Preview:
- Toggle behavior: clicking Preview again hides the preview
- Per-slot preview uses preview-raw API (renders current editor content)
i18n: added common.description, templateConfig.descriptionPlaceholder
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
249 lines
11 KiB
Svelte
249 lines
11 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 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 ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
|
import Hint from '$lib/components/Hint.svelte';
|
|
import IconButton from '$lib/components/IconButton.svelte';
|
|
|
|
let loaded = $state(false);
|
|
let loadError = $state('');
|
|
let trackers = $state<any[]>([]);
|
|
let servers = $state<any[]>([]);
|
|
let targets = $state<any[]>([]);
|
|
let albums = $state<any[]>([]);
|
|
let showForm = $state(false);
|
|
let editing = $state<number | null>(null);
|
|
let albumFilter = $state('');
|
|
let submitting = $state(false);
|
|
let confirmDelete = $state<any>(null);
|
|
let toggling = $state<Record<number, boolean>>({});
|
|
let testingPeriodic = $state<Record<number, boolean>>({});
|
|
let testingMemory = $state<Record<number, boolean>>({});
|
|
let testFeedback = $state<Record<number, string>>({});
|
|
const defaultForm = () => ({
|
|
name: '', icon: '', server_id: 0, album_ids: [] as string[],
|
|
target_ids: [] as number[], scan_interval: 60,
|
|
});
|
|
let form = $state(defaultForm());
|
|
let error = $state('');
|
|
|
|
onMount(load);
|
|
async function load() {
|
|
loadError = '';
|
|
try {
|
|
[trackers, servers, targets] = await Promise.all([api('/trackers'), api('/servers'), api('/targets')]);
|
|
} catch (err: any) {
|
|
loadError = err.message || 'Failed to load data';
|
|
} finally {
|
|
loaded = true;
|
|
}
|
|
}
|
|
async function loadAlbums() { if (!form.server_id) return; albums = await api(`/servers/${form.server_id}/albums`); }
|
|
|
|
function openNew() { form = defaultForm(); editing = null; showForm = true; albums = []; }
|
|
async function edit(trk: any) {
|
|
form = {
|
|
name: trk.name, icon: trk.icon || '', server_id: trk.server_id, album_ids: [...trk.album_ids],
|
|
target_ids: [...trk.target_ids], scan_interval: trk.scan_interval,
|
|
};
|
|
editing = trk.id; showForm = true;
|
|
if (form.server_id) await loadAlbums();
|
|
}
|
|
|
|
async function save(e: SubmitEvent) {
|
|
e.preventDefault(); error = '';
|
|
if (submitting) return;
|
|
submitting = true;
|
|
try {
|
|
if (editing) {
|
|
await api(`/trackers/${editing}`, { method: 'PUT', body: JSON.stringify(form) });
|
|
} else {
|
|
await api('/trackers', { method: 'POST', body: JSON.stringify(form) });
|
|
}
|
|
showForm = false; editing = null; await load();
|
|
} catch (err: any) { error = err.message; } finally { submitting = false; }
|
|
}
|
|
async function toggle(tracker: any) {
|
|
if (toggling[tracker.id]) return;
|
|
toggling[tracker.id] = true;
|
|
try {
|
|
await api(`/trackers/${tracker.id}`, { method: 'PUT', body: JSON.stringify({ enabled: !tracker.enabled }) });
|
|
await load();
|
|
} finally { toggling[tracker.id] = false; }
|
|
}
|
|
function startDelete(tracker: any) { confirmDelete = tracker; }
|
|
async function doDelete() {
|
|
if (!confirmDelete) return;
|
|
try {
|
|
await api(`/trackers/${confirmDelete.id}`, { method: 'DELETE' });
|
|
await load();
|
|
} catch (err: any) { error = err.message; }
|
|
confirmDelete = null;
|
|
}
|
|
async function testPeriodic(tracker: any) {
|
|
if (testingPeriodic[tracker.id]) return;
|
|
testingPeriodic[tracker.id] = true;
|
|
testFeedback[tracker.id] = '';
|
|
try {
|
|
await api(`/trackers/${tracker.id}/test-periodic`, { method: 'POST' });
|
|
testFeedback[tracker.id] = 'ok';
|
|
} catch {
|
|
testFeedback[tracker.id] = 'error';
|
|
} finally {
|
|
testingPeriodic[tracker.id] = false;
|
|
setTimeout(() => { testFeedback[tracker.id] = ''; }, 3000);
|
|
}
|
|
}
|
|
async function testMemory(tracker: any) {
|
|
if (testingMemory[tracker.id]) return;
|
|
testingMemory[tracker.id] = true;
|
|
testFeedback[tracker.id] = '';
|
|
try {
|
|
await api(`/trackers/${tracker.id}/test-memory`, { method: 'POST' });
|
|
testFeedback[tracker.id] = 'ok';
|
|
} catch {
|
|
testFeedback[tracker.id] = 'error';
|
|
} finally {
|
|
testingMemory[tracker.id] = false;
|
|
setTimeout(() => { testFeedback[tracker.id] = ''; }, 3000);
|
|
}
|
|
}
|
|
function toggleAlbum(albumId: string) { form.album_ids = form.album_ids.includes(albumId) ? form.album_ids.filter(id => id !== albumId) : [...form.album_ids, albumId]; }
|
|
function toggleTarget(targetId: number) { form.target_ids = form.target_ids.includes(targetId) ? form.target_ids.filter(id => id !== targetId) : [...form.target_ids, targetId]; }
|
|
</script>
|
|
|
|
<PageHeader title={t('trackers.title')} description={t('trackers.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('trackers.cancel') : t('trackers.newTracker')}
|
|
</button>
|
|
</PageHeader>
|
|
|
|
{#if !loaded}
|
|
<Loading />
|
|
{:else if loadError}
|
|
<Card>
|
|
<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3">
|
|
{loadError}
|
|
</div>
|
|
<button onclick={load} class="mt-3 px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md hover:bg-[var(--color-muted)]">
|
|
{t('common.retry')}
|
|
</button>
|
|
</Card>
|
|
{: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-4">
|
|
<div>
|
|
<label for="trk-name" class="block text-sm font-medium mb-1">{t('trackers.name')}</label>
|
|
<div class="flex gap-2">
|
|
<IconPicker value={form.icon} onselect={(v) => form.icon = v} />
|
|
<input id="trk-name" bind:value={form.name} required placeholder={t('trackers.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 for="trk-server" class="block text-sm font-medium mb-1">{t('trackers.server')}</label>
|
|
<select id="trk-server" bind:value={form.server_id} onchange={loadAlbums} required class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
|
<option value={0} disabled>{t('trackers.selectServer')}</option>
|
|
{#each servers as s}<option value={s.id}>{s.name}</option>{/each}
|
|
</select>
|
|
</div>
|
|
{#if albums.length > 0}
|
|
<div>
|
|
<label class="block text-sm font-medium mb-1">{t('trackers.albums')} ({albums.length})</label>
|
|
<input type="text" bind:value={albumFilter} placeholder="Filter albums..."
|
|
class="w-full px-3 py-1.5 mb-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
|
<div class="max-h-56 overflow-y-auto border border-[var(--color-border)] rounded-md p-2 space-y-1">
|
|
{#each albums.filter(a => !albumFilter || a.albumName.toLowerCase().includes(albumFilter.toLowerCase())) as album}
|
|
<label class="flex items-center justify-between text-sm cursor-pointer hover:bg-[var(--color-muted)] px-2 py-1 rounded">
|
|
<span class="flex items-center gap-2">
|
|
<input type="checkbox" checked={form.album_ids.includes(album.id)} onchange={() => toggleAlbum(album.id)} />
|
|
{album.albumName} <span class="text-[var(--color-muted-foreground)]">({album.assetCount})</span>
|
|
</span>
|
|
{#if album.updatedAt}
|
|
<span class="text-xs text-[var(--color-muted-foreground)]">{new Date(album.updatedAt).toLocaleDateString()}</span>
|
|
{/if}
|
|
</label>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
<div>
|
|
<label for="trk-interval" class="block text-sm font-medium mb-1">{t('trackers.scanInterval')}<Hint text={t('hints.scanInterval')} /></label>
|
|
<input id="trk-interval" type="number" bind:value={form.scan_interval} min="10" max="3600" class="w-32 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
|
</div>
|
|
|
|
{#if targets.length > 0}
|
|
<div>
|
|
<label class="block text-sm font-medium mb-1">{t('trackers.notificationTargets')}</label>
|
|
<div class="flex flex-wrap gap-2">
|
|
{#each targets as tgt}
|
|
<label class="flex items-center gap-1 text-sm">
|
|
<input type="checkbox" checked={form.target_ids.includes(tgt.id)} onchange={() => toggleTarget(tgt.id)} />
|
|
{tgt.name} ({tgt.type})
|
|
</label>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<button type="submit" disabled={submitting} class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">{editing ? t('common.save') : t('trackers.createTracker')}</button>
|
|
</form>
|
|
</Card>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if !loaded}
|
|
<!-- skeleton shown above -->
|
|
{:else if trackers.length === 0 && !showForm}
|
|
<Card><p class="text-sm text-[var(--color-muted-foreground)]">{t('trackers.noTrackers')}</p></Card>
|
|
{:else}
|
|
<div class="space-y-3">
|
|
{#each trackers as tracker}
|
|
<Card hover>
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<div class="flex items-center gap-2">
|
|
{#if tracker.icon}<MdiIcon name={tracker.icon} />{/if}
|
|
<p class="font-medium">{tracker.name}</p>
|
|
<span class="text-xs px-1.5 py-0.5 rounded {tracker.enabled ? 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]' : 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
|
|
{tracker.enabled ? t('trackers.active') : t('trackers.paused')}
|
|
</span>
|
|
</div>
|
|
<p class="text-sm text-[var(--color-muted-foreground)]">{tracker.album_ids.length} {t('trackers.albums_count')} · {t('trackers.every')} {tracker.scan_interval}s · {tracker.target_ids.length} {t('trackers.targets')}</p>
|
|
</div>
|
|
<div class="flex items-center gap-1">
|
|
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(tracker)} />
|
|
<IconButton icon="mdiPlay" title={t('common.test')} onclick={async () => { await api(`/trackers/${tracker.id}/trigger`, { method: 'POST' }); }} />
|
|
<IconButton icon="mdiCalendarClock" title={t('trackers.testPeriodic')} onclick={() => testPeriodic(tracker)} disabled={testingPeriodic[tracker.id]} />
|
|
<IconButton icon="mdiHistory" title={t('trackers.testMemory')} onclick={() => testMemory(tracker)} disabled={testingMemory[tracker.id]} />
|
|
{#if testFeedback[tracker.id]}
|
|
<span class="text-xs {testFeedback[tracker.id] === 'ok' ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-destructive)]'}">
|
|
{testFeedback[tracker.id] === 'ok' ? '\u2713' : '\u2717'}
|
|
</span>
|
|
{/if}
|
|
<IconButton icon={tracker.enabled ? 'mdiPause' : 'mdiPlay'} title={tracker.enabled ? t('trackers.pause') : t('trackers.resume')} onclick={() => toggle(tracker)} disabled={toggling[tracker.id]} />
|
|
<IconButton icon="mdiDelete" title={t('trackers.delete')} onclick={() => startDelete(tracker)} variant="danger" />
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
<ConfirmModal
|
|
open={!!confirmDelete}
|
|
title={t('trackers.delete')}
|
|
message={t('trackers.deleteConfirm')}
|
|
onconfirm={doDelete}
|
|
oncancel={() => confirmDelete = null}
|
|
/>
|