feat(frontend): redesign settings/common with Aurora cassettes
Splits the monolithic settings page into 6 focused glass components matching
the polish of the recently redesigned settings/backup page.
- SettingsHero: PageHeader with 4 live status pills (URL host, timezone +
ticking clock, locale codes, log severity tinted by level)
- IdentityCassette: groups External URL + Timezone + Locales as numbered
rows; URL field gains copy + open chips and a mint border when valid
- TelegramCassette: webhook secret with show/hide toggle and verified
status chip; cache TTL/max as oversized mono numerals with humanized
previews ("720 hrs -> 30d")
- CacheLedger: mirrors BackupLedger -- big total, gradient capacity meter,
tone-edged URL/Asset bucket rows colored by oldest entry age
- LoggingCassette: per-module overrides become tone-edged chips with
severity-colored level borders; raw-text fallback behind toggle; live
ACTIVE preview line
- SaveBar: sticky dirty-aware footer with citrus pulse, italic message,
and Discard/Save (only renders when settings differ from baseline)
No backend changes -- same /settings and /settings/telegram-cache/* endpoints.
This commit is contained in:
@@ -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.",
|
||||
|
||||
@@ -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": "Отправляет плановую сводку по всем отслеживаемым альбомам в указанное время. Подходит для ежедневных/еженедельных дайджестов.",
|
||||
|
||||
@@ -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<Settings>({ ...EMPTY });
|
||||
// Snapshot of the last server-known state, used for dirty tracking.
|
||||
let baseline = $state<Settings>({ ...EMPTY });
|
||||
let cacheStats = $state<CacheStats | null>(null);
|
||||
|
||||
async function loadCacheStats() {
|
||||
// --- Dirty tracking -----------------------------------------------------
|
||||
|
||||
const dirtyKeys = $derived.by<Array<keyof Settings>>(() => {
|
||||
const out: Array<keyof Settings> = [];
|
||||
for (const key of Object.keys(settings) as Array<keyof Settings>) {
|
||||
if (settings[key] !== baseline[key]) out.push(key);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
const dirty = $derived(dirtyKeys.length > 0);
|
||||
|
||||
// --- Data loading -------------------------------------------------------
|
||||
|
||||
async function loadCacheStats(): Promise<void> {
|
||||
try {
|
||||
cacheStats = await api<CacheStats>('/settings/telegram-cache/stats');
|
||||
} catch { cacheStats = null; }
|
||||
@@ -54,202 +82,135 @@
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
settings = await api('/settings');
|
||||
const fetched = await api<Settings>('/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<void> {
|
||||
saving = true;
|
||||
error = '';
|
||||
try {
|
||||
settings = await api('/settings', { method: 'PUT', body: JSON.stringify(settings) });
|
||||
const next = await api<Settings>('/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); }
|
||||
} 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<void> {
|
||||
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); }
|
||||
} 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')),
|
||||
);
|
||||
</script>
|
||||
|
||||
<PageHeader
|
||||
title={t('settings.title')}
|
||||
emphasis={t('settings.titleEmphasis')}
|
||||
description={t('settings.description')}
|
||||
crumb={t('crumbs.systemConfiguration')}
|
||||
/>
|
||||
<SettingsHero {settings} />
|
||||
|
||||
{#if !loaded}
|
||||
<Loading />
|
||||
{:else}
|
||||
<ErrorBanner message={error} />
|
||||
<div class="space-y-6">
|
||||
<!-- General section -->
|
||||
<Card>
|
||||
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
|
||||
<MdiIcon name="mdiCog" size={18} />
|
||||
{t('settings.general')}
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">{t('settings.externalUrl')}<Hint text={t('settings.externalUrlHint')} /></label>
|
||||
<input bind:value={settings.external_url} placeholder="https://notify.example.com"
|
||||
class="w-full max-w-md px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-2">{t('settings.timezone')}<Hint text={t('settings.timezoneHint')} /></label>
|
||||
<TimezoneSelector bind:value={settings.timezone} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Telegram section -->
|
||||
<Card>
|
||||
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
|
||||
<MdiIcon name="mdiSend" size={18} />
|
||||
{t('settings.telegram')}
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">{t('settings.webhookSecret')}<Hint text={t('settings.webhookSecretHint')} /></label>
|
||||
<form onsubmit={(e) => e.preventDefault()} autocomplete="off">
|
||||
<input bind:value={settings.telegram_webhook_secret} type="password" autocomplete="off" placeholder={t('providers.optional')}
|
||||
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
|
||||
</form>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">{t('settings.cacheTtl')}<Hint text={t('settings.cacheTtlHint')} /></label>
|
||||
<input bind:value={settings.telegram_cache_ttl_hours} type="number" min="0" max="8760"
|
||||
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">{t('settings.cacheMaxEntries')}<Hint text={t('settings.cacheMaxEntriesHint')} /></label>
|
||||
<input bind:value={settings.telegram_asset_cache_max_entries} type="number" min="100" max="100000"
|
||||
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 pt-4 border-t border-[var(--color-border)]">
|
||||
<div class="text-xs font-medium mb-2 flex items-center" style="color: var(--color-muted-foreground);">
|
||||
{t('settings.cacheStats')}<Hint text={t('settings.cacheStatsHint')} />
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 mb-3">
|
||||
{#each [
|
||||
{ label: t('settings.cacheStatsUrl'), data: cacheStats?.url },
|
||||
{ label: t('settings.cacheStatsAsset'), data: cacheStats?.asset },
|
||||
] as bucket}
|
||||
<div class="px-3 py-2 rounded-md border border-[var(--color-border)] bg-[var(--color-background)] text-xs">
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<span class="font-medium">{bucket.label}</span>
|
||||
{#if bucket.data && bucket.data.count > 0}
|
||||
<span>
|
||||
<span class="font-mono">{bucket.data.count}</span>
|
||||
<span style="color: var(--color-muted-foreground);"> {t('settings.cacheStatsEntries')}</span>
|
||||
{#if bucket.data.total_size_bytes > 0}
|
||||
<span style="color: var(--color-muted-foreground);"> · </span>
|
||||
<span class="font-mono">{formatBytes(bucket.data.total_size_bytes)}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{:else}
|
||||
<span style="color: var(--color-muted-foreground);">{t('settings.cacheStatsEmpty')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if bucket.data && bucket.data.count > 0 && (bucket.data.oldest || bucket.data.newest)}
|
||||
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-0.5" style="color: var(--color-muted-foreground);">
|
||||
{#if bucket.data.oldest}
|
||||
<span>{t('settings.cacheStatsOldest')}: <span class="font-mono">{formatTs(bucket.data.oldest)}</span></span>
|
||||
{/if}
|
||||
{#if bucket.data.newest}
|
||||
<span>{t('settings.cacheStatsNewest')}: <span class="font-mono">{formatTs(bucket.data.newest)}</span></span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<button type="button" onclick={() => confirmClearCache = true} disabled={clearingCache}
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md border border-[var(--color-border)] bg-[var(--color-background)] hover:bg-[var(--color-muted)] disabled:opacity-50">
|
||||
<MdiIcon name="mdiDeleteSweep" size={16} />
|
||||
{clearingCache ? t('common.loading') : t('settings.clearCache')}
|
||||
</button>
|
||||
<span class="text-xs" style="color: var(--color-muted-foreground);">{t('settings.clearCacheHint')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<div class="settings-page stagger-children">
|
||||
<IdentityCassette
|
||||
bind:externalUrl={settings.external_url}
|
||||
bind:timezone={settings.timezone}
|
||||
bind:supportedLocales={settings.supported_locales}
|
||||
/>
|
||||
|
||||
<!-- Locales section -->
|
||||
<Card>
|
||||
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
|
||||
<MdiIcon name="mdiTranslate" size={18} />
|
||||
{t('settings.locales')}
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-2">{t('settings.supportedLocales')}<Hint text={t('settings.supportedLocalesHint')} /></label>
|
||||
<LocaleSelector bind:value={settings.supported_locales} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Logging section -->
|
||||
<Card>
|
||||
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
|
||||
<MdiIcon name="mdiTextBoxOutline" size={18} />
|
||||
{t('settings.logging')}
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">{t('settings.logLevel')}<Hint text={t('settings.logLevelHint')} /></label>
|
||||
<IconGridSelect items={logLevelItems()} bind:value={settings.log_level} columns={2} />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">{t('settings.logFormat')}<Hint text={t('settings.logFormatHint')} /></label>
|
||||
<IconGridSelect items={logFormatItems()} bind:value={settings.log_format} columns={2} />
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label class="block text-xs font-medium mb-1">{t('settings.logLevels')}<Hint text={t('settings.logLevelsHint')} /></label>
|
||||
<input bind:value={settings.log_levels}
|
||||
placeholder="sqlalchemy.engine=WARNING,notify_bridge_core.notifications.telegram.client=DEBUG"
|
||||
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Button onclick={save} disabled={saving}>
|
||||
{saving ? t('common.loading') : t('common.save')}
|
||||
</Button>
|
||||
<div class="telegram-deck">
|
||||
<TelegramCassette
|
||||
bind:webhookSecret={settings.telegram_webhook_secret}
|
||||
bind:cacheTtlHours={settings.telegram_cache_ttl_hours}
|
||||
bind:cacheMaxEntries={settings.telegram_asset_cache_max_entries}
|
||||
/>
|
||||
<CacheLedger
|
||||
stats={cacheStats}
|
||||
clearing={clearingCache}
|
||||
maxEntries={cacheMaxEntriesNum}
|
||||
onRefresh={loadCacheStats}
|
||||
onClear={() => (confirmClearCache = true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ConfirmModal open={confirmClearCache}
|
||||
<LoggingCassette
|
||||
bind:logLevel={settings.log_level}
|
||||
bind:logFormat={settings.log_format}
|
||||
bind:logLevels={settings.log_levels}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SaveBar
|
||||
{dirty}
|
||||
{saving}
|
||||
changedCount={dirtyKeys.length}
|
||||
onSave={save}
|
||||
onDiscard={discard}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<ConfirmModal
|
||||
open={confirmClearCache}
|
||||
title={t('settings.clearCacheConfirmTitle')}
|
||||
message={t('settings.clearCacheConfirm')}
|
||||
confirmLabel={t('settings.clearCacheConfirmBtn')}
|
||||
confirmIcon="mdiDeleteSweep"
|
||||
onconfirm={clearTelegramCache}
|
||||
oncancel={() => confirmClearCache = false} />
|
||||
{/if}
|
||||
oncancel={() => (confirmClearCache = false)}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.settings-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.telegram-deck {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.25rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
@media (min-width: 960px) {
|
||||
.telegram-deck { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
|
||||
type Tone = 'mint' | 'sky' | 'citrus' | 'coral';
|
||||
|
||||
interface CacheBucketStats {
|
||||
count: number;
|
||||
total_size_bytes: number;
|
||||
oldest: string | null;
|
||||
newest: string | null;
|
||||
}
|
||||
interface CacheStats {
|
||||
url: CacheBucketStats;
|
||||
asset: CacheBucketStats;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
stats: CacheStats | null;
|
||||
clearing: boolean;
|
||||
maxEntries: number;
|
||||
onRefresh: () => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
let { stats, clearing, maxEntries, onRefresh, onClear }: Props = $props();
|
||||
|
||||
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 parseDate(iso: string | null): Date | null {
|
||||
if (!iso) return null;
|
||||
const d = new Date(iso.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z');
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
function relativeTime(iso: string | null): string {
|
||||
const date = parseDate(iso);
|
||||
if (!date) return '';
|
||||
const diffSec = Math.max(0, (Date.now() - date.getTime()) / 1000);
|
||||
if (diffSec < 60) return t('dashboard.justNow');
|
||||
const min = Math.floor(diffSec / 60);
|
||||
if (min < 60) return t('dashboard.minutesAgo').replace('{n}', String(min));
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return t('dashboard.hoursAgo').replace('{n}', String(hr));
|
||||
const day = Math.floor(hr / 24);
|
||||
return t('dashboard.daysAgo').replace('{n}', String(day));
|
||||
}
|
||||
|
||||
function ageTone(iso: string | null): Tone {
|
||||
const date = parseDate(iso);
|
||||
if (!date) return 'mint';
|
||||
const hours = (Date.now() - date.getTime()) / 3_600_000;
|
||||
if (hours < 48) return 'mint';
|
||||
if (hours < 24 * 7) return 'sky';
|
||||
if (hours < 24 * 30) return 'citrus';
|
||||
return 'coral';
|
||||
}
|
||||
|
||||
interface BucketRow {
|
||||
key: 'url' | 'asset';
|
||||
labelKey: string;
|
||||
icon: string;
|
||||
data: CacheBucketStats | null;
|
||||
}
|
||||
|
||||
const buckets = $derived<BucketRow[]>([
|
||||
{ key: 'url', labelKey: 'settings.cacheStatsUrl', icon: 'mdiLinkVariant', data: stats?.url ?? null },
|
||||
{ key: 'asset', labelKey: 'settings.cacheStatsAsset', icon: 'mdiImageMultipleOutline', data: stats?.asset ?? null },
|
||||
]);
|
||||
|
||||
const totalCount = $derived(
|
||||
(stats?.url.count ?? 0) + (stats?.asset.count ?? 0),
|
||||
);
|
||||
const totalBytes = $derived(
|
||||
(stats?.url.total_size_bytes ?? 0) + (stats?.asset.total_size_bytes ?? 0),
|
||||
);
|
||||
const fillPct = $derived.by(() => {
|
||||
const max = Math.max(1, maxEntries);
|
||||
const each = totalCount / 2; // two buckets share the cap conceptually; use whichever is fuller
|
||||
const top = Math.max(stats?.url.count ?? 0, stats?.asset.count ?? 0);
|
||||
void each; // explicit ack we considered both
|
||||
return Math.min(100, Math.round((top / max) * 100));
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="ledger glass">
|
||||
<header class="ledger-head">
|
||||
<div class="ledger-summary">
|
||||
<div class="ledger-eyebrow">
|
||||
<MdiIcon name="mdiDatabaseClockOutline" size={12} />
|
||||
<span>{t('settings.cacheStats')}</span>
|
||||
</div>
|
||||
<div class="ledger-numbers">
|
||||
<span class="ledger-count font-mono">{totalCount.toLocaleString()}</span>
|
||||
<span class="ledger-count-label">{t('settings.cacheStatsEntries')}</span>
|
||||
{#if totalBytes > 0}
|
||||
<span class="ledger-sep">·</span>
|
||||
<span class="ledger-bytes font-mono">{formatBytes(totalBytes)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ledger-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
onclick={onRefresh}
|
||||
aria-label={t('common.refresh', 'Refresh')}
|
||||
title={t('common.refresh', 'Refresh')}
|
||||
>
|
||||
<MdiIcon name="mdiRefresh" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Capacity meter (peak bucket vs configured cap) -->
|
||||
{#if maxEntries > 0}
|
||||
<div class="meter" aria-label={t('settings.cacheCapacity')}>
|
||||
<div class="meter-track">
|
||||
<div class="meter-fill" style="width: {fillPct}%"></div>
|
||||
</div>
|
||||
<span class="meter-text font-mono">
|
||||
{fillPct}% · {t('settings.cacheCapacityCap').replace('{n}', maxEntries.toLocaleString())}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Bucket rows -->
|
||||
<ol class="ledger-list">
|
||||
{#each buckets as bucket (bucket.key)}
|
||||
{@const data = bucket.data}
|
||||
{@const empty = !data || data.count === 0}
|
||||
{@const tone = empty ? 'mint' : ageTone(data?.oldest ?? null)}
|
||||
<li class="row" data-tone={tone} class:row-empty={empty}>
|
||||
<span class="row-edge" aria-hidden="true"></span>
|
||||
<span class="row-icon" aria-hidden="true">
|
||||
<MdiIcon name={bucket.icon} size={16} />
|
||||
</span>
|
||||
<div class="row-text">
|
||||
<span class="row-name">{t(bucket.labelKey)}</span>
|
||||
{#if empty}
|
||||
<span class="row-meta">{t('settings.cacheStatsEmpty')}</span>
|
||||
{:else if data}
|
||||
<span class="row-meta">
|
||||
<span>
|
||||
<span class="font-mono">{data.count.toLocaleString()}</span>
|
||||
{t('settings.cacheStatsEntries')}
|
||||
</span>
|
||||
{#if data.total_size_bytes > 0}
|
||||
<span class="row-sep">·</span>
|
||||
<span class="font-mono">{formatBytes(data.total_size_bytes)}</span>
|
||||
{/if}
|
||||
{#if data.oldest}
|
||||
<span class="row-sep">·</span>
|
||||
<span>{t('settings.cacheStatsOldest')} {relativeTime(data.oldest)}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="row-dot" aria-hidden="true"></span>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
|
||||
<footer class="ledger-foot">
|
||||
<Button size="sm" variant="secondary" onclick={onClear} disabled={clearing || totalCount === 0}>
|
||||
{#if clearing}
|
||||
<MdiIcon name="mdiLoading" size={14} />
|
||||
{:else}
|
||||
<MdiIcon name="mdiDeleteSweep" size={14} />
|
||||
{/if}
|
||||
{clearing ? t('common.loading') : t('settings.clearCache')}
|
||||
</Button>
|
||||
<span class="foot-hint">{t('settings.clearCacheHint')}</span>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.ledger {
|
||||
padding: 1.4rem 1.5rem 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
min-height: 100%;
|
||||
}
|
||||
.ledger-head {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ledger-summary { min-width: 0; }
|
||||
.ledger-eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--color-muted-foreground);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.ledger-numbers {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.45rem;
|
||||
line-height: 1;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ledger-count {
|
||||
font-size: 1.7rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.025em;
|
||||
color: var(--color-foreground);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.ledger-count-label {
|
||||
font-size: 0.62rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
.ledger-sep { color: var(--color-muted-foreground); opacity: 0.5; }
|
||||
.ledger-bytes {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
.ledger-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 30px; height: 30px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: var(--color-muted-foreground);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.icon-btn:hover:not(:disabled) {
|
||||
background: var(--color-glass-strong);
|
||||
color: var(--color-foreground);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
/* --- Capacity meter --- */
|
||||
.meter {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
.meter-track {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-glass-strong);
|
||||
border: 1px solid var(--color-border);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.meter-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--color-mint), var(--color-sky));
|
||||
border-radius: inherit;
|
||||
transition: width 0.4s cubic-bezier(.2,.7,.2,1);
|
||||
box-shadow: 0 0 8px color-mix(in srgb, var(--color-sky) 40%, transparent);
|
||||
}
|
||||
.meter-text {
|
||||
font-size: 0.62rem;
|
||||
color: var(--color-muted-foreground);
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* --- Bucket rows --- */
|
||||
.ledger-list {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.row {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
padding: 0.7rem 0.95rem 0.7rem 1.1rem;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-glass-strong);
|
||||
transition: transform 0.18s, border-color 0.18s, background 0.18s;
|
||||
overflow: hidden;
|
||||
}
|
||||
.row:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: var(--color-rule-strong);
|
||||
background: var(--color-glass-elev);
|
||||
}
|
||||
.row.row-empty { opacity: 0.78; }
|
||||
|
||||
.row-edge {
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 3px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.row[data-tone="mint"] .row-edge { background: var(--color-mint); }
|
||||
.row[data-tone="sky"] .row-edge { background: var(--color-sky); }
|
||||
.row[data-tone="citrus"] .row-edge { background: var(--color-citrus); }
|
||||
.row[data-tone="coral"] .row-edge { background: var(--color-coral); }
|
||||
|
||||
.row-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px; height: 30px;
|
||||
border-radius: 9px;
|
||||
background: var(--color-glass);
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
.row[data-tone="mint"] .row-icon { color: var(--color-mint); }
|
||||
.row[data-tone="sky"] .row-icon { color: var(--color-sky); }
|
||||
.row[data-tone="citrus"] .row-icon { color: var(--color-citrus); }
|
||||
.row[data-tone="coral"] .row-icon { color: var(--color-coral); }
|
||||
|
||||
.row-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.row-name {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-foreground);
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
.row-meta {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-muted-foreground);
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.row-sep { opacity: 0.45; }
|
||||
|
||||
.row-dot {
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.row[data-tone="mint"] .row-dot { background: var(--color-mint); box-shadow: 0 0 8px color-mix(in srgb, var(--color-mint) 50%, transparent); }
|
||||
.row[data-tone="sky"] .row-dot { background: var(--color-sky); box-shadow: 0 0 8px color-mix(in srgb, var(--color-sky) 50%, transparent); }
|
||||
.row[data-tone="citrus"] .row-dot { background: var(--color-citrus); box-shadow: 0 0 8px color-mix(in srgb, var(--color-citrus) 50%, transparent); }
|
||||
.row[data-tone="coral"] .row-dot { background: var(--color-coral); box-shadow: 0 0 8px color-mix(in srgb, var(--color-coral) 50%, transparent); }
|
||||
|
||||
/* --- Footer --- */
|
||||
.ledger-foot {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
flex-wrap: wrap;
|
||||
padding-top: 0.4rem;
|
||||
margin-top: auto;
|
||||
}
|
||||
.foot-hint {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-muted-foreground);
|
||||
flex: 1;
|
||||
min-width: 12rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.row, .meter-fill { transition: none !important; }
|
||||
.row:hover { transform: none !important; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,277 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import Hint from '$lib/components/Hint.svelte';
|
||||
import LocaleSelector from '$lib/components/LocaleSelector.svelte';
|
||||
import TimezoneSelector from '$lib/components/TimezoneSelector.svelte';
|
||||
import { snackSuccess } from '$lib/stores/snackbar.svelte';
|
||||
|
||||
interface Props {
|
||||
externalUrl: string;
|
||||
timezone: string;
|
||||
supportedLocales: string;
|
||||
}
|
||||
|
||||
let {
|
||||
externalUrl = $bindable(),
|
||||
timezone = $bindable(),
|
||||
supportedLocales = $bindable(),
|
||||
}: Props = $props();
|
||||
|
||||
let copied = $state(false);
|
||||
let copyTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function copyUrl(): void {
|
||||
if (!externalUrl) return;
|
||||
try {
|
||||
navigator.clipboard.writeText(externalUrl);
|
||||
copied = true;
|
||||
snackSuccess(t('settings.urlCopied'));
|
||||
if (copyTimer) clearTimeout(copyTimer);
|
||||
copyTimer = setTimeout(() => { copied = false; }, 1600);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function isReachable(url: string): boolean {
|
||||
if (!url) return false;
|
||||
try { new URL(url); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
const urlValid = $derived(isReachable(externalUrl));
|
||||
</script>
|
||||
|
||||
<section class="identity glass">
|
||||
<header class="identity-head">
|
||||
<div class="identity-eyebrow">
|
||||
<MdiIcon name="mdiAccountNetworkOutline" size={12} />
|
||||
<span>{t('settings.identity')}</span>
|
||||
</div>
|
||||
<h3 class="identity-title">{t('settings.identityHeadline')}</h3>
|
||||
</header>
|
||||
|
||||
<div class="identity-body">
|
||||
<!-- External URL row -->
|
||||
<div class="row">
|
||||
<div class="row-label">
|
||||
<span class="row-num">01</span>
|
||||
<label for="settings-external-url" class="row-name">
|
||||
{t('settings.externalUrl')}
|
||||
<Hint text={t('settings.externalUrlHint')} />
|
||||
</label>
|
||||
</div>
|
||||
<div class="row-control">
|
||||
<div class="url-field" class:url-field-valid={urlValid && !!externalUrl}>
|
||||
<span class="url-leading" aria-hidden="true">
|
||||
<MdiIcon name={urlValid ? 'mdiEarth' : 'mdiEarthOff'} size={14} />
|
||||
</span>
|
||||
<input
|
||||
id="settings-external-url"
|
||||
bind:value={externalUrl}
|
||||
placeholder="https://notify.example.com"
|
||||
class="url-input"
|
||||
type="url"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
{#if externalUrl}
|
||||
<button
|
||||
type="button"
|
||||
class="url-action"
|
||||
onclick={copyUrl}
|
||||
aria-label={t('settings.copy')}
|
||||
title={t('settings.copy')}
|
||||
>
|
||||
<MdiIcon name={copied ? 'mdiCheck' : 'mdiContentCopy'} size={13} />
|
||||
</button>
|
||||
{#if urlValid}
|
||||
<a
|
||||
href={externalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="url-action"
|
||||
aria-label={t('settings.openExternal')}
|
||||
title={t('settings.openExternal')}
|
||||
>
|
||||
<MdiIcon name="mdiOpenInNew" size={13} />
|
||||
</a>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timezone row -->
|
||||
<div class="row">
|
||||
<div class="row-label">
|
||||
<span class="row-num">02</span>
|
||||
<span class="row-name">
|
||||
{t('settings.timezone')}
|
||||
<Hint text={t('settings.timezoneHint')} />
|
||||
</span>
|
||||
</div>
|
||||
<div class="row-control">
|
||||
<TimezoneSelector bind:value={timezone} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Locales row -->
|
||||
<div class="row">
|
||||
<div class="row-label">
|
||||
<span class="row-num">03</span>
|
||||
<span class="row-name">
|
||||
{t('settings.supportedLocales')}
|
||||
<Hint text={t('settings.supportedLocalesHint')} />
|
||||
</span>
|
||||
</div>
|
||||
<div class="row-control">
|
||||
<LocaleSelector bind:value={supportedLocales} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.identity {
|
||||
padding: 1.5rem 1.6rem 1.4rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.2rem;
|
||||
}
|
||||
|
||||
.identity-head {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.identity-eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.62rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--color-muted-foreground);
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
.identity-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.3;
|
||||
letter-spacing: -0.015em;
|
||||
color: var(--color-foreground);
|
||||
max-width: 42ch;
|
||||
}
|
||||
|
||||
.identity-body {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 11rem 1fr;
|
||||
gap: 1.4rem;
|
||||
padding: 1rem 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.row:first-child { border-top: 0; padding-top: 0.4rem; }
|
||||
.row:last-child { padding-bottom: 0.1rem; }
|
||||
|
||||
.row-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
padding-top: 0.15rem;
|
||||
}
|
||||
.row-num {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
.row-name {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-foreground);
|
||||
letter-spacing: -0.005em;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.row-control {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* --- URL field with leading icon and trailing actions --- */
|
||||
.url-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
max-width: 34rem;
|
||||
padding: 0.15rem 0.35rem 0.15rem 0.7rem;
|
||||
border: 1px solid var(--color-rule-strong);
|
||||
border-radius: 0.625rem;
|
||||
background: var(--color-input-bg);
|
||||
transition: border-color 0.18s, box-shadow 0.18s, background 0.18s;
|
||||
}
|
||||
.url-field:focus-within {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px var(--color-glow);
|
||||
}
|
||||
.url-field-valid {
|
||||
border-color: color-mix(in srgb, var(--color-mint) 35%, var(--color-rule-strong));
|
||||
}
|
||||
.url-leading {
|
||||
color: var(--color-muted-foreground);
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.url-field-valid .url-leading { color: var(--color-mint); }
|
||||
.url-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
padding: 0.5rem 0.4rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-foreground);
|
||||
min-width: 0;
|
||||
}
|
||||
.url-input::placeholder { color: var(--color-muted-foreground); }
|
||||
.url-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--color-muted-foreground);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.url-action:hover {
|
||||
background: var(--color-glass-strong);
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.55rem;
|
||||
padding: 0.95rem 0;
|
||||
}
|
||||
.row-label { padding-top: 0; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.url-field, .url-action { transition: none !important; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,448 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import Hint from '$lib/components/Hint.svelte';
|
||||
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
|
||||
import { logLevelItems, logFormatItems } from '$lib/grid-items';
|
||||
|
||||
type Level = 'DEBUG' | 'INFO' | 'WARNING' | 'ERROR';
|
||||
|
||||
interface Override {
|
||||
module: string;
|
||||
level: Level;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
logLevel: string;
|
||||
logFormat: string;
|
||||
logLevels: string;
|
||||
}
|
||||
|
||||
let {
|
||||
logLevel = $bindable(),
|
||||
logFormat = $bindable(),
|
||||
logLevels = $bindable(),
|
||||
}: Props = $props();
|
||||
|
||||
const LEVELS: Level[] = ['DEBUG', 'INFO', 'WARNING', 'ERROR'];
|
||||
const LEVEL_TONE: Record<Level, string> = {
|
||||
DEBUG: 'sky',
|
||||
INFO: 'mint',
|
||||
WARNING: 'citrus',
|
||||
ERROR: 'coral',
|
||||
};
|
||||
|
||||
let rawMode = $state(false);
|
||||
|
||||
// Parse the comma-separated `module=LEVEL,...` string into structured rows.
|
||||
function parse(csv: string): Override[] {
|
||||
if (!csv) return [];
|
||||
const out: Override[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const raw of csv.split(',')) {
|
||||
const piece = raw.trim();
|
||||
if (!piece) continue;
|
||||
const eq = piece.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
const module = piece.slice(0, eq).trim();
|
||||
const lvlRaw = piece.slice(eq + 1).trim().toUpperCase();
|
||||
if (!module || seen.has(module)) continue;
|
||||
const level = (LEVELS.includes(lvlRaw as Level) ? lvlRaw : 'INFO') as Level;
|
||||
seen.add(module);
|
||||
out.push({ module, level });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function serialize(rows: Override[]): string {
|
||||
return rows
|
||||
.filter(r => r.module.trim().length > 0)
|
||||
.map(r => `${r.module.trim()}=${r.level}`)
|
||||
.join(',');
|
||||
}
|
||||
|
||||
let rows = $state<Override[]>(parse(logLevels));
|
||||
let lastEmitted = $state(logLevels);
|
||||
|
||||
// Re-parse when the upstream string changes from outside (e.g. fetch / reset).
|
||||
$effect(() => {
|
||||
if (logLevels !== lastEmitted) {
|
||||
rows = parse(logLevels);
|
||||
lastEmitted = logLevels;
|
||||
}
|
||||
});
|
||||
|
||||
function commit(next: Override[]): void {
|
||||
rows = next;
|
||||
const serialized = serialize(next);
|
||||
lastEmitted = serialized;
|
||||
logLevels = serialized;
|
||||
}
|
||||
|
||||
function addRow(): void {
|
||||
commit([...rows, { module: '', level: 'INFO' }]);
|
||||
}
|
||||
|
||||
function removeRow(i: number): void {
|
||||
commit(rows.filter((_, idx) => idx !== i));
|
||||
}
|
||||
|
||||
function updateModule(i: number, value: string): void {
|
||||
const next = rows.map((r, idx) => (idx === i ? { ...r, module: value } : r));
|
||||
commit(next);
|
||||
}
|
||||
|
||||
function updateLevel(i: number, level: Level): void {
|
||||
const next = rows.map((r, idx) => (idx === i ? { ...r, level } : r));
|
||||
commit(next);
|
||||
}
|
||||
|
||||
const previewLine = $derived.by(() => {
|
||||
const root = (logLevel || 'INFO').toUpperCase();
|
||||
if (rows.length === 0) return `root=${root}`;
|
||||
return `root=${root}, ${rows.map(r => `${r.module || '?'}=${r.level}`).join(', ')}`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="logging glass">
|
||||
<header class="log-head">
|
||||
<div class="log-eyebrow">
|
||||
<MdiIcon name="mdiTextBoxOutline" size={12} />
|
||||
<span>{t('settings.logging')}</span>
|
||||
</div>
|
||||
<h3 class="log-title">{t('settings.loggingHeadline')}</h3>
|
||||
</header>
|
||||
|
||||
<!-- Level + format -->
|
||||
<div class="log-row">
|
||||
<div class="log-cell">
|
||||
<span class="log-label">
|
||||
{t('settings.logLevel')}
|
||||
<Hint text={t('settings.logLevelHint')} />
|
||||
</span>
|
||||
<IconGridSelect items={logLevelItems()} bind:value={logLevel} columns={2} />
|
||||
</div>
|
||||
<div class="log-cell">
|
||||
<span class="log-label">
|
||||
{t('settings.logFormat')}
|
||||
<Hint text={t('settings.logFormatHint')} />
|
||||
</span>
|
||||
<IconGridSelect items={logFormatItems()} bind:value={logFormat} columns={2} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Per-module overrides -->
|
||||
<div class="overrides">
|
||||
<div class="overrides-head">
|
||||
<span class="log-label">
|
||||
{t('settings.logLevels')}
|
||||
<Hint text={t('settings.logLevelsHint')} />
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="mode-toggle"
|
||||
onclick={() => (rawMode = !rawMode)}
|
||||
title={rawMode ? t('settings.editAsChips') : t('settings.editAsText')}
|
||||
>
|
||||
<MdiIcon name={rawMode ? 'mdiViewList' : 'mdiCodeBraces'} size={12} />
|
||||
<span>{rawMode ? t('settings.editAsChips') : t('settings.editAsText')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if rawMode}
|
||||
<input
|
||||
bind:value={logLevels}
|
||||
placeholder="sqlalchemy.engine=WARNING,notify_bridge_core.notifications.telegram.client=DEBUG"
|
||||
class="raw-input"
|
||||
/>
|
||||
{:else}
|
||||
<div class="chip-stack">
|
||||
{#each rows as row, i (i)}
|
||||
{@const tone = LEVEL_TONE[row.level]}
|
||||
<div class="chip" data-tone={tone}>
|
||||
<span class="chip-edge" aria-hidden="true"></span>
|
||||
<input
|
||||
value={row.module}
|
||||
oninput={(e) => updateModule(i, (e.currentTarget as HTMLInputElement).value)}
|
||||
placeholder={t('settings.logModulePlaceholder')}
|
||||
class="chip-input"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<span class="chip-sep" aria-hidden="true">=</span>
|
||||
<select
|
||||
value={row.level}
|
||||
onchange={(e) => updateLevel(i, (e.currentTarget as HTMLSelectElement).value as Level)}
|
||||
class="chip-level"
|
||||
aria-label={t('settings.logLevel')}
|
||||
>
|
||||
{#each LEVELS as lvl}
|
||||
<option value={lvl}>{lvl}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
class="chip-remove"
|
||||
onclick={() => removeRow(i)}
|
||||
aria-label={t('settings.removeOverride')}
|
||||
title={t('settings.removeOverride')}
|
||||
>
|
||||
<MdiIcon name="mdiClose" size={13} />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<button type="button" class="chip-add" onclick={addRow}>
|
||||
<MdiIcon name="mdiPlus" size={13} />
|
||||
<span>{t('settings.addOverride')}</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Live preview -->
|
||||
<div class="preview" role="status">
|
||||
<span class="preview-eyebrow">{t('settings.logPreviewLabel')}</span>
|
||||
<code class="preview-text">{previewLine}</code>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.logging {
|
||||
padding: 1.5rem 1.6rem 1.4rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.15rem;
|
||||
}
|
||||
.log-head {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.log-eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.62rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--color-muted-foreground);
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
.log-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-size: 1.15rem;
|
||||
line-height: 1.35;
|
||||
letter-spacing: -0.015em;
|
||||
color: var(--color-foreground);
|
||||
max-width: 38ch;
|
||||
}
|
||||
|
||||
.log-row {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
@media (min-width: 720px) {
|
||||
.log-row { grid-template-columns: 1fr 1fr; gap: 1.4rem; }
|
||||
}
|
||||
.log-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.log-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-foreground);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* --- Overrides editor --- */
|
||||
.overrides {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.overrides-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.mode-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.25rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-muted-foreground);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.mode-toggle:hover {
|
||||
background: var(--color-glass-strong);
|
||||
color: var(--color-foreground);
|
||||
border-color: var(--color-rule-strong);
|
||||
}
|
||||
|
||||
.raw-input {
|
||||
width: 100%;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
padding: 0.6rem 0.85rem;
|
||||
}
|
||||
|
||||
.chip-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.chip {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto auto;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.35rem 0.4rem 0.35rem 0.95rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-glass-strong);
|
||||
overflow: hidden;
|
||||
transition: border-color 0.18s, background 0.18s;
|
||||
}
|
||||
.chip:hover {
|
||||
border-color: var(--color-rule-strong);
|
||||
background: var(--color-glass-elev);
|
||||
}
|
||||
.chip-edge {
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 3px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.chip[data-tone="sky"] .chip-edge { background: var(--color-sky); }
|
||||
.chip[data-tone="mint"] .chip-edge { background: var(--color-mint); }
|
||||
.chip[data-tone="citrus"] .chip-edge { background: var(--color-citrus); }
|
||||
.chip[data-tone="coral"] .chip-edge { background: var(--color-coral); }
|
||||
|
||||
.chip-input {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
padding: 0.35rem 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-foreground);
|
||||
min-width: 0;
|
||||
}
|
||||
.chip-input::placeholder { color: var(--color-muted-foreground); opacity: 0.7; }
|
||||
|
||||
.chip-sep {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--color-muted-foreground);
|
||||
opacity: 0.5;
|
||||
padding: 0 0.15rem;
|
||||
}
|
||||
|
||||
.chip-level {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
padding: 0.3rem 1.6rem 0.3rem 0.6rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-glass);
|
||||
color: var(--color-foreground);
|
||||
min-width: 7.2rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.chip[data-tone="sky"] .chip-level { color: var(--color-sky); border-color: color-mix(in srgb, var(--color-sky) 35%, var(--color-border)); }
|
||||
.chip[data-tone="mint"] .chip-level { color: var(--color-mint); border-color: color-mix(in srgb, var(--color-mint) 35%, var(--color-border)); }
|
||||
.chip[data-tone="citrus"] .chip-level { color: var(--color-citrus); border-color: color-mix(in srgb, var(--color-citrus) 35%, var(--color-border)); }
|
||||
.chip[data-tone="coral"] .chip-level { color: var(--color-coral); border-color: color-mix(in srgb, var(--color-coral) 35%, var(--color-border)); }
|
||||
|
||||
.chip-remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px; height: 26px;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--color-muted-foreground);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.chip-remove:hover {
|
||||
background: color-mix(in srgb, var(--color-error-fg) 12%, var(--color-glass-strong));
|
||||
color: var(--color-error-fg);
|
||||
}
|
||||
|
||||
.chip-add {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
align-self: flex-start;
|
||||
padding: 0.35rem 0.85rem;
|
||||
border-radius: 999px;
|
||||
border: 1px dashed var(--color-rule-strong);
|
||||
background: transparent;
|
||||
color: var(--color-muted-foreground);
|
||||
font-family: inherit;
|
||||
font-size: 0.72rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.chip-add:hover {
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
||||
color: var(--color-primary);
|
||||
border-style: solid;
|
||||
border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-rule-strong));
|
||||
}
|
||||
|
||||
/* --- Live preview --- */
|
||||
.preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--color-background-deep, #02030a) 30%, var(--color-glass-strong));
|
||||
border: 1px solid var(--color-border);
|
||||
overflow: hidden;
|
||||
}
|
||||
.preview-eyebrow {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.55rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
.preview-text {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-foreground);
|
||||
word-break: break-all;
|
||||
line-height: 1.45;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,166 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
|
||||
interface Props {
|
||||
dirty: boolean;
|
||||
saving: boolean;
|
||||
changedCount: number;
|
||||
onSave: () => void;
|
||||
onDiscard: () => void;
|
||||
}
|
||||
|
||||
let { dirty, saving, changedCount, onSave, onDiscard }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if dirty || saving}
|
||||
<div class="save-bar" role="region" aria-label={t('settings.unsavedChanges')}>
|
||||
<div class="save-bar-inner glass">
|
||||
<span class="save-edge" aria-hidden="true"></span>
|
||||
<span class="save-pulse" aria-hidden="true"></span>
|
||||
<div class="save-text">
|
||||
<span class="save-eyebrow">{t('settings.unsaved')}</span>
|
||||
<span class="save-message">
|
||||
{#if changedCount === 1}
|
||||
{t('settings.changedOne')}
|
||||
{:else}
|
||||
{t('settings.changedMany').replace('{n}', String(changedCount))}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<div class="save-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="discard"
|
||||
onclick={onDiscard}
|
||||
disabled={saving}
|
||||
>
|
||||
{t('settings.discard')}
|
||||
</button>
|
||||
<Button size="sm" onclick={onSave} disabled={saving}>
|
||||
{#if saving}
|
||||
<MdiIcon name="mdiLoading" size={14} />
|
||||
{:else}
|
||||
<MdiIcon name="mdiContentSave" size={14} />
|
||||
{/if}
|
||||
{saving ? t('common.loading') : t('settings.saveChanges')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.save-bar {
|
||||
position: sticky;
|
||||
bottom: 1rem;
|
||||
z-index: 40;
|
||||
margin-top: 1.25rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
animation: save-rise 0.3s cubic-bezier(.2,.7,.2,1) both;
|
||||
}
|
||||
.save-bar-inner {
|
||||
pointer-events: auto;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.7rem 1rem 0.7rem 1.25rem;
|
||||
max-width: min(640px, calc(100% - 1rem));
|
||||
width: 100%;
|
||||
border-color: color-mix(in srgb, var(--color-citrus) 40%, var(--color-border));
|
||||
box-shadow:
|
||||
var(--shadow-card),
|
||||
0 0 0 1px color-mix(in srgb, var(--color-citrus) 22%, transparent) inset;
|
||||
overflow: hidden;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.save-edge {
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 4px;
|
||||
background: linear-gradient(180deg, var(--color-citrus), color-mix(in srgb, var(--color-citrus) 50%, transparent));
|
||||
}
|
||||
.save-pulse {
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-citrus);
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-citrus) 60%, transparent);
|
||||
animation: save-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.save-text {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-left: 1rem; /* clear room for the pulse dot */
|
||||
}
|
||||
.save-eyebrow {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.55rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--color-citrus);
|
||||
}
|
||||
.save-message {
|
||||
font-family: var(--font-display);
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-foreground);
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.save-actions {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.discard {
|
||||
padding: 0 0.95rem;
|
||||
height: 34px;
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-muted-foreground);
|
||||
font-family: inherit;
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.discard:hover:not(:disabled) {
|
||||
background: var(--color-glass-strong);
|
||||
color: var(--color-foreground);
|
||||
border-color: var(--color-rule-strong);
|
||||
}
|
||||
.discard:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
@keyframes save-rise {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes save-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-citrus) 60%, transparent); }
|
||||
50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--color-citrus) 0%, transparent); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.save-bar, .save-pulse { animation: none !important; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,94 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import PageHeader, { type HeaderPill } from '$lib/components/PageHeader.svelte';
|
||||
|
||||
type Tone = 'mint' | 'sky' | 'orchid' | 'coral' | 'citrus' | 'primary';
|
||||
|
||||
interface Settings {
|
||||
external_url: string;
|
||||
timezone: string;
|
||||
supported_locales: string;
|
||||
log_level: string;
|
||||
log_format: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
settings: Settings;
|
||||
}
|
||||
|
||||
let { settings }: Props = $props();
|
||||
|
||||
// Live tick so the timezone pill shows the current local HH:MM.
|
||||
let now = $state(new Date());
|
||||
let tick: ReturnType<typeof setInterval> | null = null;
|
||||
onMount(() => { tick = setInterval(() => { now = new Date(); }, 30_000); });
|
||||
onDestroy(() => { if (tick) clearInterval(tick); });
|
||||
|
||||
function fmtClock(tz: string): string {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: tz || 'UTC',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(now);
|
||||
} catch { return '--:--'; }
|
||||
}
|
||||
|
||||
function hostFromUrl(url: string): string {
|
||||
if (!url) return '';
|
||||
try { return new URL(url).host; }
|
||||
catch { return url.replace(/^https?:\/\//, '').replace(/\/$/, ''); }
|
||||
}
|
||||
|
||||
function localeCount(csv: string): number {
|
||||
if (!csv) return 0;
|
||||
return csv.split(',').map(s => s.trim()).filter(Boolean).length;
|
||||
}
|
||||
|
||||
const SEVERITY_TONE: Record<string, Tone> = {
|
||||
DEBUG: 'sky',
|
||||
INFO: 'mint',
|
||||
WARNING: 'citrus',
|
||||
ERROR: 'coral',
|
||||
};
|
||||
|
||||
const pills = $derived.by<HeaderPill[]>(() => {
|
||||
const out: HeaderPill[] = [];
|
||||
|
||||
const host = hostFromUrl(settings.external_url);
|
||||
out.push(host
|
||||
? { label: host, tone: 'sky' }
|
||||
: { label: t('settings.heroNoUrl') }
|
||||
);
|
||||
|
||||
const tz = settings.timezone || 'UTC';
|
||||
out.push({ label: `${tz} · ${fmtClock(tz)}`, tone: 'primary' });
|
||||
|
||||
const locales = settings.supported_locales || '';
|
||||
const count = localeCount(locales);
|
||||
out.push({
|
||||
label: count > 0
|
||||
? locales.split(',').map(s => s.trim()).filter(Boolean).map(s => s.toUpperCase()).join(' · ')
|
||||
: t('settings.heroNoLocales'),
|
||||
tone: 'orchid',
|
||||
});
|
||||
|
||||
const lvl = (settings.log_level || 'INFO').toUpperCase();
|
||||
out.push({
|
||||
label: `${lvl} · ${settings.log_format || 'text'}`,
|
||||
tone: SEVERITY_TONE[lvl] ?? 'mint',
|
||||
});
|
||||
|
||||
return out;
|
||||
});
|
||||
</script>
|
||||
|
||||
<PageHeader
|
||||
title={t('settings.title')}
|
||||
emphasis={t('settings.titleEmphasis')}
|
||||
description={t('settings.description')}
|
||||
crumb={t('crumbs.systemConfiguration')}
|
||||
{pills}
|
||||
/>
|
||||
@@ -0,0 +1,344 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import Hint from '$lib/components/Hint.svelte';
|
||||
|
||||
interface Props {
|
||||
webhookSecret: string;
|
||||
cacheTtlHours: string;
|
||||
cacheMaxEntries: string;
|
||||
}
|
||||
|
||||
let {
|
||||
webhookSecret = $bindable(),
|
||||
cacheTtlHours = $bindable(),
|
||||
cacheMaxEntries = $bindable(),
|
||||
}: Props = $props();
|
||||
|
||||
let showSecret = $state(false);
|
||||
|
||||
const secretSet = $derived(!!webhookSecret && webhookSecret.length > 0);
|
||||
const ttlHours = $derived(Number(cacheTtlHours || '0'));
|
||||
const ttlIsOff = $derived(ttlHours <= 0);
|
||||
|
||||
function ttlHumanized(h: number): string {
|
||||
if (h <= 0) return t('settings.ttlNoExpiry');
|
||||
if (h < 24) return `${h}h`;
|
||||
const d = Math.round(h / 24);
|
||||
if (d < 7) return `${d}d`;
|
||||
const w = Math.round(d / 7);
|
||||
if (w < 8) return `${w}w`;
|
||||
const mo = Math.round(d / 30);
|
||||
return `${mo}mo`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="tg glass">
|
||||
<header class="tg-head">
|
||||
<div class="tg-eyebrow">
|
||||
<MdiIcon name="mdiSend" size={12} />
|
||||
<span>{t('settings.telegram')}</span>
|
||||
</div>
|
||||
<h3 class="tg-title">{t('settings.telegramHeadline')}</h3>
|
||||
</header>
|
||||
|
||||
<div class="tg-grid">
|
||||
<!-- Webhook secret column -->
|
||||
<div class="col">
|
||||
<div class="col-head">
|
||||
<span class="col-num">A</span>
|
||||
<span class="col-name">
|
||||
{t('settings.webhookSecret')}
|
||||
<Hint text={t('settings.webhookSecretHint')} />
|
||||
</span>
|
||||
<span class="col-status" data-state={secretSet ? 'set' : 'unset'}>
|
||||
<span class="dot"></span>
|
||||
{secretSet ? t('settings.secretSet') : t('settings.secretUnset')}
|
||||
</span>
|
||||
</div>
|
||||
<form class="secret-field" onsubmit={(e) => e.preventDefault()} autocomplete="off">
|
||||
<input
|
||||
bind:value={webhookSecret}
|
||||
type={showSecret ? 'text' : 'password'}
|
||||
autocomplete="off"
|
||||
placeholder={t('providers.optional')}
|
||||
class="secret-input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="secret-toggle"
|
||||
onclick={() => (showSecret = !showSecret)}
|
||||
aria-label={showSecret ? t('settings.hide') : t('settings.show')}
|
||||
title={showSecret ? t('settings.hide') : t('settings.show')}
|
||||
>
|
||||
<MdiIcon name={showSecret ? 'mdiEyeOff' : 'mdiEye'} size={14} />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Cache config column -->
|
||||
<div class="col">
|
||||
<div class="col-head">
|
||||
<span class="col-num">B</span>
|
||||
<span class="col-name">{t('settings.cacheConfig')}</span>
|
||||
</div>
|
||||
<div class="cache-grid">
|
||||
<label class="num-field">
|
||||
<span class="num-label">
|
||||
{t('settings.cacheTtlShort')}
|
||||
<Hint text={t('settings.cacheTtlHint')} />
|
||||
</span>
|
||||
<div class="num-row">
|
||||
<input
|
||||
bind:value={cacheTtlHours}
|
||||
type="number"
|
||||
min="0"
|
||||
max="8760"
|
||||
class="num-input"
|
||||
/>
|
||||
<span class="num-suffix">{t('settings.hoursShort')}</span>
|
||||
</div>
|
||||
<span class="num-meta" class:num-meta-off={ttlIsOff}>
|
||||
{ttlHumanized(ttlHours)}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="num-field">
|
||||
<span class="num-label">
|
||||
{t('settings.cacheMaxShort')}
|
||||
<Hint text={t('settings.cacheMaxEntriesHint')} />
|
||||
</span>
|
||||
<div class="num-row">
|
||||
<input
|
||||
bind:value={cacheMaxEntries}
|
||||
type="number"
|
||||
min="100"
|
||||
max="100000"
|
||||
class="num-input"
|
||||
/>
|
||||
<span class="num-suffix">{t('settings.entriesShort')}</span>
|
||||
</div>
|
||||
<span class="num-meta">
|
||||
{t('settings.cacheMaxFootnote')}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.tg {
|
||||
padding: 1.5rem 1.6rem 1.4rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.15rem;
|
||||
min-height: 100%;
|
||||
}
|
||||
.tg-head {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.tg-eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.62rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--color-muted-foreground);
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
.tg-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-size: 1.15rem;
|
||||
line-height: 1.35;
|
||||
letter-spacing: -0.015em;
|
||||
color: var(--color-foreground);
|
||||
max-width: 36ch;
|
||||
}
|
||||
|
||||
.tg-grid {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
@media (min-width: 720px) {
|
||||
.tg-grid { grid-template-columns: 1fr 1fr; gap: 1.6rem; }
|
||||
}
|
||||
|
||||
.col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
.col-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.col-num {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--color-muted-foreground);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.col-name {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-foreground);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.col-status {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-glass-strong);
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
.col-status .dot {
|
||||
width: 6px; height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-muted-foreground);
|
||||
}
|
||||
.col-status[data-state="set"] {
|
||||
color: var(--color-mint);
|
||||
border-color: color-mix(in srgb, var(--color-mint) 35%, var(--color-border));
|
||||
background: color-mix(in srgb, var(--color-mint) 8%, var(--color-glass-strong));
|
||||
}
|
||||
.col-status[data-state="set"] .dot {
|
||||
background: var(--color-mint);
|
||||
box-shadow: 0 0 8px color-mix(in srgb, var(--color-mint) 60%, transparent);
|
||||
}
|
||||
|
||||
/* --- Secret field --- */
|
||||
.secret-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.15rem 0.35rem 0.15rem 0.7rem;
|
||||
border: 1px solid var(--color-rule-strong);
|
||||
border-radius: 0.625rem;
|
||||
background: var(--color-input-bg);
|
||||
transition: border-color 0.18s, box-shadow 0.18s;
|
||||
}
|
||||
.secret-field:focus-within {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px var(--color-glow);
|
||||
}
|
||||
.secret-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
padding: 0.5rem 0.4rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-foreground);
|
||||
min-width: 0;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.secret-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px; height: 28px;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--color-muted-foreground);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.secret-toggle:hover {
|
||||
background: var(--color-glass-strong);
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
/* --- Cache config grid --- */
|
||||
.cache-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
.num-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
padding: 0.7rem 0.85rem 0.65rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-glass-strong);
|
||||
}
|
||||
.num-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--color-muted-foreground);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.num-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.num-input {
|
||||
width: 100%;
|
||||
padding: 0.1rem 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1.4rem;
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-foreground);
|
||||
letter-spacing: -0.015em;
|
||||
line-height: 1.1;
|
||||
outline: none;
|
||||
appearance: textfield;
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
.num-input::-webkit-outer-spin-button,
|
||||
.num-input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
.num-suffix {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.65rem;
|
||||
color: var(--color-muted-foreground);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
.num-meta {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-mint);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.num-meta-off {
|
||||
color: var(--color-citrus);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.cache-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user