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:
@@ -0,0 +1,764 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import MdiIcon from './MdiIcon.svelte';
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
|
||||||
|
interface LocaleMeta {
|
||||||
|
code: string;
|
||||||
|
name: string; // English name
|
||||||
|
native: string; // Native script
|
||||||
|
rtl?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATALOG: LocaleMeta[] = [
|
||||||
|
{ code: 'en', name: 'English', native: 'English' },
|
||||||
|
{ code: 'ru', name: 'Russian', native: 'Русский' },
|
||||||
|
{ code: 'de', name: 'German', native: 'Deutsch' },
|
||||||
|
{ code: 'fr', name: 'French', native: 'Français' },
|
||||||
|
{ code: 'es', name: 'Spanish', native: 'Español' },
|
||||||
|
{ code: 'it', name: 'Italian', native: 'Italiano' },
|
||||||
|
{ code: 'pt', name: 'Portuguese', native: 'Português' },
|
||||||
|
{ code: 'pl', name: 'Polish', native: 'Polski' },
|
||||||
|
{ code: 'nl', name: 'Dutch', native: 'Nederlands' },
|
||||||
|
{ code: 'sv', name: 'Swedish', native: 'Svenska' },
|
||||||
|
{ code: 'fi', name: 'Finnish', native: 'Suomi' },
|
||||||
|
{ code: 'no', name: 'Norwegian', native: 'Norsk' },
|
||||||
|
{ code: 'da', name: 'Danish', native: 'Dansk' },
|
||||||
|
{ code: 'cs', name: 'Czech', native: 'Čeština' },
|
||||||
|
{ code: 'hu', name: 'Hungarian', native: 'Magyar' },
|
||||||
|
{ code: 'ro', name: 'Romanian', native: 'Română' },
|
||||||
|
{ code: 'el', name: 'Greek', native: 'Ελληνικά' },
|
||||||
|
{ code: 'tr', name: 'Turkish', native: 'Türkçe' },
|
||||||
|
{ code: 'uk', name: 'Ukrainian', native: 'Українська' },
|
||||||
|
{ code: 'be', name: 'Belarusian', native: 'Беларуская' },
|
||||||
|
{ code: 'bg', name: 'Bulgarian', native: 'Български' },
|
||||||
|
{ code: 'sr', name: 'Serbian', native: 'Српски' },
|
||||||
|
{ code: 'ar', name: 'Arabic', native: 'العربية', rtl: true },
|
||||||
|
{ code: 'he', name: 'Hebrew', native: 'עברית', rtl: true },
|
||||||
|
{ code: 'fa', name: 'Persian', native: 'فارسی', rtl: true },
|
||||||
|
{ code: 'zh', name: 'Chinese', native: '中文' },
|
||||||
|
{ code: 'ja', name: 'Japanese', native: '日本語' },
|
||||||
|
{ code: 'ko', name: 'Korean', native: '한국어' },
|
||||||
|
{ code: 'hi', name: 'Hindi', native: 'हिन्दी' },
|
||||||
|
{ code: 'vi', name: 'Vietnamese', native: 'Tiếng Việt' },
|
||||||
|
{ code: 'th', name: 'Thai', native: 'ไทย' },
|
||||||
|
{ code: 'id', name: 'Indonesian', native: 'Bahasa Indonesia' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Locales that ship with default notification & command templates.
|
||||||
|
const SHIPPED = new Set(['en', 'ru']);
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = $bindable<string>(''),
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// Parse the comma-separated backend string into an ordered array of codes.
|
||||||
|
const codes = $derived.by<string[]>(() => {
|
||||||
|
if (!value) return [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const raw of value.split(',')) {
|
||||||
|
const c = raw.trim().toLowerCase();
|
||||||
|
if (!c || seen.has(c)) continue;
|
||||||
|
seen.add(c);
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
function commit(next: string[]) {
|
||||||
|
// De-dupe (preserve order) and serialise back to the backend format.
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const clean = next.map(c => c.trim().toLowerCase())
|
||||||
|
.filter(c => c && !seen.has(c) && (seen.add(c), true));
|
||||||
|
value = clean.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
function meta(code: string): LocaleMeta {
|
||||||
|
return CATALOG.find(l => l.code === code) ?? {
|
||||||
|
code,
|
||||||
|
name: code.toUpperCase(),
|
||||||
|
native: code.toUpperCase(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(code: string) {
|
||||||
|
commit(codes.filter(c => c !== code));
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePrimary(code: string) {
|
||||||
|
commit([code, ...codes.filter(c => c !== code)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveUp(code: string) {
|
||||||
|
const i = codes.indexOf(code);
|
||||||
|
if (i <= 0) return;
|
||||||
|
const next = [...codes];
|
||||||
|
[next[i - 1], next[i]] = [next[i], next[i - 1]];
|
||||||
|
commit(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveDown(code: string) {
|
||||||
|
const i = codes.indexOf(code);
|
||||||
|
if (i < 0 || i >= codes.length - 1) return;
|
||||||
|
const next = [...codes];
|
||||||
|
[next[i], next[i + 1]] = [next[i + 1], next[i]];
|
||||||
|
commit(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Add flow ----------------------------------------------------------
|
||||||
|
|
||||||
|
let addOpen = $state(false);
|
||||||
|
let addQuery = $state('');
|
||||||
|
let addInputEl = $state<HTMLInputElement | null>(null);
|
||||||
|
let highlightIdx = $state(0);
|
||||||
|
|
||||||
|
// Valid BCP 47-ish: 2–3 letter primary, optional '-' subtag(s) 2-8 chars.
|
||||||
|
const CUSTOM_RE = /^[a-z]{2,3}(-[a-z0-9]{2,8})*$/i;
|
||||||
|
|
||||||
|
const selectedSet = $derived(new Set(codes));
|
||||||
|
|
||||||
|
const suggestions = $derived.by(() => {
|
||||||
|
const q = addQuery.trim().toLowerCase();
|
||||||
|
const available = CATALOG.filter(l => !selectedSet.has(l.code));
|
||||||
|
if (!q) return available;
|
||||||
|
return available.filter(l =>
|
||||||
|
l.code.includes(q)
|
||||||
|
|| l.name.toLowerCase().includes(q)
|
||||||
|
|| l.native.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const canAddCustom = $derived.by(() => {
|
||||||
|
const q = addQuery.trim().toLowerCase();
|
||||||
|
if (!q) return false;
|
||||||
|
if (!CUSTOM_RE.test(q)) return false;
|
||||||
|
if (selectedSet.has(q)) return false;
|
||||||
|
// Skip "custom" entry when it matches an existing catalog entry exactly.
|
||||||
|
if (CATALOG.some(l => l.code === q)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
function openAdd() {
|
||||||
|
addOpen = true;
|
||||||
|
addQuery = '';
|
||||||
|
highlightIdx = 0;
|
||||||
|
requestAnimationFrame(() => addInputEl?.focus());
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAdd() {
|
||||||
|
addOpen = false;
|
||||||
|
addQuery = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCode(code: string) {
|
||||||
|
const c = code.trim().toLowerCase();
|
||||||
|
if (!c) return;
|
||||||
|
commit([...codes, c]);
|
||||||
|
addQuery = '';
|
||||||
|
highlightIdx = 0;
|
||||||
|
requestAnimationFrame(() => addInputEl?.focus());
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAddKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') { closeAdd(); return; }
|
||||||
|
const total = suggestions.length + (canAddCustom ? 1 : 0);
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
highlightIdx = Math.min(highlightIdx + 1, Math.max(0, total - 1));
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
highlightIdx = Math.max(highlightIdx - 1, 0);
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (highlightIdx < suggestions.length) {
|
||||||
|
addCode(suggestions[highlightIdx].code);
|
||||||
|
} else if (canAddCustom) {
|
||||||
|
addCode(addQuery);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => { addQuery; highlightIdx = 0; });
|
||||||
|
|
||||||
|
// --- Drag & drop -------------------------------------------------------
|
||||||
|
|
||||||
|
let dragCode = $state<string | null>(null);
|
||||||
|
let dragOverCode = $state<string | null>(null);
|
||||||
|
|
||||||
|
function onDragStart(e: DragEvent, code: string) {
|
||||||
|
dragCode = code;
|
||||||
|
if (e.dataTransfer) {
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
e.dataTransfer.setData('text/plain', code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragOver(e: DragEvent, code: string) {
|
||||||
|
if (!dragCode || dragCode === code) return;
|
||||||
|
e.preventDefault();
|
||||||
|
dragOverCode = code;
|
||||||
|
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(e: DragEvent, code: string) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!dragCode || dragCode === code) return;
|
||||||
|
const from = codes.indexOf(dragCode);
|
||||||
|
const to = codes.indexOf(code);
|
||||||
|
if (from < 0 || to < 0) return;
|
||||||
|
const next = [...codes];
|
||||||
|
const [moved] = next.splice(from, 1);
|
||||||
|
next.splice(to, 0, moved);
|
||||||
|
commit(next);
|
||||||
|
dragCode = null;
|
||||||
|
dragOverCode = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragEnd() {
|
||||||
|
dragCode = null;
|
||||||
|
dragOverCode = null;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="ls-root">
|
||||||
|
{#if codes.length === 0}
|
||||||
|
<div class="ls-empty">
|
||||||
|
<div class="ls-empty-glyph" aria-hidden="true">A あ Я</div>
|
||||||
|
<p class="ls-empty-text">{t('locales.empty')}</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<ul class="ls-list" role="list">
|
||||||
|
{#each codes as code, i (code)}
|
||||||
|
{@const m = meta(code)}
|
||||||
|
{@const isPrimary = i === 0}
|
||||||
|
{@const isShipped = SHIPPED.has(code)}
|
||||||
|
<li
|
||||||
|
class="ls-row"
|
||||||
|
class:ls-row-primary={isPrimary}
|
||||||
|
class:ls-row-dragover={dragOverCode === code}
|
||||||
|
class:ls-row-dragging={dragCode === code}
|
||||||
|
draggable="true"
|
||||||
|
ondragstart={(e) => onDragStart(e, code)}
|
||||||
|
ondragover={(e) => onDragOver(e, code)}
|
||||||
|
ondrop={(e) => onDrop(e, code)}
|
||||||
|
ondragend={onDragEnd}
|
||||||
|
>
|
||||||
|
<span class="ls-rail" aria-hidden="true"></span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ls-handle"
|
||||||
|
aria-label={t('locales.reorder')}
|
||||||
|
title={t('locales.reorder')}
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<MdiIcon name="mdiDragVertical" size={16} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="ls-text">
|
||||||
|
<div class="ls-native" dir={m.rtl ? 'rtl' : 'ltr'} lang={code}>{m.native}</div>
|
||||||
|
<div class="ls-meta">
|
||||||
|
<span class="ls-name">{m.name}</span>
|
||||||
|
<span class="ls-dot" aria-hidden="true">·</span>
|
||||||
|
<span class="ls-code">{code}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ls-badges">
|
||||||
|
{#if isPrimary}
|
||||||
|
<span class="ls-tag ls-tag-primary">
|
||||||
|
<MdiIcon name="mdiStar" size={10} />
|
||||||
|
{t('locales.primary')}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if isShipped}
|
||||||
|
<span class="ls-tag ls-tag-shipped" title={t('locales.shippedHint')}>
|
||||||
|
<MdiIcon name="mdiPackageVariantClosedCheck" size={10} />
|
||||||
|
{t('locales.shipped')}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ls-actions">
|
||||||
|
{#if !isPrimary}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ls-icon-btn"
|
||||||
|
onclick={() => makePrimary(code)}
|
||||||
|
aria-label={t('locales.makePrimary')}
|
||||||
|
title={t('locales.makePrimary')}
|
||||||
|
>
|
||||||
|
<MdiIcon name="mdiStarOutline" size={14} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ls-icon-btn"
|
||||||
|
onclick={() => moveUp(code)}
|
||||||
|
disabled={i === 0}
|
||||||
|
aria-label={t('locales.moveUp')}
|
||||||
|
title={t('locales.moveUp')}
|
||||||
|
>
|
||||||
|
<MdiIcon name="mdiChevronUp" size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ls-icon-btn"
|
||||||
|
onclick={() => moveDown(code)}
|
||||||
|
disabled={i === codes.length - 1}
|
||||||
|
aria-label={t('locales.moveDown')}
|
||||||
|
title={t('locales.moveDown')}
|
||||||
|
>
|
||||||
|
<MdiIcon name="mdiChevronDown" size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ls-icon-btn ls-icon-danger"
|
||||||
|
onclick={() => remove(code)}
|
||||||
|
disabled={codes.length <= 1}
|
||||||
|
aria-label={t('locales.remove')}
|
||||||
|
title={codes.length <= 1 ? t('locales.removeLast') : t('locales.remove')}
|
||||||
|
>
|
||||||
|
<MdiIcon name="mdiClose" size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Add zone -->
|
||||||
|
<div class="ls-add" class:ls-add-open={addOpen}>
|
||||||
|
{#if !addOpen}
|
||||||
|
<button type="button" class="ls-add-trigger" onclick={openAdd}>
|
||||||
|
<MdiIcon name="mdiPlus" size={14} />
|
||||||
|
<span>{t('locales.add')}</span>
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<div class="ls-add-panel">
|
||||||
|
<div class="ls-add-input-row">
|
||||||
|
<MdiIcon name="mdiMagnify" size={14} />
|
||||||
|
<input
|
||||||
|
bind:this={addInputEl}
|
||||||
|
bind:value={addQuery}
|
||||||
|
onkeydown={onAddKeydown}
|
||||||
|
onblur={() => setTimeout(() => { if (addOpen && !addQuery) closeAdd(); }, 150)}
|
||||||
|
placeholder={t('locales.searchPlaceholder')}
|
||||||
|
class="ls-add-input"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<button type="button" class="ls-icon-btn" onclick={closeAdd} aria-label={t('common.cancel')}>
|
||||||
|
<MdiIcon name="mdiClose" size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ls-add-list" role="listbox">
|
||||||
|
{#each suggestions as s, i (s.code)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={i === highlightIdx}
|
||||||
|
class="ls-sugg"
|
||||||
|
class:ls-sugg-hl={i === highlightIdx}
|
||||||
|
onmouseenter={() => highlightIdx = i}
|
||||||
|
onmousedown={(e) => { e.preventDefault(); addCode(s.code); }}
|
||||||
|
>
|
||||||
|
<span class="ls-sugg-native" dir={s.rtl ? 'rtl' : 'ltr'} lang={s.code}>{s.native}</span>
|
||||||
|
<span class="ls-sugg-name">{s.name}</span>
|
||||||
|
<span class="ls-sugg-code">{s.code}</span>
|
||||||
|
{#if SHIPPED.has(s.code)}
|
||||||
|
<span class="ls-sugg-shipped" title={t('locales.shippedHint')}>
|
||||||
|
<MdiIcon name="mdiPackageVariantClosedCheck" size={10} />
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if canAddCustom}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={highlightIdx === suggestions.length}
|
||||||
|
class="ls-sugg ls-sugg-custom"
|
||||||
|
class:ls-sugg-hl={highlightIdx === suggestions.length}
|
||||||
|
onmouseenter={() => highlightIdx = suggestions.length}
|
||||||
|
onmousedown={(e) => { e.preventDefault(); addCode(addQuery); }}
|
||||||
|
>
|
||||||
|
<MdiIcon name="mdiPlusCircleOutline" size={14} />
|
||||||
|
<span class="ls-sugg-custom-label">{t('locales.addCustom')}</span>
|
||||||
|
<span class="ls-sugg-code">{addQuery.trim().toLowerCase()}</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if suggestions.length === 0 && !canAddCustom}
|
||||||
|
<div class="ls-sugg-empty">{t('locales.noSuggestions')}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="ls-hint">
|
||||||
|
<MdiIcon name="mdiInformationOutline" size={12} />
|
||||||
|
<span>{t('locales.orderHint')}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.ls-root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 34rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Empty state -------------------------------------------------- */
|
||||||
|
.ls-empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.875rem;
|
||||||
|
padding: 1rem 1.125rem;
|
||||||
|
border: 1px dashed var(--color-border);
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg,
|
||||||
|
color-mix(in srgb, var(--color-primary) 4%, transparent) 0%,
|
||||||
|
transparent 60%),
|
||||||
|
var(--color-background);
|
||||||
|
}
|
||||||
|
.ls-empty-glyph {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
font-weight: 300;
|
||||||
|
color: color-mix(in srgb, var(--color-primary) 70%, var(--color-muted-foreground));
|
||||||
|
flex-shrink: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.ls-empty-text {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- List --------------------------------------------------------- */
|
||||||
|
.ls-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ls-row {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
padding: 0.625rem 0.75rem 0.625rem 0.875rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--color-background);
|
||||||
|
transition: border-color 0.15s, background 0.15s, transform 0.15s;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.ls-row:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--color-primary) 35%, var(--color-border));
|
||||||
|
}
|
||||||
|
.ls-row.ls-row-dragging {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
.ls-row.ls-row-dragover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 6%, var(--color-background));
|
||||||
|
}
|
||||||
|
.ls-row.ls-row-primary {
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg,
|
||||||
|
color-mix(in srgb, var(--color-primary) 5%, transparent) 0%,
|
||||||
|
transparent 30%),
|
||||||
|
var(--color-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Accent rail — pronounced on primary, near-invisible otherwise */
|
||||||
|
.ls-rail {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 3px;
|
||||||
|
background: transparent;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.ls-row.ls-row-primary .ls-rail {
|
||||||
|
background: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ls-handle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.125rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: grab;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.ls-row:hover .ls-handle {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
.ls-handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ls-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.125rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.ls-native {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.2;
|
||||||
|
letter-spacing: -0.005em;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ls-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.ls-name {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.625rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ls-dot {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.ls-code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0.05rem 0.375rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: var(--color-muted);
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ls-badges {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.ls-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.15rem;
|
||||||
|
font-size: 0.55rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ls-tag-primary {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-primary-foreground, #fff);
|
||||||
|
}
|
||||||
|
.ls-tag-shipped {
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||||
|
color: var(--color-primary);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ls-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.0625rem;
|
||||||
|
}
|
||||||
|
.ls-icon-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s, color 0.12s;
|
||||||
|
}
|
||||||
|
.ls-icon-btn:hover:not(:disabled) {
|
||||||
|
background: var(--color-muted);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
.ls-icon-btn:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.ls-icon-btn.ls-icon-danger:hover:not(:disabled) {
|
||||||
|
background: color-mix(in srgb, #ef4444 14%, transparent);
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Add zone ----------------------------------------------------- */
|
||||||
|
.ls-add {
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
}
|
||||||
|
.ls-add-trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border: 1px dashed var(--color-border);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.ls-add-trigger:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
border-style: solid;
|
||||||
|
color: var(--color-primary);
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 5%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ls-add-panel {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--color-background);
|
||||||
|
overflow: hidden;
|
||||||
|
animation: ls-pop 0.15s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes ls-pop {
|
||||||
|
from { opacity: 0; transform: translateY(-2px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
.ls-add-input-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.375rem 0.625rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
.ls-add-input {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
padding: 0.125rem 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ls-add-list {
|
||||||
|
max-height: 14rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
.ls-sugg {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.375rem 0.625rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.ls-sugg.ls-sugg-hl {
|
||||||
|
background: var(--color-muted);
|
||||||
|
}
|
||||||
|
.ls-sugg-native {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ls-sugg-name {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ls-sugg-code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0.05rem 0.375rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: var(--color-muted);
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
.ls-sugg.ls-sugg-hl .ls-sugg-code {
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 15%, var(--color-muted));
|
||||||
|
}
|
||||||
|
.ls-sugg-shipped {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--color-primary);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ls-sugg-custom {
|
||||||
|
border-top: 1px dashed var(--color-border);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
.ls-sugg-custom-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ls-sugg-empty {
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Hint --------------------------------------------------------- */
|
||||||
|
.ls-hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.3rem;
|
||||||
|
margin: 0.125rem 0 0;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,585 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import MdiIcon from './MdiIcon.svelte';
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = $bindable<string>('UTC'),
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// --- Catalog -----------------------------------------------------------
|
||||||
|
|
||||||
|
const timezones = $derived.by<string[]>(() => {
|
||||||
|
try {
|
||||||
|
const intl = Intl as unknown as { supportedValuesOf?: (k: string) => string[] };
|
||||||
|
if (typeof intl.supportedValuesOf === 'function') {
|
||||||
|
return intl.supportedValuesOf('timeZone');
|
||||||
|
}
|
||||||
|
} catch { /* fall through */ }
|
||||||
|
return ['UTC'];
|
||||||
|
});
|
||||||
|
|
||||||
|
const detectedTz = (() => {
|
||||||
|
try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; }
|
||||||
|
catch { return 'UTC'; }
|
||||||
|
})();
|
||||||
|
|
||||||
|
// --- Live clock --------------------------------------------------------
|
||||||
|
|
||||||
|
let now = $state(new Date());
|
||||||
|
let tickHandle: ReturnType<typeof setInterval> | null = null;
|
||||||
|
onMount(() => {
|
||||||
|
tickHandle = setInterval(() => { now = new Date(); }, 1000);
|
||||||
|
});
|
||||||
|
onDestroy(() => { if (tickHandle) clearInterval(tickHandle); });
|
||||||
|
|
||||||
|
function splitTz(tz: string): { region: string; city: string } {
|
||||||
|
if (!tz || tz === 'UTC' || tz === 'Etc/UTC') return { region: 'UTC', city: 'UTC' };
|
||||||
|
const parts = tz.split('/');
|
||||||
|
if (parts.length === 1) return { region: 'Other', city: parts[0].replace(/_/g, ' ') };
|
||||||
|
const city = parts[parts.length - 1].replace(/_/g, ' ');
|
||||||
|
const region = parts.slice(0, -1).join(' / ').replace(/_/g, ' ');
|
||||||
|
return { region, city };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(tz: string): string {
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat('en-GB', {
|
||||||
|
timeZone: tz,
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
}).format(now);
|
||||||
|
} catch { return '--:--:--'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(tz: string): string {
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat(undefined, {
|
||||||
|
timeZone: tz,
|
||||||
|
weekday: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
}).format(now);
|
||||||
|
} catch { return ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtOffset(tz: string): string {
|
||||||
|
try {
|
||||||
|
const parts = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: tz,
|
||||||
|
timeZoneName: 'shortOffset',
|
||||||
|
}).formatToParts(now);
|
||||||
|
const off = parts.find(p => p.type === 'timeZoneName')?.value ?? '';
|
||||||
|
return off || 'UTC';
|
||||||
|
} catch { return ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Selected state ----------------------------------------------------
|
||||||
|
|
||||||
|
const selected = $derived.by(() => {
|
||||||
|
const s = splitTz(value || 'UTC');
|
||||||
|
return {
|
||||||
|
iana: value || 'UTC',
|
||||||
|
region: s.region,
|
||||||
|
city: s.city,
|
||||||
|
time: fmtTime(value || 'UTC'),
|
||||||
|
date: fmtDate(value || 'UTC'),
|
||||||
|
offset: fmtOffset(value || 'UTC'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Picker ------------------------------------------------------------
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
let query = $state('');
|
||||||
|
let highlightIdx = $state(0);
|
||||||
|
let inputEl = $state<HTMLInputElement | null>(null);
|
||||||
|
let panelEl = $state<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const filtered = $derived.by(() => {
|
||||||
|
const q = query.trim().toLowerCase().replace(/\s+/g, '_');
|
||||||
|
if (!q) return timezones;
|
||||||
|
return timezones.filter(tz => tz.toLowerCase().includes(q));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Group filtered tz list by region prefix for visual hierarchy.
|
||||||
|
interface Group { region: string; items: string[] }
|
||||||
|
const groups = $derived.by<Group[]>(() => {
|
||||||
|
const map = new Map<string, string[]>();
|
||||||
|
for (const tz of filtered) {
|
||||||
|
const region = tz.includes('/') ? tz.split('/')[0] : 'Other';
|
||||||
|
if (!map.has(region)) map.set(region, []);
|
||||||
|
map.get(region)!.push(tz);
|
||||||
|
}
|
||||||
|
const REGION_ORDER = ['UTC', 'Europe', 'America', 'Asia', 'Africa', 'Australia', 'Pacific', 'Atlantic', 'Indian', 'Antarctica', 'Arctic', 'Etc', 'Other'];
|
||||||
|
return [...map.entries()]
|
||||||
|
.sort(([a], [b]) => {
|
||||||
|
const ai = REGION_ORDER.indexOf(a);
|
||||||
|
const bi = REGION_ORDER.indexOf(b);
|
||||||
|
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
|
||||||
|
})
|
||||||
|
.map(([region, items]) => ({ region, items }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Flattened index for keyboard navigation.
|
||||||
|
const flat = $derived<string[]>(groups.flatMap(g => g.items));
|
||||||
|
|
||||||
|
function openPicker() {
|
||||||
|
open = true;
|
||||||
|
query = '';
|
||||||
|
highlightIdx = Math.max(0, flat.indexOf(value));
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
inputEl?.focus();
|
||||||
|
scrollToHighlight();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePicker() {
|
||||||
|
open = false;
|
||||||
|
query = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectTz(tz: string) {
|
||||||
|
value = tz;
|
||||||
|
closePicker();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') { closePicker(); return; }
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
highlightIdx = Math.min(highlightIdx + 1, flat.length - 1);
|
||||||
|
scrollToHighlight();
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
highlightIdx = Math.max(highlightIdx - 1, 0);
|
||||||
|
scrollToHighlight();
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (flat[highlightIdx]) selectTz(flat[highlightIdx]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToHighlight() {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
panelEl?.querySelector('.tz-opt-hl')?.scrollIntoView({ block: 'nearest' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => { query; highlightIdx = 0; });
|
||||||
|
|
||||||
|
// Close on outside click
|
||||||
|
function onDocClick(e: MouseEvent) {
|
||||||
|
if (!open) return;
|
||||||
|
const target = e.target as Node;
|
||||||
|
if (panelEl && !panelEl.contains(target)) closePicker();
|
||||||
|
}
|
||||||
|
onMount(() => {
|
||||||
|
document.addEventListener('mousedown', onDocClick);
|
||||||
|
});
|
||||||
|
onDestroy(() => {
|
||||||
|
document.removeEventListener('mousedown', onDocClick);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="tz-root">
|
||||||
|
<!-- Selected card -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tz-card"
|
||||||
|
class:tz-card-open={open}
|
||||||
|
onclick={() => (open ? closePicker() : openPicker())}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
<div class="tz-card-left">
|
||||||
|
<div class="tz-region">{selected.region}</div>
|
||||||
|
<div class="tz-city">{selected.city}</div>
|
||||||
|
<div class="tz-sub">
|
||||||
|
<span class="tz-iana">{selected.iana}</span>
|
||||||
|
{#if selected.date}
|
||||||
|
<span class="tz-dot">·</span>
|
||||||
|
<span class="tz-date">{selected.date}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tz-card-right">
|
||||||
|
<div class="tz-clock">{selected.time}</div>
|
||||||
|
<div class="tz-offset">{selected.offset}</div>
|
||||||
|
</div>
|
||||||
|
<span class="tz-chev" aria-hidden="true">
|
||||||
|
<MdiIcon name={open ? 'mdiChevronUp' : 'mdiChevronDown'} size={16} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div class="tz-panel" bind:this={panelEl} role="listbox">
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="tz-search-row">
|
||||||
|
<MdiIcon name="mdiMagnify" size={14} />
|
||||||
|
<input
|
||||||
|
bind:this={inputEl}
|
||||||
|
bind:value={query}
|
||||||
|
onkeydown={onKeydown}
|
||||||
|
placeholder={t('timezone.searchPlaceholder')}
|
||||||
|
class="tz-search"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<kbd class="tz-kbd">ESC</kbd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick picks -->
|
||||||
|
{#if !query}
|
||||||
|
<div class="tz-quick">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tz-quick-btn"
|
||||||
|
class:tz-quick-active={value === detectedTz}
|
||||||
|
onclick={() => selectTz(detectedTz)}
|
||||||
|
>
|
||||||
|
<MdiIcon name="mdiCrosshairsGps" size={12} />
|
||||||
|
<span class="tz-quick-label">{t('timezone.detect')}</span>
|
||||||
|
<span class="tz-quick-val">{detectedTz}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tz-quick-btn"
|
||||||
|
class:tz-quick-active={value === 'UTC' || value === 'Etc/UTC'}
|
||||||
|
onclick={() => selectTz('UTC')}
|
||||||
|
>
|
||||||
|
<MdiIcon name="mdiEarth" size={12} />
|
||||||
|
<span class="tz-quick-label">{t('timezone.utc')}</span>
|
||||||
|
<span class="tz-quick-val">UTC+00</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Grouped list -->
|
||||||
|
<div class="tz-list">
|
||||||
|
{#if filtered.length === 0}
|
||||||
|
<div class="tz-empty">{t('timezone.noMatches')}</div>
|
||||||
|
{:else}
|
||||||
|
{#each groups as g (g.region)}
|
||||||
|
<div class="tz-group">
|
||||||
|
<div class="tz-group-head">
|
||||||
|
<span class="tz-group-name">{g.region}</span>
|
||||||
|
<span class="tz-group-count">{g.items.length}</span>
|
||||||
|
</div>
|
||||||
|
{#each g.items as tz (tz)}
|
||||||
|
{@const parts = splitTz(tz)}
|
||||||
|
{@const idx = flat.indexOf(tz)}
|
||||||
|
{@const hl = idx === highlightIdx}
|
||||||
|
{@const sel = tz === value}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={sel}
|
||||||
|
class="tz-opt"
|
||||||
|
class:tz-opt-hl={hl}
|
||||||
|
class:tz-opt-sel={sel}
|
||||||
|
onmouseenter={() => (highlightIdx = idx)}
|
||||||
|
onclick={() => selectTz(tz)}
|
||||||
|
>
|
||||||
|
<span class="tz-opt-city">{parts.city}</span>
|
||||||
|
<span class="tz-opt-iana">{tz}</span>
|
||||||
|
<span class="tz-opt-offset">{fmtOffset(tz)}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.tz-root {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 34rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Selected card ------------------------------------------------ */
|
||||||
|
.tz-card {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.875rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem 0.75rem 0.875rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg,
|
||||||
|
color-mix(in srgb, var(--color-primary) 5%, transparent) 0%,
|
||||||
|
transparent 55%),
|
||||||
|
var(--color-background);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, transform 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
.tz-card:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-border));
|
||||||
|
}
|
||||||
|
.tz-card.tz-card-open {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tz-card-left {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.tz-region {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
font-weight: 600;
|
||||||
|
color: color-mix(in srgb, var(--color-primary) 70%, var(--color-muted-foreground));
|
||||||
|
}
|
||||||
|
.tz-city {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.1;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.tz-sub {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.tz-iana {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.tz-dot { opacity: 0.5; }
|
||||||
|
|
||||||
|
.tz-card-right {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
.tz-clock {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
line-height: 1;
|
||||||
|
/* Stable width so seconds ticker doesn't shift layout */
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.tz-offset {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 0.1rem 0.375rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||||
|
color: var(--color-primary);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-primary) 25%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tz-chev {
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Panel -------------------------------------------------------- */
|
||||||
|
.tz-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 0.375rem);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 20;
|
||||||
|
background: var(--color-card, var(--color-background));
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 26rem;
|
||||||
|
animation: tz-pop 0.15s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes tz-pop {
|
||||||
|
from { opacity: 0; transform: translateY(-3px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.tz-search-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
.tz-search {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
padding: 0.125rem 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.tz-kbd {
|
||||||
|
font-size: 0.55rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
background: var(--color-muted);
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tz-quick {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.5rem 0.625rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.tz-quick-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: var(--color-background);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.12s, background 0.12s, color 0.12s;
|
||||||
|
}
|
||||||
|
.tz-quick-btn:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--color-primary) 40%, var(--color-border));
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
.tz-quick-active {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
.tz-quick-label {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.tz-quick-val {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tz-list {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
.tz-empty {
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tz-group {
|
||||||
|
margin-bottom: 0.125rem;
|
||||||
|
}
|
||||||
|
.tz-group-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.375rem 0.75rem 0.25rem;
|
||||||
|
font-size: 0.55rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--color-card, var(--color-background));
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.tz-group-count {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tz-opt {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.tz-opt.tz-opt-hl {
|
||||||
|
background: var(--color-muted);
|
||||||
|
}
|
||||||
|
.tz-opt.tz-opt-sel {
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
||||||
|
}
|
||||||
|
.tz-opt-city {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.tz-opt.tz-opt-sel .tz-opt-city {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
.tz-opt-iana {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.tz-opt-offset {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
padding: 0.1rem 0.375rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: var(--color-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.tz-opt.tz-opt-hl .tz-opt-offset {
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 15%, var(--color-muted));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -671,13 +671,29 @@
|
|||||||
"telegram": "Telegram",
|
"telegram": "Telegram",
|
||||||
"webhookSecret": "Webhook Secret",
|
"webhookSecret": "Webhook Secret",
|
||||||
"webhookSecretHint": "Secret token to verify webhook requests from Telegram",
|
"webhookSecretHint": "Secret token to verify webhook requests from Telegram",
|
||||||
"cacheTtl": "Media Cache TTL (hours)",
|
"cacheTtl": "URL Cache TTL (hours)",
|
||||||
"cacheTtlHint": "How long to cache uploaded Telegram file_ids before re-uploading",
|
"cacheTtlHint": "How long to keep URL-keyed Telegram file_ids (e.g. shared links). Set 0 to disable TTL. The asset cache uses content hashing (thumbhash) and ignores this.",
|
||||||
|
"cacheMaxEntries": "Cache Max Entries",
|
||||||
|
"cacheMaxEntriesHint": "Upper bound per cache (URL and asset). Oldest entries are evicted first (LRU). Default 5000.",
|
||||||
|
"cacheStats": "Cache contents",
|
||||||
|
"cacheStatsHint": "Size shown is the total bytes of media originally uploaded to Telegram for cached entries — i.e. approximate re-upload bandwidth the cache is saving. The cache file itself is only a few KB; the media lives on Telegram's servers.",
|
||||||
|
"cacheStatsUrl": "URL cache",
|
||||||
|
"cacheStatsAsset": "Asset cache",
|
||||||
|
"cacheStatsEntries": "entries",
|
||||||
|
"cacheStatsEmpty": "empty",
|
||||||
|
"cacheStatsOldest": "oldest",
|
||||||
|
"cacheStatsNewest": "newest",
|
||||||
|
"clearCache": "Clear Media Cache",
|
||||||
|
"clearCacheHint": "Delete cached Telegram file_ids. Next send will re-upload media.",
|
||||||
|
"clearCacheConfirmTitle": "Clear Telegram cache?",
|
||||||
|
"clearCacheConfirm": "This removes all cached Telegram file_ids. Subsequent notifications will re-upload media, which may take longer and use more bandwidth.",
|
||||||
|
"clearCacheConfirmBtn": "Clear cache",
|
||||||
|
"clearCacheDone": "Telegram cache cleared",
|
||||||
"timezone": "Timezone",
|
"timezone": "Timezone",
|
||||||
"timezoneHint": "IANA timezone (e.g. UTC, Europe/Warsaw, America/New_York). Used to interpret HH:MM fields like quiet hours.",
|
"timezoneHint": "IANA timezone (e.g. UTC, Europe/Warsaw, America/New_York). Used to interpret HH:MM fields like quiet hours.",
|
||||||
"locales": "Template Languages",
|
"locales": "Template Languages",
|
||||||
"supportedLocales": "Supported Locales",
|
"supportedLocales": "Supported Locales",
|
||||||
"supportedLocalesHint": "Comma-separated locale codes for template editing (e.g. en,ru,de,fr)",
|
"supportedLocalesHint": "Languages available when authoring notification and command templates. Built-in defaults ship for English and Russian; other languages start empty.",
|
||||||
"saved": "Settings saved"
|
"saved": "Settings saved"
|
||||||
},
|
},
|
||||||
"hints": {
|
"hints": {
|
||||||
@@ -813,6 +829,29 @@
|
|||||||
"showDetails": "Show details",
|
"showDetails": "Show details",
|
||||||
"hideDetails": "Hide details"
|
"hideDetails": "Hide details"
|
||||||
},
|
},
|
||||||
|
"timezone": {
|
||||||
|
"searchPlaceholder": "Search cities or IANA codes…",
|
||||||
|
"detect": "Detect",
|
||||||
|
"utc": "UTC",
|
||||||
|
"noMatches": "No timezones match"
|
||||||
|
},
|
||||||
|
"locales": {
|
||||||
|
"empty": "No languages selected. Add one below to start authoring templates.",
|
||||||
|
"add": "Add language",
|
||||||
|
"searchPlaceholder": "Search or type a code (e.g. de-CH)…",
|
||||||
|
"addCustom": "Add custom code",
|
||||||
|
"noSuggestions": "No matches. Type a valid locale code (2–3 letters).",
|
||||||
|
"primary": "Primary",
|
||||||
|
"shipped": "Built-in",
|
||||||
|
"shippedHint": "Default notification & command templates ship for this language.",
|
||||||
|
"makePrimary": "Make primary",
|
||||||
|
"moveUp": "Move up",
|
||||||
|
"moveDown": "Move down",
|
||||||
|
"remove": "Remove",
|
||||||
|
"removeLast": "At least one language is required",
|
||||||
|
"reorder": "Drag to reorder",
|
||||||
|
"orderHint": "First language is the primary fallback when a translation is missing. Drag to reorder."
|
||||||
|
},
|
||||||
"snack": {
|
"snack": {
|
||||||
"eventsCleared": "{count} event(s) cleared",
|
"eventsCleared": "{count} event(s) cleared",
|
||||||
"providerSaved": "Provider saved",
|
"providerSaved": "Provider saved",
|
||||||
|
|||||||
@@ -671,13 +671,29 @@
|
|||||||
"telegram": "Telegram",
|
"telegram": "Telegram",
|
||||||
"webhookSecret": "Секрет вебхука",
|
"webhookSecret": "Секрет вебхука",
|
||||||
"webhookSecretHint": "Секретный токен для проверки запросов вебхука от Telegram",
|
"webhookSecretHint": "Секретный токен для проверки запросов вебхука от Telegram",
|
||||||
"cacheTtl": "TTL кэша медиа (часы)",
|
"cacheTtl": "TTL URL-кэша (часы)",
|
||||||
"cacheTtlHint": "Сколько хранить кэш Telegram file_id перед повторной загрузкой",
|
"cacheTtlHint": "Сколько хранить Telegram file_id, привязанные к URL (напр. публичные ссылки). 0 — отключить TTL. Кэш ассетов использует хэширование содержимого (thumbhash) и не зависит от этой настройки.",
|
||||||
|
"cacheMaxEntries": "Макс. записей в кэше",
|
||||||
|
"cacheMaxEntriesHint": "Верхний предел записей в каждом кэше (URL и ассеты). При превышении удаляются самые старые (LRU). По умолчанию 5000.",
|
||||||
|
"cacheStats": "Содержимое кэша",
|
||||||
|
"cacheStatsHint": "Показываемый размер — это суммарный объём медиа, который был изначально загружен в Telegram для закэшированных записей, т.е. приблизительный объём повторных загрузок, который экономит кэш. Сам файл кэша занимает лишь несколько КБ; медиа хранится на серверах Telegram.",
|
||||||
|
"cacheStatsUrl": "Кэш URL",
|
||||||
|
"cacheStatsAsset": "Кэш ассетов",
|
||||||
|
"cacheStatsEntries": "записей",
|
||||||
|
"cacheStatsEmpty": "пусто",
|
||||||
|
"cacheStatsOldest": "самая старая",
|
||||||
|
"cacheStatsNewest": "самая свежая",
|
||||||
|
"clearCache": "Очистить кэш медиа",
|
||||||
|
"clearCacheHint": "Удалить кэшированные Telegram file_id. При следующей отправке медиа будут загружены заново.",
|
||||||
|
"clearCacheConfirmTitle": "Очистить кэш Telegram?",
|
||||||
|
"clearCacheConfirm": "Это удалит все кэшированные Telegram file_id. Следующие уведомления будут повторно загружать медиа, что может занять больше времени и трафика.",
|
||||||
|
"clearCacheConfirmBtn": "Очистить кэш",
|
||||||
|
"clearCacheDone": "Кэш Telegram очищен",
|
||||||
"timezone": "Часовой пояс",
|
"timezone": "Часовой пояс",
|
||||||
"timezoneHint": "Часовой пояс IANA (например UTC, Europe/Warsaw, America/New_York). Используется для интерпретации полей HH:MM, таких как тихие часы.",
|
"timezoneHint": "Часовой пояс IANA (например UTC, Europe/Warsaw, America/New_York). Используется для интерпретации полей HH:MM, таких как тихие часы.",
|
||||||
"locales": "Языки шаблонов",
|
"locales": "Языки шаблонов",
|
||||||
"supportedLocales": "Поддерживаемые локали",
|
"supportedLocales": "Поддерживаемые локали",
|
||||||
"supportedLocalesHint": "Коды локалей через запятую для редактирования шаблонов (например en,ru,de,fr)",
|
"supportedLocalesHint": "Языки, доступные для редактирования шаблонов уведомлений и команд. Встроенные шаблоны поставляются для английского и русского; другие языки начинают с пустых.",
|
||||||
"saved": "Настройки сохранены"
|
"saved": "Настройки сохранены"
|
||||||
},
|
},
|
||||||
"hints": {
|
"hints": {
|
||||||
@@ -813,6 +829,29 @@
|
|||||||
"showDetails": "Показать детали",
|
"showDetails": "Показать детали",
|
||||||
"hideDetails": "Скрыть детали"
|
"hideDetails": "Скрыть детали"
|
||||||
},
|
},
|
||||||
|
"timezone": {
|
||||||
|
"searchPlaceholder": "Поиск по городам или IANA-кодам…",
|
||||||
|
"detect": "Определить",
|
||||||
|
"utc": "UTC",
|
||||||
|
"noMatches": "Нет совпадений"
|
||||||
|
},
|
||||||
|
"locales": {
|
||||||
|
"empty": "Языки не выбраны. Добавьте язык ниже, чтобы начать редактирование шаблонов.",
|
||||||
|
"add": "Добавить язык",
|
||||||
|
"searchPlaceholder": "Найти или ввести код (например de-CH)…",
|
||||||
|
"addCustom": "Добавить свой код",
|
||||||
|
"noSuggestions": "Ничего не найдено. Введите код локали (2–3 буквы).",
|
||||||
|
"primary": "Основной",
|
||||||
|
"shipped": "Встроенный",
|
||||||
|
"shippedHint": "Для этого языка есть встроенные шаблоны уведомлений и команд.",
|
||||||
|
"makePrimary": "Сделать основным",
|
||||||
|
"moveUp": "Выше",
|
||||||
|
"moveDown": "Ниже",
|
||||||
|
"remove": "Удалить",
|
||||||
|
"removeLast": "Должен быть хотя бы один язык",
|
||||||
|
"reorder": "Перетащите для изменения порядка",
|
||||||
|
"orderHint": "Первый язык используется как основной при отсутствии перевода. Перетаскивайте, чтобы изменить порядок."
|
||||||
|
},
|
||||||
"snack": {
|
"snack": {
|
||||||
"eventsCleared": "Очищено событий: {count}",
|
"eventsCleared": "Очищено событий: {count}",
|
||||||
"providerSaved": "Провайдер сохранён",
|
"providerSaved": "Провайдер сохранён",
|
||||||
|
|||||||
@@ -226,24 +226,15 @@
|
|||||||
{ href: '/targets', key: 'nav.targets', icon: 'mdiTarget' },
|
{ href: '/targets', key: 'nav.targets', icon: 'mdiTarget' },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// "More" panel items — everything not in the bottom bar
|
// "More" panel mirrors the full desktop sidebar tree so every subnode is
|
||||||
const mobileMoreItems = $derived<NavItem[]>([
|
// reachable on mobile (previously it was a flat hand-picked list that
|
||||||
{ href: '/providers', key: 'nav.providers', icon: 'mdiServer' },
|
// hid all target types, bot channels, and several nested pages).
|
||||||
{ 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' },
|
|
||||||
] : []),
|
|
||||||
]);
|
|
||||||
|
|
||||||
let mobileMoreOpen = $state(false);
|
let mobileMoreOpen = $state(false);
|
||||||
|
|
||||||
|
function closeMobileMore() {
|
||||||
|
mobileMoreOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
const isAuthPage = $derived(
|
const isAuthPage = $derived(
|
||||||
page.url.pathname === '/login' || page.url.pathname === '/setup'
|
page.url.pathname === '/login' || page.url.pathname === '/setup'
|
||||||
);
|
);
|
||||||
@@ -538,7 +529,7 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- Mobile bottom nav -->
|
<!-- 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}
|
{#each mobileNavItems as item}
|
||||||
<a href={item.href} aria-label={t(item.key)}
|
<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"
|
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>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Mobile "More" panel -->
|
<!-- Mobile "More" panel — mirrors the full desktop nav tree -->
|
||||||
{#if mobileMoreOpen}
|
{#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);"
|
<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>
|
onclick={closeMobileMore} 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;"
|
<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 }}>
|
transition:slide={{ duration: 200, easing: cubicOut }}>
|
||||||
{#if allProviders.length >= 1}
|
{#if allProviders.length >= 1}
|
||||||
<div class="mb-3 pb-3" style="border-bottom: 1px solid var(--color-border);">
|
<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 />
|
<IconGridSelect items={providerFilterItems} bind:value={providerFilterValue} columns={Math.min(providerFilterItems.length, 4)} compact />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="grid grid-cols-3 gap-2">
|
<div class="space-y-3">
|
||||||
{#each mobileMoreItems as item}
|
{#each navEntries as entry}
|
||||||
<a href={item.href}
|
{#if isGroup(entry)}
|
||||||
onclick={() => mobileMoreOpen = false}
|
<div>
|
||||||
class="flex flex-col items-center gap-1 p-3 rounded-lg transition-all duration-200"
|
<div class="flex items-center gap-1.5 px-1 pb-1.5 text-[0.65rem] font-semibold uppercase tracking-wider"
|
||||||
style="color: {isActive(item.href) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'}; background: {isActive(item.href) ? 'var(--color-sidebar-active)' : 'transparent'};"
|
style="color: var(--color-muted-foreground);">
|
||||||
>
|
<MdiIcon name={entry.icon} size={13} />
|
||||||
<MdiIcon name={item.icon} size={20} />
|
<span>{t(entry.key)}</span>
|
||||||
<span class="text-xs text-center leading-tight">{t(item.key)}</span>
|
</div>
|
||||||
</a>
|
<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}
|
{/each}
|
||||||
<button onclick={() => { mobileMoreOpen = false; logout(); }}
|
<div class="pt-2" style="border-top: 1px solid var(--color-border);">
|
||||||
class="flex flex-col items-center gap-1 p-3 rounded-lg transition-all duration-200"
|
<button onclick={() => { closeMobileMore(); logout(); }}
|
||||||
style="color: var(--color-muted-foreground);">
|
class="flex items-center gap-2 p-3 w-full rounded-lg transition-all duration-200"
|
||||||
<MdiIcon name="mdiLogout" size={20} />
|
style="color: var(--color-muted-foreground);">
|
||||||
<span class="text-xs text-center leading-tight">{t('nav.logout')}</span>
|
<MdiIcon name="mdiLogout" size={18} />
|
||||||
</button>
|
<span class="text-sm">{t('nav.logout')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Main content -->
|
<!-- 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}
|
{#key page.url.pathname}
|
||||||
<div class="max-w-5xl mx-auto p-4 md:p-8" in:fade={{ duration: 200, delay: 50 }}>
|
<div class="max-w-5xl mx-auto p-4 md:p-8" in:fade={{ duration: 200, delay: 50 }}>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
@@ -611,19 +631,22 @@
|
|||||||
<!-- Password change modal -->
|
<!-- Password change modal -->
|
||||||
<Modal open={showPasswordForm} title={t('common.changePassword')} onclose={() => { showPasswordForm = false; pwdMsg = ''; pwdSuccess = false; pwdConfirm = ''; }}>
|
<Modal open={showPasswordForm} title={t('common.changePassword')} onclose={() => { showPasswordForm = false; pwdMsg = ''; pwdSuccess = false; pwdConfirm = ''; }}>
|
||||||
<form onsubmit={changePassword} class="space-y-3">
|
<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>
|
<div>
|
||||||
<label for="pwd-current" class="block text-sm font-medium mb-1">{t('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
|
<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)]" />
|
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="pwd-new" class="block text-sm font-medium mb-1">{t('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 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)]" />
|
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="pwd-confirm" class="block text-sm font-medium mb-1">{t('auth.confirmPassword')}</label>
|
<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)]" />
|
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" />
|
||||||
</div>
|
</div>
|
||||||
{#if pwdMsg}
|
{#if pwdMsg}
|
||||||
|
|||||||
@@ -9,26 +9,66 @@
|
|||||||
import Hint from '$lib/components/Hint.svelte';
|
import Hint from '$lib/components/Hint.svelte';
|
||||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||||
import Button from '$lib/components/Button.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';
|
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 loaded = $state(false);
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
|
let clearingCache = $state(false);
|
||||||
|
let confirmClearCache = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let settings = $state({
|
let settings = $state({
|
||||||
external_url: '',
|
external_url: '',
|
||||||
telegram_webhook_secret: '',
|
telegram_webhook_secret: '',
|
||||||
telegram_cache_ttl_hours: '48',
|
telegram_cache_ttl_hours: '720',
|
||||||
|
telegram_asset_cache_max_entries: '5000',
|
||||||
supported_locales: 'en,ru',
|
supported_locales: 'en,ru',
|
||||||
timezone: 'UTC',
|
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 () => {
|
onMount(async () => {
|
||||||
try {
|
try {
|
||||||
settings = await api('/settings');
|
settings = await api('/settings');
|
||||||
|
await loadCacheStats();
|
||||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||||
finally { loaded = true; }
|
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() {
|
async function save() {
|
||||||
saving = true; error = '';
|
saving = true; error = '';
|
||||||
try {
|
try {
|
||||||
@@ -37,6 +77,17 @@
|
|||||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||||
saving = false;
|
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>
|
</script>
|
||||||
|
|
||||||
<PageHeader title={t('settings.title')} description={t('settings.description')} />
|
<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" />
|
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>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-medium mb-1">{t('settings.timezone')}<Hint text={t('settings.timezoneHint')} /></label>
|
<label class="block text-xs font-medium mb-2">{t('settings.timezone')}<Hint text={t('settings.timezoneHint')} /></label>
|
||||||
<input bind:value={settings.timezone} placeholder="UTC"
|
<TimezoneSelector bind:value={settings.timezone} />
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -75,14 +125,68 @@
|
|||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-medium mb-1">{t('settings.webhookSecret')}<Hint text={t('settings.webhookSecretHint')} /></label>
|
<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')}
|
<form onsubmit={(e) => e.preventDefault()} autocomplete="off">
|
||||||
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-medium mb-1">{t('settings.cacheTtl')}<Hint text={t('settings.cacheTtlHint')} /></label>
|
<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)]" />
|
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -94,9 +198,8 @@
|
|||||||
</h3>
|
</h3>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-medium mb-1">{t('settings.supportedLocales')}<Hint text={t('settings.supportedLocalesHint')} /></label>
|
<label class="block text-xs font-medium mb-2">{t('settings.supportedLocales')}<Hint text={t('settings.supportedLocalesHint')} /></label>
|
||||||
<input bind:value={settings.supported_locales} placeholder="en,ru,de,fr"
|
<LocaleSelector bind:value={settings.supported_locales} />
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -105,4 +208,12 @@
|
|||||||
{saving ? t('common.loading') : t('common.save')}
|
{saving ? t('common.loading') : t('common.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ConfirmModal open={confirmClearCache}
|
||||||
|
title={t('settings.clearCacheConfirmTitle')}
|
||||||
|
message={t('settings.clearCacheConfirm')}
|
||||||
|
confirmLabel={t('settings.clearCacheConfirmBtn')}
|
||||||
|
confirmIcon="mdiDeleteSweep"
|
||||||
|
onconfirm={clearTelegramCache}
|
||||||
|
oncancel={() => confirmClearCache = false} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -294,10 +294,24 @@ class NotificationDispatcher:
|
|||||||
await self._preload_asset_data(assets, media_assets, session, max_size)
|
await self._preload_asset_data(assets, media_assets, session, max_size)
|
||||||
default_message = self._render_message(event, target, target.locale)
|
default_message = self._render_message(event, target, target.locale)
|
||||||
|
|
||||||
|
# Asset cache (when in thumbhash mode) invalidates entries when the
|
||||||
|
# asset's visual content changes. The resolver maps asset id → its
|
||||||
|
# current thumbhash. Providers that expose thumbhash put it in
|
||||||
|
# ``asset.extra["thumbhash"]`` (currently Immich).
|
||||||
|
thumbhash_map = {
|
||||||
|
asset.id: asset.extra.get("thumbhash")
|
||||||
|
for asset in event.added_assets
|
||||||
|
if asset.extra.get("thumbhash")
|
||||||
|
}
|
||||||
|
thumbhash_resolver = (
|
||||||
|
(lambda key: thumbhash_map.get(key)) if thumbhash_map else None
|
||||||
|
)
|
||||||
|
|
||||||
client = TelegramClient(
|
client = TelegramClient(
|
||||||
session, bot_token,
|
session, bot_token,
|
||||||
url_cache=self._url_cache,
|
url_cache=self._url_cache,
|
||||||
asset_cache=self._asset_cache,
|
asset_cache=self._asset_cache,
|
||||||
|
thumbhash_resolver=thumbhash_resolver,
|
||||||
)
|
)
|
||||||
|
|
||||||
for receiver in target.receivers:
|
for receiver in target.receivers:
|
||||||
|
|||||||
@@ -11,56 +11,69 @@ from notify_bridge_core.storage import StorageBackend
|
|||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
DEFAULT_TELEGRAM_CACHE_TTL = 48 * 60 * 60
|
DEFAULT_TELEGRAM_CACHE_TTL = 48 * 60 * 60
|
||||||
|
DEFAULT_MAX_ENTRIES = 5000
|
||||||
|
|
||||||
|
|
||||||
class TelegramFileCache:
|
class TelegramFileCache:
|
||||||
"""Cache for Telegram file_ids to avoid re-uploading media.
|
"""Cache for Telegram file_ids to avoid re-uploading media.
|
||||||
|
|
||||||
Supports two validation modes:
|
Two complementary invalidation strategies, usable together or separately:
|
||||||
- TTL mode (default): entries expire after a configured time-to-live
|
- TTL: entries expire after ``ttl_seconds``. Set to 0 to disable TTL
|
||||||
- Thumbhash mode: entries validated by comparing stored thumbhash with current
|
(cache essentially forever, subject only to the size cap).
|
||||||
"""
|
- Thumbhash mode: entries are validated on read by comparing the stored
|
||||||
|
thumbhash with the one the caller supplies; a mismatch drops the entry.
|
||||||
|
Intended for content-addressable assets (e.g. Immich) where re-uploads
|
||||||
|
should be triggered by visual change, not elapsed time.
|
||||||
|
|
||||||
THUMBHASH_MAX_ENTRIES = 2000
|
``max_entries`` always applies as an LRU size cap (by ``cached_at``).
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
backend: StorageBackend,
|
backend: StorageBackend,
|
||||||
ttl_seconds: int = DEFAULT_TELEGRAM_CACHE_TTL,
|
ttl_seconds: int = DEFAULT_TELEGRAM_CACHE_TTL,
|
||||||
use_thumbhash: bool = False,
|
use_thumbhash: bool = False,
|
||||||
|
max_entries: int = DEFAULT_MAX_ENTRIES,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._backend = backend
|
self._backend = backend
|
||||||
self._data: dict[str, Any] | None = None
|
self._data: dict[str, Any] | None = None
|
||||||
self._ttl_seconds = ttl_seconds
|
self._ttl_seconds = ttl_seconds
|
||||||
self._use_thumbhash = use_thumbhash
|
self._use_thumbhash = use_thumbhash
|
||||||
|
self._max_entries = max_entries
|
||||||
|
|
||||||
async def async_load(self) -> None:
|
async def async_load(self) -> None:
|
||||||
self._data = await self._backend.load() or {"files": {}}
|
self._data = await self._backend.load() or {"files": {}}
|
||||||
await self._cleanup_expired()
|
await self._cleanup_expired()
|
||||||
|
|
||||||
async def _cleanup_expired(self) -> None:
|
async def _cleanup_expired(self) -> None:
|
||||||
if self._use_thumbhash:
|
|
||||||
files = self._data.get("files", {}) if self._data else {}
|
|
||||||
if len(files) > self.THUMBHASH_MAX_ENTRIES:
|
|
||||||
sorted_keys = sorted(files, key=lambda k: files[k].get("cached_at", ""))
|
|
||||||
for key in sorted_keys[: len(files) - self.THUMBHASH_MAX_ENTRIES]:
|
|
||||||
del files[key]
|
|
||||||
await self._backend.save(self._data)
|
|
||||||
return
|
|
||||||
|
|
||||||
if not self._data or "files" not in self._data:
|
if not self._data or "files" not in self._data:
|
||||||
return
|
return
|
||||||
|
files = self._data["files"]
|
||||||
|
changed = False
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
# TTL sweep — only when TTL validation is active (i.e. no thumbhash
|
||||||
expired = [
|
# mode and a positive TTL). In thumbhash mode we rely entirely on
|
||||||
url for url, entry in self._data["files"].items()
|
# content validation; in "TTL disabled" mode (ttl_seconds <= 0) we
|
||||||
if entry.get("cached_at") and
|
# cache forever, subject only to the size cap.
|
||||||
(now - datetime.fromisoformat(entry["cached_at"])).total_seconds() > self._ttl_seconds
|
if not self._use_thumbhash and self._ttl_seconds > 0:
|
||||||
]
|
now = datetime.now(timezone.utc)
|
||||||
|
expired = [
|
||||||
if expired:
|
url for url, entry in files.items()
|
||||||
|
if entry.get("cached_at") and
|
||||||
|
(now - datetime.fromisoformat(entry["cached_at"])).total_seconds() > self._ttl_seconds
|
||||||
|
]
|
||||||
for key in expired:
|
for key in expired:
|
||||||
del self._data["files"][key]
|
del files[key]
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
# LRU cap — always enforced. Evicts oldest-cached entries first.
|
||||||
|
if self._max_entries > 0 and len(files) > self._max_entries:
|
||||||
|
sorted_keys = sorted(files, key=lambda k: files[k].get("cached_at", ""))
|
||||||
|
for key in sorted_keys[: len(files) - self._max_entries]:
|
||||||
|
del files[key]
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if changed:
|
||||||
await self._backend.save(self._data)
|
await self._backend.save(self._data)
|
||||||
|
|
||||||
def get(self, key: str, thumbhash: str | None = None) -> dict[str, Any] | None:
|
def get(self, key: str, thumbhash: str | None = None) -> dict[str, Any] | None:
|
||||||
@@ -77,7 +90,7 @@ class TelegramFileCache:
|
|||||||
if stored and stored != thumbhash:
|
if stored and stored != thumbhash:
|
||||||
del self._data["files"][key]
|
del self._data["files"][key]
|
||||||
return None
|
return None
|
||||||
else:
|
elif self._ttl_seconds > 0:
|
||||||
cached_at_str = entry.get("cached_at")
|
cached_at_str = entry.get("cached_at")
|
||||||
if cached_at_str:
|
if cached_at_str:
|
||||||
age = (datetime.now(timezone.utc) - datetime.fromisoformat(cached_at_str)).total_seconds()
|
age = (datetime.now(timezone.utc) - datetime.fromisoformat(cached_at_str)).total_seconds()
|
||||||
@@ -152,3 +165,32 @@ class TelegramFileCache:
|
|||||||
async def async_remove(self) -> None:
|
async def async_remove(self) -> None:
|
||||||
await self._backend.remove()
|
await self._backend.remove()
|
||||||
self._data = None
|
self._data = None
|
||||||
|
|
||||||
|
def stats(self) -> dict[str, Any]:
|
||||||
|
"""Return summary stats about the current cache contents.
|
||||||
|
|
||||||
|
Includes the number of cached entries, total tracked size in bytes
|
||||||
|
(only counts entries with a recorded ``size``), and the oldest /
|
||||||
|
newest ``cached_at`` timestamps (ISO strings, or ``None`` if empty).
|
||||||
|
"""
|
||||||
|
files = self._data.get("files", {}) if self._data else {}
|
||||||
|
count = len(files)
|
||||||
|
total_size = 0
|
||||||
|
oldest: str | None = None
|
||||||
|
newest: str | None = None
|
||||||
|
for entry in files.values():
|
||||||
|
size = entry.get("size")
|
||||||
|
if isinstance(size, int):
|
||||||
|
total_size += size
|
||||||
|
cached_at = entry.get("cached_at")
|
||||||
|
if cached_at:
|
||||||
|
if oldest is None or cached_at < oldest:
|
||||||
|
oldest = cached_at
|
||||||
|
if newest is None or cached_at > newest:
|
||||||
|
newest = cached_at
|
||||||
|
return {
|
||||||
|
"count": count,
|
||||||
|
"total_size_bytes": total_size,
|
||||||
|
"oldest": oldest,
|
||||||
|
"newest": newest,
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ router = APIRouter(prefix="/api/settings", tags=["settings"])
|
|||||||
_SETTING_KEYS = {
|
_SETTING_KEYS = {
|
||||||
"external_url": "NOTIFY_BRIDGE_EXTERNAL_URL",
|
"external_url": "NOTIFY_BRIDGE_EXTERNAL_URL",
|
||||||
"telegram_webhook_secret": "NOTIFY_BRIDGE_TELEGRAM_WEBHOOK_SECRET",
|
"telegram_webhook_secret": "NOTIFY_BRIDGE_TELEGRAM_WEBHOOK_SECRET",
|
||||||
"telegram_cache_ttl_hours": None, # no env fallback, default 48
|
"telegram_cache_ttl_hours": None, # URL cache TTL; 0 disables TTL
|
||||||
|
"telegram_asset_cache_max_entries": None, # LRU cap for both caches
|
||||||
"supported_locales": None, # comma-separated locale codes
|
"supported_locales": None, # comma-separated locale codes
|
||||||
"timezone": "NOTIFY_BRIDGE_TIMEZONE", # IANA tz (e.g. "Europe/Warsaw"); empty = UTC
|
"timezone": "NOTIFY_BRIDGE_TIMEZONE", # IANA tz (e.g. "Europe/Warsaw"); empty = UTC
|
||||||
}
|
}
|
||||||
@@ -28,11 +29,18 @@ _SETTING_KEYS = {
|
|||||||
_DEFAULTS = {
|
_DEFAULTS = {
|
||||||
"external_url": "",
|
"external_url": "",
|
||||||
"telegram_webhook_secret": "",
|
"telegram_webhook_secret": "",
|
||||||
"telegram_cache_ttl_hours": "48",
|
# 720h = 30d. URL cache only; asset cache uses thumbhash validation
|
||||||
|
# (content-addressable) and ignores TTL entirely.
|
||||||
|
"telegram_cache_ttl_hours": "720",
|
||||||
|
"telegram_asset_cache_max_entries": "5000",
|
||||||
"supported_locales": "en,ru",
|
"supported_locales": "en,ru",
|
||||||
"timezone": "UTC",
|
"timezone": "UTC",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Settings whose changes require dropping in-memory Telegram caches so the
|
||||||
|
# next dispatch rebuilds them with the new parameters. Files are preserved.
|
||||||
|
_CACHE_SETTING_KEYS = {"telegram_cache_ttl_hours", "telegram_asset_cache_max_entries"}
|
||||||
|
|
||||||
|
|
||||||
async def get_setting(session: AsyncSession, key: str) -> str:
|
async def get_setting(session: AsyncSession, key: str) -> str:
|
||||||
"""Read a setting from DB, falling back to env var then default."""
|
"""Read a setting from DB, falling back to env var then default."""
|
||||||
@@ -51,6 +59,7 @@ class SettingsUpdate(BaseModel):
|
|||||||
external_url: str | None = None
|
external_url: str | None = None
|
||||||
telegram_webhook_secret: str | None = None
|
telegram_webhook_secret: str | None = None
|
||||||
telegram_cache_ttl_hours: str | None = None
|
telegram_cache_ttl_hours: str | None = None
|
||||||
|
telegram_asset_cache_max_entries: str | None = None
|
||||||
supported_locales: str | None = None
|
supported_locales: str | None = None
|
||||||
timezone: str | None = None
|
timezone: str | None = None
|
||||||
|
|
||||||
@@ -80,6 +89,7 @@ async def update_settings(
|
|||||||
"""Update app settings (admin). Re-registers webhooks when base URL changes."""
|
"""Update app settings (admin). Re-registers webhooks when base URL changes."""
|
||||||
old_base_url = await get_setting(session, "external_url")
|
old_base_url = await get_setting(session, "external_url")
|
||||||
old_secret = await get_setting(session, "telegram_webhook_secret")
|
old_secret = await get_setting(session, "telegram_webhook_secret")
|
||||||
|
old_cache_values = {k: await get_setting(session, k) for k in _CACHE_SETTING_KEYS}
|
||||||
|
|
||||||
for key in _SETTING_KEYS:
|
for key in _SETTING_KEYS:
|
||||||
value = getattr(body, key, None)
|
value = getattr(body, key, None)
|
||||||
@@ -93,6 +103,17 @@ async def update_settings(
|
|||||||
session.add(row)
|
session.add(row)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
# Drop in-memory caches if any cache-tuning setting actually changed, so
|
||||||
|
# the next dispatch rebuilds them with the new parameters. Files survive.
|
||||||
|
cache_changed = False
|
||||||
|
for key in _CACHE_SETTING_KEYS:
|
||||||
|
if await get_setting(session, key) != old_cache_values[key]:
|
||||||
|
cache_changed = True
|
||||||
|
break
|
||||||
|
if cache_changed:
|
||||||
|
from ..services.watcher import reset_telegram_caches_in_memory
|
||||||
|
await reset_telegram_caches_in_memory()
|
||||||
|
|
||||||
new_base_url = await get_setting(session, "external_url")
|
new_base_url = await get_setting(session, "external_url")
|
||||||
new_secret = await get_setting(session, "telegram_webhook_secret")
|
new_secret = await get_setting(session, "telegram_webhook_secret")
|
||||||
|
|
||||||
@@ -111,6 +132,25 @@ async def update_settings(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/telegram-cache/stats")
|
||||||
|
async def telegram_cache_stats(
|
||||||
|
user: User = Depends(require_admin),
|
||||||
|
):
|
||||||
|
"""Return counts and sizes for the Telegram file_id caches."""
|
||||||
|
from ..services.watcher import get_telegram_cache_stats
|
||||||
|
return await get_telegram_cache_stats()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/telegram-cache/clear")
|
||||||
|
async def clear_telegram_cache(
|
||||||
|
user: User = Depends(require_admin),
|
||||||
|
):
|
||||||
|
"""Clear the Telegram file_id cache (URL and asset) from disk and memory."""
|
||||||
|
from ..services.watcher import clear_telegram_caches
|
||||||
|
result = await clear_telegram_caches()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.get("/locales")
|
@router.get("/locales")
|
||||||
async def get_supported_locales(
|
async def get_supported_locales(
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
|
|||||||
@@ -35,8 +35,34 @@ _asset_cache: TelegramFileCache | None = None
|
|||||||
_cache_lock = asyncio.Lock()
|
_cache_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_cache_settings() -> tuple[int, int]:
|
||||||
|
"""Return (url_ttl_seconds, asset_max_entries) from app settings.
|
||||||
|
|
||||||
|
Defaults apply when the settings rows are missing. Reads in a short-lived
|
||||||
|
session to avoid coupling to the caller's transaction.
|
||||||
|
"""
|
||||||
|
from ..api.app_settings import get_setting
|
||||||
|
async with AsyncSession(get_engine()) as session:
|
||||||
|
ttl_hours_str = await get_setting(session, "telegram_cache_ttl_hours")
|
||||||
|
max_entries_str = await get_setting(session, "telegram_asset_cache_max_entries")
|
||||||
|
try:
|
||||||
|
ttl_hours = int(ttl_hours_str) if ttl_hours_str else 720
|
||||||
|
except ValueError:
|
||||||
|
ttl_hours = 720
|
||||||
|
try:
|
||||||
|
max_entries = int(max_entries_str) if max_entries_str else 5000
|
||||||
|
except ValueError:
|
||||||
|
max_entries = 5000
|
||||||
|
return ttl_hours * 3600, max_entries
|
||||||
|
|
||||||
|
|
||||||
async def _get_telegram_caches() -> tuple[TelegramFileCache | None, TelegramFileCache | None]:
|
async def _get_telegram_caches() -> tuple[TelegramFileCache | None, TelegramFileCache | None]:
|
||||||
"""Lazily initialize shared Telegram file caches using NOTIFY_BRIDGE_DATA_DIR."""
|
"""Lazily initialize shared Telegram file caches using NOTIFY_BRIDGE_DATA_DIR.
|
||||||
|
|
||||||
|
The URL cache runs in TTL mode (URLs aren't content-addressable); the asset
|
||||||
|
cache runs in thumbhash mode so entries invalidate on visual change rather
|
||||||
|
than age. Both honor an LRU size cap from settings.
|
||||||
|
"""
|
||||||
global _url_cache, _asset_cache
|
global _url_cache, _asset_cache
|
||||||
if _url_cache is not None:
|
if _url_cache is not None:
|
||||||
return _url_cache, _asset_cache
|
return _url_cache, _asset_cache
|
||||||
@@ -50,16 +76,91 @@ async def _get_telegram_caches() -> tuple[TelegramFileCache | None, TelegramFile
|
|||||||
if not data_dir:
|
if not data_dir:
|
||||||
return None, None
|
return None, None
|
||||||
cache_dir = Path(data_dir) / "cache"
|
cache_dir = Path(data_dir) / "cache"
|
||||||
url_cache = TelegramFileCache(JsonFileBackend(cache_dir / "telegram_url_cache.json"))
|
ttl_seconds, max_entries = await _load_cache_settings()
|
||||||
asset_cache = TelegramFileCache(JsonFileBackend(cache_dir / "telegram_asset_cache.json"))
|
url_cache = TelegramFileCache(
|
||||||
|
JsonFileBackend(cache_dir / "telegram_url_cache.json"),
|
||||||
|
ttl_seconds=ttl_seconds,
|
||||||
|
max_entries=max_entries,
|
||||||
|
)
|
||||||
|
asset_cache = TelegramFileCache(
|
||||||
|
JsonFileBackend(cache_dir / "telegram_asset_cache.json"),
|
||||||
|
use_thumbhash=True,
|
||||||
|
max_entries=max_entries,
|
||||||
|
)
|
||||||
await url_cache.async_load()
|
await url_cache.async_load()
|
||||||
await asset_cache.async_load()
|
await asset_cache.async_load()
|
||||||
_url_cache = url_cache
|
_url_cache = url_cache
|
||||||
_asset_cache = asset_cache
|
_asset_cache = asset_cache
|
||||||
_LOGGER.info("Initialized Telegram file caches in %s", cache_dir)
|
_LOGGER.info(
|
||||||
|
"Initialized Telegram caches in %s (url ttl=%ds, max_entries=%d, asset thumbhash mode)",
|
||||||
|
cache_dir, ttl_seconds, max_entries,
|
||||||
|
)
|
||||||
return _url_cache, _asset_cache
|
return _url_cache, _asset_cache
|
||||||
|
|
||||||
|
|
||||||
|
async def reset_telegram_caches_in_memory() -> None:
|
||||||
|
"""Drop in-memory cache refs without touching files on disk.
|
||||||
|
|
||||||
|
Used after settings changes so the next dispatch re-initializes caches
|
||||||
|
with fresh parameters. Contrast with ``clear_telegram_caches`` which also
|
||||||
|
deletes cached file_ids.
|
||||||
|
"""
|
||||||
|
global _url_cache, _asset_cache
|
||||||
|
async with _cache_lock:
|
||||||
|
_url_cache = None
|
||||||
|
_asset_cache = None
|
||||||
|
_LOGGER.info("Reset Telegram cache refs in memory (files preserved)")
|
||||||
|
|
||||||
|
|
||||||
|
async def get_telegram_cache_stats() -> dict[str, Any]:
|
||||||
|
"""Return stats for the URL and asset Telegram caches.
|
||||||
|
|
||||||
|
Loads caches lazily if they haven't been touched by a dispatch yet.
|
||||||
|
Returns zero-counts when ``NOTIFY_BRIDGE_DATA_DIR`` is not configured.
|
||||||
|
"""
|
||||||
|
url_cache, asset_cache = await _get_telegram_caches()
|
||||||
|
empty = {"count": 0, "total_size_bytes": 0, "oldest": None, "newest": None}
|
||||||
|
return {
|
||||||
|
"url": url_cache.stats() if url_cache else empty,
|
||||||
|
"asset": asset_cache.stats() if asset_cache else empty,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def clear_telegram_caches() -> dict[str, Any]:
|
||||||
|
"""Delete both Telegram file caches from disk and reset in-memory state.
|
||||||
|
|
||||||
|
Next dispatch re-initializes the caches via `_get_telegram_caches()`.
|
||||||
|
Returns a summary with the paths that were removed.
|
||||||
|
"""
|
||||||
|
global _url_cache, _asset_cache
|
||||||
|
async with _cache_lock:
|
||||||
|
removed: list[str] = []
|
||||||
|
for cache, label in ((_url_cache, "url"), (_asset_cache, "asset")):
|
||||||
|
if cache is not None:
|
||||||
|
await cache.async_remove()
|
||||||
|
removed.append(label)
|
||||||
|
|
||||||
|
# Also remove files from disk in case caches were never initialized
|
||||||
|
# in this process (data_dir set but dispatch never ran).
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
data_dir = os.environ.get("NOTIFY_BRIDGE_DATA_DIR")
|
||||||
|
if data_dir:
|
||||||
|
cache_dir = Path(data_dir) / "cache"
|
||||||
|
for name in ("telegram_url_cache.json", "telegram_asset_cache.json"):
|
||||||
|
path = cache_dir / name
|
||||||
|
if path.exists():
|
||||||
|
try:
|
||||||
|
path.unlink()
|
||||||
|
except OSError as e:
|
||||||
|
_LOGGER.warning("Failed to remove %s: %s", path, e)
|
||||||
|
|
||||||
|
_url_cache = None
|
||||||
|
_asset_cache = None
|
||||||
|
_LOGGER.info("Cleared Telegram file caches: %s", removed or "none in memory")
|
||||||
|
return {"cleared": True, "removed": removed}
|
||||||
|
|
||||||
|
|
||||||
async def check_tracker(tracker_id: int) -> dict[str, Any]:
|
async def check_tracker(tracker_id: int) -> dict[str, Any]:
|
||||||
"""Poll a tracker's provider for changes and dispatch notifications."""
|
"""Poll a tracker's provider for changes and dispatch notifications."""
|
||||||
engine = get_engine()
|
engine = get_engine()
|
||||||
|
|||||||
Reference in New Issue
Block a user