Phase 8: Server health, album filter, Jinja2 engine, password change
Some checks failed
Validate / Hassfest (push) Has been cancelled
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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user