-
-
+
+
+
+
+
+ ESC
- {/if}
-
-
- {#if filtered.length === 0}
-
{t('timezone.noMatches')}
- {:else}
- {#each groups as g (g.region)}
-
-
- {g.region}
- {g.items.length}
-
- {#each g.items as tz (tz)}
- {@const parts = splitTz(tz)}
- {@const idx = flat.indexOf(tz)}
- {@const hl = idx === highlightIdx}
- {@const sel = tz === value}
-
- {/each}
-
- {/each}
+
+ {#if !query}
+
+
+
+
{/if}
+
+
+
+ {#if filtered.length === 0}
+
{t('timezone.noMatches')}
+ {:else}
+ {#each groups as g (g.region)}
+
+
+ {g.region}
+ {g.items.length}
+
+ {#each g.items as tz (tz)}
+ {@const parts = splitTz(tz)}
+ {@const idx = flat.indexOf(tz)}
+ {@const hl = idx === highlightIdx}
+ {@const sel = tz === value}
+
+ {/each}
+
+ {/each}
+ {/if}
+
{/if}
@@ -408,35 +407,66 @@
align-items: center;
}
- /* ---- Panel -------------------------------------------------------- */
- .tz-panel {
+ /* ---- Portal + overlay (escapes Card's overflow:hidden / backdrop-filter) ---- */
+ .tz-portal-root {
+ position: fixed;
+ inset: 0;
+ z-index: 9998;
+ pointer-events: none;
+ }
+ .tz-overlay {
position: absolute;
- top: calc(100% + 0.375rem);
- left: 0;
- right: 0;
- z-index: 20;
- background: var(--color-card, var(--color-background));
- border: 1px solid var(--color-border);
- border-radius: 0.625rem;
- box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35);
+ inset: 0;
+ pointer-events: auto;
+ background: rgba(0, 0, 0, 0.55);
+ backdrop-filter: blur(8px) saturate(120%);
+ -webkit-backdrop-filter: blur(8px) saturate(120%);
+ }
+
+ /* ---- 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;
display: flex;
flex-direction: column;
- max-height: 26rem;
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 {
- from { opacity: 0; transform: translateY(-3px); }
- to { opacity: 1; transform: translateY(0); }
+ from { opacity: 0; transform: translate(-50%, -3px); }
+ to { opacity: 1; transform: translate(-50%, 0); }
}
.tz-search-row {
display: flex;
align-items: center;
gap: 0.5rem;
- padding: 0.5rem 0.75rem;
+ padding: 0.85rem 1rem;
border-bottom: 1px solid var(--color-border);
color: var(--color-muted-foreground);
+ position: relative;
+ z-index: 1;
}
.tz-search {
flex: 1;
@@ -464,6 +494,8 @@
padding: 0.5rem 0.625rem;
border-bottom: 1px solid var(--color-border);
flex-wrap: wrap;
+ position: relative;
+ z-index: 1;
}
.tz-quick-btn {
display: inline-flex;
@@ -498,8 +530,13 @@
.tz-list {
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;
+ position: relative;
+ z-index: 1;
}
.tz-empty {
padding: 1rem;
@@ -523,7 +560,7 @@
color: var(--color-muted-foreground);
position: sticky;
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);
z-index: 1;
}
diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json
index 0b063b4..fb29bf0 100644
--- a/frontend/src/lib/i18n/en.json
+++ b/frontend/src/lib/i18n/en.json
@@ -627,6 +627,7 @@
"countLabel": "templates",
"title": "Template Configs",
"description": "Define how notification messages are formatted",
+ "language": "Language",
"providerType": "Service Provider Type",
"newConfig": "New Config",
"name": "Name",
@@ -940,6 +941,7 @@
"empty": "No languages selected. Add one below to start authoring templates.",
"add": "Add language",
"searchPlaceholder": "Search or type a code (e.g. de-CH)…",
+ "customPlaceholder": "or de-CH",
"addCustom": "Add custom code",
"noSuggestions": "No matches. Type a valid locale code (2–3 letters).",
"primary": "Primary",
diff --git a/frontend/src/lib/i18n/ru.json b/frontend/src/lib/i18n/ru.json
index 76897ad..7ae7f49 100644
--- a/frontend/src/lib/i18n/ru.json
+++ b/frontend/src/lib/i18n/ru.json
@@ -627,6 +627,7 @@
"countLabel": "шаблонов",
"title": "Конфигурации шаблонов",
"description": "Определите формат уведомлений",
+ "language": "Язык",
"providerType": "Тип сервис-провайдера",
"newConfig": "Новая конфигурация",
"name": "Название",
@@ -940,6 +941,7 @@
"empty": "Языки не выбраны. Добавьте язык ниже, чтобы начать редактирование шаблонов.",
"add": "Добавить язык",
"searchPlaceholder": "Найти или ввести код (например de-CH)…",
+ "customPlaceholder": "или de-CH",
"addCustom": "Добавить свой код",
"noSuggestions": "Ничего не найдено. Введите код локали (2–3 буквы).",
"primary": "Основной",
diff --git a/frontend/src/lib/locales.ts b/frontend/src/lib/locales.ts
new file mode 100644
index 0000000..79c28b2
--- /dev/null
+++ b/frontend/src/lib/locales.ts
@@ -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(),
+ };
+}
diff --git a/frontend/src/routes/command-template-configs/+page.svelte b/frontend/src/routes/command-template-configs/+page.svelte
index 60eb342..cab6529 100644
--- a/frontend/src/routes/command-template-configs/+page.svelte
+++ b/frontend/src/routes/command-template-configs/+page.svelte
@@ -20,6 +20,8 @@
import Modal from '$lib/components/Modal.svelte';
import JinjaEditor from '$lib/components/JinjaEditor.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 { highlightFromUrl } from '$lib/highlight';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
@@ -41,6 +43,7 @@
}
let LOCALES = $derived(supportedLocalesCache.items);
+ let primaryLocale = $derived(LOCALES[0] || 'en');
let allCmdTplConfigs = $state
([]);
let filterText = $state('');
@@ -73,7 +76,18 @@
});
let varsRef = $state>({});
let showVarsFor = $state(null);
- let activeLocale = $state('en');
+ let activeLocale = $state('');
+ const localeItems = $derived(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>(new Set());
let slotFilter = $state('');
let showPreviewFor = $state>(new Set());
@@ -215,7 +229,7 @@
if (typesWithCmdSlots.length > 0) form.provider_type = typesWithCmdSlots[0];
editing = null;
showForm = true;
- activeLocale = 'en';
+ activeLocale = primaryLocale;
slotPreview = {};
slotErrors = {};
expandedSlots = new Set();
@@ -238,7 +252,7 @@
};
editing = c.id;
showForm = true;
- activeLocale = 'en';
+ activeLocale = primaryLocale;
slotPreview = {};
slotErrors = {};
expandedSlots = new Set();
@@ -332,7 +346,7 @@
};
editing = null;
showForm = true;
- activeLocale = 'en';
+ activeLocale = primaryLocale;
slotPreview = {};
slotErrors = {};
expandedSlots = new Set();
@@ -414,15 +428,19 @@
{t('cmdTemplateConfig.commandResponsesHint')}
-
-
- {#each LOCALES as loc}
-
- {/each}
+
+
+
+ {t('templateConfig.language')}
+
+
+ { activeLocale = (v as string) || primaryLocale; refreshAllPreviews(); }}
+ />
+
{#if form.provider_type}
-
-
- {#each LOCALES as loc}
-
- {/each}
+
+
+
+ {t('templateConfig.language')}
+
+
+ { activeLocale = (v as string) || primaryLocale; refreshAllPreviews(); }}
+ />
+
{#if form.provider_type}