Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c43dc598a1 | |||
| 1bfec521d8 | |||
| b320090a56 | |||
| cc8d961c33 |
+8
-45
@@ -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>
|
|
||||||
|
|||||||
Generated
+6
-6
@@ -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,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",
|
||||||
|
|||||||
@@ -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: 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 -------------------------------------------------------
|
||||||
|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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')}
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
Reference in New Issue
Block a user