diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json index 7df0271..6ba9fff 100644 --- a/frontend/src/lib/i18n/en.json +++ b/frontend/src/lib/i18n/en.json @@ -836,7 +836,41 @@ "logFormatHint": "Output format. 'text' is human-readable; 'json' emits one object per line for log aggregators (Loki, ELK). Changing this requires a server restart.", "logLevels": "Per-Module Overrides", "logLevelsHint": "Comma-separated 'module=LEVEL' pairs to silence noisy modules or drill into one area. Example: sqlalchemy.engine=WARNING,notify_bridge_core.notifications.telegram.client=DEBUG", - "saved": "Settings saved" + "saved": "Settings saved", + "identity": "Identity", + "identityHeadline": "How this instance presents itself to bots, webhooks, and recipients", + "telegramHeadline": "Webhook authentication and media cache tuning", + "loggingHeadline": "Verbosity, output format, and per-module overrides", + "heroNoUrl": "External URL not set", + "heroNoLocales": "no locales", + "copy": "Copy", + "urlCopied": "URL copied", + "openExternal": "Open", + "show": "Show", + "hide": "Hide", + "secretSet": "Verified", + "secretUnset": "Not configured", + "cacheConfig": "Cache", + "cacheTtlShort": "TTL", + "cacheMaxShort": "Max entries", + "cacheMaxFootnote": "per bucket (LRU)", + "hoursShort": "hrs", + "entriesShort": "max", + "ttlNoExpiry": "no expiry", + "cacheCapacity": "Cache capacity", + "cacheCapacityCap": "of {n} cap", + "logModulePlaceholder": "module.path", + "addOverride": "Add override", + "removeOverride": "Remove", + "editAsText": "Edit as text", + "editAsChips": "Edit as chips", + "logPreviewLabel": "ACTIVE", + "unsavedChanges": "Unsaved changes", + "unsaved": "UNSAVED", + "changedOne": "1 setting changed", + "changedMany": "{n} settings changed", + "discard": "Discard", + "saveChanges": "Save changes" }, "hints": { "periodicSummary": "Sends a scheduled summary of all tracked albums at specified times. Great for daily/weekly digests.", diff --git a/frontend/src/lib/i18n/ru.json b/frontend/src/lib/i18n/ru.json index b787568..a83f4b6 100644 --- a/frontend/src/lib/i18n/ru.json +++ b/frontend/src/lib/i18n/ru.json @@ -836,7 +836,41 @@ "logFormatHint": "Формат вывода. 'text' — читаемый человеком; 'json' — по одному объекту в строке для агрегаторов (Loki, ELK). Смена требует перезапуска сервера.", "logLevels": "Переопределения по модулям", "logLevelsHint": "Пары 'модуль=УРОВЕНЬ' через запятую, чтобы приглушить шумные модули или углубиться в один. Пример: sqlalchemy.engine=WARNING,notify_bridge_core.notifications.telegram.client=DEBUG", - "saved": "Настройки сохранены" + "saved": "Настройки сохранены", + "identity": "Идентификация", + "identityHeadline": "Как этот сервер представляется ботам, вебхукам и получателям", + "telegramHeadline": "Аутентификация вебхуков и настройка медиакэша", + "loggingHeadline": "Подробность, формат вывода и переопределения по модулям", + "heroNoUrl": "Внешний URL не задан", + "heroNoLocales": "нет локалей", + "copy": "Копировать", + "urlCopied": "URL скопирован", + "openExternal": "Открыть", + "show": "Показать", + "hide": "Скрыть", + "secretSet": "Задан", + "secretUnset": "Не настроен", + "cacheConfig": "Кэш", + "cacheTtlShort": "TTL", + "cacheMaxShort": "Макс. записей", + "cacheMaxFootnote": "на корзину (LRU)", + "hoursShort": "ч", + "entriesShort": "макс", + "ttlNoExpiry": "без срока", + "cacheCapacity": "Заполненность кэша", + "cacheCapacityCap": "из {n}", + "logModulePlaceholder": "путь.модуля", + "addOverride": "Добавить", + "removeOverride": "Удалить", + "editAsText": "Редактировать как текст", + "editAsChips": "Редактировать как чипы", + "logPreviewLabel": "АКТИВНО", + "unsavedChanges": "Несохранённые изменения", + "unsaved": "НЕ СОХРАНЕНО", + "changedOne": "Изменена 1 настройка", + "changedMany": "Изменено настроек: {n}", + "discard": "Отменить", + "saveChanges": "Сохранить" }, "hints": { "periodicSummary": "Отправляет плановую сводку по всем отслеживаемым альбомам в указанное время. Подходит для ежедневных/еженедельных дайджестов.", diff --git a/frontend/src/routes/settings/+page.svelte b/frontend/src/routes/settings/+page.svelte index 62a5369..3a8b987 100644 --- a/frontend/src/routes/settings/+page.svelte +++ b/frontend/src/routes/settings/+page.svelte @@ -2,21 +2,19 @@ import { onMount } from 'svelte'; import { api } from '$lib/api'; import { t } from '$lib/i18n'; - import PageHeader from '$lib/components/PageHeader.svelte'; - import Card from '$lib/components/Card.svelte'; import Loading from '$lib/components/Loading.svelte'; - import MdiIcon from '$lib/components/MdiIcon.svelte'; - import Hint from '$lib/components/Hint.svelte'; import ErrorBanner from '$lib/components/ErrorBanner.svelte'; - import Button from '$lib/components/Button.svelte'; import ConfirmModal from '$lib/components/ConfirmModal.svelte'; - import LocaleSelector from '$lib/components/LocaleSelector.svelte'; - import TimezoneSelector from '$lib/components/TimezoneSelector.svelte'; - import IconGridSelect from '$lib/components/IconGridSelect.svelte'; - import { logLevelItems, logFormatItems } from '$lib/grid-items'; import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte'; import { externalUrlCache } from '$lib/stores/caches.svelte'; + import SettingsHero from './SettingsHero.svelte'; + import IdentityCassette from './IdentityCassette.svelte'; + import TelegramCassette from './TelegramCassette.svelte'; + import CacheLedger from './CacheLedger.svelte'; + import LoggingCassette from './LoggingCassette.svelte'; + import SaveBar from './SaveBar.svelte'; + interface CacheBucketStats { count: number; total_size_bytes: number; @@ -28,12 +26,19 @@ asset: CacheBucketStats; } - let loaded = $state(false); - let saving = $state(false); - let clearingCache = $state(false); - let confirmClearCache = $state(false); - let error = $state(''); - let settings = $state({ + interface Settings { + external_url: string; + telegram_webhook_secret: string; + telegram_cache_ttl_hours: string; + telegram_asset_cache_max_entries: string; + supported_locales: string; + timezone: string; + log_level: string; + log_format: string; + log_levels: string; + } + + const EMPTY: Settings = { external_url: '', telegram_webhook_secret: '', telegram_cache_ttl_hours: '720', @@ -43,10 +48,33 @@ log_level: 'INFO', log_format: 'text', log_levels: '', - }); + }; + + let loaded = $state(false); + let saving = $state(false); + let clearingCache = $state(false); + let confirmClearCache = $state(false); + let error = $state(''); + + let settings = $state({ ...EMPTY }); + // Snapshot of the last server-known state, used for dirty tracking. + let baseline = $state({ ...EMPTY }); let cacheStats = $state(null); - async function loadCacheStats() { + // --- Dirty tracking ----------------------------------------------------- + + const dirtyKeys = $derived.by>(() => { + const out: Array = []; + for (const key of Object.keys(settings) as Array) { + if (settings[key] !== baseline[key]) out.push(key); + } + return out; + }); + const dirty = $derived(dirtyKeys.length > 0); + + // --- Data loading ------------------------------------------------------- + + async function loadCacheStats(): Promise { try { cacheStats = await api('/settings/telegram-cache/stats'); } catch { cacheStats = null; } @@ -54,202 +82,135 @@ onMount(async () => { try { - settings = await api('/settings'); + const fetched = await api('/settings'); + settings = { ...EMPTY, ...fetched }; + baseline = { ...settings }; await loadCacheStats(); - } catch (err: any) { error = err.message; snackError(err.message); } - finally { loaded = true; } + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Failed to load settings'; + error = msg; + snackError(msg); + } 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]}`; - } + // --- Actions ------------------------------------------------------------ - function formatTs(iso: string | null): string { - if (!iso) return '—'; - const d = new Date(iso.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z'); - return isNaN(d.getTime()) ? iso : d.toLocaleString(); - } - - async function save() { - saving = true; error = ''; + async function save(): Promise { + saving = true; + error = ''; try { - settings = await api('/settings', { method: 'PUT', body: JSON.stringify(settings) }); + const next = await api('/settings', { + method: 'PUT', + body: JSON.stringify(settings), + }); + settings = { ...EMPTY, ...next }; + baseline = { ...settings }; externalUrlCache.invalidate(); snackSuccess(t('settings.saved')); - } catch (err: any) { error = err.message; snackError(err.message); } - saving = false; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Save failed'; + error = msg; + snackError(msg); + } finally { + saving = false; + } } - async function clearTelegramCache() { + function discard(): void { + settings = { ...baseline }; + } + + async function clearTelegramCache(): Promise { 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; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Clear cache failed'; + snackError(msg); + } finally { + clearingCache = false; + } } + + const cacheMaxEntriesNum = $derived( + Math.max(0, Number(settings.telegram_asset_cache_max_entries || '0')), + ); - + {#if !loaded} {:else} -
- - -

- - {t('settings.general')} -

-
-
- - -
-
- - -
-
-
- - -

- - {t('settings.telegram')} -

-
-
- -
e.preventDefault()} autocomplete="off"> - -
-
-
- - -
-
- - -
-
-
-
- {t('settings.cacheStats')} -
-
- {#each [ - { label: t('settings.cacheStatsUrl'), data: cacheStats?.url }, - { label: t('settings.cacheStatsAsset'), data: cacheStats?.asset }, - ] as bucket} -
-
- {bucket.label} - {#if bucket.data && bucket.data.count > 0} - - {bucket.data.count} - {t('settings.cacheStatsEntries')} - {#if bucket.data.total_size_bytes > 0} - · - {formatBytes(bucket.data.total_size_bytes)} - {/if} - - {:else} - {t('settings.cacheStatsEmpty')} - {/if} -
- {#if bucket.data && bucket.data.count > 0 && (bucket.data.oldest || bucket.data.newest)} -
- {#if bucket.data.oldest} - {t('settings.cacheStatsOldest')}: {formatTs(bucket.data.oldest)} - {/if} - {#if bucket.data.newest} - {t('settings.cacheStatsNewest')}: {formatTs(bucket.data.newest)} - {/if} -
- {/if} -
- {/each} -
-
- - {t('settings.clearCacheHint')} -
-
-
+
+ - - -

- - {t('settings.locales')} -

-
-
- - -
-
-
+
+ + (confirmClearCache = true)} + /> +
- - -

- - {t('settings.logging')} -

-
-
- - -
-
- - -
-
- - -
-
-
- - +
- confirmClearCache = false} /> + {/if} + + (confirmClearCache = false)} +/> + + diff --git a/frontend/src/routes/settings/CacheLedger.svelte b/frontend/src/routes/settings/CacheLedger.svelte new file mode 100644 index 0000000..6e77dd1 --- /dev/null +++ b/frontend/src/routes/settings/CacheLedger.svelte @@ -0,0 +1,404 @@ + + +
+
+
+
+ + {t('settings.cacheStats')} +
+
+ {totalCount.toLocaleString()} + {t('settings.cacheStatsEntries')} + {#if totalBytes > 0} + · + {formatBytes(totalBytes)} + {/if} +
+
+
+ +
+
+ + + {#if maxEntries > 0} +
+
+
+
+ + {fillPct}% · {t('settings.cacheCapacityCap').replace('{n}', maxEntries.toLocaleString())} + +
+ {/if} + + +
    + {#each buckets as bucket (bucket.key)} + {@const data = bucket.data} + {@const empty = !data || data.count === 0} + {@const tone = empty ? 'mint' : ageTone(data?.oldest ?? null)} +
  1. + + +
    + {t(bucket.labelKey)} + {#if empty} + {t('settings.cacheStatsEmpty')} + {:else if data} + + + {data.count.toLocaleString()} + {t('settings.cacheStatsEntries')} + + {#if data.total_size_bytes > 0} + · + {formatBytes(data.total_size_bytes)} + {/if} + {#if data.oldest} + · + {t('settings.cacheStatsOldest')} {relativeTime(data.oldest)} + {/if} + + {/if} +
    + +
  2. + {/each} +
+ +
+ + {t('settings.clearCacheHint')} +
+
+ + diff --git a/frontend/src/routes/settings/IdentityCassette.svelte b/frontend/src/routes/settings/IdentityCassette.svelte new file mode 100644 index 0000000..e8d5db6 --- /dev/null +++ b/frontend/src/routes/settings/IdentityCassette.svelte @@ -0,0 +1,277 @@ + + +
+
+
+ + {t('settings.identity')} +
+

{t('settings.identityHeadline')}

+
+ +
+ +
+
+ 01 + +
+
+
+ + + {#if externalUrl} + + {#if urlValid} + + + + {/if} + {/if} +
+
+
+ + +
+
+ 02 + + {t('settings.timezone')} + + +
+
+ +
+
+ + +
+
+ 03 + + {t('settings.supportedLocales')} + + +
+
+ +
+
+
+
+ + diff --git a/frontend/src/routes/settings/LoggingCassette.svelte b/frontend/src/routes/settings/LoggingCassette.svelte new file mode 100644 index 0000000..d6ef7f5 --- /dev/null +++ b/frontend/src/routes/settings/LoggingCassette.svelte @@ -0,0 +1,448 @@ + + +
+
+
+ + {t('settings.logging')} +
+

{t('settings.loggingHeadline')}

+
+ + +
+
+ + {t('settings.logLevel')} + + + +
+
+ + {t('settings.logFormat')} + + + +
+
+ + +
+
+ + {t('settings.logLevels')} + + + +
+ + {#if rawMode} + + {:else} +
+ {#each rows as row, i (i)} + {@const tone = LEVEL_TONE[row.level]} +
+ + updateModule(i, (e.currentTarget as HTMLInputElement).value)} + placeholder={t('settings.logModulePlaceholder')} + class="chip-input" + autocomplete="off" + spellcheck="false" + /> + + + +
+ {/each} + + +
+ {/if} + + +
+ {t('settings.logPreviewLabel')} + {previewLine} +
+
+
+ + diff --git a/frontend/src/routes/settings/SaveBar.svelte b/frontend/src/routes/settings/SaveBar.svelte new file mode 100644 index 0000000..f804a19 --- /dev/null +++ b/frontend/src/routes/settings/SaveBar.svelte @@ -0,0 +1,166 @@ + + +{#if dirty || saving} +
+
+ + +
+ {t('settings.unsaved')} + + {#if changedCount === 1} + {t('settings.changedOne')} + {:else} + {t('settings.changedMany').replace('{n}', String(changedCount))} + {/if} + +
+
+ + +
+
+
+{/if} + + diff --git a/frontend/src/routes/settings/SettingsHero.svelte b/frontend/src/routes/settings/SettingsHero.svelte new file mode 100644 index 0000000..d2210ef --- /dev/null +++ b/frontend/src/routes/settings/SettingsHero.svelte @@ -0,0 +1,94 @@ + + + diff --git a/frontend/src/routes/settings/TelegramCassette.svelte b/frontend/src/routes/settings/TelegramCassette.svelte new file mode 100644 index 0000000..f4636c7 --- /dev/null +++ b/frontend/src/routes/settings/TelegramCassette.svelte @@ -0,0 +1,344 @@ + + +
+
+
+ + {t('settings.telegram')} +
+

{t('settings.telegramHeadline')}

+
+ +
+ +
+
+ A + + {t('settings.webhookSecret')} + + + + + {secretSet ? t('settings.secretSet') : t('settings.secretUnset')} + +
+
e.preventDefault()} autocomplete="off"> + + +
+
+ + +
+
+ B + {t('settings.cacheConfig')} +
+
+ + + +
+
+
+
+ +