Files
notify-bridge/frontend/src/lib/components/LocaleSelector.svelte
T
alexei.dolgolyov 2be608ba95 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.
2026-04-22 15:09:59 +03:00

765 lines
20 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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: 23 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>