Compare commits

...

4 Commits

Author SHA1 Message Date
alexei.dolgolyov c43dc598a1 chore: release v0.6.2
Release / release (push) Successful in 1m39s
2026-04-27 14:29:44 +03:00
alexei.dolgolyov 1bfec521d8 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.
2026-04-27 14:18:58 +03:00
alexei.dolgolyov b320090a56 chore: release v0.6.1
Release / release (push) Successful in 3m17s
2026-04-25 15:25:23 +03:00
alexei.dolgolyov cc8d961c33 fix(redesign): make Active Wires pipe visually prominent
Wire column was content-width (min 100px) so the line vanished between
two wide endpoint blocks. Bumped to minmax(220px, 1.6fr) so the pipe
takes ~60% more space than either side, thickened the line 2→3px,
faded both ends via color-mix transparency stops, added a soft
primary-glow halo plus a 1px specular sheen, and beefed up the count
badge with a rule-strong border / inset highlight / drop shadow so it
reads as a node on the wire. Stacks to a single column below 880px.
2026-04-25 15:23:30 +03:00
13 changed files with 420 additions and 472 deletions
+8 -45
View File
@@ -1,55 +1,18 @@
# v0.6.0 (2026-04-25) # v0.6.2 (2026-04-27)
This release ships the **Aurora redesign** of the frontend — a glass-and-tokens visual language applied across the dashboard, sidebar, page headers, and overlays — together with **per-chat command localization** for the Telegram bot. Polishing pass on locale and timezone pickers in the redesigned UI: editors and selectors now use the same `EntitySelect` palette pattern, and the timezone dropdown is portalled to escape Card clipping.
## User-facing changes ## User-facing changes
### Features
#### Frontend — Aurora redesign
- Aurora foundation: design tokens, glass sidebar, redesigned dashboard ([d9ef3c6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/d9ef3c6))
- Project mockup richness onto the live dashboard ([d3210fd](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/d3210fd))
- Subpage hero header, IconPicker portal, tighter gaps ([9733e5c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/9733e5c))
- Roll subpage hero across all pages, plus Aurora Button, JinjaEditor, and pulse fix ([d662b50](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/d662b50))
- Stack PageHeader meter top-right, action button bottom-right ([9643fe5](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/9643fe5))
- Collapsible dashboard sections + glass mobile-more sheet ([9eb76c1](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/9eb76c1))
#### Telegram
- Per-chat command localization with a unified locale resolver ([ef942b7](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ef942b7))
### Bug Fixes ### Bug Fixes
- Portal IconGridSelect popup + snap navbar to mockup ([0105d9f](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0105d9f)) - Template editors (notification & command) now use `EntitySelect` for locale switching and default to the configured **primary locale** when opening, editing, or cloning a config (previously always defaulted to `en`) ([1bfec52](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1bfec52))
- Brand snap, event sentences, palette glass, full-width layouts ([1895c5e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1895c5e)) - `LocaleSelector` add-flow now uses `EntitySelect` for catalog pick; custom BCP-47 codes (e.g. `de-CH`) keep a small dedicated input ([1bfec52](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1bfec52))
- Align topbar horizontal padding with page content ([46a4a6e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/46a4a6e)) - `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`) ([1bfec52](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1bfec52))
- Portal overlays + solid popup surfaces for legibility ([d356e5a](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/d356e5a)) - Removed top padding on the timezone scroll list so sticky region group headers no longer leak rows above them ([1bfec52](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1bfec52))
- A11y, mobile, and perf polish for production push ([711f218](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/711f218))
## Development / Internal ## Development / Internal
### Chores ### Refactoring
- Add Aurora redesign mockups + chooser under `design-mockups/` ([1e35724](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1e35724)) - Extracted shared locale catalog to `frontend/src/lib/locales.ts` for reuse across selectors ([1bfec52](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1bfec52))
<details>
<summary>All Commits</summary>
| Hash | Message | Author |
| ---- | ------- | ------ |
| [ef942b7](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ef942b7) | feat(telegram): per-chat command localization + unified locale resolver | alexei.dolgolyov |
| [711f218](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/711f218) | fix(redesign): a11y, mobile, perf polish for production push | alexei.dolgolyov |
| [9eb76c1](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/9eb76c1) | feat(redesign): collapsible dashboard sections + glass mobile-more sheet | alexei.dolgolyov |
| [d356e5a](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/d356e5a) | fix(redesign): portal overlays + solid popup surfaces for legibility | alexei.dolgolyov |
| [9643fe5](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/9643fe5) | feat(redesign): stack PageHeader meter top-right, button bottom-right | alexei.dolgolyov |
| [d662b50](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/d662b50) | feat(redesign): roll subpage hero across all pages + Aurora Button + JinjaEditor + pulse fix | alexei.dolgolyov |
| [9733e5c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/9733e5c) | feat(redesign): subpage hero header + iconpicker portal + tighter gaps | alexei.dolgolyov |
| [46a4a6e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/46a4a6e) | fix(redesign): align topbar horizontal padding with page content | alexei.dolgolyov |
| [1895c5e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1895c5e) | fix(redesign): brand snap, event sentences, palette glass, full width | alexei.dolgolyov |
| [0105d9f](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0105d9f) | fix(redesign): portal IconGridSelect popup + snap navbar to mockup | alexei.dolgolyov |
| [d3210fd](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/d3210fd) | feat(redesign): project mockup richness onto live dashboard | alexei.dolgolyov |
| [d9ef3c6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/d9ef3c6) | feat(redesign): aurora foundation — tokens, glass sidebar, dashboard | alexei.dolgolyov |
| [1e35724](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1e35724) | chore(design): add aurora redesign mockups + chooser | alexei.dolgolyov |
</details>
+6 -6
View File
@@ -1,12 +1,12 @@
{ {
"name": "notify-bridge-frontend", "name": "notify-bridge-frontend",
"version": "0.6.0", "version": "0.6.1",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "notify-bridge-frontend", "name": "notify-bridge-frontend",
"version": "0.6.0", "version": "0.6.1",
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "^6.18.0", "@codemirror/autocomplete": "^6.18.0",
"@codemirror/lang-html": "^6.4.11", "@codemirror/lang-html": "^6.4.11",
@@ -1464,7 +1464,7 @@
} }
}, },
"node_modules/@types/cookie": { "node_modules/@types/cookie": {
"version": "0.6.0", "version": "0.6.1",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
"dev": true "dev": true
@@ -1587,7 +1587,7 @@
} }
}, },
"node_modules/cookie": { "node_modules/cookie": {
"version": "0.6.0", "version": "0.6.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
"dev": true, "dev": true,
@@ -3417,7 +3417,7 @@
} }
}, },
"@types/cookie": { "@types/cookie": {
"version": "0.6.0", "version": "0.6.1",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
"dev": true "dev": true
@@ -3502,7 +3502,7 @@
} }
}, },
"cookie": { "cookie": {
"version": "0.6.0", "version": "0.6.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
"dev": true "dev": true
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "notify-bridge-frontend", "name": "notify-bridge-frontend",
"private": true, "private": true,
"version": "0.6.0", "version": "0.6.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
+93 -269
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,78 +67,47 @@
// --- 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 -------------------------------------------------------
@@ -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} /> />
</div>
<div class="ls-add-custom">
<input <input
bind:this={addInputEl} type="text"
bind:value={addQuery} bind:value={customCode}
onkeydown={onAddKeydown} onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addCustom(); } }}
onblur={() => setTimeout(() => { if (addOpen && !addQuery) closeAdd(); }, 150)} placeholder={t('locales.customPlaceholder')}
placeholder={t('locales.searchPlaceholder')} class="ls-add-custom-input"
class="ls-add-input"
autocomplete="off" autocomplete="off"
spellcheck="false" 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 <button
type="button" type="button"
role="option" class="ls-add-custom-btn"
aria-selected={i === highlightIdx} disabled={!customCodeValid}
class="ls-sugg" onclick={addCustom}
class:ls-sugg-hl={i === highlightIdx} title={t('locales.addCustom')}
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> <MdiIcon name="mdiPlus" size={14} />
<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> </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> </div>
{/if}
</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,6 +212,9 @@
</button> </button>
{#if open} {#if open}
<div use:portal class="tz-portal-root">
<div class="tz-overlay" onclick={closePicker} role="presentation"></div>
<div class="tz-panel" bind:this={panelEl} role="listbox"> <div class="tz-panel" bind:this={panelEl} role="listbox">
<!-- Search --> <!-- Search -->
<div class="tz-search-row"> <div class="tz-search-row">
@@ -296,6 +294,7 @@
{/if} {/if}
</div> </div>
</div> </div>
</div>
{/if} {/if}
</div> </div>
@@ -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(),
};
}
+36 -12
View File
@@ -1315,10 +1315,10 @@
} }
.wire { .wire {
display: grid; display: grid;
grid-template-columns: 1fr auto 1fr; grid-template-columns: minmax(0, 1fr) minmax(220px, 1.6fr) minmax(0, 1fr);
gap: 0.85rem; gap: 1rem;
align-items: center; align-items: center;
padding: 0.7rem 0; padding: 0.85rem 0;
} }
.wire + .wire { border-top: 1px dashed var(--color-border); } .wire + .wire { border-top: 1px dashed var(--color-border); }
.wire-from, .wire-to { .wire-from, .wire-to {
@@ -1333,26 +1333,50 @@
.wire-sub { font-size: 0.65rem; color: var(--color-muted-foreground); margin-top: 0.15rem; } .wire-sub { font-size: 0.65rem; color: var(--color-muted-foreground); margin-top: 0.15rem; }
.wire-pipe { .wire-pipe {
position: relative; position: relative;
min-width: 100px;
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
height: 22px; height: 26px;
} }
.wire-pipe::before { .wire-pipe::before {
content: ''; content: '';
position: absolute; inset: 50% 0 auto 0; height: 2px; position: absolute; left: 0; right: 0; top: 50%;
background: linear-gradient(90deg, var(--color-primary), var(--color-orchid), var(--color-mint)); height: 3px; transform: translateY(-50%);
opacity: 0.5; background: linear-gradient(90deg,
border-radius: 2px; color-mix(in srgb, var(--color-primary) 35%, transparent),
var(--color-primary),
var(--color-orchid),
var(--color-mint),
color-mix(in srgb, var(--color-mint) 35%, transparent));
opacity: 0.85;
border-radius: 3px;
box-shadow: 0 0 12px -2px var(--color-glow-strong);
}
.wire-pipe::after {
content: '';
position: absolute; left: 0; right: 0; top: 50%;
height: 1px; transform: translateY(-50%);
background: linear-gradient(90deg, transparent 8%, rgba(255,255,255,0.35) 50%, transparent 92%);
pointer-events: none;
} }
.wire-count { .wire-count {
position: relative; z-index: 1; position: relative; z-index: 1;
background: var(--color-glass-elev); background: var(--color-glass-elev);
border: 1px solid var(--color-border); border: 1px solid var(--color-rule-strong);
padding: 0.15rem 0.6rem; padding: 0.2rem 0.7rem;
border-radius: 999px; border-radius: 999px;
font-size: 0.7rem; font-size: 0.72rem;
font-weight: 500;
color: var(--color-foreground); color: var(--color-foreground);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
box-shadow: 0 2px 10px -4px var(--color-glow), inset 0 1px 0 var(--color-highlight);
}
@media (max-width: 880px) {
.wire {
grid-template-columns: 1fr;
gap: 0.4rem;
}
.wire-to { justify-content: flex-start; text-align: left; }
.wire-pipe { height: 18px; }
} }
/* ============================================================ /* ============================================================
@@ -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')}
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "notify-bridge-core" name = "notify-bridge-core"
version = "0.6.0" version = "0.6.2"
description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates" description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "notify-bridge-server" name = "notify-bridge-server"
version = "0.6.0" version = "0.6.2"
description = "Standalone Notify Bridge server — FastAPI REST API with SQLite database" description = "Standalone Notify Bridge server — FastAPI REST API with SQLite database"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [