Fix UI issues: locale switching, dark theme, loading, edit support
Some checks failed
Validate / Hassfest (push) Has been cancelled
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:
13
CLAUDE.md
13
CLAUDE.md
@@ -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/`
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
9
frontend/src/lib/components/Loading.svelte
Normal file
9
frontend/src/lib/components/Loading.svelte
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user