diff --git a/CLAUDE.md b/CLAUDE.md index 6805d50..5089dfd 100644 --- a/CLAUDE.md +++ b/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 && 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/` diff --git a/frontend/src/app.css b/frontend/src/app.css index 8dff938..9b532e2 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -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; +} diff --git a/frontend/src/lib/components/Loading.svelte b/frontend/src/lib/components/Loading.svelte new file mode 100644 index 0000000..47ab4f5 --- /dev/null +++ b/frontend/src/lib/components/Loading.svelte @@ -0,0 +1,9 @@ + + +
+ {#each Array(lines) as _} +
+ {/each} +
diff --git a/frontend/src/lib/i18n/index.ts b/frontend/src/lib/i18n/index.ts index 7d8c793..6d6c009 100644 --- a/frontend/src/lib/i18n/index.ts +++ b/frontend/src/lib/i18n/index.ts @@ -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> = { en, ru }; -let currentLocale = $state('en'); +let currentLocale: Locale = 'en'; export function getLocale(): Locale { return currentLocale; diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 31b6285..d655538 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -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)); + } } @@ -47,68 +69,93 @@ {@render children()} {:else if auth.loading}
-

{t('common.loading')}

+

{tt('common.loading')}

{:else if auth.user}
-
{/each} {/if} + +{/if} diff --git a/frontend/src/routes/targets/+page.svelte b/frontend/src/routes/targets/+page.svelte index 860a376..30299dc 100644 --- a/frontend/src/routes/targets/+page.svelte +++ b/frontend/src/routes/targets/+page.svelte @@ -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([]); let showForm = $state(false); + let editing = $state(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 @@ - +{#if !loaded}{:else} + {#if testResult}
{testResult}
{/if} @@ -57,7 +81,7 @@ {#if showForm} {#if error}
{error}
{/if} -
+
{t('targets.type')}
@@ -112,7 +136,7 @@ - + {/if} @@ -134,6 +158,7 @@

+
@@ -142,3 +167,5 @@ {/each}
{/if} + +{/if} diff --git a/frontend/src/routes/templates/+page.svelte b/frontend/src/routes/templates/+page.svelte index d1dd5b2..f905660 100644 --- a/frontend/src/routes/templates/+page.svelte +++ b/frontend/src/routes/templates/+page.svelte @@ -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([]); let showForm = $state(false); @@ -12,9 +13,10 @@ let previewId = $state(null); let editing = $state(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 @@ +{#if !loaded}{:else} + {#if showForm} {#if error}
{error}
{/if} @@ -109,3 +113,5 @@ {/each} {/if} + +{/if} diff --git a/frontend/src/routes/trackers/+page.svelte b/frontend/src/routes/trackers/+page.svelte index 5958d98..7c62454 100644 --- a/frontend/src/routes/trackers/+page.svelte +++ b/frontend/src/routes/trackers/+page.svelte @@ -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([]); let servers = $state([]); let targets = $state([]); let albums = $state([]); let showForm = $state(false); - let form = $state({ + let editing = $state(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 @@ - -{#if showForm} +{#if !loaded} + +{:else if showForm} {#if error}
{error}
{/if} -
+
@@ -143,12 +170,14 @@
{/if} - +
{/if} -{#if trackers.length === 0 && !showForm} +{#if !loaded} + +{:else if trackers.length === 0 && !showForm}

{t('trackers.noTrackers')}

{:else}
@@ -165,6 +194,7 @@

{tracker.album_ids.length} {t('trackers.albums_count')} · {t('trackers.every')} {tracker.scan_interval}s · {tracker.event_types.join(', ')}

+ diff --git a/frontend/src/routes/users/+page.svelte b/frontend/src/routes/users/+page.svelte index 9fac9b0..7eb5ec5 100644 --- a/frontend/src/routes/users/+page.svelte +++ b/frontend/src/routes/users/+page.svelte @@ -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([]); 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 @@ +{#if !loaded}{:else} + {#if showForm} {#if error}
{error}
{/if} @@ -72,3 +76,5 @@
{/each}
+ +{/if}