Fix UI issues: locale switching, dark theme, loading, edit support
Some checks failed
Validate / Hassfest (push) Has been cancelled

- Fix i18n: remove $state rune (SSR incompatible in .ts files),
  use reactive localeVersion counter in layout to trigger re-render
  on locale change. Language switching now works immediately.
- Fix dark theme: add global CSS rules for input/select/textarea to
  use theme colors, override browser autofill in dark mode, set
  color-scheme for native controls (scrollbars, checkboxes)
- Collapsible sidebar: toggle button (▶/◀) with persistent state,
  icons-only mode when collapsed. Theme/language buttons moved to
  bottom above user info.
- Loading skeletons: all pages show animated pulse placeholders
  while data loads, eliminating content flicker on tab switch
- Edit support: Servers, Trackers, and Targets now have Edit buttons
  that open the form pre-filled with current values. Save calls PUT.
  Sensitive fields (API key, bot token) can be left empty to keep
  current value when editing.
- CLAUDE.md: add dev server restart rules

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 16:15:17 +03:00
parent 42063b7bf6
commit fd1ad91fbe
11 changed files with 264 additions and 71 deletions

View File

@@ -30,3 +30,16 @@ When modifying the integration interface, you MUST update the corresponding docu
- **services.yaml**: Keep service definitions in sync with implementation
The README is the primary user-facing documentation and must accurately reflect the current state of the integration.
## Development Servers
**IMPORTANT**: When the user requests it OR when backend code changes are made (files in `packages/server/`), you MUST restart the standalone server:
1. Kill the existing process on port 8420
2. Reinstall: `cd packages/server && pip install -e .`
3. Start: `cd <repo_root> && IMMICH_WATCHER_DATA_DIR=./test-data IMMICH_WATCHER_SECRET_KEY=test-secret-key-minimum-32chars nohup python -m uvicorn immich_watcher_server.main:app --host 0.0.0.0 --port 8420 > /dev/null 2>&1 &`
4. Verify: `curl -s http://localhost:8420/api/health`
**IMPORTANT**: When the user requests it, restart the frontend dev server:
1. Kill existing process on port 5173
2. Start: `cd frontend && npx vite dev --port 5173 --host &`
3. Verify: `curl -s -o /dev/null -w "%{http_code}" http://localhost:5173/`

View File

@@ -51,3 +51,29 @@ body {
color: var(--color-foreground);
transition: background-color 0.2s, color 0.2s;
}
/* Ensure all form controls respect the theme */
input, select, textarea {
color: var(--color-foreground);
background-color: var(--color-background);
border-color: var(--color-border);
}
/* Override browser autofill styles in dark mode */
[data-theme="dark"] input:-webkit-autofill,
[data-theme="dark"] input:-webkit-autofill:hover,
[data-theme="dark"] input:-webkit-autofill:focus,
[data-theme="dark"] select:-webkit-autofill {
-webkit-box-shadow: 0 0 0 1000px #18181b inset !important;
-webkit-text-fill-color: #fafafa !important;
caret-color: #fafafa;
}
/* Dark mode color-scheme for native controls (scrollbars, checkboxes) */
[data-theme="dark"] {
color-scheme: dark;
}
[data-theme="light"] {
color-scheme: light;
}

View File

@@ -0,0 +1,9 @@
<script lang="ts">
let { lines = 3 } = $props<{ lines?: number }>();
</script>
<div class="space-y-3 animate-pulse">
{#each Array(lines) as _}
<div class="bg-[var(--color-muted)] rounded-lg h-16"></div>
{/each}
</div>

View File

@@ -1,6 +1,6 @@
/**
* Simple i18n store using Svelte 5 runes.
* Supports nested keys like "nav.dashboard".
* Simple i18n module. Uses plain variable (no $state rune)
* so it works in both SSR and client contexts.
*/
import en from './en.json';
@@ -10,7 +10,7 @@ export type Locale = 'en' | 'ru';
const translations: Record<Locale, Record<string, any>> = { en, ru };
let currentLocale = $state<Locale>('en');
let currentLocale: Locale = 'en';
export function getLocale(): Locale {
return currentLocale;

View File

@@ -11,6 +11,10 @@
const auth = getAuth();
const theme = getTheme();
// Reactive counter to force re-render on locale change
let localeVersion = $state(0);
let collapsed = $state(false);
const navItems = [
{ href: '/', key: 'nav.dashboard', icon: '⊞' },
{ href: '/servers', key: 'nav.servers', icon: '⬡' },
@@ -23,9 +27,19 @@
page.url.pathname === '/login' || page.url.pathname === '/setup'
);
// Re-derive translations when locale changes
function tt(key: string): string {
void localeVersion; // dependency on reactive counter
return t(key);
}
onMount(async () => {
initLocale();
initTheme();
// Restore sidebar state
if (typeof localStorage !== 'undefined') {
collapsed = localStorage.getItem('sidebar_collapsed') === 'true';
}
await loadUser();
if (!auth.user && !isAuthPage) {
goto('/login');
@@ -40,6 +54,14 @@
function toggleLocale() {
setLocale(getLocale() === 'en' ? 'ru' : 'en');
localeVersion++; // trigger re-render
}
function toggleSidebar() {
collapsed = !collapsed;
if (typeof localStorage !== 'undefined') {
localStorage.setItem('sidebar_collapsed', String(collapsed));
}
}
</script>
@@ -47,68 +69,93 @@
{@render children()}
{:else if auth.loading}
<div class="min-h-screen flex items-center justify-center">
<p class="text-sm text-[var(--color-muted-foreground)]">{t('common.loading')}</p>
<p class="text-sm text-[var(--color-muted-foreground)]">{tt('common.loading')}</p>
</div>
{:else if auth.user}
<div class="flex h-screen">
<!-- Sidebar -->
<aside class="w-56 border-r border-[var(--color-border)] bg-[var(--color-card)] flex flex-col">
<div class="p-4 border-b border-[var(--color-border)] flex items-center justify-between">
<div>
<h1 class="text-base font-semibold tracking-tight">{t('app.name')}</h1>
<p class="text-xs text-[var(--color-muted-foreground)] mt-0.5">{t('app.tagline')}</p>
</div>
<div class="flex gap-1">
<button onclick={toggleLocale}
class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] transition-colors"
title={t('common.language')}>
{getLocale().toUpperCase()}
</button>
<button onclick={cycleTheme}
class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] transition-colors"
title={t('common.theme')}>
{theme.resolved === 'dark' ? '🌙' : '☀️'}
</button>
</div>
<aside class="{collapsed ? 'w-14' : 'w-56'} border-r border-[var(--color-border)] bg-[var(--color-card)] flex flex-col transition-all duration-200">
<div class="p-2 border-b border-[var(--color-border)] flex items-center {collapsed ? 'justify-center' : 'justify-between px-4 py-4'}">
{#if !collapsed}
<div>
<h1 class="text-base font-semibold tracking-tight">{tt('app.name')}</h1>
<p class="text-xs text-[var(--color-muted-foreground)] mt-0.5">{tt('app.tagline')}</p>
</div>
{/if}
<button onclick={toggleSidebar}
class="flex items-center justify-center w-8 h-8 rounded-md text-base text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] hover:bg-[var(--color-muted)] transition-colors"
title={collapsed ? 'Expand' : 'Collapse'}>
{collapsed ? '▶' : '◀'}
</button>
</div>
<nav class="flex-1 p-2 space-y-0.5">
{#each navItems as item}
<a
href={item.href}
class="flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors
class="flex items-center gap-2 {collapsed ? 'justify-center px-2' : 'px-3'} py-2 rounded-md text-sm transition-colors
{page.url.pathname === item.href
? 'bg-[var(--color-accent)] text-[var(--color-accent-foreground)] font-medium'
: 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-accent)] hover:text-[var(--color-accent-foreground)]'}"
title={collapsed ? tt(item.key) : ''}
>
<span class="text-base">{item.icon}</span>
{t(item.key)}
{#if !collapsed}{tt(item.key)}{/if}
</a>
{/each}
{#if auth.isAdmin}
<a
href="/users"
class="flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors
class="flex items-center gap-2 {collapsed ? 'justify-center px-2' : 'px-3'} py-2 rounded-md text-sm transition-colors
{page.url.pathname === '/users'
? 'bg-[var(--color-accent)] text-[var(--color-accent-foreground)] font-medium'
: 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-accent)] hover:text-[var(--color-accent-foreground)]'}"
title={collapsed ? tt('nav.users') : ''}
>
<span class="text-base"></span>
{t('nav.users')}
{#if !collapsed}{tt('nav.users')}{/if}
</a>
{/if}
</nav>
<div class="p-3 border-t border-[var(--color-border)]">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium">{auth.user.username}</p>
<p class="text-xs text-[var(--color-muted-foreground)]">{auth.user.role}</p>
</div>
<button
onclick={logout}
class="text-xs text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] transition-colors"
>
{t('nav.logout')}
<!-- Settings + User footer -->
<div class="border-t border-[var(--color-border)]">
<!-- Theme & Language -->
<div class="flex {collapsed ? 'flex-col items-center gap-1 p-1.5' : 'gap-1.5 px-3 py-2'}">
<button onclick={toggleLocale}
class="flex items-center justify-center {collapsed ? 'w-8 h-8' : 'px-2 py-1'} rounded-md text-xs bg-[var(--color-muted)] text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] transition-colors"
title={tt('common.language')}>
{getLocale().toUpperCase()}
</button>
<button onclick={cycleTheme}
class="flex items-center justify-center {collapsed ? 'w-8 h-8' : 'px-2 py-1'} rounded-md text-xs bg-[var(--color-muted)] text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] transition-colors"
title={tt('common.theme')}>
{theme.resolved === 'dark' ? '🌙' : '☀️'}
</button>
</div>
<!-- User info -->
<div class="p-2 border-t border-[var(--color-border)]">
{#if collapsed}
<button onclick={logout}
class="w-full flex justify-center py-2 text-sm text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] rounded hover:bg-[var(--color-muted)] transition-colors"
title={tt('nav.logout')}>
</button>
{:else}
<div class="flex items-center justify-between px-1">
<div>
<p class="text-sm font-medium">{auth.user.username}</p>
<p class="text-xs text-[var(--color-muted-foreground)]">{auth.user.role}</p>
</div>
<button
onclick={logout}
class="text-xs text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] transition-colors"
>
{tt('nav.logout')}
</button>
</div>
{/if}
</div>
</div>
</aside>

View File

@@ -4,14 +4,18 @@
import { t } from '$lib/i18n';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
let status = $state<any>(null);
onMount(async () => { try { status = await api('/status'); } catch {} });
let loaded = $state(false);
onMount(async () => { try { status = await api('/status'); } catch {} finally { loaded = true; } });
</script>
<PageHeader title={t('dashboard.title')} description={t('dashboard.description')} />
{#if status}
{#if !loaded}
<Loading lines={4} />
{:else if status}
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8">
<Card>
<p class="text-sm text-[var(--color-muted-foreground)]">{t('dashboard.servers')}</p>
@@ -47,6 +51,4 @@
</div>
</Card>
{/if}
{:else}
<p class="text-sm text-[var(--color-muted-foreground)]">{t('dashboard.loading')}</p>
{/if}

View File

@@ -4,21 +4,39 @@
import { t } from '$lib/i18n';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
let servers = $state<any[]>([]);
let showForm = $state(false);
let editing = $state<number | null>(null);
let form = $state({ name: 'Immich', url: '', api_key: '' });
let error = $state('');
let submitting = $state(false);
let loaded = $state(false);
onMount(load);
async function load() { try { servers = await api('/servers'); } catch {} }
async function load() { try { servers = await api('/servers'); } catch {} finally { loaded = true; } }
async function create(e: SubmitEvent) {
function openNew() {
form = { name: 'Immich', url: '', api_key: '' };
editing = null; showForm = true;
}
function edit(s: any) {
form = { name: s.name, url: s.url, api_key: '' };
editing = s.id; showForm = true;
}
async function save(e: SubmitEvent) {
e.preventDefault(); error = ''; submitting = true;
try {
await api('/servers', { method: 'POST', body: JSON.stringify(form) });
form = { name: 'Immich', url: '', api_key: '' }; showForm = false; await load();
if (editing) {
const body: any = { name: form.name, url: form.url };
if (form.api_key) body.api_key = form.api_key;
await api(`/servers/${editing}`, { method: 'PUT', body: JSON.stringify(body) });
} else {
await api('/servers', { method: 'POST', body: JSON.stringify(form) });
}
showForm = false; editing = null; await load();
} catch (err: any) { error = err.message; }
submitting = false;
}
@@ -30,16 +48,20 @@
</script>
<PageHeader title={t('servers.title')} description={t('servers.description')}>
<button onclick={() => showForm = !showForm}
<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('servers.cancel') : t('servers.addServer')}
</button>
</PageHeader>
{#if !loaded}
<Loading />
{:else}
{#if showForm}
<Card class="mb-6">
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
<form onsubmit={create} class="space-y-3">
<form onsubmit={save} class="space-y-3">
<div>
<label for="srv-name" class="block text-sm font-medium mb-1">{t('servers.name')}</label>
<input id="srv-name" bind:value={form.name} required class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
@@ -49,11 +71,11 @@
<input id="srv-url" bind:value={form.url} required placeholder={t('servers.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="srv-key" class="block text-sm font-medium mb-1">{t('servers.apiKey')}</label>
<input id="srv-key" bind:value={form.api_key} required type="password" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
<label for="srv-key" class="block text-sm font-medium mb-1">{t('servers.apiKey')}{editing ? ' (leave empty to keep current)' : ''}</label>
<input id="srv-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>
<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('servers.connecting') : t('servers.addServer')}
{submitting ? t('servers.connecting') : (editing ? t('common.save') : t('servers.addServer'))}
</button>
</form>
</Card>
@@ -70,9 +92,14 @@
<p class="font-medium">{server.name}</p>
<p class="text-sm text-[var(--color-muted-foreground)]">{server.url}</p>
</div>
<button onclick={() => remove(server.id)} class="text-xs text-[var(--color-destructive)] hover:underline">{t('servers.delete')}</button>
<div class="flex items-center gap-3">
<button onclick={() => edit(server)} class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('common.edit')}</button>
<button onclick={() => remove(server.id)} class="text-xs text-[var(--color-destructive)] hover:underline">{t('servers.delete')}</button>
</div>
</div>
</Card>
{/each}
</div>
{/if}
{/if}

View File

@@ -4,31 +4,53 @@
import { t } from '$lib/i18n';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
let targets = $state<any[]>([]);
let showForm = $state(false);
let editing = $state<number | null>(null);
let formType = $state<'telegram' | 'webhook'>('telegram');
let form = $state({ name: '', bot_token: '', chat_id: '', url: '', headers: '',
const defaultForm = () => ({ name: '', bot_token: '', chat_id: '', 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 testResult = $state('');
let loaded = $state(false);
onMount(load);
async function load() { try { targets = await api('/targets'); } catch {} }
async function load() { try { targets = await api('/targets'); } catch {} finally { loaded = true; } }
async function create(e: SubmitEvent) {
function openNew() { form = defaultForm(); formType = 'telegram'; editing = null; showForm = true; }
function edit(tgt: any) {
formType = tgt.type;
const c = tgt.config || {};
form = {
name: tgt.name, 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; showForm = true;
}
async function save(e: SubmitEvent) {
e.preventDefault(); error = '';
try {
const config = formType === 'telegram'
? { bot_token: form.bot_token, chat_id: form.chat_id,
? { ...(form.bot_token ? { bot_token: form.bot_token } : {}), chat_id: form.chat_id,
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: form.headers ? JSON.parse(form.headers) : {}, ai_captions: form.ai_captions };
await api('/targets', { method: 'POST', body: JSON.stringify({ type: formType, name: form.name, config }) });
showForm = false; await load();
if (editing) {
await api(`/targets/${editing}`, { method: 'PUT', body: JSON.stringify({ name: form.name, config }) });
} else {
await api('/targets', { method: 'POST', body: JSON.stringify({ type: formType, name: form.name, config }) });
}
showForm = false; editing = null; await load();
} catch (err: any) { error = err.message; }
}
async function test(id: number) {
@@ -44,12 +66,14 @@
</script>
<PageHeader title={t('targets.title')} description={t('targets.description')}>
<button onclick={() => showForm = !showForm}
<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 testResult}
<div class="mb-4 p-3 rounded-md text-sm {testResult.includes(t('targets.testSent')) ? 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]' : 'bg-[var(--color-warning-bg)] text-[var(--color-warning-fg)]'}">{testResult}</div>
{/if}
@@ -57,7 +81,7 @@
{#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-4">
<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">
@@ -112,7 +136,7 @@
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.ai_captions} /> {t('targets.aiCaptions')}</label>
<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('targets.create')}</button>
<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('targets.create')}</button>
</form>
</Card>
{/if}
@@ -134,6 +158,7 @@
</p>
</div>
<div class="flex items-center gap-3">
<button onclick={() => edit(target)} class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('common.edit')}</button>
<button onclick={() => test(target.id)} class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('targets.test')}</button>
<button onclick={() => remove(target.id)} class="text-xs text-[var(--color-destructive)] hover:underline">{t('targets.delete')}</button>
</div>
@@ -142,3 +167,5 @@
{/each}
</div>
{/if}
{/if}

View File

@@ -4,6 +4,7 @@
import { t } from '$lib/i18n';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
let templates = $state<any[]>([]);
let showForm = $state(false);
@@ -12,9 +13,10 @@
let previewId = $state<number | null>(null);
let editing = $state<number | null>(null);
let error = $state('');
let loaded = $state(false);
onMount(load);
async function load() { try { templates = await api('/templates'); } catch {} }
async function load() { try { templates = await api('/templates'); } catch {} finally { loaded = true; } }
async function save(e: SubmitEvent) {
e.preventDefault(); error = '';
@@ -43,6 +45,8 @@
</button>
</PageHeader>
{#if !loaded}<Loading />{:else}
{#if showForm}
<Card class="mb-6">
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
@@ -109,3 +113,5 @@
{/each}
</div>
{/if}
{/if}

View File

@@ -4,30 +4,55 @@
import { t } from '$lib/i18n';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
let loaded = $state(false);
let trackers = $state<any[]>([]);
let servers = $state<any[]>([]);
let targets = $state<any[]>([]);
let albums = $state<any[]>([]);
let showForm = $state(false);
let form = $state({
let editing = $state<number | null>(null);
const defaultForm = () => ({
name: '', server_id: 0, album_ids: [] as string[], event_types: ['assets_added'],
target_ids: [] as number[], scan_interval: 60,
track_images: true, track_videos: true, notify_favorites_only: false,
include_people: true, include_asset_details: false,
max_assets_to_show: 5, assets_order_by: 'none', assets_order: 'descending',
});
let form = $state(defaultForm());
let error = $state('');
onMount(load);
async function load() {
try { [trackers, servers, targets] = await Promise.all([api('/trackers'), api('/servers'), api('/targets')]); } catch {}
try { [trackers, servers, targets] = await Promise.all([api('/trackers'), api('/servers'), api('/targets')]); } catch {} finally { loaded = true; }
}
async function loadAlbums() { if (!form.server_id) return; albums = await api(`/servers/${form.server_id}/albums`); }
async function create(e: SubmitEvent) {
function openNew() { form = defaultForm(); editing = null; showForm = true; albums = []; }
async function edit(trk: any) {
form = {
name: trk.name, server_id: trk.server_id, album_ids: [...trk.album_ids],
event_types: [...trk.event_types], target_ids: [...trk.target_ids], scan_interval: trk.scan_interval,
track_images: trk.track_images ?? true, track_videos: trk.track_videos ?? true,
notify_favorites_only: trk.notify_favorites_only ?? false, include_people: trk.include_people ?? true,
include_asset_details: trk.include_asset_details ?? false, max_assets_to_show: trk.max_assets_to_show ?? 5,
assets_order_by: trk.assets_order_by ?? 'none', assets_order: trk.assets_order ?? 'descending',
};
editing = trk.id; showForm = true;
if (form.server_id) await loadAlbums();
}
async function save(e: SubmitEvent) {
e.preventDefault(); error = '';
try { await api('/trackers', { method: 'POST', body: JSON.stringify(form) }); showForm = false; await load(); } catch (err: any) { error = err.message; }
try {
if (editing) {
await api(`/trackers/${editing}`, { method: 'PUT', body: JSON.stringify(form) });
} else {
await api('/trackers', { method: 'POST', body: JSON.stringify(form) });
}
showForm = false; editing = null; await load();
} catch (err: any) { error = err.message; }
}
async function toggle(tracker: any) {
await api(`/trackers/${tracker.id}`, { method: 'PUT', body: JSON.stringify({ enabled: !tracker.enabled }) }); await load();
@@ -41,16 +66,18 @@
</script>
<PageHeader title={t('trackers.title')} description={t('trackers.description')}>
<button onclick={() => { showForm = !showForm; form = { name: '', server_id: 0, album_ids: [], event_types: ['assets_added'], target_ids: [], scan_interval: 60, track_images: true, track_videos: true, notify_favorites_only: false, include_people: true, include_asset_details: false, max_assets_to_show: 5, assets_order_by: 'none', assets_order: 'descending' }; }}
<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 showForm}
{#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-4">
<form onsubmit={save} class="space-y-4">
<div>
<label for="trk-name" class="block text-sm font-medium mb-1">{t('trackers.name')}</label>
<input id="trk-name" bind:value={form.name} required placeholder={t('trackers.namePlaceholder')} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
@@ -143,12 +170,14 @@
</div>
{/if}
<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('trackers.createTracker')}</button>
<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('trackers.createTracker')}</button>
</form>
</Card>
{/if}
{#if trackers.length === 0 && !showForm}
{#if !loaded}
<!-- skeleton shown above -->
{:else if trackers.length === 0 && !showForm}
<Card><p class="text-sm text-[var(--color-muted-foreground)]">{t('trackers.noTrackers')}</p></Card>
{:else}
<div class="space-y-3">
@@ -165,6 +194,7 @@
<p class="text-sm text-[var(--color-muted-foreground)]">{tracker.album_ids.length} {t('trackers.albums_count')} · {t('trackers.every')} {tracker.scan_interval}s · {tracker.event_types.join(', ')}</p>
</div>
<div class="flex items-center gap-3">
<button onclick={() => edit(tracker)} class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('common.edit')}</button>
<button onclick={() => toggle(tracker)} class="text-xs text-[var(--color-muted-foreground)] hover:underline">
{tracker.enabled ? t('trackers.pause') : t('trackers.resume')}
</button>

View File

@@ -5,15 +5,17 @@
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';
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);
onMount(load);
async function load() { try { users = await api('/users'); } catch {} }
async function load() { try { users = await api('/users'); } catch {} finally { loaded = true; } }
async function create(e: SubmitEvent) {
e.preventDefault(); error = '';
@@ -33,6 +35,8 @@
</button>
</PageHeader>
{#if !loaded}<Loading />{:else}
{#if showForm}
<Card class="mb-6">
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
@@ -72,3 +76,5 @@
</Card>
{/each}
</div>
{/if}