Phase 8: Server health, album filter, Jinja2 engine, password change
Some checks failed
Validate / Hassfest (push) Has been cancelled

5 features implemented:

1. Server health indicator: green/red/yellow dot on each server card.
   Pings Immich in background on page load. New GET /api/servers/{id}/ping.

2. Album selector filter: search input above album list in tracker form.
   Filters by name as you type (case-insensitive). Shows total count.

3. Album last update time: each album in the selector shows its
   updatedAt date. Backend now returns updatedAt from Immich API.

4. Full Jinja2 template engine in notifier:
   - build_full_context() assembles all ~40 variables from blueprint
   - Common date/location detection across assets
   - Per-asset date/location when they differ
   - Favorite indicator, people formatting, asset list with truncation
   - Video warning for Telegram
   - All template slots from TemplateConfig used contextually

5. Password change: PUT /api/auth/password endpoint (validates current
   password, min 6 chars). UI in sidebar footer with inline form.

Also: Phase 9 plan (Telegram bot management) added.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 17:27:00 +03:00
parent 431069fbdb
commit 0200b9929f
10 changed files with 269 additions and 52 deletions

View File

@@ -244,6 +244,10 @@
"dark": "Dark",
"system": "System",
"test": "Test",
"create": "Create"
"create": "Create",
"changePassword": "Change Password",
"currentPassword": "Current password",
"newPassword": "New password",
"passwordChanged": "Password changed successfully"
}
}

View File

@@ -244,6 +244,10 @@
"dark": "Тёмная",
"system": "Системная",
"test": "Тест",
"create": "Создать"
"create": "Создать",
"changePassword": "Сменить пароль",
"currentPassword": "Текущий пароль",
"newPassword": "Новый пароль",
"passwordChanged": "Пароль успешно изменён"
}
}

View File

@@ -3,6 +3,7 @@
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { getAuth, loadUser, logout } from '$lib/auth.svelte';
import { t, initLocale, getLocale, setLocale, type Locale } from '$lib/i18n';
import { getTheme, initTheme, setTheme, type Theme } from '$lib/theme.svelte';
@@ -11,6 +12,21 @@
const auth = getAuth();
const theme = getTheme();
let showPasswordForm = $state(false);
let pwdCurrent = $state('');
let pwdNew = $state('');
let pwdMsg = $state('');
async function changePassword(e: SubmitEvent) {
e.preventDefault(); pwdMsg = '';
try {
await api('/auth/password', { method: 'PUT', body: JSON.stringify({ current_password: pwdCurrent, new_password: pwdNew }) });
pwdMsg = t('common.passwordChanged');
pwdCurrent = ''; pwdNew = '';
setTimeout(() => { showPasswordForm = false; pwdMsg = ''; }, 2000);
} catch (err: any) { pwdMsg = err.message; }
}
// Reactive counter to force re-render on locale change
let localeVersion = $state(0);
let collapsed = $state(false);
@@ -146,17 +162,33 @@
</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 class="px-1">
<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">
{tt('nav.logout')}
</button>
</div>
<button
onclick={logout}
class="text-xs text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] transition-colors"
>
{tt('nav.logout')}
<button onclick={() => showPasswordForm = !showPasswordForm}
class="text-xs text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] mt-1">
🔑 {tt('common.changePassword')}
</button>
{#if showPasswordForm}
<form onsubmit={changePassword} class="mt-2 space-y-2">
<input type="password" bind:value={pwdCurrent} required placeholder={tt('common.currentPassword')}
class="w-full px-2 py-1 text-xs border border-[var(--color-border)] rounded bg-[var(--color-background)]" />
<input type="password" bind:value={pwdNew} required placeholder={tt('common.newPassword')}
class="w-full px-2 py-1 text-xs border border-[var(--color-border)] rounded bg-[var(--color-background)]" />
{#if pwdMsg}<p class="text-xs text-[var(--color-muted-foreground)]">{pwdMsg}</p>{/if}
<button type="submit" class="w-full px-2 py-1 text-xs bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded hover:opacity-90">
{tt('common.save')}
</button>
</form>
{/if}
</div>
{/if}
</div>

View File

@@ -14,8 +14,17 @@
let submitting = $state(false);
let loaded = $state(false);
let health = $state<Record<number, boolean | null>>({});
onMount(load);
async function load() { try { servers = await api('/servers'); } catch {} finally { loaded = true; } }
async function load() {
try { servers = await api('/servers'); } catch {} finally { loaded = true; }
// Ping all servers in background
for (const s of servers) {
health[s.id] = null; // loading
api(`/servers/${s.id}/ping`).then(r => health[s.id] = r.online).catch(() => health[s.id] = false);
}
}
function openNew() {
form = { name: 'Immich', url: '', api_key: '' };
@@ -88,9 +97,13 @@
{#each servers as server}
<Card>
<div class="flex items-center justify-between">
<div>
<p class="font-medium">{server.name}</p>
<p class="text-sm text-[var(--color-muted-foreground)]">{server.url}</p>
<div class="flex items-center gap-2">
<span class="inline-block w-2.5 h-2.5 rounded-full {health[server.id] === true ? 'bg-green-500' : health[server.id] === false ? 'bg-red-500' : 'bg-yellow-400 animate-pulse'}"
title={health[server.id] === true ? 'Online' : health[server.id] === false ? 'Offline' : 'Checking...'}></span>
<div>
<p class="font-medium">{server.name}</p>
<p class="text-sm text-[var(--color-muted-foreground)]">{server.url}</p>
</div>
</div>
<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>

View File

@@ -13,6 +13,7 @@
let albums = $state<any[]>([]);
let showForm = $state(false);
let editing = $state<number | null>(null);
let albumFilter = $state('');
const defaultForm = () => ({
name: '', server_id: 0, album_ids: [] as string[],
target_ids: [] as number[], scan_interval: 60,
@@ -84,12 +85,19 @@
</div>
{#if albums.length > 0}
<div>
<label class="block text-sm font-medium mb-1">{t('trackers.albums')}</label>
<div class="max-h-48 overflow-y-auto border border-[var(--color-border)] rounded-md p-2 space-y-1">
{#each albums as album}
<label class="flex items-center gap-2 text-sm cursor-pointer hover:bg-[var(--color-muted)] px-2 py-1 rounded">
<input type="checkbox" checked={form.album_ids.includes(album.id)} onchange={() => toggleAlbum(album.id)} />
{album.albumName} <span class="text-[var(--color-muted-foreground)]">({album.assetCount})</span>
<label class="block text-sm font-medium mb-1">{t('trackers.albums')} ({albums.length})</label>
<input type="text" bind:value={albumFilter} placeholder="Filter albums..."
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 albums.filter(a => !albumFilter || a.albumName.toLowerCase().includes(albumFilter.toLowerCase())) as album}
<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.album_ids.includes(album.id)} onchange={() => toggleAlbum(album.id)} />
{album.albumName} <span class="text-[var(--color-muted-foreground)]">({album.assetCount})</span>
</span>
{#if album.updatedAt}
<span class="text-xs text-[var(--color-muted-foreground)]">{new Date(album.updatedAt).toLocaleDateString()}</span>
{/if}
</label>
{/each}
</div>