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

@@ -8,6 +8,7 @@
import { t, initLocale, getLocale, setLocale, type Locale } from '$lib/i18n';
import { getTheme, initTheme, setTheme, type Theme } from '$lib/theme.svelte';
import Modal from '$lib/components/Modal.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
let { children } = $props();
const auth = getAuth();
@@ -17,45 +18,38 @@
let pwdCurrent = $state('');
let pwdNew = $state('');
let pwdMsg = $state('');
let pwdSuccess = $state(false);
async function changePassword(e: SubmitEvent) {
e.preventDefault(); pwdMsg = '';
e.preventDefault(); pwdMsg = ''; pwdSuccess = false;
try {
await api('/auth/password', { method: 'PUT', body: JSON.stringify({ current_password: pwdCurrent, new_password: pwdNew }) });
pwdMsg = t('common.passwordChanged');
pwdSuccess = true;
pwdCurrent = ''; pwdNew = '';
setTimeout(() => { showPasswordForm = false; pwdMsg = ''; }, 2000);
} catch (err: any) { pwdMsg = err.message; }
setTimeout(() => { showPasswordForm = false; pwdMsg = ''; pwdSuccess = false; }, 2000);
} catch (err: any) { pwdMsg = err.message; pwdSuccess = false; }
}
// 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: '' },
{ href: '/trackers', key: 'nav.trackers', icon: '' },
{ href: '/tracking-configs', key: 'nav.trackingConfigs', icon: '' },
{ href: '/template-configs', key: 'nav.templateConfigs', icon: '' },
{ href: '/telegram-bots', key: 'nav.telegramBots', icon: '' },
{ href: '/targets', key: 'nav.targets', icon: '' },
{ href: '/', key: 'nav.dashboard', icon: 'mdiViewDashboard' },
{ href: '/servers', key: 'nav.servers', icon: 'mdiServer' },
{ href: '/trackers', key: 'nav.trackers', icon: 'mdiRadar' },
{ href: '/tracking-configs', key: 'nav.trackingConfigs', icon: 'mdiCog' },
{ href: '/template-configs', key: 'nav.templateConfigs', icon: 'mdiFileDocumentEdit' },
{ href: '/telegram-bots', key: 'nav.telegramBots', icon: 'mdiRobot' },
{ href: '/targets', key: 'nav.targets', icon: 'mdiTarget' },
];
const isAuthPage = $derived(
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';
}
@@ -73,9 +67,6 @@
function toggleLocale() {
setLocale(getLocale() === 'en' ? 'ru' : 'en');
localeVersion++;
// Force full page re-render so child components re-evaluate t() calls
window.location.reload();
}
function toggleSidebar() {
@@ -90,22 +81,22 @@
{@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)]">{tt('common.loading')}</p>
<p class="text-sm text-[var(--color-muted-foreground)]">{t('common.loading')}</p>
</div>
{:else if auth.user}
<div class="flex h-screen">
<!-- Sidebar -->
<aside class="{collapsed ? 'w-14' : 'w-56'} border-r border-[var(--color-border)] bg-[var(--color-card)] flex flex-col transition-all duration-200">
<aside class="{collapsed ? 'w-14' : 'w-56'} border-r border-[var(--color-border)] bg-[var(--color-card)] flex flex-col transition-all duration-200 max-md:hidden">
<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>
<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>
{/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'}>
title={collapsed ? t('common.expand') : t('common.collapse')}>
{collapsed ? '▶' : '◀'}
</button>
</div>
@@ -118,10 +109,10 @@
{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) : ''}
title={collapsed ? t(item.key) : ''}
>
<span class="text-base">{item.icon}</span>
{#if !collapsed}{tt(item.key)}{/if}
<MdiIcon name={item.icon} size={18} />
{#if !collapsed}{t(item.key)}{/if}
</a>
{/each}
{#if auth.isAdmin}
@@ -131,10 +122,10 @@
{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') : ''}
title={collapsed ? t('nav.users') : ''}
>
<span class="text-base"></span>
{#if !collapsed}{tt('nav.users')}{/if}
<MdiIcon name="mdiAccountGroup" size={18} />
{#if !collapsed}{t('nav.users')}{/if}
</a>
{/if}
</nav>
@@ -145,12 +136,12 @@
<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')}>
title={t('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')}>
title={t('common.theme')}>
{theme.resolved === 'dark' ? '🌙' : '☀️'}
</button>
</div>
@@ -160,7 +151,7 @@
{#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')}>
title={t('nav.logout')}>
</button>
{:else}
@@ -172,13 +163,13 @@
</div>
<button onclick={logout}
class="text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] transition-colors"
title={tt('nav.logout')}>
title={t('nav.logout')}>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
</button>
</div>
<button onclick={() => showPasswordForm = true}
class="text-xs text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] mt-1">
🔑 {tt('common.changePassword')}
🔑 {t('common.changePassword')}
</button>
</div>
{/if}
@@ -186,33 +177,55 @@
</div>
</aside>
<!-- Mobile bottom nav -->
<nav class="fixed bottom-0 left-0 right-0 z-50 md:hidden bg-[var(--color-card)] border-t border-[var(--color-border)] flex justify-around py-1.5">
{#each navItems.slice(0, 5) as item}
<a href={item.href}
class="flex flex-col items-center gap-0.5 px-2 py-1 text-xs rounded-md transition-colors
{page.url.pathname === item.href
? 'text-[var(--color-accent-foreground)] font-medium'
: 'text-[var(--color-muted-foreground)]'}">
<MdiIcon name={item.icon} size={20} />
</a>
{/each}
<button onclick={logout}
class="flex flex-col items-center gap-0.5 px-2 py-1 text-xs text-[var(--color-muted-foreground)]">
<MdiIcon name="mdiLogout" size={20} />
</button>
</nav>
<!-- Main content -->
<main class="flex-1 overflow-auto">
<div class="max-w-5xl mx-auto p-6">
<main class="flex-1 overflow-auto pb-16 md:pb-0">
<div class="max-w-5xl mx-auto p-4 md:p-6">
{@render children()}
</div>
</main>
</div>
{:else}
<!-- Redirect in progress -->
<div class="min-h-screen flex items-center justify-center">
<p class="text-sm text-[var(--color-muted-foreground)]">{t('common.loading')}</p>
</div>
{/if}
<!-- Password change modal (outside flex container) -->
<Modal open={showPasswordForm} title={tt('common.changePassword')} onclose={() => { showPasswordForm = false; pwdMsg = ''; }}>
<!-- Password change modal -->
<Modal open={showPasswordForm} title={t('common.changePassword')} onclose={() => { showPasswordForm = false; pwdMsg = ''; pwdSuccess = false; }}>
<form onsubmit={changePassword} class="space-y-3">
<div>
<label for="pwd-current" class="block text-sm font-medium mb-1">{tt('common.currentPassword')}</label>
<label for="pwd-current" class="block text-sm font-medium mb-1">{t('common.currentPassword')}</label>
<input id="pwd-current" type="password" bind:value={pwdCurrent} required
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="pwd-new" class="block text-sm font-medium mb-1">{tt('common.newPassword')}</label>
<label for="pwd-new" class="block text-sm font-medium mb-1">{t('common.newPassword')}</label>
<input id="pwd-new" type="password" bind:value={pwdNew} required
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
{#if pwdMsg}
<p class="text-sm" style="color: var(--color-success-fg);">{pwdMsg}</p>
<p class="text-sm" style="color: var({pwdSuccess ? '--color-success-fg' : '--color-error-fg'});">{pwdMsg}</p>
{/if}
<button type="submit" class="w-full py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
{tt('common.save')}
<button type="submit" class="w-full py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 transition-opacity">
{t('common.save')}
</button>
</form>
</Modal>

View File

@@ -5,35 +5,79 @@
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
let status = $state<any>(null);
let loaded = $state(false);
onMount(async () => { try { status = await api('/status'); } catch {} finally { loaded = true; } });
let error = $state('');
onMount(async () => {
try {
status = await api('/status');
} catch (err: any) {
error = err.message || t('common.error');
} finally {
loaded = true;
}
});
</script>
<PageHeader title={t('dashboard.title')} description={t('dashboard.description')} />
{#if !loaded}
<Loading lines={4} />
{:else if error}
<Card>
<div class="flex items-center gap-2 text-[var(--color-error-fg)]">
<MdiIcon name="mdiAlertCircle" size={20} />
<p class="text-sm">{error}</p>
</div>
</Card>
{: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>
<p class="text-3xl font-semibold mt-1">{status.servers}</p>
<Card hover>
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-[var(--color-muted)]">
<MdiIcon name="mdiServer" size={22} />
</div>
<div>
<p class="text-sm text-[var(--color-muted-foreground)]">{t('dashboard.servers')}</p>
<p class="text-2xl font-semibold">{status.servers}</p>
</div>
</div>
</Card>
<Card>
<p class="text-sm text-[var(--color-muted-foreground)]">{t('dashboard.activeTrackers')}</p>
<p class="text-3xl font-semibold mt-1">{status.trackers.active}<span class="text-base font-normal text-[var(--color-muted-foreground)]"> / {status.trackers.total}</span></p>
<Card hover>
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-[var(--color-muted)]">
<MdiIcon name="mdiRadar" size={22} />
</div>
<div>
<p class="text-sm text-[var(--color-muted-foreground)]">{t('dashboard.activeTrackers')}</p>
<p class="text-2xl font-semibold">{status.trackers.active}<span class="text-base font-normal text-[var(--color-muted-foreground)]"> / {status.trackers.total}</span></p>
</div>
</div>
</Card>
<Card>
<p class="text-sm text-[var(--color-muted-foreground)]">{t('dashboard.targets')}</p>
<p class="text-3xl font-semibold mt-1">{status.targets}</p>
<Card hover>
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-[var(--color-muted)]">
<MdiIcon name="mdiTarget" size={22} />
</div>
<div>
<p class="text-sm text-[var(--color-muted-foreground)]">{t('dashboard.targets')}</p>
<p class="text-2xl font-semibold">{status.targets}</p>
</div>
</div>
</Card>
</div>
<h3 class="text-lg font-medium mb-3">{t('dashboard.recentEvents')}</h3>
{#if status.recent_events.length === 0}
<Card><p class="text-sm text-[var(--color-muted-foreground)]">{t('dashboard.noEvents')}</p></Card>
<Card>
<div class="flex flex-col items-center py-4 gap-2 text-[var(--color-muted-foreground)]">
<MdiIcon name="mdiCalendarBlank" size={32} />
<p class="text-sm">{t('dashboard.noEvents')}</p>
</div>
</Card>
{:else}
<Card>
<div class="divide-y divide-[var(--color-border)]">

View File

@@ -39,7 +39,7 @@
<div class="w-full max-w-sm">
<div class="bg-[var(--color-card)] border border-[var(--color-border)] rounded-lg p-6 shadow-sm">
<div class="flex justify-end gap-1 mb-4">
<button onclick={() => { setLocale(getLocale() === 'en' ? 'ru' : 'en'); window.location.reload(); }}
<button onclick={() => { setLocale(getLocale() === 'en' ? 'ru' : 'en'); }}
class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">
{getLocale().toUpperCase()}
</button>

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,20 +8,28 @@
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 servers = $state<any[]>([]);
let showForm = $state(false);
let editing = $state<number | null>(null);
let form = $state({ name: 'Immich', url: '', api_key: '', icon: '' });
let error = $state('');
let loadError = $state('');
let submitting = $state(false);
let loaded = $state(false);
let confirmDelete = $state<any>(null);
let health = $state<Record<number, boolean | null>>({});
onMount(load);
async function load() {
try { servers = await api('/servers'); } catch {} finally { loaded = true; }
try {
servers = await api('/servers');
loadError = '';
} catch (err: any) {
loadError = err.message || t('servers.loadError');
} finally { loaded = true; }
// Ping all servers in background
for (const s of servers) {
health[s.id] = null; // loading
@@ -52,8 +61,14 @@
submitting = false;
}
async function remove(id: number) {
if (!confirm(t('servers.confirmDelete'))) return;
function startDelete(server: any) {
confirmDelete = server;
}
async function doDelete() {
if (!confirmDelete) return;
const id = confirmDelete.id;
confirmDelete = null;
try { await api(`/servers/${id}`, { method: 'DELETE' }); await load(); } catch (err: any) { error = err.message; }
}
</script>
@@ -69,7 +84,14 @@
<Loading />
{:else}
{#if loadError}
<Card class="mb-6">
<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3">{loadError}</div>
</Card>
{/if}
{#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-3">
@@ -95,6 +117,7 @@
</button>
</form>
</Card>
</div>
{/if}
{#if servers.length === 0 && !showForm}
@@ -102,11 +125,11 @@
{:else}
<div class="space-y-3">
{#each servers as server}
<Card>
<Card hover>
<div class="flex items-center justify-between">
<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>
title={health[server.id] === true ? t('servers.online') : health[server.id] === false ? t('servers.offline') : t('servers.checking')}></span>
{#if server.icon}<MdiIcon name={server.icon} />{/if}
<div>
<p class="font-medium">{server.name}</p>
@@ -115,7 +138,7 @@
</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>
<button onclick={() => remove(server.id)} class="text-xs text-[var(--color-destructive)] hover:underline">{t('servers.delete')}</button>
<button onclick={() => startDelete(server)} class="text-xs text-[var(--color-destructive)] hover:underline">{t('servers.delete')}</button>
</div>
</div>
</Card>
@@ -124,3 +147,6 @@
{/if}
{/if}
<ConfirmModal open={!!confirmDelete} title={t('common.delete')} message={t('servers.confirmDelete')}
onconfirm={doDelete} oncancel={() => confirmDelete = null} />

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,6 +8,7 @@
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 targets = $state<any[]>([]);
let trackingConfigs = $state<any[]>([]);
@@ -22,8 +24,12 @@
tracking_config_id: 0, template_config_id: 0 });
let form = $state(defaultForm());
let error = $state('');
let headersError = $state('');
let testResult = $state('');
let loaded = $state(false);
let loadError = $state('');
let showTelegramSettings = $state(false);
let confirmDelete = $state<any>(null);
onMount(load);
async function load() {
@@ -31,7 +37,8 @@
[targets, trackingConfigs, templateConfigs, bots] = await Promise.all([
api('/targets'), api('/tracking-configs'), api('/template-configs'), api('/telegram-bots')
]);
} catch {} finally { loaded = true; }
loadError = '';
} catch (err: any) { loadError = err.message || t('common.loadError'); } finally { loaded = true; }
}
async function loadBotChats() {
@@ -39,7 +46,7 @@
try { botChats[form.bot_id] = await api(`/telegram-bots/${form.bot_id}/chats`); } catch {}
}
function openNew() { form = defaultForm(); formType = 'telegram'; editing = null; showForm = true; }
function openNew() { form = defaultForm(); formType = 'telegram'; editing = null; showTelegramSettings = false; showForm = true; }
async function edit(tgt: any) {
formType = tgt.type;
const c = tgt.config || {};
@@ -52,11 +59,11 @@
tracking_config_id: tgt.tracking_config_id ?? 0,
template_config_id: tgt.template_config_id ?? 0,
};
editing = tgt.id; showForm = true;
editing = tgt.id; showTelegramSettings = false; showForm = true;
}
async function save(e: SubmitEvent) {
e.preventDefault(); error = '';
e.preventDefault(); error = ''; headersError = '';
try {
let botToken = form.bot_token;
// Resolve token from registered bot if selected
@@ -64,6 +71,15 @@
const tokenRes = await api(`/telegram-bots/${form.bot_id}/token`);
botToken = tokenRes.token;
}
let parsedHeaders = {};
if (formType === 'webhook' && form.headers) {
try {
parsedHeaders = JSON.parse(form.headers);
} catch {
headersError = t('common.headersInvalid');
return;
}
}
const config = formType === 'telegram'
? { ...(botToken ? { bot_token: botToken } : {}), chat_id: form.chat_id,
bot_id: form.bot_id || undefined,
@@ -71,7 +87,7 @@
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 };
: { url: form.url, headers: parsedHeaders, ai_captions: form.ai_captions };
const trkId = form.tracking_config_id || null;
const tplId = form.template_config_id || null;
if (editing) {
@@ -89,7 +105,6 @@
setTimeout(() => testResult = '', 5000);
}
async function remove(id: number) {
if (!confirm(t('targets.confirmDelete'))) return;
try { await api(`/targets/${id}`, { method: 'DELETE' }); await load(); } catch (err: any) { error = err.message; }
}
</script>
@@ -103,11 +118,16 @@
{#if !loaded}<Loading />{:else}
{#if loadError}
<div class="mb-4 p-3 rounded-md text-sm bg-[var(--color-error-bg)] text-[var(--color-error-fg)]">{loadError}</div>
{/if}
{#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}
{#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">
@@ -163,9 +183,14 @@
{/if}
<!-- Telegram media settings -->
<details class="border border-[var(--color-border)] rounded-md p-3">
<summary class="text-sm font-medium cursor-pointer">{t('targets.telegramSettings')}</summary>
<div class="grid grid-cols-2 gap-3 mt-3">
<div class="border border-[var(--color-border)] rounded-md p-3">
<button type="button" onclick={() => showTelegramSettings = !showTelegramSettings}
class="text-sm font-medium cursor-pointer w-full text-left flex items-center justify-between">
{t('targets.telegramSettings')}
<span class="text-xs transition-transform duration-200" class:rotate-180={showTelegramSettings}>▼</span>
</button>
{#if showTelegramSettings}
<div transition:slide={{ duration: 150 }} class="grid grid-cols-2 gap-3 mt-3">
<div>
<label for="tgt-maxmedia" class="block text-xs mb-1">{t('targets.maxMedia')}</label>
<input id="tgt-maxmedia" type="number" bind:value={form.max_media_to_send} min="0" max="50" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" />
@@ -185,12 +210,18 @@
<label class="flex items-center gap-2 text-sm col-span-2"><input type="checkbox" bind:checked={form.disable_url_preview} /> {t('targets.disableUrlPreview')}</label>
<label class="flex items-center gap-2 text-sm col-span-2"><input type="checkbox" bind:checked={form.send_large_photos_as_documents} /> {t('targets.sendLargeAsDocuments')}</label>
</div>
</details>
{/if}
</div>
{:else}
<div>
<label for="tgt-url" class="block text-sm font-medium mb-1">{t('targets.webhookUrl')}</label>
<input id="tgt-url" bind:value={form.url} required placeholder="https://..." class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="tgt-headers" class="block text-sm font-medium mb-1">Headers (JSON)</label>
<input id="tgt-headers" bind:value={form.headers} placeholder={'{"Authorization": "Bearer ..."}'} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" style={headersError ? 'border-color: var(--color-error-fg)' : ''} />
{#if headersError}<p class="text-xs text-[var(--color-destructive)] mt-1">{headersError}</p>{/if}
</div>
{/if}
<!-- Config assignments -->
@@ -198,14 +229,14 @@
<div>
<label for="tgt-trk" class="block text-sm font-medium mb-1">{t('trackingConfig.title')}</label>
<select id="tgt-trk" bind:value={form.tracking_config_id} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
<option value={0}> None —</option>
<option value={0}> {t('common.none')} —</option>
{#each trackingConfigs as tc}<option value={tc.id}>{tc.name}</option>{/each}
</select>
</div>
<div>
<label for="tgt-tpl" class="block text-sm font-medium mb-1">{t('templateConfig.title')}</label>
<select id="tgt-tpl" bind:value={form.template_config_id} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
<option value={0}> None (default) —</option>
<option value={0}> {t('common.noneDefault')} —</option>
{#each templateConfigs as tc}<option value={tc.id}>{tc.name}</option>{/each}
</select>
</div>
@@ -216,6 +247,7 @@
<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>
</div>
{/if}
{#if targets.length === 0 && !showForm}
@@ -223,7 +255,7 @@
{:else}
<div class="space-y-3">
{#each targets as target}
<Card>
<Card hover>
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
@@ -238,7 +270,7 @@
<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>
<button onclick={() => confirmDelete = target} class="text-xs text-[var(--color-destructive)] hover:underline">{t('targets.delete')}</button>
</div>
</div>
</Card>
@@ -247,3 +279,11 @@
{/if}
{/if}
<ConfirmModal
open={!!confirmDelete}
title={t('targets.confirmDelete')}
message={confirmDelete?.name ?? ''}
onconfirm={() => { if (confirmDelete) { remove(confirmDelete.id); confirmDelete = null; } }}
oncancel={() => confirmDelete = null}
/>

View File

@@ -7,6 +7,7 @@
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 bots = $state<any[]>([]);
let loaded = $state(false);
@@ -14,6 +15,7 @@
let form = $state({ name: '', icon: '', token: '' });
let error = $state('');
let submitting = $state(false);
let confirmDelete = $state<any>(null);
// Per-bot chat lists
let chats = $state<Record<number, any[]>>({});
@@ -21,7 +23,11 @@
let expandedBot = $state<number | null>(null);
onMount(load);
async function load() { try { bots = await api('/telegram-bots'); } catch {} finally { loaded = true; } }
async function load() {
try { bots = await api('/telegram-bots'); }
catch (err: any) { error = err.message || t('common.loadError'); }
finally { loaded = true; }
}
async function create(e: SubmitEvent) {
e.preventDefault(); error = ''; submitting = true;
@@ -32,9 +38,15 @@
submitting = false;
}
async function remove(id: number) {
if (!confirm(t('common.delete') + '?')) return;
try { await api(`/telegram-bots/${id}`, { method: 'DELETE' }); await load(); } catch (err: any) { error = err.message; }
function remove(id: number) {
confirmDelete = {
id,
onconfirm: async () => {
try { await api(`/telegram-bots/${id}`, { method: 'DELETE' }); await load(); }
catch (err: any) { error = err.message; }
finally { confirmDelete = null; }
}
};
}
async function loadChats(botId: number) {
@@ -96,7 +108,7 @@
{:else}
<div class="space-y-3">
{#each bots as bot}
<Card>
<Card hover>
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
@@ -149,3 +161,6 @@
{/if}
{/if}
<ConfirmModal open={confirmDelete !== null} message={t('telegramBot.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />

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,12 +8,14 @@
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 configs = $state<any[]>([]);
let loaded = $state(false);
let showForm = $state(false);
let editing = $state<number | null>(null);
let error = $state('');
let confirmDelete = $state<any>(null);
let previewSlot = $state('message_assets_added');
let previewResult = $state('');
let previewId = $state<number | null>(null);
@@ -78,7 +81,11 @@
];
onMount(load);
async function load() { try { configs = await api('/template-configs'); } catch {} finally { loaded = true; } }
async function load() {
try { configs = await api('/template-configs'); }
catch (err: any) { error = err.message || t('common.loadError'); }
finally { loaded = true; }
}
function openNew() { form = defaultForm(); editing = null; showForm = true; }
function edit(c: any) { form = { ...defaultForm(), ...c }; editing = c.id; showForm = true; }
@@ -100,9 +107,15 @@
} catch (err: any) { previewResult = `Error: ${err.message}`; }
}
async function remove(id: number) {
if (!confirm(t('common.delete') + '?')) return;
try { await api(`/template-configs/${id}`, { method: 'DELETE' }); await load(); } catch (err: any) { error = err.message; }
function remove(id: number) {
confirmDelete = {
id,
onconfirm: async () => {
try { await api(`/template-configs/${id}`, { method: 'DELETE' }); await load(); }
catch (err: any) { error = err.message; }
finally { confirmDelete = null; }
}
};
}
</script>
@@ -116,6 +129,7 @@
{#if !loaded}<Loading />{:else}
{#if showForm}
<div transition:slide>
<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-5">
@@ -135,7 +149,7 @@
{#each group.slots as slot}
<div>
<label class="block text-xs text-[var(--color-muted-foreground)] mb-1">{t(`templateConfig.${slot.label}`)}</label>
<textarea bind:value={form[slot.key]} rows={slot.key.includes('message_asset_') || slot.key.includes('_template') || slot.key === 'favorite_indicator' || slot.key === 'date_format' || slot.key === 'location_format' ? 1 : 2}
<textarea bind:value={(form as any)[slot.key]} rows={slot.key.includes('message_asset_') || slot.key.includes('_template') || slot.key === 'favorite_indicator' || slot.key === 'date_format' || slot.key === 'location_format' ? 1 : 2}
class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)] font-mono"></textarea>
</div>
{/each}
@@ -148,6 +162,7 @@
</button>
</form>
</Card>
</div>
{/if}
{#if configs.length === 0 && !showForm}
@@ -155,7 +170,7 @@
{:else}
<div class="space-y-3">
{#each configs as config}
<Card>
<Card hover>
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2">
@@ -182,3 +197,6 @@
{/if}
{/if}
<ConfirmModal open={confirmDelete !== null} message={t('templateConfig.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />

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}
/>

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,12 +8,14 @@
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 configs = $state<any[]>([]);
let loaded = $state(false);
let showForm = $state(false);
let editing = $state<number | null>(null);
let error = $state('');
let confirmDelete = $state<any>(null);
const defaultForm = () => ({
name: '', icon: '', track_assets_added: true, track_assets_removed: false,
@@ -30,7 +33,11 @@
let form = $state(defaultForm());
onMount(load);
async function load() { try { configs = await api('/tracking-configs'); } catch {} finally { loaded = true; } }
async function load() {
try { configs = await api('/tracking-configs'); }
catch (err: any) { error = err.message || t('common.loadError'); }
finally { loaded = true; }
}
function openNew() { form = defaultForm(); editing = null; showForm = true; }
function edit(c: any) {
@@ -47,9 +54,15 @@
} catch (err: any) { error = err.message; }
}
async function remove(id: number) {
if (!confirm(t('common.delete') + '?')) return;
try { await api(`/tracking-configs/${id}`, { method: 'DELETE' }); await load(); } catch (err: any) { error = err.message; }
function remove(id: number) {
confirmDelete = {
id,
onconfirm: async () => {
try { await api(`/tracking-configs/${id}`, { method: 'DELETE' }); await load(); }
catch (err: any) { error = err.message; }
finally { confirmDelete = null; }
}
};
}
</script>
@@ -63,6 +76,7 @@
{#if !loaded}<Loading />{:else}
{#if showForm}
<div transition:slide>
<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-5">
@@ -104,13 +118,13 @@
<div>
<label for="tc-sort" class="block text-xs mb-1">{t('trackingConfig.sortBy')}</label>
<select id="tc-sort" bind:value={form.assets_order_by} class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]">
<option value="none">None</option><option value="date">Date</option><option value="rating">Rating</option><option value="name">Name</option>
<option value="none">{t('trackingConfig.sortNone')}</option><option value="date">{t('trackingConfig.sortDate')}</option><option value="rating">{t('trackingConfig.sortRating')}</option><option value="name">{t('trackingConfig.sortName')}</option>
</select>
</div>
<div>
<label for="tc-order" class="block text-xs mb-1">{t('trackingConfig.sortOrder')}</label>
<select id="tc-order" bind:value={form.assets_order} class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]">
<option value="descending">Desc</option><option value="ascending">Asc</option>
<option value="descending">{t('trackingConfig.orderDesc')}</option><option value="ascending">{t('trackingConfig.orderAsc')}</option>
</select>
</div>
</div>
@@ -138,12 +152,12 @@
<div><label class="block text-xs mb-1">{t('trackingConfig.times')}</label><input bind:value={form.scheduled_times} placeholder="09:00" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /></div>
<div><label class="block text-xs mb-1">{t('trackingConfig.albumMode')}</label>
<select bind:value={form.scheduled_album_mode} class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]">
<option value="per_album">Per album</option><option value="combined">Combined</option><option value="random">Random</option>
<option value="per_album">{t('trackingConfig.albumModePerAlbum')}</option><option value="combined">{t('trackingConfig.albumModeCombined')}</option><option value="random">{t('trackingConfig.albumModeRandom')}</option>
</select></div>
<div><label class="block text-xs mb-1">{t('trackingConfig.limit')}</label><input type="number" bind:value={form.scheduled_limit} min="1" max="100" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /></div>
<div><label class="block text-xs mb-1">{t('trackingConfig.assetType')}</label>
<select bind:value={form.scheduled_asset_type} class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]">
<option value="all">All</option><option value="photo">Photo</option><option value="video">Video</option>
<option value="all">{t('trackingConfig.assetTypeAll')}</option><option value="photo">{t('trackingConfig.assetTypePhoto')}</option><option value="video">{t('trackingConfig.assetTypeVideo')}</option>
</select></div>
<div><label class="block text-xs mb-1">{t('trackingConfig.minRating')}</label><input type="number" bind:value={form.scheduled_min_rating} min="0" max="5" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /></div>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.scheduled_favorite_only} /> {t('trackingConfig.favoritesOnly')}</label>
@@ -160,12 +174,12 @@
<div><label class="block text-xs mb-1">{t('trackingConfig.times')}</label><input bind:value={form.memory_times} placeholder="09:00" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /></div>
<div><label class="block text-xs mb-1">{t('trackingConfig.albumMode')}</label>
<select bind:value={form.memory_album_mode} class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]">
<option value="per_album">Per album</option><option value="combined">Combined</option><option value="random">Random</option>
<option value="per_album">{t('trackingConfig.albumModePerAlbum')}</option><option value="combined">{t('trackingConfig.albumModeCombined')}</option><option value="random">{t('trackingConfig.albumModeRandom')}</option>
</select></div>
<div><label class="block text-xs mb-1">{t('trackingConfig.limit')}</label><input type="number" bind:value={form.memory_limit} min="1" max="100" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /></div>
<div><label class="block text-xs mb-1">{t('trackingConfig.assetType')}</label>
<select bind:value={form.memory_asset_type} class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]">
<option value="all">All</option><option value="photo">Photo</option><option value="video">Video</option>
<option value="all">{t('trackingConfig.assetTypeAll')}</option><option value="photo">{t('trackingConfig.assetTypePhoto')}</option><option value="video">{t('trackingConfig.assetTypeVideo')}</option>
</select></div>
<div><label class="block text-xs mb-1">{t('trackingConfig.minRating')}</label><input type="number" bind:value={form.memory_min_rating} min="0" max="5" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /></div>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.memory_favorite_only} /> {t('trackingConfig.favoritesOnly')}</label>
@@ -178,6 +192,7 @@
</button>
</form>
</Card>
</div>
{/if}
{#if configs.length === 0 && !showForm}
@@ -185,7 +200,7 @@
{:else}
<div class="space-y-3">
{#each configs as config}
<Card>
<Card hover>
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
@@ -210,3 +225,6 @@
{/if}
{/if}
<ConfirmModal open={confirmDelete !== null} message={t('trackingConfig.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />

View File

@@ -7,6 +7,7 @@
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
import Modal from '$lib/components/Modal.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
const auth = getAuth();
let users = $state<any[]>([]);
@@ -14,35 +15,48 @@
let form = $state({ username: '', password: '', role: 'user' });
let error = $state('');
let loaded = $state(false);
let confirmDelete = $state<any>(null);
// Admin reset password
let resetUserId = $state<number | null>(null);
let resetUsername = $state('');
let resetPassword = $state('');
let resetMsg = $state('');
let resetSuccess = $state(false);
onMount(load);
async function load() { try { users = await api('/users'); } catch {} finally { loaded = true; } }
async function load() {
try { users = await api('/users'); }
catch (err: any) { error = err.message || t('common.loadError'); }
finally { loaded = true; }
}
async function create(e: SubmitEvent) {
e.preventDefault(); error = '';
try { await api('/users', { method: 'POST', body: JSON.stringify(form) }); form = { username: '', password: '', role: 'user' }; showForm = false; await load(); }
catch (err: any) { error = err.message; }
}
async function remove(id: number) {
if (!confirm(t('users.confirmDelete'))) return;
try { await api(`/users/${id}`, { method: 'DELETE' }); await load(); } catch (err: any) { alert(err.message); }
function remove(id: number) {
confirmDelete = {
id,
onconfirm: async () => {
try { await api(`/users/${id}`, { method: 'DELETE' }); await load(); }
catch (err: any) { error = err.message; }
finally { confirmDelete = null; }
}
};
}
function openResetPassword(user: any) {
resetUserId = user.id; resetUsername = user.username; resetPassword = ''; resetMsg = '';
resetUserId = user.id; resetUsername = user.username; resetPassword = ''; resetMsg = ''; resetSuccess = false;
}
async function resetUserPassword(e: SubmitEvent) {
e.preventDefault(); resetMsg = '';
e.preventDefault(); resetMsg = ''; resetSuccess = false;
try {
await api(`/users/${resetUserId}/password`, { method: 'PUT', body: JSON.stringify({ new_password: resetPassword }) });
resetMsg = t('common.passwordChanged');
setTimeout(() => { resetUserId = null; resetMsg = ''; }, 2000);
} catch (err: any) { resetMsg = err.message; }
resetSuccess = true;
setTimeout(() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }, 2000);
} catch (err: any) { resetMsg = err.message; resetSuccess = false; }
}
</script>
@@ -81,7 +95,7 @@
<div class="space-y-3">
{#each users as user}
<Card>
<Card hover>
<div class="flex items-center justify-between">
<div>
<p class="font-medium">{user.username}</p>
@@ -101,7 +115,7 @@
{/if}
<!-- Admin reset password modal -->
<Modal open={resetUserId !== null} title="{t('common.changePassword')}: {resetUsername}" onclose={() => { resetUserId = null; resetMsg = ''; }}>
<Modal open={resetUserId !== null} title="{t('common.changePassword')}: {resetUsername}" onclose={() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }}>
<form onsubmit={resetUserPassword} class="space-y-3">
<div>
<label for="reset-pwd" class="block text-sm font-medium mb-1">{t('common.newPassword')}</label>
@@ -109,10 +123,13 @@
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
{#if resetMsg}
<p class="text-sm {resetMsg.includes(t('common.passwordChanged')) ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{resetMsg}</p>
<p class="text-sm {resetSuccess ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{resetMsg}</p>
{/if}
<button type="submit" class="w-full py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
{t('common.save')}
</button>
</form>
</Modal>
<ConfirmModal open={confirmDelete !== null} message={t('users.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />