fix(redesign): EntitySelect for language pickers + portal Timezone picker

- Template editors (notification & command) now use EntitySelect for
  locale switching and default to the configured primary locale
  instead of always 'en' when opening, editing, or cloning a config.
- LocaleSelector's add-flow uses EntitySelect for catalog pick;
  custom BCP-47 codes (e.g. de-CH) keep a small dedicated input.
- TimezoneSelector dropdown was being clipped by Card's overflow:hidden
  and backdrop-filter; portalled to <body> with an overlay backdrop and
  styled as a centered modal palette (same pattern as EntitySelect).
- Removed top padding on the timezone scroll list so sticky region
  group headers no longer leak rows above them.
- Extracted shared locale catalog to lib/locales.ts.
This commit is contained in:
2026-04-27 14:18:58 +03:00
parent b320090a56
commit 1bfec521d8
7 changed files with 367 additions and 406 deletions
+103 -279
View File
@@ -1,48 +1,10 @@
<script lang="ts"> <script lang="ts">
import MdiIcon from './MdiIcon.svelte'; import MdiIcon from './MdiIcon.svelte';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { LOCALE_CATALOG, getLocaleMeta, type LocaleMeta } from '$lib/locales';
import EntitySelect, { type EntityItem } from './EntitySelect.svelte';
interface LocaleMeta { const CATALOG: LocaleMeta[] = LOCALE_CATALOG;
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. // Locales that ship with default notification & command templates.
const SHIPPED = new Set(['en', 'ru']); const SHIPPED = new Set(['en', 'ru']);
@@ -76,11 +38,7 @@
} }
function meta(code: string): LocaleMeta { function meta(code: string): LocaleMeta {
return CATALOG.find(l => l.code === code) ?? { return getLocaleMeta(code);
code,
name: code.toUpperCase(),
native: code.toUpperCase(),
};
} }
function remove(code: string) { function remove(code: string) {
@@ -109,79 +67,48 @@
// --- Add flow ---------------------------------------------------------- // --- 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. // 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 CUSTOM_RE = /^[a-z]{2,3}(-[a-z0-9]{2,8})*$/i;
const selectedSet = $derived(new Set(codes)); const selectedSet = $derived(new Set(codes));
const suggestions = $derived.by(() => { /**
const q = addQuery.trim().toLowerCase(); * Catalog languages not yet selected, surfaced through EntitySelect.
const available = CATALOG.filter(l => !selectedSet.has(l.code)); * Native name is the label so the user sees their own script; the
if (!q) return available; * English name + code lives in the description for searchability.
return available.filter(l => */
l.code.includes(q) const addItems = $derived<EntityItem[]>(
|| l.name.toLowerCase().includes(q) CATALOG
|| l.native.toLowerCase().includes(q), .filter(l => !selectedSet.has(l.code))
); .map(l => ({
}); value: l.code,
label: l.native,
desc: `${l.name} · ${l.code.toUpperCase()}`,
})),
);
const canAddCustom = $derived.by(() => { let customCode = $state('');
const q = addQuery.trim().toLowerCase(); const customCodeValid = $derived.by(() => {
if (!q) return false; const c = customCode.trim().toLowerCase();
if (!CUSTOM_RE.test(q)) return false; if (!c || !CUSTOM_RE.test(c)) return false;
if (selectedSet.has(q)) return false; if (selectedSet.has(c)) return false;
// Skip "custom" entry when it matches an existing catalog entry exactly. if (CATALOG.some(l => l.code === c)) return false;
if (CATALOG.some(l => l.code === q)) return false;
return true; return true;
}); });
function openAdd() { function addCode(code: string | number | null) {
addOpen = true; if (code === null) return;
addQuery = ''; const c = String(code).trim().toLowerCase();
highlightIdx = 0;
requestAnimationFrame(() => addInputEl?.focus());
}
function closeAdd() {
addOpen = false;
addQuery = '';
}
function addCode(code: string) {
const c = code.trim().toLowerCase();
if (!c) return; if (!c) return;
commit([...codes, c]); commit([...codes, c]);
addQuery = '';
highlightIdx = 0;
requestAnimationFrame(() => addInputEl?.focus());
} }
function onAddKeydown(e: KeyboardEvent) { function addCustom() {
if (e.key === 'Escape') { closeAdd(); return; } if (!customCodeValid) return;
const total = suggestions.length + (canAddCustom ? 1 : 0); addCode(customCode);
if (e.key === 'ArrowDown') { customCode = '';
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 ------------------------------------------------------- // --- Drag & drop -------------------------------------------------------
let dragCode = $state<string | null>(null); let dragCode = $state<string | null>(null);
@@ -329,77 +256,39 @@
</ul> </ul>
{/if} {/if}
<!-- Add zone --> <!-- Add zone — EntitySelect for catalog languages, separate input for custom BCP-47 codes -->
<div class="ls-add" class:ls-add-open={addOpen}> <div class="ls-add">
{#if !addOpen} <div class="ls-add-row">
<button type="button" class="ls-add-trigger" onclick={openAdd}> <div class="ls-add-picker">
<MdiIcon name="mdiPlus" size={14} /> <EntitySelect
<span>{t('locales.add')}</span> items={addItems}
</button> value={null}
{:else} placeholder={t('locales.add')}
<div class="ls-add-panel"> size="sm"
<div class="ls-add-input-row"> onselect={addCode}
<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> </div>
{/if} <div class="ls-add-custom">
<input
type="text"
bind:value={customCode}
onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addCustom(); } }}
placeholder={t('locales.customPlaceholder')}
class="ls-add-custom-input"
autocomplete="off"
spellcheck="false"
/>
<button
type="button"
class="ls-add-custom-btn"
disabled={!customCodeValid}
onclick={addCustom}
title={t('locales.addCustom')}
>
<MdiIcon name="mdiPlus" size={14} />
</button>
</div>
</div>
</div> </div>
<p class="ls-hint"> <p class="ls-hint">
@@ -630,125 +519,60 @@
.ls-add { .ls-add {
margin-top: 0.125rem; margin-top: 0.125rem;
} }
.ls-add-trigger { .ls-add-row {
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; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
padding: 0.375rem 0.625rem; flex-wrap: wrap;
border-bottom: 1px solid var(--color-border);
color: var(--color-muted-foreground);
} }
.ls-add-input { .ls-add-picker {
flex: 1; flex: 1;
min-width: 12rem;
}
.ls-add-custom {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.15rem 0.15rem 0.15rem 0.55rem;
border: 1px dashed var(--color-border);
border-radius: 0.5rem;
background: transparent;
}
.ls-add-custom-input {
width: 6rem;
border: none; border: none;
outline: none; outline: none;
background: transparent; 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-family: var(--font-mono);
font-size: 0.7rem; font-size: 0.75rem;
padding: 0.05rem 0.375rem; color: var(--color-foreground);
border-radius: 0.25rem; padding: 0.25rem 0;
background: var(--color-muted); }
.ls-add-custom-input::placeholder {
color: var(--color-muted-foreground); color: var(--color-muted-foreground);
opacity: 0.7;
} }
.ls-sugg.ls-sugg-hl .ls-sugg-code { .ls-add-custom-btn {
background: color-mix(in srgb, var(--color-primary) 15%, var(--color-muted));
}
.ls-sugg-shipped {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
color: var(--color-primary); justify-content: center;
opacity: 0.85; width: 1.5rem;
} height: 1.5rem;
padding: 0;
.ls-sugg-custom { border: none;
border-top: 1px dashed var(--color-border); background: transparent;
color: var(--color-primary); border-radius: 0.25rem;
}
.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); color: var(--color-muted-foreground);
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.ls-add-custom-btn:hover:not(:disabled) {
background: var(--color-muted);
color: var(--color-primary);
}
.ls-add-custom-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
} }
/* ---- Hint --------------------------------------------------------- */ /* ---- Hint --------------------------------------------------------- */
@@ -2,6 +2,7 @@
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import MdiIcon from './MdiIcon.svelte'; import MdiIcon from './MdiIcon.svelte';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { portal } from '$lib/portal';
let { let {
value = $bindable<string>('UTC'), value = $bindable<string>('UTC'),
@@ -172,18 +173,12 @@
$effect(() => { query; highlightIdx = 0; }); $effect(() => { query; highlightIdx = 0; });
// Close on outside click /**
function onDocClick(e: MouseEvent) { * The panel is portalled to <body> to escape Card's overflow:hidden +
if (!open) return; * backdrop-filter (which would otherwise clip and stacking-trap the
const target = e.target as Node; * dropdown). Outside-click is detected via the dedicated overlay div
if (panelEl && !panelEl.contains(target)) closePicker(); * rather than a document listener, so we don't need a global handler.
} */
onMount(() => {
document.addEventListener('mousedown', onDocClick);
});
onDestroy(() => {
document.removeEventListener('mousedown', onDocClick);
});
</script> </script>
<div class="tz-root"> <div class="tz-root">
@@ -217,83 +212,87 @@
</button> </button>
{#if open} {#if open}
<div class="tz-panel" bind:this={panelEl} role="listbox"> <div use:portal class="tz-portal-root">
<!-- Search --> <div class="tz-overlay" onclick={closePicker} role="presentation"></div>
<div class="tz-search-row">
<MdiIcon name="mdiMagnify" size={14} />
<input
bind:this={inputEl}
bind:value={query}
onkeydown={onKeydown}
placeholder={t('timezone.searchPlaceholder')}
class="tz-search"
autocomplete="off"
spellcheck="false"
type="text"
/>
<kbd class="tz-kbd">ESC</kbd>
</div>
<!-- Quick picks --> <div class="tz-panel" bind:this={panelEl} role="listbox">
{#if !query} <!-- Search -->
<div class="tz-quick"> <div class="tz-search-row">
<button <MdiIcon name="mdiMagnify" size={14} />
type="button" <input
class="tz-quick-btn" bind:this={inputEl}
class:tz-quick-active={value === detectedTz} bind:value={query}
onclick={() => selectTz(detectedTz)} onkeydown={onKeydown}
> placeholder={t('timezone.searchPlaceholder')}
<MdiIcon name="mdiCrosshairsGps" size={12} /> class="tz-search"
<span class="tz-quick-label">{t('timezone.detect')}</span> autocomplete="off"
<span class="tz-quick-val">{detectedTz}</span> spellcheck="false"
</button> type="text"
<button />
type="button" <kbd class="tz-kbd">ESC</kbd>
class="tz-quick-btn"
class:tz-quick-active={value === 'UTC' || value === 'Etc/UTC'}
onclick={() => selectTz('UTC')}
>
<MdiIcon name="mdiEarth" size={12} />
<span class="tz-quick-label">{t('timezone.utc')}</span>
<span class="tz-quick-val">UTC+00</span>
</button>
</div> </div>
{/if}
<!-- Grouped list --> <!-- Quick picks -->
<div class="tz-list"> {#if !query}
{#if filtered.length === 0} <div class="tz-quick">
<div class="tz-empty">{t('timezone.noMatches')}</div> <button
{:else} type="button"
{#each groups as g (g.region)} class="tz-quick-btn"
<div class="tz-group"> class:tz-quick-active={value === detectedTz}
<div class="tz-group-head"> onclick={() => selectTz(detectedTz)}
<span class="tz-group-name">{g.region}</span> >
<span class="tz-group-count">{g.items.length}</span> <MdiIcon name="mdiCrosshairsGps" size={12} />
</div> <span class="tz-quick-label">{t('timezone.detect')}</span>
{#each g.items as tz (tz)} <span class="tz-quick-val">{detectedTz}</span>
{@const parts = splitTz(tz)} </button>
{@const idx = flat.indexOf(tz)} <button
{@const hl = idx === highlightIdx} type="button"
{@const sel = tz === value} class="tz-quick-btn"
<button class:tz-quick-active={value === 'UTC' || value === 'Etc/UTC'}
type="button" onclick={() => selectTz('UTC')}
role="option" >
aria-selected={sel} <MdiIcon name="mdiEarth" size={12} />
class="tz-opt" <span class="tz-quick-label">{t('timezone.utc')}</span>
class:tz-opt-hl={hl} <span class="tz-quick-val">UTC+00</span>
class:tz-opt-sel={sel} </button>
onmouseenter={() => (highlightIdx = idx)} </div>
onclick={() => selectTz(tz)}
>
<span class="tz-opt-city">{parts.city}</span>
<span class="tz-opt-iana">{tz}</span>
<span class="tz-opt-offset">{fmtOffset(tz)}</span>
</button>
{/each}
</div>
{/each}
{/if} {/if}
<!-- Grouped list -->
<div class="tz-list">
{#if filtered.length === 0}
<div class="tz-empty">{t('timezone.noMatches')}</div>
{:else}
{#each groups as g (g.region)}
<div class="tz-group">
<div class="tz-group-head">
<span class="tz-group-name">{g.region}</span>
<span class="tz-group-count">{g.items.length}</span>
</div>
{#each g.items as tz (tz)}
{@const parts = splitTz(tz)}
{@const idx = flat.indexOf(tz)}
{@const hl = idx === highlightIdx}
{@const sel = tz === value}
<button
type="button"
role="option"
aria-selected={sel}
class="tz-opt"
class:tz-opt-hl={hl}
class:tz-opt-sel={sel}
onmouseenter={() => (highlightIdx = idx)}
onclick={() => selectTz(tz)}
>
<span class="tz-opt-city">{parts.city}</span>
<span class="tz-opt-iana">{tz}</span>
<span class="tz-opt-offset">{fmtOffset(tz)}</span>
</button>
{/each}
</div>
{/each}
{/if}
</div>
</div> </div>
</div> </div>
{/if} {/if}
@@ -408,35 +407,66 @@
align-items: center; align-items: center;
} }
/* ---- Panel -------------------------------------------------------- */ /* ---- Portal + overlay (escapes Card's overflow:hidden / backdrop-filter) ---- */
.tz-panel { .tz-portal-root {
position: fixed;
inset: 0;
z-index: 9998;
pointer-events: none;
}
.tz-overlay {
position: absolute; position: absolute;
top: calc(100% + 0.375rem); inset: 0;
left: 0; pointer-events: auto;
right: 0; background: rgba(0, 0, 0, 0.55);
z-index: 20; backdrop-filter: blur(8px) saturate(120%);
background: var(--color-card, var(--color-background)); -webkit-backdrop-filter: blur(8px) saturate(120%);
border: 1px solid var(--color-border); }
border-radius: 0.625rem;
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35); /* ---- Panel (centered modal palette) -------------------------------- */
.tz-panel {
pointer-events: auto;
position: absolute;
top: min(20vh, 120px);
left: 50%;
transform: translateX(-50%);
z-index: 1;
width: min(540px, 92vw);
max-height: min(60vh, 30rem);
background: var(--tz-solid-bg);
border: 1px solid var(--color-rule-strong, var(--color-border));
border-radius: 16px;
box-shadow: var(--shadow-card, 0 18px 40px rgba(0, 0, 0, 0.35)),
0 24px 48px -16px rgba(0, 0, 0, 0.55);
overflow: hidden; overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
max-height: 26rem;
animation: tz-pop 0.15s ease-out; animation: tz-pop 0.15s ease-out;
--tz-solid-bg: #131520;
}
:global([data-theme="light"]) .tz-panel { --tz-solid-bg: #fafafe; }
.tz-panel::after {
content: '';
position: absolute; inset: 0;
border-radius: inherit;
pointer-events: none;
background: linear-gradient(180deg, var(--color-highlight, transparent), transparent 30%);
opacity: 0.4;
} }
@keyframes tz-pop { @keyframes tz-pop {
from { opacity: 0; transform: translateY(-3px); } from { opacity: 0; transform: translate(-50%, -3px); }
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: translate(-50%, 0); }
} }
.tz-search-row { .tz-search-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
padding: 0.5rem 0.75rem; padding: 0.85rem 1rem;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
color: var(--color-muted-foreground); color: var(--color-muted-foreground);
position: relative;
z-index: 1;
} }
.tz-search { .tz-search {
flex: 1; flex: 1;
@@ -464,6 +494,8 @@
padding: 0.5rem 0.625rem; padding: 0.5rem 0.625rem;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
flex-wrap: wrap; flex-wrap: wrap;
position: relative;
z-index: 1;
} }
.tz-quick-btn { .tz-quick-btn {
display: inline-flex; display: inline-flex;
@@ -498,8 +530,13 @@
.tz-list { .tz-list {
overflow-y: auto; overflow-y: auto;
padding: 0.25rem 0; /* No top padding — the sticky group head is at top:0 of the
scroll container, so any padding-top would let scrolling
items leak into the gap above the sticky header. */
padding: 0 0 0.25rem;
scrollbar-width: thin; scrollbar-width: thin;
position: relative;
z-index: 1;
} }
.tz-empty { .tz-empty {
padding: 1rem; padding: 1rem;
@@ -523,7 +560,7 @@
color: var(--color-muted-foreground); color: var(--color-muted-foreground);
position: sticky; position: sticky;
top: 0; top: 0;
background: var(--color-card, var(--color-background)); background: var(--tz-solid-bg);
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent); border-bottom: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent);
z-index: 1; z-index: 1;
} }
+2
View File
@@ -627,6 +627,7 @@
"countLabel": "templates", "countLabel": "templates",
"title": "Template Configs", "title": "Template Configs",
"description": "Define how notification messages are formatted", "description": "Define how notification messages are formatted",
"language": "Language",
"providerType": "Service Provider Type", "providerType": "Service Provider Type",
"newConfig": "New Config", "newConfig": "New Config",
"name": "Name", "name": "Name",
@@ -940,6 +941,7 @@
"empty": "No languages selected. Add one below to start authoring templates.", "empty": "No languages selected. Add one below to start authoring templates.",
"add": "Add language", "add": "Add language",
"searchPlaceholder": "Search or type a code (e.g. de-CH)…", "searchPlaceholder": "Search or type a code (e.g. de-CH)…",
"customPlaceholder": "or de-CH",
"addCustom": "Add custom code", "addCustom": "Add custom code",
"noSuggestions": "No matches. Type a valid locale code (23 letters).", "noSuggestions": "No matches. Type a valid locale code (23 letters).",
"primary": "Primary", "primary": "Primary",
+2
View File
@@ -627,6 +627,7 @@
"countLabel": "шаблонов", "countLabel": "шаблонов",
"title": "Конфигурации шаблонов", "title": "Конфигурации шаблонов",
"description": "Определите формат уведомлений", "description": "Определите формат уведомлений",
"language": "Язык",
"providerType": "Тип сервис-провайдера", "providerType": "Тип сервис-провайдера",
"newConfig": "Новая конфигурация", "newConfig": "Новая конфигурация",
"name": "Название", "name": "Название",
@@ -940,6 +941,7 @@
"empty": "Языки не выбраны. Добавьте язык ниже, чтобы начать редактирование шаблонов.", "empty": "Языки не выбраны. Добавьте язык ниже, чтобы начать редактирование шаблонов.",
"add": "Добавить язык", "add": "Добавить язык",
"searchPlaceholder": "Найти или ввести код (например de-CH)…", "searchPlaceholder": "Найти или ввести код (например de-CH)…",
"customPlaceholder": "или de-CH",
"addCustom": "Добавить свой код", "addCustom": "Добавить свой код",
"noSuggestions": "Ничего не найдено. Введите код локали (2–3 буквы).", "noSuggestions": "Ничего не найдено. Введите код локали (2–3 буквы).",
"primary": "Основной", "primary": "Основной",
+55
View File
@@ -0,0 +1,55 @@
/**
* Shared locale catalog used by LocaleSelector (settings) and the
* template editors (notification & command). Single source of truth so
* native names and metadata stay consistent across pickers.
*/
export interface LocaleMeta {
code: string;
name: string; // English name
native: string; // Native script
rtl?: boolean;
}
export const LOCALE_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' },
];
export function getLocaleMeta(code: string): LocaleMeta {
return LOCALE_CATALOG.find(l => l.code === code) ?? {
code,
name: code.toUpperCase(),
native: code.toUpperCase(),
};
}
@@ -20,6 +20,8 @@
import Modal from '$lib/components/Modal.svelte'; import Modal from '$lib/components/Modal.svelte';
import JinjaEditor from '$lib/components/JinjaEditor.svelte'; import JinjaEditor from '$lib/components/JinjaEditor.svelte';
import CollapsibleSlot from '$lib/components/CollapsibleSlot.svelte'; import CollapsibleSlot from '$lib/components/CollapsibleSlot.svelte';
import EntitySelect, { type EntityItem } from '$lib/components/EntitySelect.svelte';
import { getLocaleMeta } from '$lib/locales';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte'; import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight'; import { highlightFromUrl } from '$lib/highlight';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte'; import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
@@ -41,6 +43,7 @@
} }
let LOCALES = $derived(supportedLocalesCache.items); let LOCALES = $derived(supportedLocalesCache.items);
let primaryLocale = $derived(LOCALES[0] || 'en');
let allCmdTplConfigs = $state<CmdTemplateConfig[]>([]); let allCmdTplConfigs = $state<CmdTemplateConfig[]>([]);
let filterText = $state(''); let filterText = $state('');
@@ -73,7 +76,18 @@
}); });
let varsRef = $state<Record<string, any>>({}); let varsRef = $state<Record<string, any>>({});
let showVarsFor = $state<string | null>(null); let showVarsFor = $state<string | null>(null);
let activeLocale = $state<string>('en'); let activeLocale = $state<string>('');
const localeItems = $derived<EntityItem[]>(LOCALES.map((code, i) => {
const m = getLocaleMeta(code);
return {
value: code,
label: m.native,
desc: i === 0 ? `${code.toUpperCase()} · ${t('locales.primary')}` : code.toUpperCase(),
};
}));
$effect(() => {
if (!activeLocale && LOCALES.length > 0) activeLocale = primaryLocale;
});
let expandedSlots = $state<Set<string>>(new Set()); let expandedSlots = $state<Set<string>>(new Set());
let slotFilter = $state(''); let slotFilter = $state('');
let showPreviewFor = $state<Set<string>>(new Set()); let showPreviewFor = $state<Set<string>>(new Set());
@@ -215,7 +229,7 @@
if (typesWithCmdSlots.length > 0) form.provider_type = typesWithCmdSlots[0]; if (typesWithCmdSlots.length > 0) form.provider_type = typesWithCmdSlots[0];
editing = null; editing = null;
showForm = true; showForm = true;
activeLocale = 'en'; activeLocale = primaryLocale;
slotPreview = {}; slotPreview = {};
slotErrors = {}; slotErrors = {};
expandedSlots = new Set(); expandedSlots = new Set();
@@ -238,7 +252,7 @@
}; };
editing = c.id; editing = c.id;
showForm = true; showForm = true;
activeLocale = 'en'; activeLocale = primaryLocale;
slotPreview = {}; slotPreview = {};
slotErrors = {}; slotErrors = {};
expandedSlots = new Set(); expandedSlots = new Set();
@@ -332,7 +346,7 @@
}; };
editing = null; editing = null;
showForm = true; showForm = true;
activeLocale = 'en'; activeLocale = primaryLocale;
slotPreview = {}; slotPreview = {};
slotErrors = {}; slotErrors = {};
expandedSlots = new Set(); expandedSlots = new Set();
@@ -414,15 +428,19 @@
<legend class="text-sm font-medium px-1">{t('cmdTemplateConfig.commandResponses')}</legend> <legend class="text-sm font-medium px-1">{t('cmdTemplateConfig.commandResponses')}</legend>
<p class="text-xs text-[var(--color-muted-foreground)] mb-2">{t('cmdTemplateConfig.commandResponsesHint')}</p> <p class="text-xs text-[var(--color-muted-foreground)] mb-2">{t('cmdTemplateConfig.commandResponsesHint')}</p>
<!-- Locale tabs --> <!-- Language picker -->
<div class="flex items-center gap-1 mb-3 border-b border-[var(--color-border)]"> <div class="flex items-center gap-2 mb-3">
{#each LOCALES as loc} <span class="text-xs font-medium text-[var(--color-muted-foreground)] shrink-0">
<button type="button" {t('templateConfig.language')}
class="px-3 py-1.5 text-xs font-medium rounded-t-md transition-colors {activeLocale === loc ? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]' : 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]'}" </span>
onclick={() => { activeLocale = loc; refreshAllPreviews(); }}> <div class="flex-1 max-w-xs">
{loc.toUpperCase()} <EntitySelect
</button> items={localeItems}
{/each} value={activeLocale}
size="sm"
onselect={(v) => { activeLocale = (v as string) || primaryLocale; refreshAllPreviews(); }}
/>
</div>
{#if form.provider_type} {#if form.provider_type}
<button type="button" onclick={resetAllToDefaults} <button type="button" onclick={resetAllToDefaults}
title={t('templateConfig.resetAllToDefaults')} title={t('templateConfig.resetAllToDefaults')}
@@ -21,6 +21,8 @@
import Modal from '$lib/components/Modal.svelte'; import Modal from '$lib/components/Modal.svelte';
import JinjaEditor from '$lib/components/JinjaEditor.svelte'; import JinjaEditor from '$lib/components/JinjaEditor.svelte';
import CollapsibleSlot from '$lib/components/CollapsibleSlot.svelte'; import CollapsibleSlot from '$lib/components/CollapsibleSlot.svelte';
import EntitySelect, { type EntityItem } from '$lib/components/EntitySelect.svelte';
import { getLocaleMeta } from '$lib/locales';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte'; import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight'; import { highlightFromUrl } from '$lib/highlight';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte'; import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
@@ -71,7 +73,24 @@
let showPreviewFor = $state<Set<string>>(new Set()); let showPreviewFor = $state<Set<string>>(new Set());
let LOCALES = $derived(supportedLocalesCache.items); let LOCALES = $derived(supportedLocalesCache.items);
let activeLocale = $state<string>('en'); let primaryLocale = $derived(LOCALES[0] || 'en');
let activeLocale = $state<string>('');
const localeItems = $derived<EntityItem[]>(LOCALES.map((code, i) => {
const m = getLocaleMeta(code);
return {
value: code,
label: m.native,
desc: i === 0 ? `${code.toUpperCase()} · ${t('locales.primary')}` : code.toUpperCase(),
};
}));
/**
* Promote primary to be the active locale once the supported-locales
* cache loads (covers initial mount before openNew/edit ran). Without
* this, opening a form before fetch resolves would stay on '' / 'en'.
*/
$effect(() => {
if (!activeLocale && LOCALES.length > 0) activeLocale = primaryLocale;
});
function toggleSlot(key: string) { function toggleSlot(key: string) {
const next = new Set(expandedSlots); const next = new Set(expandedSlots);
@@ -272,7 +291,7 @@
function openNew() { function openNew() {
form = defaultForm(); form = defaultForm();
if (providerTypes.length > 0) form.provider_type = providerTypes[0]; if (providerTypes.length > 0) form.provider_type = providerTypes[0];
editing = null; showForm = true; activeLocale = 'en'; slotPreview = {}; slotErrors = {}; dateFormatPreview = {}; expandedSlots = new Set(); showPreviewFor = new Set(); slotFilter = ''; editing = null; showForm = true; activeLocale = primaryLocale; slotPreview = {}; slotErrors = {}; dateFormatPreview = {}; expandedSlots = new Set(); showPreviewFor = new Set(); slotFilter = '';
refreshDateFormatPreview(); refreshDateFormatPreview();
} }
function edit(c: TemplateConfig) { function edit(c: TemplateConfig) {
@@ -285,7 +304,7 @@
date_format: c.date_format || '%d.%m.%Y, %H:%M UTC', date_format: c.date_format || '%d.%m.%Y, %H:%M UTC',
date_only_format: c.date_only_format || '%d.%m.%Y', date_only_format: c.date_only_format || '%d.%m.%Y',
}; };
editing = c.id; showForm = true; activeLocale = 'en'; editing = c.id; showForm = true; activeLocale = primaryLocale;
slotPreview = {}; slotErrors = {}; dateFormatPreview = {}; slotPreview = {}; slotErrors = {}; dateFormatPreview = {};
expandedSlots = new Set(); showPreviewFor = new Set(); slotFilter = ''; expandedSlots = new Set(); showPreviewFor = new Set(); slotFilter = '';
setTimeout(() => refreshAllPreviews(), 100); setTimeout(() => refreshAllPreviews(), 100);
@@ -372,7 +391,7 @@
}; };
editing = null; editing = null;
showForm = true; showForm = true;
activeLocale = 'en'; activeLocale = primaryLocale;
slotPreview = {}; slotPreview = {};
slotErrors = {}; slotErrors = {};
setTimeout(() => refreshAllPreviews(), 100); setTimeout(() => refreshAllPreviews(), 100);
@@ -447,15 +466,19 @@
<IconGridSelect items={previewTargetTypeItems()} bind:value={previewTargetType} columns={2} /> <IconGridSelect items={previewTargetTypeItems()} bind:value={previewTargetType} columns={2} />
</div> </div>
<!-- Locale tabs --> <!-- Language picker -->
<div class="flex items-center gap-1 mb-3 border-b border-[var(--color-border)]"> <div class="flex items-center gap-2 mb-3">
{#each LOCALES as loc} <span class="text-xs font-medium text-[var(--color-muted-foreground)] shrink-0">
<button type="button" {t('templateConfig.language')}
class="px-3 py-1.5 text-xs font-medium rounded-t-md transition-colors {activeLocale === loc ? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]' : 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]'}" </span>
onclick={() => { activeLocale = loc; refreshAllPreviews(); }}> <div class="flex-1 max-w-xs">
{loc.toUpperCase()} <EntitySelect
</button> items={localeItems}
{/each} value={activeLocale}
size="sm"
onselect={(v) => { activeLocale = (v as string) || primaryLocale; refreshAllPreviews(); }}
/>
</div>
{#if form.provider_type} {#if form.provider_type}
<button type="button" onclick={resetAllToDefaults} <button type="button" onclick={resetAllToDefaults}
title={t('templateConfig.resetAllToDefaults')} title={t('templateConfig.resetAllToDefaults')}