feat(cache): thumbhash-validated asset cache + settings UX overhaul
Cache engine: - TelegramFileCache: configurable max_entries (LRU cap applies in both TTL and thumbhash modes), ttl_seconds<=0 disables TTL, stats() method. - Dispatcher builds an asset.id -> thumbhash resolver from event.added_assets (Immich populates thumbhash in extra) and passes it to TelegramClient, so asset-cache entries invalidate on visual change rather than age. - Watcher wires app settings into cache init: URL cache = TTL + LRU cap, asset cache = thumbhash + LRU cap. Adds soft-reset (in-memory only) used when cache params change. Settings: - New key telegram_asset_cache_max_entries (default 5000). - telegram_cache_ttl_hours default bumped 48 -> 720 (30d); now URL-only. - PUT /settings resets in-memory caches when cache keys change (files kept). - New endpoints: GET/POST /settings/telegram-cache/stats and /clear. Settings page: - Cache stats card (count + size + oldest/newest per bucket) with a hint explaining that the size is cumulative uploaded-to-Telegram bytes. - Clear-cache button behind a confirm modal. - New TimezoneSelector + LocaleSelector components replace raw inputs. - max-entries input, TTL range updated (0..8760, 0 = disabled). Mobile nav: - "More" panel now mirrors the full sidebar tree (groups + subnodes) so every destination is reachable on mobile; previously flat hand-picked list. - Nav height uses env(safe-area-inset-bottom); panel bottom + z-index fixed so content can't visually overlay the bottom bar. A11y / DOM warnings: - Password-change form has a hidden username field for password-manager association; autocomplete hints on all three password inputs. - Telegram webhook secret wrapped in a no-op form + autocomplete=off. Bug fix: - update_settings used any(await ... for ...) which raised TypeError at runtime (async generator not an iterator); replaced with explicit loop.
This commit is contained in:
@@ -226,24 +226,15 @@
|
||||
{ href: '/targets', key: 'nav.targets', icon: 'mdiTarget' },
|
||||
]);
|
||||
|
||||
// "More" panel items — everything not in the bottom bar
|
||||
const mobileMoreItems = $derived<NavItem[]>([
|
||||
{ href: '/providers', key: 'nav.providers', icon: 'mdiServer' },
|
||||
{ href: '/bots?tab=telegram', key: 'nav.bots', icon: 'mdiRobot' },
|
||||
{ href: '/actions', key: 'nav.actions', icon: 'mdiPlayCircleOutline' },
|
||||
{ href: '/tracking-configs', key: 'nav.configs', icon: 'mdiCog' },
|
||||
{ href: '/template-configs', key: 'nav.templates', icon: 'mdiFileDocumentEdit' },
|
||||
{ href: '/command-configs', key: 'nav.configs', icon: 'mdiConsoleLine' },
|
||||
{ href: '/command-template-configs', key: 'nav.templates', icon: 'mdiCodeBracesBox' },
|
||||
...(auth.isAdmin ? [
|
||||
{ href: '/settings', key: 'nav.settings', icon: 'mdiCogOutline' },
|
||||
{ href: '/settings/backup', key: 'nav.backup', icon: 'mdiBackupRestore' },
|
||||
{ href: '/users', key: 'nav.users', icon: 'mdiAccountGroup' },
|
||||
] : []),
|
||||
]);
|
||||
|
||||
// "More" panel mirrors the full desktop sidebar tree so every subnode is
|
||||
// reachable on mobile (previously it was a flat hand-picked list that
|
||||
// hid all target types, bot channels, and several nested pages).
|
||||
let mobileMoreOpen = $state(false);
|
||||
|
||||
function closeMobileMore() {
|
||||
mobileMoreOpen = false;
|
||||
}
|
||||
|
||||
const isAuthPage = $derived(
|
||||
page.url.pathname === '/login' || page.url.pathname === '/setup'
|
||||
);
|
||||
@@ -538,7 +529,7 @@
|
||||
</aside>
|
||||
|
||||
<!-- Mobile bottom nav -->
|
||||
<nav class="mobile-nav" style="position: fixed; bottom: 0; left: 0; right: 0; z-index: 50; background: var(--color-sidebar); border-top: 1px solid var(--color-border); display: none; justify-content: space-around; padding: 0.375rem 0; backdrop-filter: blur(12px);">
|
||||
<nav class="mobile-nav" style="position: fixed; bottom: 0; left: 0; right: 0; z-index: 60; background: var(--color-sidebar); border-top: 1px solid var(--color-border); display: none; justify-content: space-around; padding: 0.375rem 0 calc(0.375rem + env(safe-area-inset-bottom, 0px)); backdrop-filter: blur(12px);">
|
||||
{#each mobileNavItems as item}
|
||||
<a href={item.href} aria-label={t(item.key)}
|
||||
class="flex flex-col items-center gap-0.5 px-2 py-1.5 text-xs rounded-lg transition-all duration-200"
|
||||
@@ -558,40 +549,69 @@
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Mobile "More" panel -->
|
||||
<!-- Mobile "More" panel — mirrors the full desktop nav tree -->
|
||||
{#if mobileMoreOpen}
|
||||
<div class="mobile-more-backdrop" style="position: fixed; inset: 0; z-index: 49; background: rgba(0,0,0,0.4); backdrop-filter: blur(2px);"
|
||||
onclick={() => mobileMoreOpen = false} role="presentation"></div>
|
||||
<div class="mobile-more-panel" style="position: fixed; bottom: 3.25rem; left: 0; right: 0; z-index: 50; background: var(--color-sidebar); border-top: 1px solid var(--color-border); border-radius: 1rem 1rem 0 0; padding: 1rem; max-height: 60vh; overflow-y: auto;"
|
||||
onclick={closeMobileMore} role="presentation"></div>
|
||||
<div class="mobile-more-panel" style="position: fixed; bottom: calc(3rem + env(safe-area-inset-bottom, 0px)); left: 0; right: 0; z-index: 50; background: var(--color-sidebar); border-top: 1px solid var(--color-border); border-radius: 1rem 1rem 0 0; padding: 1rem; max-height: calc(70vh - env(safe-area-inset-bottom, 0px)); overflow-y: auto;"
|
||||
transition:slide={{ duration: 200, easing: cubicOut }}>
|
||||
{#if allProviders.length >= 1}
|
||||
<div class="mb-3 pb-3" style="border-bottom: 1px solid var(--color-border);">
|
||||
<IconGridSelect items={providerFilterItems} bind:value={providerFilterValue} columns={Math.min(providerFilterItems.length, 4)} compact />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
{#each mobileMoreItems as item}
|
||||
<a href={item.href}
|
||||
onclick={() => mobileMoreOpen = false}
|
||||
class="flex flex-col items-center gap-1 p-3 rounded-lg transition-all duration-200"
|
||||
style="color: {isActive(item.href) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'}; background: {isActive(item.href) ? 'var(--color-sidebar-active)' : 'transparent'};"
|
||||
>
|
||||
<MdiIcon name={item.icon} size={20} />
|
||||
<span class="text-xs text-center leading-tight">{t(item.key)}</span>
|
||||
</a>
|
||||
<div class="space-y-3">
|
||||
{#each navEntries as entry}
|
||||
{#if isGroup(entry)}
|
||||
<div>
|
||||
<div class="flex items-center gap-1.5 px-1 pb-1.5 text-[0.65rem] font-semibold uppercase tracking-wider"
|
||||
style="color: var(--color-muted-foreground);">
|
||||
<MdiIcon name={entry.icon} size={13} />
|
||||
<span>{t(entry.key)}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
{#each entry.children as child}
|
||||
<a href={child.href} onclick={closeMobileMore}
|
||||
class="flex flex-col items-center gap-1 p-3 rounded-lg transition-all duration-200 relative"
|
||||
style="color: {isActive(child.href) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'}; background: {isActive(child.href) ? 'var(--color-sidebar-active)' : 'transparent'};"
|
||||
>
|
||||
<MdiIcon name={child.icon} size={20} />
|
||||
<span class="text-xs text-center leading-tight">{t(child.key)}</span>
|
||||
{#if child.countKey && navCounts[child.countKey]}
|
||||
<span class="nav-badge-sm" style="position: absolute; top: 0.25rem; right: 0.25rem;">{navCounts[child.countKey]}</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<a href={entry.href} onclick={closeMobileMore}
|
||||
class="flex items-center gap-2 p-3 rounded-lg transition-all duration-200 relative"
|
||||
style="color: {isActive(entry.href) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'}; background: {isActive(entry.href) ? 'var(--color-sidebar-active)' : 'transparent'};"
|
||||
>
|
||||
<MdiIcon name={entry.icon} size={18} />
|
||||
<span class="text-sm flex-1">{t(entry.key)}</span>
|
||||
{#if entry.countKey && navCounts[entry.countKey]}
|
||||
<span class="nav-badge">{navCounts[entry.countKey]}</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
<button onclick={() => { mobileMoreOpen = false; logout(); }}
|
||||
class="flex flex-col items-center gap-1 p-3 rounded-lg transition-all duration-200"
|
||||
style="color: var(--color-muted-foreground);">
|
||||
<MdiIcon name="mdiLogout" size={20} />
|
||||
<span class="text-xs text-center leading-tight">{t('nav.logout')}</span>
|
||||
</button>
|
||||
<div class="pt-2" style="border-top: 1px solid var(--color-border);">
|
||||
<button onclick={() => { closeMobileMore(); logout(); }}
|
||||
class="flex items-center gap-2 p-3 w-full rounded-lg transition-all duration-200"
|
||||
style="color: var(--color-muted-foreground);">
|
||||
<MdiIcon name="mdiLogout" size={18} />
|
||||
<span class="text-sm">{t('nav.logout')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="flex-1 overflow-auto pb-16 md:pb-0">
|
||||
<main class="flex-1 overflow-auto md:pb-0"
|
||||
style="padding-bottom: calc(4rem + env(safe-area-inset-bottom, 0px));">
|
||||
{#key page.url.pathname}
|
||||
<div class="max-w-5xl mx-auto p-4 md:p-8" in:fade={{ duration: 200, delay: 50 }}>
|
||||
{@render children()}
|
||||
@@ -611,19 +631,22 @@
|
||||
<!-- Password change modal -->
|
||||
<Modal open={showPasswordForm} title={t('common.changePassword')} onclose={() => { showPasswordForm = false; pwdMsg = ''; pwdSuccess = false; pwdConfirm = ''; }}>
|
||||
<form onsubmit={changePassword} class="space-y-3">
|
||||
<input type="text" name="username" autocomplete="username" value={auth.user?.username ?? ''}
|
||||
readonly aria-hidden="true" tabindex="-1"
|
||||
style="position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none;" />
|
||||
<div>
|
||||
<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
|
||||
<input id="pwd-current" type="password" autocomplete="current-password" bind:value={pwdCurrent} required
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<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 minlength="8"
|
||||
<input id="pwd-new" type="password" autocomplete="new-password" bind:value={pwdNew} required minlength="8"
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="pwd-confirm" class="block text-sm font-medium mb-1">{t('auth.confirmPassword')}</label>
|
||||
<input id="pwd-confirm" type="password" bind:value={pwdConfirm} required minlength="8"
|
||||
<input id="pwd-confirm" type="password" autocomplete="new-password" bind:value={pwdConfirm} required minlength="8"
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
{#if pwdMsg}
|
||||
|
||||
@@ -9,26 +9,66 @@
|
||||
import Hint from '$lib/components/Hint.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import LocaleSelector from '$lib/components/LocaleSelector.svelte';
|
||||
import TimezoneSelector from '$lib/components/TimezoneSelector.svelte';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
|
||||
interface CacheBucketStats {
|
||||
count: number;
|
||||
total_size_bytes: number;
|
||||
oldest: string | null;
|
||||
newest: string | null;
|
||||
}
|
||||
interface CacheStats {
|
||||
url: CacheBucketStats;
|
||||
asset: CacheBucketStats;
|
||||
}
|
||||
|
||||
let loaded = $state(false);
|
||||
let saving = $state(false);
|
||||
let clearingCache = $state(false);
|
||||
let confirmClearCache = $state(false);
|
||||
let error = $state('');
|
||||
let settings = $state({
|
||||
external_url: '',
|
||||
telegram_webhook_secret: '',
|
||||
telegram_cache_ttl_hours: '48',
|
||||
telegram_cache_ttl_hours: '720',
|
||||
telegram_asset_cache_max_entries: '5000',
|
||||
supported_locales: 'en,ru',
|
||||
timezone: 'UTC',
|
||||
});
|
||||
let cacheStats = $state<CacheStats | null>(null);
|
||||
|
||||
async function loadCacheStats() {
|
||||
try {
|
||||
cacheStats = await api<CacheStats>('/settings/telegram-cache/stats');
|
||||
} catch { cacheStats = null; }
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
settings = await api('/settings');
|
||||
await loadCacheStats();
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
finally { loaded = true; }
|
||||
});
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!bytes) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let i = 0;
|
||||
let v = bytes;
|
||||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
||||
return `${v.toFixed(v < 10 && i > 0 ? 1 : 0)} ${units[i]}`;
|
||||
}
|
||||
|
||||
function formatTs(iso: string | null): string {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z');
|
||||
return isNaN(d.getTime()) ? iso : d.toLocaleString();
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving = true; error = '';
|
||||
try {
|
||||
@@ -37,6 +77,17 @@
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
saving = false;
|
||||
}
|
||||
|
||||
async function clearTelegramCache() {
|
||||
confirmClearCache = false;
|
||||
clearingCache = true;
|
||||
try {
|
||||
await api('/settings/telegram-cache/clear', { method: 'POST' });
|
||||
snackSuccess(t('settings.clearCacheDone'));
|
||||
await loadCacheStats();
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
clearingCache = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageHeader title={t('settings.title')} description={t('settings.description')} />
|
||||
@@ -59,9 +110,8 @@
|
||||
class="w-full max-w-md px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">{t('settings.timezone')}<Hint text={t('settings.timezoneHint')} /></label>
|
||||
<input bind:value={settings.timezone} placeholder="UTC"
|
||||
class="w-full max-w-md px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
|
||||
<label class="block text-xs font-medium mb-2">{t('settings.timezone')}<Hint text={t('settings.timezoneHint')} /></label>
|
||||
<TimezoneSelector bind:value={settings.timezone} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -75,14 +125,68 @@
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">{t('settings.webhookSecret')}<Hint text={t('settings.webhookSecretHint')} /></label>
|
||||
<input bind:value={settings.telegram_webhook_secret} type="password" placeholder={t('providers.optional')}
|
||||
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
|
||||
<form onsubmit={(e) => e.preventDefault()} autocomplete="off">
|
||||
<input bind:value={settings.telegram_webhook_secret} type="password" autocomplete="off" placeholder={t('providers.optional')}
|
||||
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
|
||||
</form>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">{t('settings.cacheTtl')}<Hint text={t('settings.cacheTtlHint')} /></label>
|
||||
<input bind:value={settings.telegram_cache_ttl_hours} type="number" min="1" max="720"
|
||||
<input bind:value={settings.telegram_cache_ttl_hours} type="number" min="0" max="8760"
|
||||
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">{t('settings.cacheMaxEntries')}<Hint text={t('settings.cacheMaxEntriesHint')} /></label>
|
||||
<input bind:value={settings.telegram_asset_cache_max_entries} type="number" min="100" max="100000"
|
||||
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 pt-4 border-t border-[var(--color-border)]">
|
||||
<div class="text-xs font-medium mb-2 flex items-center" style="color: var(--color-muted-foreground);">
|
||||
{t('settings.cacheStats')}<Hint text={t('settings.cacheStatsHint')} />
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 mb-3">
|
||||
{#each [
|
||||
{ label: t('settings.cacheStatsUrl'), data: cacheStats?.url },
|
||||
{ label: t('settings.cacheStatsAsset'), data: cacheStats?.asset },
|
||||
] as bucket}
|
||||
<div class="px-3 py-2 rounded-md border border-[var(--color-border)] bg-[var(--color-background)] text-xs">
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<span class="font-medium">{bucket.label}</span>
|
||||
{#if bucket.data && bucket.data.count > 0}
|
||||
<span>
|
||||
<span class="font-mono">{bucket.data.count}</span>
|
||||
<span style="color: var(--color-muted-foreground);"> {t('settings.cacheStatsEntries')}</span>
|
||||
{#if bucket.data.total_size_bytes > 0}
|
||||
<span style="color: var(--color-muted-foreground);"> · </span>
|
||||
<span class="font-mono">{formatBytes(bucket.data.total_size_bytes)}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{:else}
|
||||
<span style="color: var(--color-muted-foreground);">{t('settings.cacheStatsEmpty')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if bucket.data && bucket.data.count > 0 && (bucket.data.oldest || bucket.data.newest)}
|
||||
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-0.5" style="color: var(--color-muted-foreground);">
|
||||
{#if bucket.data.oldest}
|
||||
<span>{t('settings.cacheStatsOldest')}: <span class="font-mono">{formatTs(bucket.data.oldest)}</span></span>
|
||||
{/if}
|
||||
{#if bucket.data.newest}
|
||||
<span>{t('settings.cacheStatsNewest')}: <span class="font-mono">{formatTs(bucket.data.newest)}</span></span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<button type="button" onclick={() => confirmClearCache = true} disabled={clearingCache}
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md border border-[var(--color-border)] bg-[var(--color-background)] hover:bg-[var(--color-muted)] disabled:opacity-50">
|
||||
<MdiIcon name="mdiDeleteSweep" size={16} />
|
||||
{clearingCache ? t('common.loading') : t('settings.clearCache')}
|
||||
</button>
|
||||
<span class="text-xs" style="color: var(--color-muted-foreground);">{t('settings.clearCacheHint')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -94,9 +198,8 @@
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">{t('settings.supportedLocales')}<Hint text={t('settings.supportedLocalesHint')} /></label>
|
||||
<input bind:value={settings.supported_locales} placeholder="en,ru,de,fr"
|
||||
class="w-full max-w-md px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
|
||||
<label class="block text-xs font-medium mb-2">{t('settings.supportedLocales')}<Hint text={t('settings.supportedLocalesHint')} /></label>
|
||||
<LocaleSelector bind:value={settings.supported_locales} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -105,4 +208,12 @@
|
||||
{saving ? t('common.loading') : t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ConfirmModal open={confirmClearCache}
|
||||
title={t('settings.clearCacheConfirmTitle')}
|
||||
message={t('settings.clearCacheConfirm')}
|
||||
confirmLabel={t('settings.clearCacheConfirmBtn')}
|
||||
confirmIcon="mdiDeleteSweep"
|
||||
onconfirm={clearTelegramCache}
|
||||
oncancel={() => confirmClearCache = false} />
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user