Comprehensive review fixes: security, performance, code quality, and UI polish
Some checks failed
Validate / Hassfest (push) Has been cancelled
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:
@@ -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>
|
||||
|
||||
@@ -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)]">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user