Files
haos-hacs-immich-album-watcher/frontend/src/routes/template-configs/+page.svelte
alexei.dolgolyov b708b14f32
Some checks failed
Validate / Hassfest (push) Has been cancelled
Add frontend for TrackingConfig + TemplateConfig, fix locale, simplify trackers
New pages:
- /tracking-configs: Full CRUD with event tracking, asset display,
  periodic summary, scheduled assets, and memory mode sections.
  Collapsible sub-sections that show/hide based on enabled state.
- /template-configs: Full CRUD with all 21 template slots organized
  into 5 fieldsets (event messages, asset formatting, date/location,
  scheduled messages, telegram). Preview support per slot.

Updated pages:
- Targets: added tracking_config_id + template_config_id selectors
  (dropdowns populated from configs). Configs are reusable.
- Trackers: simplified to album selection + scan interval + targets.
  Added Test, Test Periodic, Test Memory buttons per tracker.
- Nav: replaced Templates with Tracking + Templates config links

Other fixes:
- Language button: now triggers window.location.reload() to force
  all child pages to re-evaluate t() calls
- Dark theme buttons: changed primary color to dark gray in dark mode
- Removed old /templates page (replaced by /template-configs)
- Added .gitignore for __pycache__ in server package

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 17:10:34 +03:00

177 lines
8.0 KiB
Svelte
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { onMount } from 'svelte';
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';
let configs = $state<any[]>([]);
let loaded = $state(false);
let showForm = $state(false);
let editing = $state<number | null>(null);
let error = $state('');
let previewSlot = $state('message_assets_added');
let previewResult = $state('');
let previewId = $state<number | null>(null);
const defaultForm = () => ({
name: '',
message_assets_added: '📷 {added_count} new photo(s) added to album "{album_name}"{common_date}{common_location}.{people}{assets}{video_warning}',
message_assets_removed: '🗑️ {removed_count} photo(s) removed from album "{album_name}".',
message_album_renamed: '✏️ Album "{old_name}" renamed to "{new_name}".',
message_album_deleted: '🗑️ Album "{album_name}" was deleted.',
message_asset_image: '\n • 🖼️ {filename}',
message_asset_video: '\n • 🎬 {filename}',
message_assets_format: '\nAssets:{assets}',
message_assets_more: '\n • ...and {more_count} more',
message_people_format: ' People: {people}.',
date_format: '%d.%m.%Y, %H:%M UTC',
common_date_template: ' from {date}',
date_if_unique_template: ' ({date})',
location_format: '{city}, {country}',
common_location_template: ' in {location}',
location_if_unique_template: ' 📍 {location}',
favorite_indicator: '❤️',
periodic_summary_message: '📋 Tracked Albums Summary ({album_count} albums):{albums}',
periodic_album_template: '\n • {album_name}: {album_url}',
scheduled_assets_message: '📸 Here are some photos from album "{album_name}":{assets}',
memory_mode_message: '📅 On this day:{assets}',
video_warning: '\n\n⚠ Note: Videos may not be sent due to Telegram\'s 50 MB file size limit.',
});
let form = $state(defaultForm());
const templateSlots = [
{ group: 'eventMessages', slots: [
{ key: 'message_assets_added', label: 'assetsAdded' },
{ key: 'message_assets_removed', label: 'assetsRemoved' },
{ key: 'message_album_renamed', label: 'albumRenamed' },
{ key: 'message_album_deleted', label: 'albumDeleted' },
]},
{ group: 'assetFormatting', slots: [
{ key: 'message_asset_image', label: 'imageTemplate' },
{ key: 'message_asset_video', label: 'videoTemplate' },
{ key: 'message_assets_format', label: 'assetsWrapper' },
{ key: 'message_assets_more', label: 'moreMessage' },
{ key: 'message_people_format', label: 'peopleFormat' },
]},
{ group: 'dateLocation', slots: [
{ key: 'date_format', label: 'dateFormat' },
{ key: 'common_date_template', label: 'commonDate' },
{ key: 'date_if_unique_template', label: 'uniqueDate' },
{ key: 'location_format', label: 'locationFormat' },
{ key: 'common_location_template', label: 'commonLocation' },
{ key: 'location_if_unique_template', label: 'uniqueLocation' },
{ key: 'favorite_indicator', label: 'favoriteIndicator' },
]},
{ group: 'scheduledMessages', slots: [
{ key: 'periodic_summary_message', label: 'periodicSummary' },
{ key: 'periodic_album_template', label: 'periodicAlbum' },
{ key: 'scheduled_assets_message', label: 'scheduledAssets' },
{ key: 'memory_mode_message', label: 'memoryMode' },
]},
{ group: 'telegramSettings', slots: [
{ key: 'video_warning', label: 'videoWarning' },
]},
];
onMount(load);
async function load() { try { configs = await api('/template-configs'); } catch {} finally { loaded = true; } }
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(`/template-configs/${editing}`, { method: 'PUT', body: JSON.stringify(form) });
else await api('/template-configs', { method: 'POST', body: JSON.stringify(form) });
showForm = false; editing = null; await load();
} catch (err: any) { error = err.message; }
}
async function preview(id: number, slot: string) {
previewId = id; previewSlot = slot;
try {
const res = await api(`/template-configs/${id}/preview?slot=${slot}`, { method: 'POST' });
previewResult = res.rendered;
} catch (err: any) { previewResult = `Error: ${err.message}`; }
}
async function remove(id: number) {
if (!confirm(t('common.delete') + '?')) return;
try { await api(`/template-configs/${id}`, { method: 'DELETE' }); await load(); } catch (err: any) { error = err.message; }
}
</script>
<PageHeader title={t('templateConfig.title')} description={t('templateConfig.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('templateConfig.newConfig')}
</button>
</PageHeader>
{#if !loaded}<Loading />{:else}
{#if showForm}
<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="tpc-name" class="block text-sm font-medium mb-1">{t('templateConfig.name')}</label>
<input id="tpc-name" bind:value={form.name} required placeholder={t('templateConfig.namePlaceholder')}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
{#each templateSlots as group}
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
<legend class="text-sm font-medium px-1">{t(`templateConfig.${group.group}`)}</legend>
<div class="space-y-3 mt-2">
{#each group.slots as slot}
<div>
<label class="block text-xs text-[var(--color-muted-foreground)] mb-1">{t(`templateConfig.${slot.label}`)}</label>
<textarea bind:value={form[slot.key]} rows={slot.key.includes('message_asset_') || slot.key.includes('_template') || slot.key === 'favorite_indicator' || slot.key === 'date_format' || slot.key === 'location_format' ? 1 : 2}
class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)] font-mono"></textarea>
</div>
{/each}
</div>
</fieldset>
{/each}
<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>
{/if}
{#if configs.length === 0 && !showForm}
<Card><p class="text-sm text-[var(--color-muted-foreground)]">{t('templateConfig.noConfigs')}</p></Card>
{:else}
<div class="space-y-3">
{#each configs as config}
<Card>
<div class="flex items-start justify-between">
<div class="flex-1">
<p class="font-medium">{config.name}</p>
<pre class="text-xs text-[var(--color-muted-foreground)] mt-1 whitespace-pre-wrap font-mono bg-[var(--color-muted)] rounded p-2">{config.message_assets_added?.slice(0, 120)}...</pre>
{#if previewResult && previewId === config.id}
<div class="mt-2 p-2 bg-[var(--color-success-bg)] rounded text-sm">
<p class="text-xs font-medium mb-1">{previewSlot}:</p>
<pre class="whitespace-pre-wrap">{previewResult}</pre>
</div>
{/if}
</div>
<div class="flex items-center gap-3 ml-4">
<button onclick={() => preview(config.id, 'message_assets_added')} class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templateConfig.preview')}</button>
<button onclick={() => edit(config)} class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('common.edit')}</button>
<button onclick={() => remove(config.id)} class="text-xs text-[var(--color-destructive)] hover:underline">{t('common.delete')}</button>
</div>
</div>
</Card>
{/each}
</div>
{/if}
{/if}