From 1bfec521d85209a931694de28b596cc9bba68fd5 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Mon, 27 Apr 2026 14:18:58 +0300 Subject: [PATCH] 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 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. --- .../src/lib/components/LocaleSelector.svelte | 382 +++++------------- .../lib/components/TimezoneSelector.svelte | 239 ++++++----- frontend/src/lib/i18n/en.json | 2 + frontend/src/lib/i18n/ru.json | 2 + frontend/src/lib/locales.ts | 55 +++ .../command-template-configs/+page.svelte | 44 +- .../src/routes/template-configs/+page.svelte | 49 ++- 7 files changed, 367 insertions(+), 406 deletions(-) create mode 100644 frontend/src/lib/locales.ts diff --git a/frontend/src/lib/components/LocaleSelector.svelte b/frontend/src/lib/components/LocaleSelector.svelte index ed39bbd..e165577 100644 --- a/frontend/src/lib/components/LocaleSelector.svelte +++ b/frontend/src/lib/components/LocaleSelector.svelte @@ -1,48 +1,10 @@
@@ -217,83 +212,87 @@ {#if open} -
- -
- - - ESC -
+
+ - - {#if !query} -
- - +
+ +
+ + + 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.commandResponses')}

{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}