fix: comprehensive API/UI review — 26 bug fixes and improvements

Backend:
- Scheduler lifecycle sync: create/update/delete tracker now syncs APScheduler jobs
- Test-periodic/test-memory endpoints render actual Jinja2 templates with sample data
- Cascade cleanup on tracker delete (TrackerState removed, EventLog nullified)
- Fix user_id=0 FK violation for system-owned TemplateConfig (removed FK constraint)
- Fix API key leak: only attach x-api-key header for internal provider URLs
- Validate config ownership in tracker_targets create/update
- Fix _response() double-emit of created_at in template/tracking configs
- Add per-target-link test endpoints (test, test-periodic, test-memory)

Frontend:
- Fix orphaned provider on test exception in providers/new
- Add submitting guard + disabled state to targets save button
- Move test buttons from tracker card to per-target-link rows
- Fix Svelte 5 async $state reactivity (spread reassignment for all Record mutations)
- i18n for dashboard timeAgo and event type badges (EN + RU)
- Add required attribute to chat select dropdown in targets
- Fix font CSS vars to prioritize imported DM Sans / JetBrains Mono
- Standardize empty states with centered icon + text across all 6 list pages
- Add stagger-children animation class to all list containers
- Fix slide transition duration consistency (200ms everywhere)
- Standardize border-radius to rounded-md across all form inputs
- Fix providers/new page structure (h2 + mb-8 spacing)
- Fix tracker card action row overflow (flex-wrap justify-end)
- JinjaEditor dark mode reactivity (recreate editor on theme change)
- Add aria-labels to mobile nav items
- Make ConfirmModal confirm button label/icon configurable
- Remove double error reporting on providers page
- Add telegram bot edit functionality (name editing via PUT)
- i18n for External Domain label on provider forms

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-20 14:26:20 +03:00
parent 9eec21a5b2
commit 91e5cd58e9
24 changed files with 3514 additions and 375 deletions
+2 -2
View File
@@ -229,13 +229,13 @@
<!-- Mobile bottom nav -->
<nav class="mobile-nav" style="position: fixed; bottom: 0; left: 0; right: 0; z-index: 50; background: var(--color-sidebar); border-top: 1px solid var(--color-border); display: none; justify-content: space-around; padding: 0.375rem 0; backdrop-filter: blur(12px);">
{#each navItems.slice(0, 5) as item}
<a href={item.href}
<a href={item.href} aria-label={t(item.key)}
class="flex flex-col items-center gap-0.5 px-2 py-1.5 text-xs rounded-lg transition-all duration-200"
style="color: {isActive(item.href) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'};">
<MdiIcon name={item.icon} size={20} />
</a>
{/each}
<button onclick={logout}
<button onclick={logout} aria-label={t('nav.logout')}
class="flex flex-col items-center gap-0.5 px-2 py-1.5 text-xs" style="color: var(--color-muted-foreground);">
<MdiIcon name="mdiLogout" size={20} />
</button>
+14 -16
View File
@@ -31,17 +31,7 @@
onMount(async () => {
try {
const [providers, trackers, targets] = await Promise.all([
api<any[]>('/providers'),
api<any[]>('/trackers'),
api<any[]>('/targets'),
]);
status = {
providers: providers.length,
trackers: { active: trackers.filter((t: any) => t.enabled).length, total: trackers.length },
targets: targets.length,
recent_events: [],
};
status = await api<any>('/status');
setTimeout(() => {
animateCount(0, status.providers, (v) => displayProviders = v);
animateCount(0, status.trackers.active, (v) => displayActive = v);
@@ -64,13 +54,21 @@
function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
if (mins < 1) return t('dashboard.justNow');
if (mins < 60) return t('dashboard.minutesAgo').replace('{n}', String(mins));
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
if (hours < 24) return t('dashboard.hoursAgo').replace('{n}', String(hours));
return t('dashboard.daysAgo').replace('{n}', String(Math.floor(hours / 24)));
}
const eventLabels: Record<string, string> = {
assets_added: 'dashboard.assetsAdded',
assets_removed: 'dashboard.assetsRemoved',
collection_renamed: 'dashboard.collectionRenamed',
collection_deleted: 'dashboard.collectionDeleted',
sharing_changed: 'dashboard.sharingChanged',
};
const eventIcons: Record<string, string> = {
assets_added: 'mdiImagePlus', assets_removed: 'mdiImageMinus',
collection_renamed: 'mdiRename', collection_deleted: 'mdiDeleteAlert', sharing_changed: 'mdiShareVariant',
@@ -137,7 +135,7 @@
<MdiIcon name={eventIcons[event.event_type] || 'mdiBell'} size={16} />
</span>
<span class="text-sm font-medium truncate">{event.collection_name}</span>
<span class="event-badge">{event.event_type.replace('_', ' ')}</span>
<span class="event-badge">{t(eventLabels[event.event_type] || event.event_type)}</span>
</div>
<span class="text-xs whitespace-nowrap font-mono" style="color: var(--color-muted-foreground);">{timeAgo(event.created_at)}</span>
</div>
+159 -47
View File
@@ -1,96 +1,208 @@
<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 IconButton from '$lib/components/IconButton.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
let providers = $state<any[]>([]);
let showForm = $state(false);
let editing = $state<number | null>(null);
let form = $state({ name: 'Immich', type: 'immich', url: '', api_key: '', external_domain: '', icon: '' });
let error = $state('');
let loadError = $state('');
let submitting = $state(false);
let loaded = $state(false);
let confirmDelete = $state<any>(null);
let deleteTarget = $state<any>(null);
let health = $state<Record<number, boolean | null>>({});
onMount(async () => {
await loadProviders();
});
async function loadProviders() {
onMount(load);
async function load() {
try {
providers = await api('/providers');
loadError = '';
} catch (err: any) {
snackError(err.message);
} finally {
loaded = true;
loadError = err.message || t('providers.loadError');
} finally { loaded = true; }
// Ping all providers in background
for (const p of providers) {
health = { ...health, [p.id]: null };
api(`/providers/${p.id}/test`, { method: 'POST' })
.then((r: any) => { health = { ...health, [p.id]: r.ok }; })
.catch(() => { health = { ...health, [p.id]: false }; });
}
}
async function deleteProvider() {
if (!deleteTarget) return;
function openNew() {
form = { name: 'Immich', type: 'immich', url: '', api_key: '', external_domain: '', icon: '' };
editing = null; showForm = true;
}
function edit(p: any) {
const cfg = p.config || {};
form = { name: p.name, type: p.type, url: cfg.url || '', api_key: '', external_domain: cfg.external_domain || '', icon: p.icon || '' };
editing = p.id; showForm = true;
}
async function save(e: SubmitEvent) {
e.preventDefault(); error = ''; submitting = true;
try {
await api(`/providers/${deleteTarget.id}`, { method: 'DELETE' });
snackSuccess(t('snack.providerDeleted'));
deleteTarget = null;
await loadProviders();
} catch (err: any) {
snackError(err.message);
}
const config: any = { url: form.url };
if (form.api_key) config.api_key = form.api_key;
if (form.external_domain) config.external_domain = form.external_domain;
if (editing) {
await api(`/providers/${editing}`, { method: 'PUT', body: JSON.stringify({ name: form.name, icon: form.icon, config }) });
} else {
config.api_key = form.api_key; // required on create
await api('/providers', { method: 'POST', body: JSON.stringify({ type: form.type, name: form.name, icon: form.icon, config }) });
}
showForm = false; editing = null; await load();
snackSuccess(t('snack.providerSaved'));
} catch (err: any) { error = err.message; snackError(err.message); }
submitting = false;
}
function startDelete(provider: any) { confirmDelete = provider; }
async function doDelete() {
if (!confirmDelete) return;
const id = confirmDelete.id;
confirmDelete = null;
try { await api(`/providers/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.providerDeleted')); }
catch (err: any) { error = err.message; snackError(err.message); }
}
</script>
<PageHeader title={t('providers.title')} description={t('providers.description')}>
<a href="/providers/new"
class="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200"
style="background: var(--color-primary); color: var(--color-primary-foreground);"
onmouseenter={(e) => { e.currentTarget.style.boxShadow = '0 0 16px var(--color-glow-strong)'; }}
onmouseleave={(e) => { e.currentTarget.style.boxShadow = 'none'; }}>
<MdiIcon name="mdiPlus" size={16} />
{t('providers.addProvider')}
</a>
<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('providers.cancel') : t('providers.addProvider')}
</button>
</PageHeader>
{#if !loaded}
<Loading />
{:else if providers.length === 0}
{:else}
{#if loadError}
<Card class="mb-6">
<div class="flex items-center gap-2 text-sm" style="color: var(--color-error-fg);">
<MdiIcon name="mdiAlertCircle" size={18} />
{loadError}
</div>
</Card>
{/if}
{#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-3">
<div>
<label for="prv-type" class="block text-sm font-medium mb-1">{t('providers.type')}</label>
<select id="prv-type" bind:value={form.type} disabled={!!editing}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] disabled:opacity-60">
<option value="immich">Immich</option>
</select>
</div>
<div>
<label for="prv-name" class="block text-sm font-medium mb-1">{t('providers.name')}</label>
<div class="flex gap-2">
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
<input id="prv-name" bind:value={form.name} required 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="prv-url" class="block text-sm font-medium mb-1">{t('providers.url')}</label>
<input id="prv-url" bind:value={form.url} required placeholder={t('providers.urlPlaceholder')} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="prv-key" class="block text-sm font-medium mb-1">{editing ? t('providers.apiKeyKeep') : t('providers.apiKey')}</label>
<input id="prv-key" bind:value={form.api_key} type="password" required={!editing} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="prv-ext" class="block text-sm font-medium mb-1">{t('providers.externalDomain')} <span class="text-xs text-[var(--color-muted-foreground)]">({t('providers.optional')})</span></label>
<input id="prv-ext" bind:value={form.external_domain} placeholder="https://photos.example.com" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<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">
{submitting ? t('providers.connecting') : (editing ? t('common.save') : t('providers.addProvider'))}
</button>
</form>
</Card>
</div>
{/if}
{#if providers.length === 0 && !showForm}
<Card>
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
<div style="opacity: 0.4;">
<MdiIcon name="mdiServer" size={40} />
</div>
<div style="opacity: 0.4;"><MdiIcon name="mdiServer" size={40} /></div>
<p class="text-sm">{t('providers.noProviders')}</p>
</div>
</Card>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 stagger-children">
<div class="space-y-3 stagger-children">
{#each providers as provider}
<Card hover>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-10 h-10 rounded-lg"
style="background: var(--color-primary); color: var(--color-primary-foreground); opacity: 0.9;">
<MdiIcon name={provider.icon || 'mdiServer'} size={20} />
</div>
<div class="health-dot {health[provider.id] === true ? 'online' : health[provider.id] === false ? 'offline' : 'checking'}"></div>
{#if provider.icon}
<span style="color: var(--color-primary);"><MdiIcon name={provider.icon} size={20} /></span>
{/if}
<div>
<h3 class="font-medium text-sm">{provider.name}</h3>
<p class="text-xs capitalize" style="color: var(--color-muted-foreground);">{provider.type}</p>
<div class="flex items-center gap-2">
<p class="font-medium">{provider.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{provider.type}</span>
</div>
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{provider.config?.url || ''}</p>
</div>
</div>
<IconButton icon="mdiDelete" variant="danger" title={t('providers.delete')}
onclick={() => deleteTarget = provider} />
<div class="flex items-center gap-1">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(provider)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => startDelete(provider)} variant="danger" />
</div>
</div>
</Card>
{/each}
</div>
{/if}
<ConfirmModal
open={!!deleteTarget}
title={t('providers.confirmDelete')}
message={deleteTarget?.name || ''}
onconfirm={deleteProvider}
oncancel={() => deleteTarget = null}
/>
{/if}
<ConfirmModal open={!!confirmDelete} message={t('providers.confirmDelete')}
onconfirm={doDelete} oncancel={() => confirmDelete = null} />
<style>
.health-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
transition: all 0.3s ease;
}
.health-dot.online {
background: #059669;
box-shadow: 0 0 8px rgba(5, 150, 105, 0.4);
}
.health-dot.offline {
background: #ef4444;
box-shadow: 0 0 8px rgba(239, 68, 68, 0.3);
}
.health-dot.checking {
background: #f59e0b;
animation: pulseCheck 1.5s ease-in-out infinite;
}
@keyframes pulseCheck {
0%, 100% { box-shadow: 0 0 4px rgba(245, 158, 11, 0.3); }
50% { box-shadow: 0 0 12px rgba(245, 158, 11, 0.6); }
}
</style>
+61 -83
View File
@@ -1,133 +1,111 @@
<script lang="ts">
import { t } from '$lib/i18n/index.svelte.ts';
import { api } from '$lib/api.ts';
import { api } from '$lib/api';
import { t } from '$lib/i18n';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import Card from '$lib/components/Card.svelte';
import IconPicker from '$lib/components/IconPicker.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
let providerType = $state('immich');
let name = $state('');
let icon = $state('');
let url = $state('');
let apiKey = $state('');
let externalDomain = $state('');
let error = $state('');
let testing = $state(false);
let testResult = $state<{ ok: boolean; message: string } | null>(null);
let saving = $state(false);
async function testConnection() {
if (!url || !apiKey) {
error = 'URL and API Key are required';
return;
}
testing = true;
testResult = null;
error = '';
async function testAndSave() {
if (!url || !apiKey) { error = 'URL and API Key are required'; return; }
testing = true; error = '';
let createdId: number | null = null;
try {
// Save first to get an ID, then test
const provider = await api.post<any>('/providers', {
type: providerType,
name: name || 'Immich',
config: { url, api_key: apiKey, external_domain: externalDomain || undefined },
const provider = await api('/providers', {
method: 'POST',
body: JSON.stringify({ type: providerType, name: name || 'Immich', icon, config: { url, api_key: apiKey, external_domain: externalDomain || undefined } }),
});
testResult = await api.post<{ ok: boolean; message: string }>(`/providers/${provider.id}/test`);
if (!testResult.ok) {
// Clean up failed provider
await api.delete(`/providers/${provider.id}`);
createdId = provider.id;
const result = await api(`/providers/${provider.id}/test`, { method: 'POST' });
if (!result.ok) {
await api(`/providers/${provider.id}`, { method: 'DELETE' }).catch(() => {});
createdId = null;
error = result.message || 'Connection test failed';
snackError(error);
} else {
// Success — redirect to providers list
snackSuccess(t('snack.providerSaved'));
window.location.href = '/providers';
return;
}
} catch (e: any) {
error = e.message || 'Test failed';
} finally {
testing = false;
if (createdId) await api(`/providers/${createdId}`, { method: 'DELETE' }).catch(() => {});
error = e.message || 'Test failed'; snackError(error);
}
finally { testing = false; }
}
async function handleSave() {
if (!url || !apiKey) {
error = 'URL and API Key are required';
return;
}
saving = true;
error = '';
async function saveWithoutTest() {
if (!url || !apiKey) { error = 'URL and API Key are required'; return; }
saving = true; error = '';
try {
await api.post('/providers', {
type: providerType,
name: name || 'Immich',
config: { url, api_key: apiKey, external_domain: externalDomain || undefined },
await api('/providers', {
method: 'POST',
body: JSON.stringify({ type: providerType, name: name || 'Immich', icon, config: { url, api_key: apiKey, external_domain: externalDomain || undefined } }),
});
snackSuccess(t('snack.providerSaved'));
window.location.href = '/providers';
} catch (e: any) {
error = e.message || 'Save failed';
} finally {
saving = false;
}
} catch (e: any) { error = e.message || 'Save failed'; snackError(error); }
finally { saving = false; }
}
</script>
<div class="p-6 max-w-2xl mx-auto">
<div class="mb-6">
<a href="/providers" class="text-sm text-muted-foreground hover:text-foreground">&larr; Back to Providers</a>
</div>
<div class="mb-4">
<a href="/providers" class="text-sm text-[var(--color-muted-foreground)] hover:underline">{t('providers.title')}</a>
</div>
<h1 class="text-2xl font-bold mb-6">{t('provider.addProvider')}</h1>
<h2 class="text-xl font-semibold mb-8">{t('providers.addProvider')}</h2>
<div class="bg-card rounded-xl border border-border p-6 space-y-5">
<!-- Provider Type -->
<Card>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-muted-foreground mb-1">Provider Type</label>
<select bind:value={providerType} class="w-full px-3 py-2 border border-border rounded-[var(--radius)] bg-background">
<option value="immich">{t('provider.immich')}</option>
</select>
</div>
<!-- Name -->
<div>
<label class="block text-sm font-medium text-muted-foreground mb-1">Name</label>
<input type="text" bind:value={name} placeholder="My Immich Server" class="w-full px-3 py-2 border border-border rounded-[var(--radius)]" />
<label for="prv-name" class="block text-sm font-medium mb-1">{t('providers.name')}</label>
<div class="flex gap-2">
<IconPicker value={icon} onselect={(v: string) => icon = v} />
<input id="prv-name" bind:value={name} placeholder="My Immich Server" class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
{#if providerType === 'immich'}
<!-- Immich URL -->
<div>
<label class="block text-sm font-medium text-muted-foreground mb-1">Server URL <span class="text-destructive">*</span></label>
<input type="url" bind:value={url} placeholder="http://192.168.1.100:2283" class="w-full px-3 py-2 border border-border rounded-[var(--radius)]" required />
<label for="prv-url" class="block text-sm font-medium mb-1">{t('providers.url')}</label>
<input id="prv-url" type="url" bind:value={url} required placeholder={t('providers.urlPlaceholder')} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<!-- API Key -->
<div>
<label class="block text-sm font-medium text-muted-foreground mb-1">API Key <span class="text-destructive">*</span></label>
<input type="password" bind:value={apiKey} placeholder="Your Immich API key" autocomplete="off" class="w-full px-3 py-2 border border-border rounded-[var(--radius)]" required />
<label for="prv-key" class="block text-sm font-medium mb-1">{t('providers.apiKey')}</label>
<input id="prv-key" type="password" bind:value={apiKey} required autocomplete="off" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<!-- External Domain -->
<div>
<label class="block text-sm font-medium text-muted-foreground mb-1">External Domain <span class="text-muted-foreground font-normal">(optional)</span></label>
<input type="url" bind:value={externalDomain} placeholder="https://photos.example.com" class="w-full px-3 py-2 border border-border rounded-[var(--radius)]" />
<p class="text-xs text-muted-foreground mt-1">Public-facing URL for notification links. Falls back to server URL.</p>
<label for="prv-ext" class="block text-sm font-medium mb-1">{t('providers.externalDomain')} <span class="text-xs text-[var(--color-muted-foreground)]">({t('providers.optional')})</span></label>
<input id="prv-ext" type="url" bind:value={externalDomain} placeholder="https://photos.example.com" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">Public-facing URL for notification links. Falls back to server URL.</p>
</div>
{/if}
{#if error}
<p class="text-sm text-destructive">{error}</p>
{/if}
{#if testResult}
<div class="p-3 rounded-lg {testResult.ok ? 'bg-success-bg text-success-fg' : 'bg-error-bg text-error-fg'}">
{testResult.message}
</div>
<p class="text-sm text-[var(--color-error-fg)]">{error}</p>
{/if}
<div class="flex gap-3 pt-2">
<button onclick={testConnection} disabled={testing || saving} class="px-5 py-2.5 bg-primary text-primary-foreground rounded-[var(--radius)] font-medium hover:opacity-90 transition-opacity disabled:opacity-50">
{testing ? 'Testing...' : 'Test & Save'}
<button onclick={testAndSave} disabled={testing || saving}
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">
{testing ? t('providers.connecting') : 'Test & Save'}
</button>
<button onclick={handleSave} disabled={testing || saving} class="px-5 py-2.5 bg-muted text-foreground rounded-[var(--radius)] font-medium hover:bg-accent transition-colors disabled:opacity-50">
{saving ? 'Saving...' : 'Save without testing'}
<button onclick={saveWithoutTest} disabled={testing || saving}
class="px-4 py-2 bg-[var(--color-muted)] text-[var(--color-foreground)] rounded-md text-sm font-medium hover:opacity-80 disabled:opacity-50">
{saving ? t('common.loading') : 'Save without testing'}
</button>
<a href="/providers" class="px-5 py-2.5 bg-muted text-muted-foreground rounded-[var(--radius)] font-medium hover:bg-accent transition-colors">
<a href="/providers" class="px-4 py-2 bg-[var(--color-muted)] text-[var(--color-muted-foreground)] rounded-md text-sm font-medium hover:opacity-80">
{t('common.cancel')}
</a>
</div>
</div>
</div>
</Card>
+233 -16
View File
@@ -1,26 +1,227 @@
<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';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
let targets = $state<any[]>([]);
let bots = $state<any[]>([]);
let botChats = $state<Record<number, any[]>>({});
let showForm = $state(false);
let editing = $state<number | null>(null);
let formType = $state<'telegram' | 'webhook'>('telegram');
const defaultForm = () => ({ name: '', icon: '', bot_id: 0, chat_id: '', bot_token: '', url: '', headers: '',
max_media_to_send: 50, max_media_per_group: 10, media_delay: 500, max_asset_size: 50,
disable_url_preview: false, send_large_photos_as_documents: false, ai_captions: false });
let form = $state(defaultForm());
let error = $state('');
let headersError = $state('');
let loaded = $state(false);
let submitting = $state(false);
let loadError = $state('');
let showTelegramSettings = $state(false);
let confirmDelete = $state<any>(null);
onMount(async () => {
try { targets = await api('/targets'); } catch {}
loaded = true;
});
onMount(load);
async function load() {
try {
[targets, bots] = await Promise.all([api('/targets'), api('/telegram-bots')]);
loadError = '';
} catch (err: any) { loadError = err.message || t('common.loadError'); snackError(loadError); } finally { loaded = true; }
}
async function loadBotChats() {
if (!form.bot_id) return;
try { botChats[form.bot_id] = await api(`/telegram-bots/${form.bot_id}/chats`); } catch {}
}
function openNew() { form = defaultForm(); formType = 'telegram'; editing = null; showTelegramSettings = false; showForm = true; }
async function edit(tgt: any) {
formType = tgt.type;
const c = tgt.config || {};
form = {
name: tgt.name, icon: tgt.icon || '', bot_id: c.bot_id || 0, bot_token: '', chat_id: c.chat_id || '', url: c.url || '', headers: '',
max_media_to_send: c.max_media_to_send ?? 50, max_media_per_group: c.max_media_per_group ?? 10,
media_delay: c.media_delay ?? 500, max_asset_size: c.max_asset_size ?? 50,
disable_url_preview: c.disable_url_preview ?? false, send_large_photos_as_documents: c.send_large_photos_as_documents ?? false,
ai_captions: c.ai_captions ?? false,
};
editing = tgt.id; showTelegramSettings = false; showForm = true;
if (form.bot_id) await loadBotChats();
}
async function save(e: SubmitEvent) {
e.preventDefault(); error = ''; headersError = '';
if (submitting) return;
submitting = true;
try {
let botToken = form.bot_token;
if (formType === 'telegram' && form.bot_id && !botToken) {
const tokenRes = await api(`/telegram-bots/${form.bot_id}/token`);
botToken = tokenRes.token;
}
let parsedHeaders = {};
if (formType === 'webhook' && form.headers) {
try { parsedHeaders = JSON.parse(form.headers); }
catch { headersError = t('common.headersInvalid'); return; }
}
const config = formType === 'telegram'
? { ...(botToken ? { bot_token: botToken } : {}), chat_id: form.chat_id,
bot_id: form.bot_id || undefined,
max_media_to_send: form.max_media_to_send, max_media_per_group: form.max_media_per_group,
media_delay: form.media_delay, max_asset_size: form.max_asset_size,
disable_url_preview: form.disable_url_preview, send_large_photos_as_documents: form.send_large_photos_as_documents,
ai_captions: form.ai_captions }
: { url: form.url, headers: parsedHeaders, ai_captions: form.ai_captions };
if (editing) {
await api(`/targets/${editing}`, { method: 'PUT', body: JSON.stringify({ name: form.name, icon: form.icon, config }) });
} else {
await api('/targets', { method: 'POST', body: JSON.stringify({ type: formType, name: form.name, icon: form.icon, config }) });
}
showForm = false; editing = null; await load();
snackSuccess(t('snack.targetSaved'));
} catch (err: any) { error = err.message; snackError(err.message); }
finally { submitting = false; }
}
async function test(id: number) {
try {
const res = await api(`/targets/${id}/test`, { method: 'POST' });
if (res.success) snackSuccess(t('snack.targetTestSent'));
else snackError(`Failed: ${res.error}`);
} catch (err: any) { snackError(err.message); }
}
async function remove(id: number) {
try { await api(`/targets/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.targetDeleted')); }
catch (err: any) { error = err.message; snackError(err.message); }
}
</script>
<PageHeader title={t('targets.title')} description={t('targets.description')} />
<PageHeader title={t('targets.title')} description={t('targets.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('targets.cancel') : t('targets.addTarget')}
</button>
</PageHeader>
{#if !loaded}
<Loading />
{:else if targets.length === 0}
{#if !loaded}<Loading />{:else}
{#if loadError}
<div class="mb-4 p-3 rounded-md text-sm bg-[var(--color-error-bg)] text-[var(--color-error-fg)]">{loadError}</div>
{/if}
{#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>
<span class="block text-sm font-medium mb-1">{t('targets.type')}</span>
<div class="flex gap-4">
<label class="flex items-center gap-1 text-sm"><input type="radio" bind:group={formType} value="telegram" /> Telegram</label>
<label class="flex items-center gap-1 text-sm"><input type="radio" bind:group={formType} value="webhook" /> Webhook</label>
</div>
</div>
<div>
<label for="tgt-name" class="block text-sm font-medium mb-1">{t('targets.name')}</label>
<div class="flex gap-2">
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
<input id="tgt-name" bind:value={form.name} required placeholder={t('targets.namePlaceholder')} class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
{#if formType === 'telegram'}
<div>
<label for="tgt-bot" class="block text-sm font-medium mb-1">{t('telegramBot.selectBot')}</label>
<select id="tgt-bot" bind:value={form.bot_id} onchange={loadBotChats} 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('telegramBot.selectBot')}</option>
{#each bots as bot}<option value={bot.id}>{bot.name} (@{bot.bot_username})</option>{/each}
</select>
{#if bots.length === 0}
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('telegramBot.noBots')} <a href="/telegram-bots" class="underline"></a></p>
{/if}
</div>
{#if form.bot_id}
<div>
<label for="tgt-chat" class="block text-sm font-medium mb-1">{t('telegramBot.selectChat')}</label>
{#if (botChats[form.bot_id] || []).length > 0}
<select id="tgt-chat" bind:value={form.chat_id} required
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
<option value="">{t('telegramBot.selectChat')}</option>
{#each botChats[form.bot_id] as chat}
<option value={chat.chat_id}>{chat.title || chat.username || 'Unknown'} ({chat.type}) [{chat.chat_id}]</option>
{/each}
</select>
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">
<button type="button" onclick={loadBotChats} class="hover:underline">{t('telegramBot.refreshChats')}</button>
</p>
{:else}
<input id="tgt-chat" bind:value={form.chat_id} required placeholder="Chat ID"
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('telegramBot.noChats')}</p>
{/if}
</div>
{/if}
<div class="border border-[var(--color-border)] rounded-md p-3">
<button type="button" onclick={() => showTelegramSettings = !showTelegramSettings}
class="text-sm font-medium cursor-pointer w-full text-left flex items-center justify-between">
{t('targets.telegramSettings')}
<span class="text-xs transition-transform duration-200" class:rotate-180={showTelegramSettings}>▼</span>
</button>
{#if showTelegramSettings}
<div in:slide={{ duration: 150 }} class="grid grid-cols-2 gap-3 mt-3">
<div>
<label for="tgt-maxmedia" class="block text-xs mb-1">{t('targets.maxMedia')}<Hint text={t('hints.maxMedia')} /></label>
<input id="tgt-maxmedia" type="number" bind:value={form.max_media_to_send} 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 for="tgt-groupsize" class="block text-xs mb-1">{t('targets.maxGroupSize')}<Hint text={t('hints.groupSize')} /></label>
<input id="tgt-groupsize" type="number" bind:value={form.max_media_per_group} min="2" max="10" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="tgt-delay" class="block text-xs mb-1">{t('targets.chunkDelay')}<Hint text={t('hints.chunkDelay')} /></label>
<input id="tgt-delay" type="number" bind:value={form.media_delay} min="0" max="60000" step="100" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="tgt-maxsize" class="block text-xs mb-1">{t('targets.maxAssetSize')}<Hint text={t('hints.maxAssetSize')} /></label>
<input id="tgt-maxsize" type="number" bind:value={form.max_asset_size} min="1" max="50" 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 col-span-2"><input type="checkbox" bind:checked={form.disable_url_preview} /> {t('targets.disableUrlPreview')}</label>
<label class="flex items-center gap-2 text-sm col-span-2"><input type="checkbox" bind:checked={form.send_large_photos_as_documents} /> {t('targets.sendLargeAsDocuments')}</label>
</div>
{/if}
</div>
{:else}
<div>
<label for="tgt-url" class="block text-sm font-medium mb-1">{t('targets.webhookUrl')}</label>
<input id="tgt-url" bind:value={form.url} required placeholder="https://..." class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="tgt-headers" class="block text-sm font-medium mb-1">Headers (JSON)</label>
<input id="tgt-headers" bind:value={form.headers} placeholder={'{"Authorization": "Bearer ..."}'} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" style={headersError ? 'border-color: var(--color-error-fg)' : ''} />
{#if headersError}<p class="text-xs text-[var(--color-error-fg)] mt-1">{headersError}</p>{/if}
</div>
{/if}
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.ai_captions} /> {t('targets.aiCaptions')}<Hint text={t('hints.aiCaptions')} /></label>
<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">{submitting ? t('common.loading') : (editing ? t('common.save') : t('targets.create'))}</button>
</form>
</Card>
</div>
{/if}
{#if targets.length === 0 && !showForm}
<Card>
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
<div style="opacity: 0.4;"><MdiIcon name="mdiTarget" size={40} /></div>
@@ -28,20 +229,36 @@
</div>
</Card>
{:else}
<div class="grid gap-4 sm:grid-cols-2 stagger-children">
<div class="space-y-3 stagger-children">
{#each targets as target}
<Card hover>
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-10 h-10 rounded-lg"
style="background: var(--color-primary); color: var(--color-primary-foreground); opacity: 0.9;">
<MdiIcon name={target.type === 'telegram' ? 'mdiTelegram' : 'mdiWebhook'} size={20} />
</div>
<div class="flex items-center justify-between">
<div>
<h3 class="font-medium text-sm">{target.name}</h3>
<p class="text-xs capitalize" style="color: var(--color-muted-foreground);">{target.type}</p>
<div class="flex items-center gap-2">
{#if target.icon}<MdiIcon name={target.icon} />{/if}
<p class="font-medium">{target.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.type}</span>
</div>
<p class="text-sm text-[var(--color-muted-foreground)]">
{target.type === 'telegram' ? `Chat: ${target.config?.chat_id || '***'}` : target.config?.url || ''}
</p>
</div>
<div class="flex items-center gap-1">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(target)} />
<IconButton icon="mdiSend" title={t('targets.test')} onclick={() => test(target.id)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => confirmDelete = target} variant="danger" />
</div>
</div>
</Card>
{/each}
</div>
{/if}
{/if}
<ConfirmModal
open={!!confirmDelete}
message={t('targets.confirmDelete')}
onconfirm={() => { if (confirmDelete) { remove(confirmDelete.id); confirmDelete = null; } }}
oncancel={() => confirmDelete = null}
/>
+191 -16
View File
@@ -1,26 +1,153 @@
<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 IconButton from '$lib/components/IconButton.svelte';
import { snackSuccess, snackError, snackInfo } from '$lib/stores/snackbar.svelte';
let bots = $state<any[]>([]);
let loaded = $state(false);
let showForm = $state(false);
let editing = $state<number | null>(null);
let form = $state({ name: '', icon: '', token: '' });
let error = $state('');
let submitting = $state(false);
let confirmDelete = $state<any>(null);
onMount(async () => {
try { bots = await api('/telegram-bots'); } catch {}
loaded = true;
});
// Per-bot expandable sections
let chats = $state<Record<number, any[]>>({});
let chatsLoading = $state<Record<number, boolean>>({});
let expandedSection = $state<Record<number, string>>({});
onMount(load);
async function load() {
try { bots = await api('/telegram-bots'); }
catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
finally { loaded = true; }
}
function openNew() { form = { name: '', icon: '', token: '' }; editing = null; showForm = true; }
function editBot(bot: any) { form = { name: bot.name, icon: bot.icon || '', token: '' }; editing = bot.id; showForm = true; }
async function saveBot(e: SubmitEvent) {
e.preventDefault(); error = ''; submitting = true;
try {
if (editing) {
await api(`/telegram-bots/${editing}`, { method: 'PUT', body: JSON.stringify({ name: form.name }) });
snackSuccess(t('snack.botUpdated'));
} else {
await api('/telegram-bots', { method: 'POST', body: JSON.stringify(form) });
snackSuccess(t('snack.botRegistered'));
}
form = { name: '', icon: '', token: '' }; showForm = false; editing = null; await load();
} catch (err: any) { error = err.message; snackError(err.message); }
submitting = false;
}
function remove(id: number) {
confirmDelete = {
id,
onconfirm: async () => {
try { await api(`/telegram-bots/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.botDeleted')); }
catch (err: any) { error = err.message; snackError(err.message); }
finally { confirmDelete = null; }
}
};
}
function toggleSection(botId: number, section: string) {
if (expandedSection[botId] === section) {
expandedSection = { ...expandedSection, [botId]: '' };
return;
}
expandedSection = { ...expandedSection, [botId]: section };
if (section === 'chats') loadChats(botId);
}
async function loadChats(botId: number) {
chatsLoading = { ...chatsLoading, [botId]: true };
try { chats = { ...chats, [botId]: await api(`/telegram-bots/${botId}/chats`) }; } catch { chats = { ...chats, [botId]: [] }; }
chatsLoading = { ...chatsLoading, [botId]: false };
}
async function discoverChats(botId: number) {
chatsLoading = { ...chatsLoading, [botId]: true };
try {
chats = { ...chats, [botId]: await api(`/telegram-bots/${botId}/chats/discover`, { method: 'POST' }) };
snackSuccess(t('telegramBot.chatsDiscovered'));
} catch (err: any) { snackError(err.message); }
chatsLoading = { ...chatsLoading, [botId]: false };
}
async function deleteChat(botId: number, chatDbId: number) {
try {
await api(`/telegram-bots/${botId}/chats/${chatDbId}`, { method: 'DELETE' });
chats[botId] = (chats[botId] || []).filter((c: any) => c.id !== chatDbId);
snackSuccess(t('telegramBot.chatDeleted'));
} catch (err: any) { snackError(err.message); }
}
function copyChatId(e: Event, chatId: string) {
e.stopPropagation();
navigator.clipboard.writeText(chatId);
snackInfo(`${t('snack.copied')}: ${chatId}`);
}
function chatTypeLabel(type: string): string {
const map: Record<string, string> = {
private: t('telegramBot.private'),
group: t('telegramBot.group'),
supergroup: t('telegramBot.supergroup'),
channel: t('telegramBot.channel'),
};
return map[type] || type;
}
</script>
<PageHeader title={t('telegramBot.title')} description={t('telegramBot.description')} />
<PageHeader title={t('telegramBot.title')} description={t('telegramBot.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('telegramBot.addBot')}
</button>
</PageHeader>
{#if !loaded}
<Loading />
{:else if bots.length === 0}
{#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={saveBot} class="space-y-3">
<div>
<label for="bot-name" class="block text-sm font-medium mb-1">{t('telegramBot.name')}</label>
<div class="flex gap-2">
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
<input id="bot-name" bind:value={form.name} required placeholder={t('telegramBot.namePlaceholder')}
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
{#if !editing}
<div>
<label for="bot-token" class="block text-sm font-medium mb-1">{t('telegramBot.token')}</label>
<input id="bot-token" bind:value={form.token} required placeholder={t('telegramBot.tokenPlaceholder')}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] font-mono" />
</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">
{submitting ? t('common.loading') : (editing ? t('common.save') : t('telegramBot.addBot'))}
</button>
</form>
</Card>
{/if}
{#if bots.length === 0 && !showForm}
<Card>
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
<div style="opacity: 0.4;"><MdiIcon name="mdiRobot" size={40} /></div>
@@ -28,20 +155,68 @@
</div>
</Card>
{:else}
<div class="grid gap-4 sm:grid-cols-2 stagger-children">
<div class="space-y-3 stagger-children">
{#each bots as bot}
<Card hover>
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-10 h-10 rounded-lg"
style="background: #229ED9; color: white;">
<MdiIcon name="mdiRobot" size={20} />
</div>
<div class="flex items-center justify-between">
<div>
<h3 class="font-medium text-sm">{bot.name}</h3>
<p class="text-xs" style="color: var(--color-muted-foreground);">@{bot.bot_username || '...'}</p>
<div class="flex items-center gap-2">
{#if bot.icon}<MdiIcon name={bot.icon} />{/if}
<p class="font-medium">{bot.name}</p>
{#if bot.bot_username}
<span class="text-xs text-[var(--color-muted-foreground)]">@{bot.bot_username}</span>
{/if}
</div>
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.token_preview}</p>
</div>
<div class="flex items-center gap-1">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editBot(bot)} />
<button onclick={() => toggleSection(bot.id, 'chats')}
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1">
{t('telegramBot.chats')} {expandedSection[bot.id] === 'chats' ? '▲' : '▼'}
</button>
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(bot.id)} variant="danger" />
</div>
</div>
<!-- Chats section -->
{#if expandedSection[bot.id] === 'chats'}
<div class="mt-3 border-t border-[var(--color-border)] pt-3" in:slide>
{#if chatsLoading[bot.id]}
<p class="text-xs text-[var(--color-muted-foreground)]">{t('common.loading')}</p>
{:else if (chats[bot.id] || []).length === 0}
<p class="text-xs text-[var(--color-muted-foreground)]">{t('telegramBot.noChats')}</p>
{:else}
<div class="space-y-1">
{#each chats[bot.id] as chat}
<div class="flex items-center justify-between text-sm px-2 py-1 rounded hover:bg-[var(--color-muted)] cursor-pointer"
onclick={(e: MouseEvent) => copyChatId(e, chat.chat_id)}
title={t('telegramBot.clickToCopy')}
role="button" tabindex="0">
<div class="flex items-center gap-2">
<span class="font-medium">{chat.title || chat.username || 'Unknown'}</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{chatTypeLabel(chat.type)}</span>
<span class="text-xs text-[var(--color-muted-foreground)] font-mono">{chat.chat_id}</span>
</div>
<IconButton icon="mdiDelete" title={t('common.delete')} size={14}
onclick={(e: MouseEvent) => { e.stopPropagation(); deleteChat(bot.id, chat.id); }} variant="danger" />
</div>
{/each}
</div>
{/if}
<button onclick={() => discoverChats(bot.id)}
class="text-xs text-[var(--color-primary)] hover:underline mt-2 flex items-center gap-1">
<MdiIcon name="mdiSync" size={14} />
{t('telegramBot.discoverChats')}
</button>
</div>
{/if}
</Card>
{/each}
</div>
{/if}
{/if}
<ConfirmModal open={confirmDelete !== null} message={t('telegramBot.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
+276 -19
View File
@@ -1,26 +1,236 @@
<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';
import Modal from '$lib/components/Modal.svelte';
import JinjaEditor from '$lib/components/JinjaEditor.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
let configs = $state<any[]>([]);
let loaded = $state(false);
let varsRef = $state<Record<string, any>>({});
let showVarsFor = $state<string | null>(null);
let showForm = $state(false);
let editing = $state<number | null>(null);
let error = $state('');
let confirmDelete = $state<any>(null);
let slotPreview = $state<Record<string, string>>({});
let slotErrors = $state<Record<string, string>>({});
let slotErrorLines = $state<Record<string, number | null>>({});
let slotErrorTypes = $state<Record<string, string>>({});
let validateTimers: Record<string, ReturnType<typeof setTimeout>> = {};
onMount(async () => {
try { configs = await api('/template-configs'); } catch {}
loaded = true;
function validateSlot(slotKey: string, template: string, immediate = false) {
if (validateTimers[slotKey]) clearTimeout(validateTimers[slotKey]);
if (!template) {
slotErrors = { ...slotErrors, [slotKey]: '' };
slotErrorLines = { ...slotErrorLines, [slotKey]: null };
slotErrorTypes = { ...slotErrorTypes, [slotKey]: '' };
const { [slotKey]: _, ...rest } = slotPreview;
slotPreview = rest;
return;
}
const doValidate = async () => {
try {
const res = await api('/template-configs/preview-raw', { method: 'POST', body: JSON.stringify({ template, target_type: previewTargetType }) });
slotErrors = { ...slotErrors, [slotKey]: res.error || '' };
slotErrorLines = { ...slotErrorLines, [slotKey]: res.error_line || null };
slotErrorTypes = { ...slotErrorTypes, [slotKey]: res.error_type || '' };
if (res.rendered) {
slotPreview = { ...slotPreview, [slotKey]: res.rendered };
} else {
const { [slotKey]: _, ...rest } = slotPreview;
slotPreview = rest;
}
} catch {
slotErrors = { ...slotErrors, [slotKey]: '' };
slotErrorLines = { ...slotErrorLines, [slotKey]: null };
slotErrorTypes = { ...slotErrorTypes, [slotKey]: '' };
}
};
if (immediate) { doValidate(); }
else { validateTimers[slotKey] = setTimeout(doValidate, 800); }
}
function refreshAllPreviews() {
for (const group of templateSlots) {
for (const slot of group.slots) {
const template = (form as any)[slot.key];
if (template && slot.key !== 'date_format') {
validateSlot(slot.key, template, true);
}
}
}
}
const defaultForm = () => ({
provider_type: 'immich', name: '', description: '', icon: '',
message_assets_added: '',
message_assets_removed: '',
message_collection_renamed: '',
message_collection_deleted: '',
message_sharing_changed: '',
periodic_summary_message: '',
scheduled_assets_message: '',
memory_mode_message: '',
date_format: '%d.%m.%Y, %H:%M UTC',
});
let form = $state(defaultForm());
let previewTargetType = $state('telegram');
const templateSlots = [
{ group: 'eventMessages', slots: [
{ key: 'message_assets_added', label: 'assetsAdded', rows: 10 },
{ key: 'message_assets_removed', label: 'assetsRemoved', rows: 3 },
{ key: 'message_collection_renamed', label: 'albumRenamed', rows: 2 },
{ key: 'message_collection_deleted', label: 'albumDeleted', rows: 2 },
{ key: 'message_sharing_changed', label: 'sharingChanged', rows: 2 },
]},
{ group: 'scheduledMessages', slots: [
{ key: 'periodic_summary_message', label: 'periodicSummary', rows: 6 },
{ key: 'scheduled_assets_message', label: 'scheduledAssets', rows: 6 },
{ key: 'memory_mode_message', label: 'memoryMode', rows: 6 },
]},
{ group: 'settings', slots: [
{ key: 'date_format', label: 'dateFormat', rows: 1 },
]},
];
onMount(load);
async function load() {
try {
[configs, varsRef] = await Promise.all([
api('/template-configs'),
api('/template-configs/variables'),
]);
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
finally { loaded = true; }
}
function openNew() { form = defaultForm(); editing = null; showForm = true; slotPreview = {}; slotErrors = {}; }
function edit(c: any) {
form = { ...defaultForm(), ...c }; editing = c.id; showForm = true;
slotPreview = {}; slotErrors = {};
setTimeout(() => refreshAllPreviews(), 100);
}
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();
snackSuccess(t('snack.templateSaved'));
} catch (err: any) { error = err.message; snackError(err.message); }
}
function remove(id: number) {
confirmDelete = {
id,
onconfirm: async () => {
try { await api(`/template-configs/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.templateDeleted')); }
catch (err: any) { error = err.message; snackError(err.message); }
finally { confirmDelete = null; }
}
};
}
</script>
<PageHeader title={t('templateConfig.title')} description={t('templateConfig.description')} />
<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 configs.length === 0}
{#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="tpc-name" class="block text-sm font-medium mb-1">{t('templateConfig.name')}</label>
<div class="flex gap-2">
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
<input id="tpc-name" bind:value={form.name} required placeholder={t('templateConfig.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="tpc-desc" class="block text-sm font-medium mb-1">{t('common.description')}</label>
<input id="tpc-desc" bind:value={form.description} placeholder={t('templateConfig.descriptionPlaceholder')}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div class="flex items-center gap-2">
<label for="preview-target" class="text-sm font-medium">{t('templateConfig.previewAs')}:</label>
<select id="preview-target" bind:value={previewTargetType} onchange={refreshAllPreviews}
class="px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
<option value="telegram">Telegram</option>
<option value="webhook">Webhook</option>
</select>
</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}`)}{#if group.group === 'eventMessages'}<Hint text={t('hints.eventMessages')} />{:else if group.group === 'scheduledMessages'}<Hint text={t('hints.scheduledMessages')} />{/if}</legend>
<div class="space-y-3 mt-2">
{#each group.slots as slot}
<div>
<div class="flex items-center justify-between mb-1">
<label class="text-xs text-[var(--color-muted-foreground)]">{t(`templateConfig.${slot.label}`)}</label>
<div class="flex items-center gap-2">
{#if varsRef[slot.key]}
<button type="button" onclick={() => showVarsFor = slot.key}
class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templateConfig.variables')}</button>
{/if}
</div>
</div>
{#if slot.key === 'date_format'}
<input bind:value={(form as any)[slot.key]}
class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] font-mono" />
{:else}
<JinjaEditor value={(form as any)[slot.key] || ''} onchange={(v: string) => { (form as any)[slot.key] = v; validateSlot(slot.key, v); }} rows={slot.rows || 3} errorLine={slotErrorLines[slot.key] || null} />
{#if slotErrors[slot.key]}
{#if slotErrorTypes[slot.key] === 'undefined'}
<p class="mt-1 text-xs" style="color: #d97706;">⚠ {t('common.undefinedVar')}: {slotErrors[slot.key]}</p>
{:else}
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">✕ {t('common.syntaxError')}: {slotErrors[slot.key]}{slotErrorLines[slot.key] ? ` (${t('common.line')} ${slotErrorLines[slot.key]})` : ''}</p>
{/if}
{/if}
{#if slotPreview[slot.key] && !slotErrors[slot.key]}
<div class="mt-1 p-2 bg-[var(--color-muted)] rounded text-sm">
<pre class="whitespace-pre-wrap text-xs">{slotPreview[slot.key]}</pre>
</div>
{/if}
{/if}
</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>
</div>
{/if}
{#if configs.length === 0 && !showForm}
<Card>
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
<div style="opacity: 0.4;"><MdiIcon name="mdiFileDocumentEdit" size={40} /></div>
@@ -28,24 +238,71 @@
</div>
</Card>
{:else}
<div class="grid gap-4 sm:grid-cols-2 stagger-children">
<div class="space-y-3 stagger-children">
{#each configs as config}
<Card hover>
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-10 h-10 rounded-lg"
style="background: var(--color-accent); color: var(--color-accent-foreground);">
<MdiIcon name={config.icon || 'mdiFileDocumentEdit'} size={20} />
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2">
{#if config.icon}<MdiIcon name={config.icon} />{/if}
<p class="font-medium">{config.name}</p>
{#if config.user_id === 0}
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">System</span>
{/if}
</div>
{#if config.description}
<p class="text-sm text-[var(--color-muted-foreground)] mt-1">{config.description}</p>
{/if}
</div>
<div>
<h3 class="font-medium text-sm">{config.name}</h3>
<p class="text-xs" style="color: var(--color-muted-foreground);">{config.description || config.provider_type}</p>
<div class="flex items-center gap-1 ml-4">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(config)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(config.id)} variant="danger" />
</div>
{#if config.user_id === 0}
<span class="ml-auto text-xs px-2 py-0.5 rounded-full"
style="background: var(--color-muted); color: var(--color-muted-foreground);">System</span>
{/if}
</div>
</Card>
{/each}
</div>
{/if}
{/if}
<ConfirmModal open={confirmDelete !== null} message={t('templateConfig.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
<!-- Variables reference modal -->
<Modal open={showVarsFor !== null} title="{t('templateConfig.variables')}: {showVarsFor ? t(`templateConfig.${templateSlots.flatMap(g => g.slots).find(s => s.key === showVarsFor)?.label || showVarsFor}`) : ''}" onclose={() => showVarsFor = null}>
{#if showVarsFor && varsRef[showVarsFor]}
<p class="text-sm text-[var(--color-muted-foreground)] mb-3">{t(`templateVars.${showVarsFor}.description`, varsRef[showVarsFor].description)}</p>
<div class="space-y-1">
<p class="text-xs font-medium mb-1">{t('templateConfig.variables')}:</p>
{#each Object.entries(varsRef[showVarsFor].variables || {}) as [name, desc]}
<div class="flex items-start gap-2 text-sm">
<code class="text-xs bg-[var(--color-muted)] px-1 py-0.5 rounded font-mono whitespace-nowrap">{'{{ ' + name + ' }}'}</code>
<span class="text-xs text-[var(--color-muted-foreground)]">{t(`templateVars.${name}`, desc as string)}</span>
</div>
{/each}
</div>
{#if varsRef[showVarsFor].asset_fields && typeof varsRef[showVarsFor].asset_fields === 'object'}
<div class="mt-3 pt-3 border-t border-[var(--color-border)]">
<p class="text-xs font-medium mb-1">{t('templateConfig.assetFields')}:</p>
{#each Object.entries(varsRef[showVarsFor].asset_fields) as [name, desc]}
<div class="flex items-start gap-2 text-sm">
<code class="text-xs bg-[var(--color-muted)] px-1 py-0.5 rounded font-mono whitespace-nowrap">{'{{ asset.' + name + ' }}'}</code>
<span class="text-xs text-[var(--color-muted-foreground)]">{t(`templateVars.asset_${name}`, desc as string)}</span>
</div>
{/each}
</div>
{/if}
{#if varsRef[showVarsFor].album_fields && typeof varsRef[showVarsFor].album_fields === 'object'}
<div class="mt-3 pt-3 border-t border-[var(--color-border)]">
<p class="text-xs font-medium mb-1">{t('templateConfig.albumFields')}:</p>
{#each Object.entries(varsRef[showVarsFor].album_fields) as [name, desc]}
<div class="flex items-start gap-2 text-sm">
<code class="text-xs bg-[var(--color-muted)] px-1 py-0.5 rounded font-mono whitespace-nowrap">{'{{ album.' + name + ' }}'}</code>
<span class="text-xs text-[var(--color-muted-foreground)]">{t(`templateVars.album_${name}`, desc as string)}</span>
</div>
{/each}
</div>
{/if}
{/if}
</Modal>
+350 -21
View File
@@ -1,53 +1,382 @@
<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';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
let trackers = $state<any[]>([]);
let loaded = $state(false);
let loadError = $state('');
let trackers = $state<any[]>([]);
let providers = $state<any[]>([]);
let targets = $state<any[]>([]);
let trackingConfigs = $state<any[]>([]);
let templateConfigs = $state<any[]>([]);
let collections = $state<any[]>([]);
let showForm = $state(false);
let editing = $state<number | null>(null);
let collectionFilter = $state('');
let submitting = $state(false);
let confirmDelete = $state<any>(null);
let toggling = $state<Record<number, boolean>>({});
// Per tracker-target test state (keyed by `${ttId}_${testType}`)
let ttTesting = $state<Record<string, string>>({});
let ttFeedback = $state<Record<string, string>>({});
onMount(async () => {
try { trackers = await api('/trackers'); } catch {}
loaded = true;
// Tracker form
const defaultForm = () => ({
name: '', icon: '', provider_id: 0, collection_ids: [] as string[],
scan_interval: 60, batch_duration: 0,
});
let form = $state(defaultForm());
let error = $state('');
// Linked targets management (inline in tracker detail)
let expandedTracker = $state<number | null>(null);
let linkedTargets = $state<Record<number, any[]>>({});
let addingTarget = $state<Record<number, boolean>>({});
let newLinkTargetId = $state<Record<number, number>>({});
let newLinkTrackingConfigId = $state<Record<number, number>>({});
let newLinkTemplateConfigId = $state<Record<number, number>>({});
onMount(load);
async function load() {
loadError = '';
try {
[trackers, providers, targets, trackingConfigs, templateConfigs] = await Promise.all([
api('/trackers'), api('/providers'), api('/targets'),
api('/tracking-configs'), api('/template-configs'),
]);
} catch (err: any) {
loadError = err.message || 'Failed to load data';
snackError(loadError);
} finally { loaded = true; }
}
async function loadCollections() {
if (!form.provider_id) return;
try { collections = await api(`/providers/${form.provider_id}/collections`); } catch { collections = []; }
}
function openNew() { form = defaultForm(); editing = null; showForm = true; collections = []; }
async function edit(trk: any) {
form = {
name: trk.name, icon: trk.icon || '', provider_id: trk.provider_id,
collection_ids: [...(trk.collection_ids || [])],
scan_interval: trk.scan_interval, batch_duration: trk.batch_duration ?? 0,
};
editing = trk.id; showForm = true;
if (form.provider_id) await loadCollections();
}
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) });
snackSuccess(t('snack.trackerUpdated'));
} else {
await api('/trackers', { method: 'POST', body: JSON.stringify(form) });
snackSuccess(t('snack.trackerCreated'));
}
showForm = false; editing = null; await load();
} catch (err: any) { error = err.message; snackError(err.message); } finally { submitting = false; }
}
async function toggle(tracker: any) {
if (toggling[tracker.id]) return;
toggling = { ...toggling, [tracker.id]: true };
try {
await api(`/trackers/${tracker.id}`, { method: 'PUT', body: JSON.stringify({ enabled: !tracker.enabled }) });
await load();
snackSuccess(tracker.enabled ? t('snack.trackerPaused') : t('snack.trackerResumed'));
} catch (err: any) { snackError(err.message); } finally { toggling = { ...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();
snackSuccess(t('snack.trackerDeleted'));
} catch (err: any) { error = err.message; snackError(err.message); }
confirmDelete = null;
}
async function testTrackerTarget(trackerId: number, ttId: number, testType: string) {
const key = `${ttId}_${testType}`;
if (ttTesting[key]) return;
ttTesting = { ...ttTesting, [key]: testType };
ttFeedback = { ...ttFeedback, [key]: '' };
try {
const endpoint = testType === 'basic' ? 'test' : `test-${testType}`;
await api(`/trackers/${trackerId}/targets/${ttId}/${endpoint}`, { method: 'POST' });
ttFeedback = { ...ttFeedback, [key]: 'ok' };
snackSuccess(t('snack.targetTestSent'));
} catch (err: any) {
ttFeedback = { ...ttFeedback, [key]: 'error' };
snackError(err.message);
} finally {
ttTesting = { ...ttTesting, [key]: '' };
setTimeout(() => { ttFeedback = { ...ttFeedback, [key]: '' }; }, 3000);
}
}
function toggleCollection(collectionId: string) { form.collection_ids = form.collection_ids.includes(collectionId) ? form.collection_ids.filter(id => id !== collectionId) : [...form.collection_ids, collectionId]; }
// --- Linked Targets Management ---
function toggleExpand(trackerId: number) {
if (expandedTracker === trackerId) { expandedTracker = null; return; }
expandedTracker = trackerId;
// tracker_targets already loaded in tracker response
}
function getUnlinkedTargets(tracker: any): any[] {
const linkedIds = new Set((tracker.tracker_targets || []).map((tt: any) => tt.target_id));
return targets.filter(t => !linkedIds.has(t.id));
}
async function addTargetLink(trackerId: number) {
const targetId = newLinkTargetId[trackerId];
if (!targetId) return;
addingTarget = { ...addingTarget, [trackerId]: true };
try {
await api(`/trackers/${trackerId}/targets`, {
method: 'POST',
body: JSON.stringify({
target_id: targetId,
tracking_config_id: newLinkTrackingConfigId[trackerId] || null,
template_config_id: newLinkTemplateConfigId[trackerId] || null,
}),
});
newLinkTargetId[trackerId] = 0;
newLinkTrackingConfigId[trackerId] = 0;
newLinkTemplateConfigId[trackerId] = 0;
await load();
snackSuccess(t('snack.targetLinked'));
} catch (err: any) { snackError(err.message); }
addingTarget = { ...addingTarget, [trackerId]: false };
}
async function removeTargetLink(trackerId: number, ttId: number) {
try {
await api(`/trackers/${trackerId}/targets/${ttId}`, { method: 'DELETE' });
await load();
snackSuccess(t('snack.targetUnlinked'));
} catch (err: any) { snackError(err.message); }
}
async function updateTargetLink(trackerId: number, tt: any, field: string, value: any) {
try {
await api(`/trackers/${trackerId}/targets/${tt.id}`, {
method: 'PUT',
body: JSON.stringify({ [field]: value }),
});
await load();
} catch (err: any) { snackError(err.message); }
}
</script>
<PageHeader title={t('trackers.title')} description={t('trackers.description')} />
<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 trackers.length === 0}
<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>
</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: string) => 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-provider" class="block text-sm font-medium mb-1">{t('trackers.server')}</label>
<select id="trk-provider" bind:value={form.provider_id} onchange={loadCollections} 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 providers as p}<option value={p.id}>{p.name}</option>{/each}
</select>
</div>
{#if collections.length > 0}
<div>
<label class="block text-sm font-medium mb-1">{t('trackers.albums')} ({collections.length})</label>
<input type="text" bind:value={collectionFilter} placeholder="Filter..."
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 collections.filter(a => !collectionFilter || (a.albumName || a.name || '').toLowerCase().includes(collectionFilter.toLowerCase())) as col}
<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.collection_ids.includes(col.id)} onchange={() => toggleCollection(col.id)} />
{col.albumName || col.name} <span class="text-[var(--color-muted-foreground)]">({col.assetCount ?? col.asset_count ?? 0})</span>
</span>
</label>
{/each}
</div>
</div>
{/if}
<div class="grid grid-cols-2 gap-3">
<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-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="trk-batch" class="block text-sm font-medium mb-1">{t('trackers.batchDuration')}<Hint text={t('hints.batchDuration')} /></label>
<input id="trk-batch" type="number" bind:value={form.batch_duration} min="0" max="3600" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
<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 && !loadError}
{#if trackers.length === 0 && !showForm}
<Card>
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
<div style="opacity: 0.4;"><MdiIcon name="mdiRadar" size={40} /></div>
<p class="text-sm">{t('trackers.noTrackers')}</p>
</div>
</Card>
{:else}
<div class="grid gap-4 sm:grid-cols-2 stagger-children">
{:else if !showForm}
<div class="space-y-3 stagger-children">
{#each trackers as tracker}
<Card hover>
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-10 h-10 rounded-lg"
style="background: {tracker.enabled ? 'var(--color-success-bg)' : 'var(--color-muted)'}; color: {tracker.enabled ? 'var(--color-success-fg)' : 'var(--color-muted-foreground)'};">
<MdiIcon name="mdiRadar" size={20} />
</div>
<div class="flex items-center justify-between">
<div>
<h3 class="font-medium text-sm">{tracker.name}</h3>
<p class="text-xs" style="color: var(--color-muted-foreground);">
{tracker.collection_ids?.length || 0} {t('trackers.albums_count')} &middot; {t('trackers.every')} {tracker.scan_interval}s
<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.collection_ids || []).length} {t('trackers.albums_count')} · {t('trackers.every')} {tracker.scan_interval}s · {(tracker.tracker_targets || []).length} {t('trackers.linkedTargets')}
</p>
</div>
<span class="ml-auto text-xs px-2 py-0.5 rounded-full"
style="background: {tracker.enabled ? 'var(--color-success-bg)' : 'var(--color-muted)'}; color: {tracker.enabled ? 'var(--color-success-fg)' : 'var(--color-muted-foreground)'};">
{tracker.enabled ? t('trackers.active') : t('trackers.paused')}
</span>
<div class="flex items-center gap-1 flex-wrap justify-end">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(tracker)} />
<IconButton icon="mdiPlay" title={t('common.test')} onclick={async () => { try { await api(`/trackers/${tracker.id}/trigger`, { method: 'POST' }); snackSuccess(t('snack.targetTestSent')); } catch (err) { snackError((err as any).message); } }} />
<IconButton icon={tracker.enabled ? 'mdiPause' : 'mdiPlay'} title={tracker.enabled ? t('trackers.pause') : t('trackers.resume')} onclick={() => toggle(tracker)} disabled={toggling[tracker.id]} />
<button onclick={() => toggleExpand(tracker.id)}
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1">
{t('trackers.linkedTargets')} {expandedTracker === tracker.id ? '▲' : '▼'}
</button>
<IconButton icon="mdiDelete" title={t('trackers.delete')} onclick={() => startDelete(tracker)} variant="danger" />
</div>
</div>
<!-- Linked Targets Section -->
{#if expandedTracker === tracker.id}
<div class="mt-3 border-t border-[var(--color-border)] pt-3 space-y-2" in:slide>
{#if (tracker.tracker_targets || []).length === 0}
<p class="text-xs text-[var(--color-muted-foreground)]">{t('trackers.noLinkedTargets')}</p>
{:else}
{#each tracker.tracker_targets as tt}
<div class="flex items-center justify-between text-sm px-2 py-1.5 rounded bg-[var(--color-muted)]/30">
<div class="flex items-center gap-2">
{#if tt.target_icon}<MdiIcon name={tt.target_icon} size={16} />{/if}
<span class="font-medium">{tt.target_name || `Target #${tt.target_id}`}</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{tt.target_type}</span>
{#if !tt.enabled}
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-warning-bg)] text-[var(--color-warning-fg)]">{t('trackers.paused')}</span>
{/if}
</div>
<div class="flex items-center gap-2 flex-wrap justify-end">
<select value={tt.tracking_config_id || 0}
onchange={(e: Event) => updateTargetLink(tracker.id, tt, 'tracking_config_id', Number((e.target as HTMLSelectElement).value) || null)}
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value={0}>— {t('trackingConfig.title')} —</option>
{#each trackingConfigs as tc}<option value={tc.id}>{tc.name}</option>{/each}
</select>
<select value={tt.template_config_id || 0}
onchange={(e: Event) => updateTargetLink(tracker.id, tt, 'template_config_id', Number((e.target as HTMLSelectElement).value) || null)}
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value={0}>— {t('templateConfig.title')} —</option>
{#each templateConfigs as tc}<option value={tc.id}>{tc.name}</option>{/each}
</select>
<IconButton icon="mdiSend" size={14} title={t('common.test')}
onclick={() => testTrackerTarget(tracker.id, tt.id, 'basic')}
disabled={!!ttTesting[`${tt.id}_basic`]} />
<IconButton icon="mdiCalendarClock" size={14} title={t('trackingConfig.periodicSummary')}
onclick={() => testTrackerTarget(tracker.id, tt.id, 'periodic')}
disabled={!!ttTesting[`${tt.id}_periodic`]} />
<IconButton icon="mdiHistory" size={14} title={t('trackingConfig.memoryMode')}
onclick={() => testTrackerTarget(tracker.id, tt.id, 'memory')}
disabled={!!ttTesting[`${tt.id}_memory`]} />
{#each ['basic', 'periodic', 'memory'] as testType}
{#if ttFeedback[`${tt.id}_${testType}`]}
<span class="text-xs {ttFeedback[`${tt.id}_${testType}`] === 'ok' ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">
{ttFeedback[`${tt.id}_${testType}`] === 'ok' ? '\u2713' : '\u2717'}
</span>
{/if}
{/each}
<IconButton icon={tt.enabled ? 'mdiPause' : 'mdiPlay'} size={14}
title={tt.enabled ? t('trackers.pause') : t('trackers.resume')}
onclick={() => updateTargetLink(tracker.id, tt, 'enabled', !tt.enabled)} />
<IconButton icon="mdiClose" size={14} title={t('common.delete')}
onclick={() => removeTargetLink(tracker.id, tt.id)} variant="danger" />
</div>
</div>
{/each}
{/if}
<!-- Add target link -->
{#if getUnlinkedTargets(tracker).length > 0}
<div class="flex items-center gap-2 mt-2">
<select bind:value={newLinkTargetId[tracker.id]}
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)] flex-1">
<option value={0}> {t('trackers.addTarget')} —</option>
{#each getUnlinkedTargets(tracker) as tgt}<option value={tgt.id}>{tgt.name} ({tgt.type})</option>{/each}
</select>
<select bind:value={newLinkTrackingConfigId[tracker.id]}
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value={0}> {t('trackingConfig.title')} —</option>
{#each trackingConfigs as tc}<option value={tc.id}>{tc.name}</option>{/each}
</select>
<select bind:value={newLinkTemplateConfigId[tracker.id]}
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value={0}> {t('templateConfig.title')} —</option>
{#each templateConfigs as tc}<option value={tc.id}>{tc.name}</option>{/each}
</select>
<button onclick={() => addTargetLink(tracker.id)}
disabled={!newLinkTargetId[tracker.id] || addingTarget[tracker.id]}
class="text-xs px-3 py-1 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded hover:opacity-90 disabled:opacity-50">
{t('common.add')}
</button>
</div>
{/if}
</div>
{/if}
</Card>
{/each}
</div>
{/if}
{/if}
<ConfirmModal
open={!!confirmDelete}
message={t('trackers.confirmDelete')}
onconfirm={doDelete}
oncancel={() => confirmDelete = null}
/>
+202 -15
View File
@@ -1,26 +1,200 @@
<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';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
let configs = $state<any[]>([]);
let loaded = $state(false);
let showForm = $state(false);
let editing = $state<number | null>(null);
let error = $state('');
let confirmDelete = $state<any>(null);
onMount(async () => {
try { configs = await api('/tracking-configs'); } catch {}
loaded = true;
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_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 { configs = await api('/tracking-configs'); }
catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
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(`/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')} />
<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 configs.length === 0}
{#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>
<!-- 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 for="tc-sort" class="block text-xs mb-1">{t('trackingConfig.sortBy')}</label>
<select id="tc-sort" bind:value={form.assets_order_by} class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
<option value="none">{t('trackingConfig.sortNone')}</option><option value="date">{t('trackingConfig.sortDate')}</option><option value="rating">{t('trackingConfig.sortRating')}</option><option value="name">{t('trackingConfig.sortName')}</option>
</select>
</div>
<div>
<label for="tc-order" class="block text-xs mb-1">{t('trackingConfig.sortOrder')}</label>
<select id="tc-order" bind:value={form.assets_order} class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
<option value="descending">{t('trackingConfig.orderDesc')}</option><option value="ascending">{t('trackingConfig.orderAsc')}</option>
</select>
</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>
<select bind:value={form.scheduled_collection_mode} class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
<option value="per_collection">{t('trackingConfig.albumModePerAlbum')}</option><option value="combined">{t('trackingConfig.albumModeCombined')}</option><option value="random">{t('trackingConfig.albumModeRandom')}</option>
</select></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>
<select bind:value={form.scheduled_asset_type} class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
<option value="all">{t('trackingConfig.assetTypeAll')}</option><option value="photo">{t('trackingConfig.assetTypePhoto')}</option><option value="video">{t('trackingConfig.assetTypeVideo')}</option>
</select></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.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>
<select bind:value={form.memory_collection_mode} class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
<option value="per_collection">{t('trackingConfig.albumModePerAlbum')}</option><option value="combined">{t('trackingConfig.albumModeCombined')}</option><option value="random">{t('trackingConfig.albumModeRandom')}</option>
</select></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>
<select bind:value={form.memory_asset_type} class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
<option value="all">{t('trackingConfig.assetTypeAll')}</option><option value="photo">{t('trackingConfig.assetTypePhoto')}</option><option value="video">{t('trackingConfig.assetTypeVideo')}</option>
</select></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>
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
<div style="opacity: 0.4;"><MdiIcon name="mdiCog" size={40} /></div>
@@ -28,20 +202,33 @@
</div>
</Card>
{:else}
<div class="grid gap-4 sm:grid-cols-2 stagger-children">
<div class="space-y-3 stagger-children">
{#each configs as config}
<Card hover>
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-10 h-10 rounded-lg"
style="background: var(--color-accent); color: var(--color-accent-foreground);">
<MdiIcon name={config.icon || 'mdiCog'} size={20} />
</div>
<div class="flex items-center justify-between">
<div>
<h3 class="font-medium text-sm">{config.name}</h3>
<p class="text-xs capitalize" style="color: var(--color-muted-foreground);">{config.provider_type}</p>
<div class="flex items-center gap-2">
{#if config.icon}<MdiIcon name={config.icon} />{/if}
<p class="font-medium">{config.name}</p>
</div>
<p class="text-sm text-[var(--color-muted-foreground)]">
{[config.track_assets_added && 'added', config.track_assets_removed && 'removed', config.track_collection_renamed && 'renamed', config.track_collection_deleted && 'deleted'].filter(Boolean).join(', ')}
{config.periodic_enabled ? ' · periodic' : ''}
{config.scheduled_enabled ? ' · scheduled' : ''}
{config.memory_enabled ? ' · 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} />
+131 -30
View File
@@ -2,46 +2,147 @@
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { t } from '$lib/i18n';
import { getAuth } from '$lib/auth.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
import Modal from '$lib/components/Modal.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
const auth = getAuth();
let users = $state<any[]>([]);
let showForm = $state(false);
let form = $state({ username: '', password: '', role: 'user' });
let error = $state('');
let loaded = $state(false);
let confirmDelete = $state<any>(null);
onMount(async () => {
try { users = await api('/users'); } catch {}
loaded = true;
});
// Admin reset password
let resetUserId = $state<number | null>(null);
let resetUsername = $state('');
let resetPassword = $state('');
let resetMsg = $state('');
let resetSuccess = $state(false);
onMount(load);
async function load() {
try { users = await api('/users'); }
catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
finally { loaded = true; }
}
async function create(e: SubmitEvent) {
e.preventDefault(); error = '';
try { await api('/users', { method: 'POST', body: JSON.stringify(form) }); form = { username: '', password: '', role: 'user' }; showForm = false; await load(); snackSuccess(t('snack.userCreated')); }
catch (err: any) { error = err.message; snackError(err.message); }
}
function remove(id: number) {
confirmDelete = {
id,
onconfirm: async () => {
try { await api(`/users/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.userDeleted')); }
catch (err: any) { error = err.message; snackError(err.message); }
finally { confirmDelete = null; }
}
};
}
function openResetPassword(user: any) {
resetUserId = user.id; resetUsername = user.username; resetPassword = ''; resetMsg = ''; resetSuccess = false;
}
async function resetUserPassword(e: SubmitEvent) {
e.preventDefault(); resetMsg = ''; resetSuccess = false;
try {
await api(`/users/${resetUserId}/password`, { method: 'PUT', body: JSON.stringify({ new_password: resetPassword }) });
resetMsg = t('common.passwordChanged');
resetSuccess = true;
snackSuccess(t('snack.passwordChanged'));
setTimeout(() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }, 2000);
} catch (err: any) { resetMsg = err.message; resetSuccess = false; snackError(err.message); }
}
</script>
<PageHeader title={t('users.title')} description={t('users.description')} />
<PageHeader title={t('users.title')} description={t('users.description')}>
<button onclick={() => showForm = !showForm}
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('users.cancel') : t('users.addUser')}
</button>
</PageHeader>
{#if !loaded}
<Loading />
{:else if users.length === 0}
<Card>
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
<div style="opacity: 0.4;"><MdiIcon name="mdiAccountGroup" size={40} /></div>
<p class="text-sm">No users found.</p>
</div>
{#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={create} class="space-y-3">
<div>
<label for="usr-name" class="block text-sm font-medium mb-1">{t('users.username')}</label>
<input id="usr-name" bind:value={form.username} required class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="usr-pass" class="block text-sm font-medium mb-1">{t('users.password')}</label>
<input id="usr-pass" bind:value={form.password} required type="password" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="usr-role" class="block text-sm font-medium mb-1">{t('users.role')}</label>
<select id="usr-role" bind:value={form.role} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
<option value="user">{t('users.roleUser')}</option>
<option value="admin">{t('users.roleAdmin')}</option>
</select>
</div>
<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">{t('users.create')}</button>
</form>
</Card>
{:else}
<div class="grid gap-4 sm:grid-cols-2 stagger-children">
{#each users as user}
<Card hover>
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold"
style="background: var(--color-primary); color: var(--color-primary-foreground);">
{user.username[0].toUpperCase()}
</div>
<div>
<h3 class="font-medium text-sm">{user.username}</h3>
<p class="text-xs uppercase tracking-wide" style="color: var(--color-muted-foreground);">{user.role}</p>
</div>
</div>
</Card>
{/each}
</div>
{/if}
{#if users.length === 0}
<Card>
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
<div style="opacity: 0.4;"><MdiIcon name="mdiAccountGroup" size={40} /></div>
<p class="text-sm">{t('common.loadError')}</p>
</div>
</Card>
{:else}
<div class="space-y-3 stagger-children">
{#each users as user}
<Card hover>
<div class="flex items-center justify-between">
<div>
<p class="font-medium">{user.username}</p>
<p class="text-sm text-[var(--color-muted-foreground)]">{user.role} · {t('users.joined')} {new Date(user.created_at).toLocaleDateString()}</p>
</div>
<div class="flex items-center gap-1">
{#if user.id !== auth.user?.id}
<IconButton icon="mdiKeyVariant" title={t('common.changePassword')} onclick={() => openResetPassword(user)} />
<IconButton icon="mdiDelete" title={t('users.delete')} onclick={() => remove(user.id)} variant="danger" />
{/if}
</div>
</div>
</Card>
{/each}
</div>
{/if}
{/if}
<!-- Admin reset password modal -->
<Modal open={resetUserId !== null} title="{t('common.changePassword')}: {resetUsername}" onclose={() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }}>
<form onsubmit={resetUserPassword} class="space-y-3">
<div>
<label for="reset-pwd" class="block text-sm font-medium mb-1">{t('common.newPassword')}</label>
<input id="reset-pwd" type="password" bind:value={resetPassword} required
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
{#if resetMsg}
<p class="text-sm {resetSuccess ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{resetMsg}</p>
{/if}
<button type="submit" class="w-full py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
{t('common.save')}
</button>
</form>
</Modal>
<ConfirmModal open={confirmDelete !== null} message={t('users.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />