Comprehensive review fixes: security, performance, code quality, and UI polish
Some checks failed
Validate / Hassfest (push) Has been cancelled

Backend: Fix CORS wildcard+credentials, add secret key warning, remove raw
API keys from sync endpoint, fix N+1 queries in watcher/sync, fix
AttributeError on event_types, delete dead scheduled.py/templates.py,
add limit cap on history, re-validate server on URL/key update, apply
tracking/template config IDs in update_target.

HA Integration: Replace datetime.now() with dt_util.now(), fix notification
queue to only remove successfully sent items, use album UUID for entity
unique IDs, add shared links dirty flag and users cache hourly refresh,
deduplicate _is_quiet_hours, add HTTP timeouts, cache albums in config
flow, change iot_class to local_polling.

Frontend: Make i18n reactive via $state (remove window.location.reload),
add Modal transitions/a11y/Escape key, create ConfirmModal replacing all
confirm() calls, add error handling to all pages, replace Unicode nav
icons with MDI SVGs, add card hover effects, dashboard stat icons, global
focus-visible styles, form slide transitions, mobile responsive bottom
nav, fix password error color, add ~20 i18n keys (EN/RU).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 18:34:31 +03:00
parent a04d5618d0
commit 381de98c40
39 changed files with 785 additions and 626 deletions

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { api } from '$lib/api';
import { t } from '$lib/i18n';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -7,8 +8,10 @@
import Loading from '$lib/components/Loading.svelte';
import IconPicker from '$lib/components/IconPicker.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
let loaded = $state(false);
let loadError = $state('');
let trackers = $state<any[]>([]);
let servers = $state<any[]>([]);
let targets = $state<any[]>([]);
@@ -16,6 +19,12 @@
let showForm = $state(false);
let editing = $state<number | null>(null);
let albumFilter = $state('');
let submitting = $state(false);
let confirmDelete = $state<any>(null);
let toggling = $state<Record<number, boolean>>({});
let testingPeriodic = $state<Record<number, boolean>>({});
let testingMemory = $state<Record<number, boolean>>({});
let testFeedback = $state<Record<number, string>>({});
const defaultForm = () => ({
name: '', icon: '', server_id: 0, album_ids: [] as string[],
target_ids: [] as number[], scan_interval: 60,
@@ -25,7 +34,14 @@
onMount(load);
async function load() {
try { [trackers, servers, targets] = await Promise.all([api('/trackers'), api('/servers'), api('/targets')]); } catch {} finally { loaded = true; }
loadError = '';
try {
[trackers, servers, targets] = await Promise.all([api('/trackers'), api('/servers'), api('/targets')]);
} catch (err: any) {
loadError = err.message || 'Failed to load data';
} finally {
loaded = true;
}
}
async function loadAlbums() { if (!form.server_id) return; albums = await api(`/servers/${form.server_id}/albums`); }
@@ -41,6 +57,8 @@
async function save(e: SubmitEvent) {
e.preventDefault(); error = '';
if (submitting) return;
submitting = true;
try {
if (editing) {
await api(`/trackers/${editing}`, { method: 'PUT', body: JSON.stringify(form) });
@@ -48,14 +66,52 @@
await api('/trackers', { method: 'POST', body: JSON.stringify(form) });
}
showForm = false; editing = null; await load();
} catch (err: any) { error = err.message; }
} catch (err: any) { error = err.message; } finally { submitting = false; }
}
async function toggle(tracker: any) {
await api(`/trackers/${tracker.id}`, { method: 'PUT', body: JSON.stringify({ enabled: !tracker.enabled }) }); await load();
if (toggling[tracker.id]) return;
toggling[tracker.id] = true;
try {
await api(`/trackers/${tracker.id}`, { method: 'PUT', body: JSON.stringify({ enabled: !tracker.enabled }) });
await load();
} finally { toggling[tracker.id] = false; }
}
async function remove(id: number) {
if (!confirm(t('trackers.confirmDelete'))) return;
try { await api(`/trackers/${id}`, { method: 'DELETE' }); await load(); } catch (err: any) { error = err.message; }
function startDelete(tracker: any) { confirmDelete = tracker; }
async function doDelete() {
if (!confirmDelete) return;
try {
await api(`/trackers/${confirmDelete.id}`, { method: 'DELETE' });
await load();
} catch (err: any) { error = err.message; }
confirmDelete = null;
}
async function testPeriodic(tracker: any) {
if (testingPeriodic[tracker.id]) return;
testingPeriodic[tracker.id] = true;
testFeedback[tracker.id] = '';
try {
await api(`/trackers/${tracker.id}/test-periodic`, { method: 'POST' });
testFeedback[tracker.id] = 'ok';
} catch {
testFeedback[tracker.id] = 'error';
} finally {
testingPeriodic[tracker.id] = false;
setTimeout(() => { testFeedback[tracker.id] = ''; }, 3000);
}
}
async function testMemory(tracker: any) {
if (testingMemory[tracker.id]) return;
testingMemory[tracker.id] = true;
testFeedback[tracker.id] = '';
try {
await api(`/trackers/${tracker.id}/test-memory`, { method: 'POST' });
testFeedback[tracker.id] = 'ok';
} catch {
testFeedback[tracker.id] = 'error';
} finally {
testingMemory[tracker.id] = false;
setTimeout(() => { testFeedback[tracker.id] = ''; }, 3000);
}
}
function toggleAlbum(albumId: string) { form.album_ids = form.album_ids.includes(albumId) ? form.album_ids.filter(id => id !== albumId) : [...form.album_ids, albumId]; }
function toggleTarget(targetId: number) { form.target_ids = form.target_ids.includes(targetId) ? form.target_ids.filter(id => id !== targetId) : [...form.target_ids, targetId]; }
@@ -70,7 +126,17 @@
{#if !loaded}
<Loading />
{:else if loadError}
<Card>
<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3">
{loadError}
</div>
<button onclick={load} class="mt-3 px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md hover:bg-[var(--color-muted)]">
{t('common.retry')}
</button>
</Card>
{:else if showForm}
<div transition:slide={{ duration: 200 }}>
<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={save} class="space-y-4">
@@ -127,9 +193,10 @@
</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">{editing ? t('common.save') : t('trackers.createTracker')}</button>
<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">{editing ? t('common.save') : t('trackers.createTracker')}</button>
</form>
</Card>
</div>
{/if}
{#if !loaded}
@@ -139,7 +206,7 @@
{:else}
<div class="space-y-3">
{#each trackers as tracker}
<Card>
<Card hover>
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
@@ -149,20 +216,37 @@
{tracker.enabled ? t('trackers.active') : t('trackers.paused')}
</span>
</div>
<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.target_ids.length} target(s)</p>
<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.target_ids.length} {t('trackers.targets')}</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={async () => { await api(`/trackers/${tracker.id}/trigger`, { method: 'POST' }); }} class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('common.test')}</button>
<button onclick={async () => { await api(`/trackers/${tracker.id}/test-periodic`, { method: 'POST' }); }} class="text-xs text-[var(--color-muted-foreground)] hover:underline">Test Periodic</button>
<button onclick={async () => { await api(`/trackers/${tracker.id}/test-memory`, { method: 'POST' }); }} class="text-xs text-[var(--color-muted-foreground)] hover:underline">Test Memory</button>
<button onclick={() => toggle(tracker)} class="text-xs text-[var(--color-muted-foreground)] hover:underline">
{tracker.enabled ? t('trackers.pause') : t('trackers.resume')}
<button onclick={() => testPeriodic(tracker)} disabled={testingPeriodic[tracker.id]} class="text-xs text-[var(--color-muted-foreground)] hover:underline disabled:opacity-50">
{testingPeriodic[tracker.id] ? '...' : t('trackers.testPeriodic')}
</button>
<button onclick={() => remove(tracker.id)} class="text-xs text-[var(--color-destructive)] hover:underline">{t('trackers.delete')}</button>
<button onclick={() => testMemory(tracker)} disabled={testingMemory[tracker.id]} class="text-xs text-[var(--color-muted-foreground)] hover:underline disabled:opacity-50">
{testingMemory[tracker.id] ? '...' : t('trackers.testMemory')}
</button>
{#if testFeedback[tracker.id]}
<span class="text-xs {testFeedback[tracker.id] === 'ok' ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-destructive)]'}">
{testFeedback[tracker.id] === 'ok' ? '\u2713' : '\u2717'}
</span>
{/if}
<button onclick={() => toggle(tracker)} disabled={toggling[tracker.id]} class="text-xs text-[var(--color-muted-foreground)] hover:underline disabled:opacity-50">
{toggling[tracker.id] ? '...' : tracker.enabled ? t('trackers.pause') : t('trackers.resume')}
</button>
<button onclick={() => startDelete(tracker)} class="text-xs text-[var(--color-destructive)] hover:underline">{t('trackers.delete')}</button>
</div>
</div>
</Card>
{/each}
</div>
{/if}
<ConfirmModal
open={!!confirmDelete}
title={t('trackers.delete')}
message={t('trackers.deleteConfirm')}
onconfirm={doDelete}
oncancel={() => confirmDelete = null}
/>