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:
@@ -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: 2–3 letter primary, optional '-' subtag(s) 2-8 chars.
|
// Valid BCP 47-ish: 2–3 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (2–3 letters).",
|
"noSuggestions": "No matches. Type a valid locale code (2–3 letters).",
|
||||||
"primary": "Primary",
|
"primary": "Primary",
|
||||||
|
|||||||
@@ -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": "Основной",
|
||||||
|
|||||||
@@ -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')}
|
||||||
|
|||||||
Reference in New Issue
Block a user