Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 349e9136a4 | |||
| 04c8e3c8b2 | |||
| 9afd38e50e | |||
| aa9548d884 | |||
| 72dd611f8c | |||
| 0e675c4b38 | |||
| 4307955163 | |||
| b107b01a00 | |||
| 42af7a6551 | |||
| c43dc598a1 | |||
| 1bfec521d8 |
@@ -1,8 +1,8 @@
|
||||
# Entity Relationships
|
||||
|
||||
```
|
||||
```text
|
||||
ServiceProvider → type: "immich" (inferred capabilities: notifications, commands)
|
||||
NotificationTracker → provider_id, collection_ids, scan_interval, batch_duration, enabled
|
||||
NotificationTracker → provider_id, collection_ids, scan_interval, adaptive_max_skip, filters, default_tracking_config_id, default_template_config_id, enabled
|
||||
NotificationTrackerTarget → notification_tracker_id, target_id, tracking_config_id, template_config_id, quiet_hours, enabled
|
||||
TrackingConfig → provider_type, event flags, scheduling rules
|
||||
TemplateConfig → provider_type, Jinja2 template slots per event type
|
||||
|
||||
+26
-3
@@ -1,9 +1,32 @@
|
||||
# v0.6.1 (2026-04-25)
|
||||
# v0.6.5 (2026-04-28)
|
||||
|
||||
Small visual follow-up to the Aurora redesign: the **Active Wires pipe** on the dashboard now reads more prominently.
|
||||
UI polish across the redesign: command-template editing now groups slots into four labelled fieldsets that mirror the notification-template page, modal/popup scrolling no longer drags the page underneath, and Telegram's Discover Chats keeps the existing list visible with a smooth shimmer instead of blanking it to "Loading…".
|
||||
|
||||
## User-facing changes
|
||||
|
||||
### Features
|
||||
|
||||
- **Command template slots grouped into 4 fieldsets:** the command-template configs page now mirrors the notification-template layout, splitting slots by name prefix into Command Responses, Error Messages (`rate_limited` / `no_results`), Command Descriptions (`desc_*`), and Usage Examples (`usage_*`). The language picker, reset-all button, and slot filter are hoisted above the groups so they apply across all fieldsets, and empty groups are hidden so providers without `usage_*` slots don't render an empty header ([04c8e3c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/04c8e3c))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Make Active Wires pipe visually prominent ([cc8d961](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/cc8d961))
|
||||
- **Modal scroll chaining contained:** scrolling past the inner boundary of a modal or popup no longer scrolls the page underneath. `overscroll-behavior: contain` was added to every in-modal/popup scroll container — Modal body, `EntitySelect`, `MultiEntitySelect`, `IconPicker`, `IconGridSelect`, `SearchPalette`, and `TimezoneSelector` ([9afd38e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/9afd38e))
|
||||
- **Smoother Telegram Discover Chats refresh:** Discover Chats no longer collapses the existing chat list into a "Loading…" placeholder. The initial-load state (`chatsLoading`) is now split from the refresh state (`chatsRefreshing`); rows are keyed by `chat.id` with flip+fade animations, the list dims with a sweeping shimmer while the Discover button shows a spinning icon and a "Discovering chats…" label. Honors `prefers-reduced-motion` ([9afd38e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/9afd38e))
|
||||
|
||||
## Development / Internal
|
||||
|
||||
### i18n
|
||||
|
||||
- Drop orphan `cmdTemplateConfig.commandResponsesHint` key — `hints.commandResponses` replaces it ([04c8e3c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/04c8e3c))
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>All Commits</summary>
|
||||
|
||||
| Hash | Message | Author |
|
||||
|------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------|------------------|
|
||||
| [04c8e3c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/04c8e3c) | feat(frontend): group command template slots into 4 logical fieldsets | alexei.dolgolyov |
|
||||
| [9afd38e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/9afd38e) | fix(redesign): contain modal scroll chaining and smooth Telegram chat refresh | alexei.dolgolyov |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "notify-bridge-frontend",
|
||||
"private": true,
|
||||
"version": "0.6.1",
|
||||
"version": "0.6.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
// Ambient type declarations for SvelteKit + project-level build-time globals.
|
||||
|
||||
declare global {
|
||||
/** App version, injected from frontend/package.json at build time. */
|
||||
const __APP_VERSION__: string;
|
||||
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -5,6 +5,23 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>Notify Bridge</title>
|
||||
<script>
|
||||
// Resolve theme before first paint to avoid dark→light FOUC on hard reload.
|
||||
(function () {
|
||||
try {
|
||||
var saved = localStorage.getItem('theme');
|
||||
var resolved =
|
||||
saved === 'light' || saved === 'dark'
|
||||
? saved
|
||||
: window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light';
|
||||
document.documentElement.setAttribute('data-theme', resolved);
|
||||
} catch (_) {
|
||||
document.documentElement.setAttribute('data-theme', 'light');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
|
||||
@@ -304,6 +304,7 @@
|
||||
/* List */
|
||||
.ep-list {
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: thin;
|
||||
padding: 0.35rem;
|
||||
position: relative;
|
||||
|
||||
@@ -195,6 +195,7 @@
|
||||
padding: 0.5rem;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
:global([data-theme="light"]) .icon-grid-popup { --igs-solid-bg: #fafafe; }
|
||||
|
||||
@@ -188,6 +188,7 @@
|
||||
max-height: 14rem;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: thin;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
@@ -1,48 +1,10 @@
|
||||
<script lang="ts">
|
||||
import MdiIcon from './MdiIcon.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { LOCALE_CATALOG, getLocaleMeta, type LocaleMeta } from '$lib/locales';
|
||||
import EntitySelect, { type EntityItem } from './EntitySelect.svelte';
|
||||
|
||||
interface LocaleMeta {
|
||||
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' },
|
||||
];
|
||||
const CATALOG: LocaleMeta[] = LOCALE_CATALOG;
|
||||
|
||||
// Locales that ship with default notification & command templates.
|
||||
const SHIPPED = new Set(['en', 'ru']);
|
||||
@@ -76,11 +38,7 @@
|
||||
}
|
||||
|
||||
function meta(code: string): LocaleMeta {
|
||||
return CATALOG.find(l => l.code === code) ?? {
|
||||
code,
|
||||
name: code.toUpperCase(),
|
||||
native: code.toUpperCase(),
|
||||
};
|
||||
return getLocaleMeta(code);
|
||||
}
|
||||
|
||||
function remove(code: string) {
|
||||
@@ -109,79 +67,48 @@
|
||||
|
||||
// --- 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.
|
||||
const CUSTOM_RE = /^[a-z]{2,3}(-[a-z0-9]{2,8})*$/i;
|
||||
|
||||
const selectedSet = $derived(new Set(codes));
|
||||
|
||||
const suggestions = $derived.by(() => {
|
||||
const q = addQuery.trim().toLowerCase();
|
||||
const available = CATALOG.filter(l => !selectedSet.has(l.code));
|
||||
if (!q) return available;
|
||||
return available.filter(l =>
|
||||
l.code.includes(q)
|
||||
|| l.name.toLowerCase().includes(q)
|
||||
|| l.native.toLowerCase().includes(q),
|
||||
);
|
||||
});
|
||||
/**
|
||||
* Catalog languages not yet selected, surfaced through EntitySelect.
|
||||
* Native name is the label so the user sees their own script; the
|
||||
* English name + code lives in the description for searchability.
|
||||
*/
|
||||
const addItems = $derived<EntityItem[]>(
|
||||
CATALOG
|
||||
.filter(l => !selectedSet.has(l.code))
|
||||
.map(l => ({
|
||||
value: l.code,
|
||||
label: l.native,
|
||||
desc: `${l.name} · ${l.code.toUpperCase()}`,
|
||||
})),
|
||||
);
|
||||
|
||||
const canAddCustom = $derived.by(() => {
|
||||
const q = addQuery.trim().toLowerCase();
|
||||
if (!q) return false;
|
||||
if (!CUSTOM_RE.test(q)) return false;
|
||||
if (selectedSet.has(q)) return false;
|
||||
// Skip "custom" entry when it matches an existing catalog entry exactly.
|
||||
if (CATALOG.some(l => l.code === q)) return false;
|
||||
let customCode = $state('');
|
||||
const customCodeValid = $derived.by(() => {
|
||||
const c = customCode.trim().toLowerCase();
|
||||
if (!c || !CUSTOM_RE.test(c)) return false;
|
||||
if (selectedSet.has(c)) return false;
|
||||
if (CATALOG.some(l => l.code === c)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
function openAdd() {
|
||||
addOpen = true;
|
||||
addQuery = '';
|
||||
highlightIdx = 0;
|
||||
requestAnimationFrame(() => addInputEl?.focus());
|
||||
}
|
||||
|
||||
function closeAdd() {
|
||||
addOpen = false;
|
||||
addQuery = '';
|
||||
}
|
||||
|
||||
function addCode(code: string) {
|
||||
const c = code.trim().toLowerCase();
|
||||
function addCode(code: string | number | null) {
|
||||
if (code === null) return;
|
||||
const c = String(code).trim().toLowerCase();
|
||||
if (!c) return;
|
||||
commit([...codes, c]);
|
||||
addQuery = '';
|
||||
highlightIdx = 0;
|
||||
requestAnimationFrame(() => addInputEl?.focus());
|
||||
}
|
||||
|
||||
function onAddKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') { closeAdd(); return; }
|
||||
const total = suggestions.length + (canAddCustom ? 1 : 0);
|
||||
if (e.key === 'ArrowDown') {
|
||||
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);
|
||||
}
|
||||
}
|
||||
function addCustom() {
|
||||
if (!customCodeValid) return;
|
||||
addCode(customCode);
|
||||
customCode = '';
|
||||
}
|
||||
|
||||
$effect(() => { addQuery; highlightIdx = 0; });
|
||||
|
||||
// --- Drag & drop -------------------------------------------------------
|
||||
|
||||
let dragCode = $state<string | null>(null);
|
||||
@@ -329,77 +256,39 @@
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<!-- Add zone -->
|
||||
<div class="ls-add" class:ls-add-open={addOpen}>
|
||||
{#if !addOpen}
|
||||
<button type="button" class="ls-add-trigger" onclick={openAdd}>
|
||||
<MdiIcon name="mdiPlus" size={14} />
|
||||
<span>{t('locales.add')}</span>
|
||||
</button>
|
||||
{:else}
|
||||
<div class="ls-add-panel">
|
||||
<div class="ls-add-input-row">
|
||||
<MdiIcon name="mdiMagnify" size={14} />
|
||||
<input
|
||||
bind:this={addInputEl}
|
||||
bind:value={addQuery}
|
||||
onkeydown={onAddKeydown}
|
||||
onblur={() => setTimeout(() => { if (addOpen && !addQuery) closeAdd(); }, 150)}
|
||||
placeholder={t('locales.searchPlaceholder')}
|
||||
class="ls-add-input"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
type="text"
|
||||
/>
|
||||
<button type="button" class="ls-icon-btn" onclick={closeAdd} aria-label={t('common.cancel')}>
|
||||
<MdiIcon name="mdiClose" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="ls-add-list" role="listbox">
|
||||
{#each suggestions as s, i (s.code)}
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={i === highlightIdx}
|
||||
class="ls-sugg"
|
||||
class:ls-sugg-hl={i === highlightIdx}
|
||||
onmouseenter={() => highlightIdx = i}
|
||||
onmousedown={(e) => { e.preventDefault(); addCode(s.code); }}
|
||||
>
|
||||
<span class="ls-sugg-native" dir={s.rtl ? 'rtl' : 'ltr'} lang={s.code}>{s.native}</span>
|
||||
<span class="ls-sugg-name">{s.name}</span>
|
||||
<span class="ls-sugg-code">{s.code}</span>
|
||||
{#if SHIPPED.has(s.code)}
|
||||
<span class="ls-sugg-shipped" title={t('locales.shippedHint')}>
|
||||
<MdiIcon name="mdiPackageVariantClosedCheck" size={10} />
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
{#if canAddCustom}
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={highlightIdx === suggestions.length}
|
||||
class="ls-sugg ls-sugg-custom"
|
||||
class:ls-sugg-hl={highlightIdx === suggestions.length}
|
||||
onmouseenter={() => highlightIdx = suggestions.length}
|
||||
onmousedown={(e) => { e.preventDefault(); addCode(addQuery); }}
|
||||
>
|
||||
<MdiIcon name="mdiPlusCircleOutline" size={14} />
|
||||
<span class="ls-sugg-custom-label">{t('locales.addCustom')}</span>
|
||||
<span class="ls-sugg-code">{addQuery.trim().toLowerCase()}</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if suggestions.length === 0 && !canAddCustom}
|
||||
<div class="ls-sugg-empty">{t('locales.noSuggestions')}</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Add zone — EntitySelect for catalog languages, separate input for custom BCP-47 codes -->
|
||||
<div class="ls-add">
|
||||
<div class="ls-add-row">
|
||||
<div class="ls-add-picker">
|
||||
<EntitySelect
|
||||
items={addItems}
|
||||
value={null}
|
||||
placeholder={t('locales.add')}
|
||||
size="sm"
|
||||
onselect={addCode}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="ls-add-custom">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={customCode}
|
||||
onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addCustom(); } }}
|
||||
placeholder={t('locales.customPlaceholder')}
|
||||
class="ls-add-custom-input"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="ls-add-custom-btn"
|
||||
disabled={!customCodeValid}
|
||||
onclick={addCustom}
|
||||
title={t('locales.addCustom')}
|
||||
>
|
||||
<MdiIcon name="mdiPlus" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="ls-hint">
|
||||
@@ -630,125 +519,60 @@
|
||||
.ls-add {
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
.ls-add-trigger {
|
||||
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 {
|
||||
.ls-add-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
color: var(--color-muted-foreground);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ls-add-input {
|
||||
.ls-add-picker {
|
||||
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;
|
||||
outline: none;
|
||||
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-size: 0.7rem;
|
||||
padding: 0.05rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
background: var(--color-muted);
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-foreground);
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
.ls-add-custom-input::placeholder {
|
||||
color: var(--color-muted-foreground);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.ls-sugg.ls-sugg-hl .ls-sugg-code {
|
||||
background: color-mix(in srgb, var(--color-primary) 15%, var(--color-muted));
|
||||
}
|
||||
.ls-sugg-shipped {
|
||||
.ls-add-custom-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--color-primary);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.ls-sugg-custom {
|
||||
border-top: 1px dashed var(--color-border);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.ls-sugg-custom-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ls-sugg-empty {
|
||||
padding: 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 0.25rem;
|
||||
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 --------------------------------------------------------- */
|
||||
|
||||
@@ -192,6 +192,7 @@
|
||||
z-index: 1;
|
||||
padding: 0 1.5rem 1.5rem;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
|
||||
@@ -307,6 +307,7 @@
|
||||
|
||||
.mes-list {
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: thin;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
@@ -342,6 +342,7 @@
|
||||
.sp-results {
|
||||
max-height: 52vh;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: thin;
|
||||
padding: 0.35rem;
|
||||
position: relative;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import MdiIcon from './MdiIcon.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { portal } from '$lib/portal';
|
||||
|
||||
let {
|
||||
value = $bindable<string>('UTC'),
|
||||
@@ -172,18 +173,12 @@
|
||||
|
||||
$effect(() => { query; highlightIdx = 0; });
|
||||
|
||||
// Close on outside click
|
||||
function onDocClick(e: MouseEvent) {
|
||||
if (!open) return;
|
||||
const target = e.target as Node;
|
||||
if (panelEl && !panelEl.contains(target)) closePicker();
|
||||
}
|
||||
onMount(() => {
|
||||
document.addEventListener('mousedown', onDocClick);
|
||||
});
|
||||
onDestroy(() => {
|
||||
document.removeEventListener('mousedown', onDocClick);
|
||||
});
|
||||
/**
|
||||
* The panel is portalled to <body> to escape Card's overflow:hidden +
|
||||
* backdrop-filter (which would otherwise clip and stacking-trap the
|
||||
* dropdown). Outside-click is detected via the dedicated overlay div
|
||||
* rather than a document listener, so we don't need a global handler.
|
||||
*/
|
||||
</script>
|
||||
|
||||
<div class="tz-root">
|
||||
@@ -217,83 +212,87 @@
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div class="tz-panel" bind:this={panelEl} role="listbox">
|
||||
<!-- Search -->
|
||||
<div class="tz-search-row">
|
||||
<MdiIcon name="mdiMagnify" size={14} />
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
bind:value={query}
|
||||
onkeydown={onKeydown}
|
||||
placeholder={t('timezone.searchPlaceholder')}
|
||||
class="tz-search"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
type="text"
|
||||
/>
|
||||
<kbd class="tz-kbd">ESC</kbd>
|
||||
</div>
|
||||
<div use:portal class="tz-portal-root">
|
||||
<div class="tz-overlay" onclick={closePicker} role="presentation"></div>
|
||||
|
||||
<!-- Quick picks -->
|
||||
{#if !query}
|
||||
<div class="tz-quick">
|
||||
<button
|
||||
type="button"
|
||||
class="tz-quick-btn"
|
||||
class:tz-quick-active={value === detectedTz}
|
||||
onclick={() => selectTz(detectedTz)}
|
||||
>
|
||||
<MdiIcon name="mdiCrosshairsGps" size={12} />
|
||||
<span class="tz-quick-label">{t('timezone.detect')}</span>
|
||||
<span class="tz-quick-val">{detectedTz}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tz-quick-btn"
|
||||
class:tz-quick-active={value === 'UTC' || value === 'Etc/UTC'}
|
||||
onclick={() => selectTz('UTC')}
|
||||
>
|
||||
<MdiIcon name="mdiEarth" size={12} />
|
||||
<span class="tz-quick-label">{t('timezone.utc')}</span>
|
||||
<span class="tz-quick-val">UTC+00</span>
|
||||
</button>
|
||||
<div class="tz-panel" bind:this={panelEl} role="listbox">
|
||||
<!-- Search -->
|
||||
<div class="tz-search-row">
|
||||
<MdiIcon name="mdiMagnify" size={14} />
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
bind:value={query}
|
||||
onkeydown={onKeydown}
|
||||
placeholder={t('timezone.searchPlaceholder')}
|
||||
class="tz-search"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
type="text"
|
||||
/>
|
||||
<kbd class="tz-kbd">ESC</kbd>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Grouped list -->
|
||||
<div class="tz-list">
|
||||
{#if filtered.length === 0}
|
||||
<div class="tz-empty">{t('timezone.noMatches')}</div>
|
||||
{:else}
|
||||
{#each groups as g (g.region)}
|
||||
<div class="tz-group">
|
||||
<div class="tz-group-head">
|
||||
<span class="tz-group-name">{g.region}</span>
|
||||
<span class="tz-group-count">{g.items.length}</span>
|
||||
</div>
|
||||
{#each g.items as tz (tz)}
|
||||
{@const parts = splitTz(tz)}
|
||||
{@const idx = flat.indexOf(tz)}
|
||||
{@const hl = idx === highlightIdx}
|
||||
{@const sel = tz === value}
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={sel}
|
||||
class="tz-opt"
|
||||
class:tz-opt-hl={hl}
|
||||
class:tz-opt-sel={sel}
|
||||
onmouseenter={() => (highlightIdx = idx)}
|
||||
onclick={() => selectTz(tz)}
|
||||
>
|
||||
<span class="tz-opt-city">{parts.city}</span>
|
||||
<span class="tz-opt-iana">{tz}</span>
|
||||
<span class="tz-opt-offset">{fmtOffset(tz)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
<!-- Quick picks -->
|
||||
{#if !query}
|
||||
<div class="tz-quick">
|
||||
<button
|
||||
type="button"
|
||||
class="tz-quick-btn"
|
||||
class:tz-quick-active={value === detectedTz}
|
||||
onclick={() => selectTz(detectedTz)}
|
||||
>
|
||||
<MdiIcon name="mdiCrosshairsGps" size={12} />
|
||||
<span class="tz-quick-label">{t('timezone.detect')}</span>
|
||||
<span class="tz-quick-val">{detectedTz}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tz-quick-btn"
|
||||
class:tz-quick-active={value === 'UTC' || value === 'Etc/UTC'}
|
||||
onclick={() => selectTz('UTC')}
|
||||
>
|
||||
<MdiIcon name="mdiEarth" size={12} />
|
||||
<span class="tz-quick-label">{t('timezone.utc')}</span>
|
||||
<span class="tz-quick-val">UTC+00</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Grouped list -->
|
||||
<div class="tz-list">
|
||||
{#if filtered.length === 0}
|
||||
<div class="tz-empty">{t('timezone.noMatches')}</div>
|
||||
{:else}
|
||||
{#each groups as g (g.region)}
|
||||
<div class="tz-group">
|
||||
<div class="tz-group-head">
|
||||
<span class="tz-group-name">{g.region}</span>
|
||||
<span class="tz-group-count">{g.items.length}</span>
|
||||
</div>
|
||||
{#each g.items as tz (tz)}
|
||||
{@const parts = splitTz(tz)}
|
||||
{@const idx = flat.indexOf(tz)}
|
||||
{@const hl = idx === highlightIdx}
|
||||
{@const sel = tz === value}
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={sel}
|
||||
class="tz-opt"
|
||||
class:tz-opt-hl={hl}
|
||||
class:tz-opt-sel={sel}
|
||||
onmouseenter={() => (highlightIdx = idx)}
|
||||
onclick={() => selectTz(tz)}
|
||||
>
|
||||
<span class="tz-opt-city">{parts.city}</span>
|
||||
<span class="tz-opt-iana">{tz}</span>
|
||||
<span class="tz-opt-offset">{fmtOffset(tz)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -408,35 +407,66 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ---- Panel -------------------------------------------------------- */
|
||||
.tz-panel {
|
||||
/* ---- Portal + overlay (escapes Card's overflow:hidden / backdrop-filter) ---- */
|
||||
.tz-portal-root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9998;
|
||||
pointer-events: none;
|
||||
}
|
||||
.tz-overlay {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.375rem);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
background: var(--color-card, var(--color-background));
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.625rem;
|
||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35);
|
||||
inset: 0;
|
||||
pointer-events: auto;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(8px) saturate(120%);
|
||||
-webkit-backdrop-filter: blur(8px) saturate(120%);
|
||||
}
|
||||
|
||||
/* ---- Panel (centered modal palette) -------------------------------- */
|
||||
.tz-panel {
|
||||
pointer-events: auto;
|
||||
position: absolute;
|
||||
top: min(20vh, 120px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1;
|
||||
width: min(540px, 92vw);
|
||||
max-height: min(60vh, 30rem);
|
||||
background: var(--tz-solid-bg);
|
||||
border: 1px solid var(--color-rule-strong, var(--color-border));
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--shadow-card, 0 18px 40px rgba(0, 0, 0, 0.35)),
|
||||
0 24px 48px -16px rgba(0, 0, 0, 0.55);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 26rem;
|
||||
animation: tz-pop 0.15s ease-out;
|
||||
--tz-solid-bg: #131520;
|
||||
}
|
||||
:global([data-theme="light"]) .tz-panel { --tz-solid-bg: #fafafe; }
|
||||
.tz-panel::after {
|
||||
content: '';
|
||||
position: absolute; inset: 0;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(180deg, var(--color-highlight, transparent), transparent 30%);
|
||||
opacity: 0.4;
|
||||
}
|
||||
@keyframes tz-pop {
|
||||
from { opacity: 0; transform: translateY(-3px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
from { opacity: 0; transform: translate(-50%, -3px); }
|
||||
to { opacity: 1; transform: translate(-50%, 0); }
|
||||
}
|
||||
|
||||
.tz-search-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
color: var(--color-muted-foreground);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.tz-search {
|
||||
flex: 1;
|
||||
@@ -464,6 +494,8 @@
|
||||
padding: 0.5rem 0.625rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-wrap: wrap;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.tz-quick-btn {
|
||||
display: inline-flex;
|
||||
@@ -498,8 +530,14 @@
|
||||
|
||||
.tz-list {
|
||||
overflow-y: auto;
|
||||
padding: 0.25rem 0;
|
||||
overscroll-behavior: contain;
|
||||
/* No top padding — the sticky group head is at top:0 of the
|
||||
scroll container, so any padding-top would let scrolling
|
||||
items leak into the gap above the sticky header. */
|
||||
padding: 0 0 0.25rem;
|
||||
scrollbar-width: thin;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.tz-empty {
|
||||
padding: 1rem;
|
||||
@@ -523,7 +561,7 @@
|
||||
color: var(--color-muted-foreground);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--color-card, var(--color-background));
|
||||
background: var(--tz-solid-bg);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@@ -246,6 +246,9 @@
|
||||
"selectAlbums": "Select albums...",
|
||||
"repositories": "Repositories",
|
||||
"selectRepositories": "Select repositories...",
|
||||
"userAllowlist": "Only from users",
|
||||
"userBlocklist": "Exclude users",
|
||||
"selectUsers": "Pick users...",
|
||||
"boards": "Boards",
|
||||
"selectBoards": "Select boards...",
|
||||
"upsDevices": "UPS Devices",
|
||||
@@ -472,6 +475,7 @@
|
||||
"noCommandsForProvider": "This provider type does not support bot commands.",
|
||||
"syncCommands": "Sync Commands",
|
||||
"discoverChats": "Discover chats from Telegram",
|
||||
"discoveringChats": "Discovering chats…",
|
||||
"clickToCopy": "Click to copy chat ID",
|
||||
"chatsDiscovered": "Chats discovered",
|
||||
"chatDeleted": "Chat removed",
|
||||
@@ -627,6 +631,7 @@
|
||||
"countLabel": "templates",
|
||||
"title": "Template Configs",
|
||||
"description": "Define how notification messages are formatted",
|
||||
"language": "Language",
|
||||
"providerType": "Service Provider Type",
|
||||
"newConfig": "New Config",
|
||||
"name": "Name",
|
||||
@@ -818,7 +823,11 @@
|
||||
"defaultCount": "How many results to return when the user doesn't specify a count (1-20).",
|
||||
"responseMode": "Media: send actual photos. Text: send filenames/links only. Media mode uses more bandwidth.",
|
||||
"botLocale": "Language for command descriptions in Telegram's menu and bot response messages.",
|
||||
"rateLimits": "Cooldown in seconds between uses of each command category per chat. 0 = no limit."
|
||||
"rateLimits": "Cooldown in seconds between uses of each command category per chat. 0 = no limit.",
|
||||
"commandResponses": "Reply templates for each /command. Use {variables} to inject dynamic data.",
|
||||
"commandErrors": "Fallback messages shown when a command can't run (rate-limited) or returns nothing.",
|
||||
"commandDescriptions": "Short menu blurbs Telegram shows next to each /command in the chat command picker.",
|
||||
"commandUsage": "Example invocations rendered inside /help to show users how to call each command."
|
||||
},
|
||||
"matrixBot": {
|
||||
"titleEmphasis": "matrix",
|
||||
@@ -871,7 +880,9 @@
|
||||
"noConfigs": "No command template configs yet.",
|
||||
"confirmDelete": "Delete this command template config?",
|
||||
"commandResponses": "Command Responses",
|
||||
"commandResponsesHint": "Leave a slot empty to use the default hardcoded response."
|
||||
"commandErrors": "Error Messages",
|
||||
"commandDescriptions": "Command Descriptions",
|
||||
"commandUsage": "Usage Examples"
|
||||
},
|
||||
"commandConfig": {
|
||||
"titleEmphasis": "configs",
|
||||
@@ -940,6 +951,7 @@
|
||||
"empty": "No languages selected. Add one below to start authoring templates.",
|
||||
"add": "Add language",
|
||||
"searchPlaceholder": "Search or type a code (e.g. de-CH)…",
|
||||
"customPlaceholder": "or de-CH",
|
||||
"addCustom": "Add custom code",
|
||||
"noSuggestions": "No matches. Type a valid locale code (2–3 letters).",
|
||||
"primary": "Primary",
|
||||
|
||||
@@ -246,6 +246,9 @@
|
||||
"selectAlbums": "Выберите альбомы...",
|
||||
"repositories": "Репозитории",
|
||||
"selectRepositories": "Выберите репозитории...",
|
||||
"userAllowlist": "Только от пользователей",
|
||||
"userBlocklist": "Исключить пользователей",
|
||||
"selectUsers": "Выберите пользователей...",
|
||||
"boards": "Доски",
|
||||
"selectBoards": "Выберите доски...",
|
||||
"upsDevices": "ИБП устройства",
|
||||
@@ -472,6 +475,7 @@
|
||||
"noCommandsForProvider": "Этот тип провайдера не поддерживает команды бота.",
|
||||
"syncCommands": "Синхр. команды",
|
||||
"discoverChats": "Обнаружить чаты из Telegram",
|
||||
"discoveringChats": "Поиск чатов…",
|
||||
"clickToCopy": "Нажмите, чтобы скопировать ID чата",
|
||||
"chatsDiscovered": "Чаты обнаружены",
|
||||
"chatDeleted": "Чат удалён",
|
||||
@@ -627,6 +631,7 @@
|
||||
"countLabel": "шаблонов",
|
||||
"title": "Конфигурации шаблонов",
|
||||
"description": "Определите формат уведомлений",
|
||||
"language": "Язык",
|
||||
"providerType": "Тип сервис-провайдера",
|
||||
"newConfig": "Новая конфигурация",
|
||||
"name": "Название",
|
||||
@@ -818,7 +823,11 @@
|
||||
"defaultCount": "Сколько результатов возвращать, если пользователь не указал количество (1-20).",
|
||||
"responseMode": "Медиа: отправка фото. Текст: только имена файлов/ссылки. Медиа-режим использует больше трафика.",
|
||||
"botLocale": "Язык описаний команд в меню Telegram и ответов бота.",
|
||||
"rateLimits": "Кулдаун в секундах между использованиями команд в каждом чате. 0 = без ограничений."
|
||||
"rateLimits": "Кулдаун в секундах между использованиями команд в каждом чате. 0 = без ограничений.",
|
||||
"commandResponses": "Шаблоны ответов на каждую /команду. Используйте {переменные} для динамических данных.",
|
||||
"commandErrors": "Резервные сообщения, когда команда не может выполниться (превышен лимит) или ничего не возвращает.",
|
||||
"commandDescriptions": "Короткие подписи в меню команд Telegram, которые показываются рядом с каждой /командой.",
|
||||
"commandUsage": "Примеры вызовов, отображаемые в /help, чтобы показать пользователям как вызывать каждую команду."
|
||||
},
|
||||
"matrixBot": {
|
||||
"titleEmphasis": "matrix",
|
||||
@@ -871,7 +880,9 @@
|
||||
"noConfigs": "Шаблонов команд пока нет.",
|
||||
"confirmDelete": "Удалить этот шаблон команд?",
|
||||
"commandResponses": "Ответы команд",
|
||||
"commandResponsesHint": "Оставьте слот пустым, чтобы использовать ответ по умолчанию."
|
||||
"commandErrors": "Сообщения об ошибках",
|
||||
"commandDescriptions": "Описания команд",
|
||||
"commandUsage": "Примеры использования"
|
||||
},
|
||||
"commandConfig": {
|
||||
"titleEmphasis": "конфигурации",
|
||||
@@ -940,6 +951,7 @@
|
||||
"empty": "Языки не выбраны. Добавьте язык ниже, чтобы начать редактирование шаблонов.",
|
||||
"add": "Добавить язык",
|
||||
"searchPlaceholder": "Найти или ввести код (например de-CH)…",
|
||||
"customPlaceholder": "или de-CH",
|
||||
"addCustom": "Добавить свой код",
|
||||
"noSuggestions": "Ничего не найдено. Введите код локали (2–3 буквы).",
|
||||
"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(),
|
||||
};
|
||||
}
|
||||
@@ -56,5 +56,20 @@ export const giteaDescriptor: ProviderDescriptor = {
|
||||
desc: () => '',
|
||||
},
|
||||
|
||||
userFilters: [
|
||||
{
|
||||
key: 'senders',
|
||||
label: 'notificationTracker.userAllowlist',
|
||||
placeholder: 'notificationTracker.selectUsers',
|
||||
icon: 'mdiAccountCheck',
|
||||
},
|
||||
{
|
||||
key: 'exclude_senders',
|
||||
label: 'notificationTracker.userBlocklist',
|
||||
placeholder: 'notificationTracker.selectUsers',
|
||||
icon: 'mdiAccountOff',
|
||||
},
|
||||
],
|
||||
|
||||
webhookUrlPattern: '/api/webhooks/gitea/{token}',
|
||||
};
|
||||
|
||||
@@ -120,6 +120,25 @@ export interface CollectionMeta {
|
||||
desc: (col: any) => string;
|
||||
}
|
||||
|
||||
// ── User-identity filters (TrackerForm) ──────────────────────────────
|
||||
|
||||
/**
|
||||
* Declares a filter that picks user identities from the provider's known
|
||||
* senders. Rendered as a MultiEntitySelect populated from the provider's
|
||||
* `/users` endpoint. The picked values are stored as `string[]` under
|
||||
* `tracker.filters[key]`.
|
||||
*/
|
||||
export interface UserFilterMeta {
|
||||
/** Filter key inside `tracker.filters` (e.g. "senders", "exclude_senders"). */
|
||||
key: string;
|
||||
/** i18n key for the label rendered above the picker. */
|
||||
label: string;
|
||||
/** i18n key for the picker placeholder. */
|
||||
placeholder: string;
|
||||
/** MDI icon shown on chips and dropdown rows. */
|
||||
icon: string;
|
||||
}
|
||||
|
||||
// ── Main descriptor ──────────────────────────────────────────────────
|
||||
|
||||
export interface ProviderDescriptor {
|
||||
@@ -153,6 +172,8 @@ export interface ProviderDescriptor {
|
||||
// ── Collections / Trackers ──
|
||||
/** Null means this provider has no collections (e.g. scheduler). */
|
||||
collectionMeta: CollectionMeta | null;
|
||||
/** Sender allowlist / blocklist pickers shown on the tracker form. */
|
||||
userFilters?: UserFilterMeta[];
|
||||
/** Whether this provider is webhook-based (hides scan_interval). */
|
||||
webhookBased?: boolean;
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@ export interface NotificationTarget {
|
||||
name: string;
|
||||
icon: string;
|
||||
config: Record<string, any>;
|
||||
chat_action?: string | null;
|
||||
chat_name?: string;
|
||||
receiver_count: number;
|
||||
receivers: TargetReceiver[];
|
||||
|
||||
@@ -38,6 +38,11 @@
|
||||
let providerFilterValue = $state(globalProviderFilter.id ?? 0);
|
||||
let _syncingFilter = false;
|
||||
|
||||
// Reserve the provider-filter row from first paint until the cache resolves.
|
||||
// Without this, the row appears mid-paint and pushes nav items down on every
|
||||
// hard reload — the most visible "jump" the user reported.
|
||||
let showProviderFilter = $derived(allProviders.length >= 1 || providersCache.fetchedAt === 0);
|
||||
|
||||
// Sync filter value → store
|
||||
$effect(() => {
|
||||
const v = providerFilterValue;
|
||||
@@ -78,7 +83,24 @@
|
||||
} catch (err: any) { pwdMsg = err.message; pwdSuccess = false; snackError(err.message); }
|
||||
}
|
||||
|
||||
let collapsed = $state(false);
|
||||
// Read persisted UI state synchronously so first paint already matches the
|
||||
// user's last session — otherwise the sidebar visibly snaps from expanded
|
||||
// to collapsed (and groups slide open) right after mount.
|
||||
function readPersistedCollapsed(): boolean {
|
||||
if (typeof localStorage === 'undefined') return false;
|
||||
return localStorage.getItem('sidebar_collapsed') === 'true';
|
||||
}
|
||||
function readPersistedExpandedGroups(): Record<string, boolean> {
|
||||
if (typeof localStorage === 'undefined') return {};
|
||||
try {
|
||||
const saved = localStorage.getItem('nav_expanded');
|
||||
return saved ? JSON.parse(saved) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
let collapsed = $state(readPersistedCollapsed());
|
||||
let isMac = $derived(typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.userAgent));
|
||||
|
||||
// Nav counts — computed reactively from caches + global provider filter
|
||||
@@ -216,7 +238,7 @@
|
||||
};
|
||||
|
||||
// Track which groups are expanded (persisted in localStorage)
|
||||
let expandedGroups = $state<Record<string, boolean>>({});
|
||||
let expandedGroups = $state<Record<string, boolean>>(readPersistedExpandedGroups());
|
||||
|
||||
function toggleGroup(key: string) {
|
||||
expandedGroups = { ...expandedGroups, [key]: !expandedGroups[key] };
|
||||
@@ -262,13 +284,8 @@
|
||||
|
||||
onMount(async () => {
|
||||
initTheme();
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
collapsed = localStorage.getItem('sidebar_collapsed') === 'true';
|
||||
try {
|
||||
const saved = localStorage.getItem('nav_expanded');
|
||||
if (saved) expandedGroups = JSON.parse(saved);
|
||||
} catch (e) { console.warn('Failed to parse nav_expanded:', e); }
|
||||
}
|
||||
// `collapsed` and `expandedGroups` are now hydrated synchronously in
|
||||
// their $state initializers above to avoid a post-mount layout snap.
|
||||
await loadUser();
|
||||
if (!auth.user && !isAuthPage) {
|
||||
redirecting = true;
|
||||
@@ -384,7 +401,7 @@
|
||||
{/if}
|
||||
Notify Bridge
|
||||
</h1>
|
||||
<p class="brand-version font-mono">v0.5.2</p>
|
||||
<p class="brand-version font-mono">v{__APP_VERSION__}</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -398,8 +415,10 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Global provider filter -->
|
||||
{#if allProviders.length >= 1}
|
||||
<!-- Global provider filter — kept rendered during the initial cache
|
||||
fetch (fetchedAt === 0) so the row doesn't pop in mid-paint and
|
||||
push the nav down. Hides only once we confirm zero providers. -->
|
||||
{#if showProviderFilter}
|
||||
<div class="{collapsed ? 'px-2 py-1' : 'px-3 py-1.5'}" style="border-bottom: 1px solid var(--color-border);">
|
||||
{#if collapsed}
|
||||
<button onclick={() => {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api, parseDate } from '$lib/api';
|
||||
import { requestHighlight } from '$lib/highlight';
|
||||
import { t } from '$lib/i18n';
|
||||
import {
|
||||
providersCache,
|
||||
@@ -320,6 +322,19 @@
|
||||
};
|
||||
});
|
||||
|
||||
function scrollToEvents(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
const el = document.getElementById('events-section');
|
||||
if (!el) return;
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
function gotoProvider(e: MouseEvent, providerId: number) {
|
||||
e.preventDefault();
|
||||
requestHighlight(providerId);
|
||||
goto('/providers');
|
||||
}
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const diff = Date.now() - parseDate(dateStr).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
@@ -424,8 +439,8 @@
|
||||
</section>
|
||||
|
||||
<!-- ==================== STATS ==================== -->
|
||||
{#snippet statCardSnippet(card: {icon: string; label: string; literalLabel?: string; value: number; literalValue?: string; suffix?: string; accent: string}, idx: number)}
|
||||
<div class="stat-card" style="--accent: {card.accent}">
|
||||
{#snippet statCardSnippet(card: {icon: string; label: string; literalLabel?: string; value: number; literalValue?: string; suffix?: string; accent: string; href: string; onclick?: (e: MouseEvent) => void}, idx: number)}
|
||||
<a class="stat-card" style="--accent: {card.accent}" href={card.href} onclick={card.onclick}>
|
||||
<div class="stat-card-inner">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="stat-icon" style="color: {card.accent};">
|
||||
@@ -439,7 +454,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/snippet}
|
||||
|
||||
{#snippet statCards()}
|
||||
@@ -452,6 +467,7 @@
|
||||
value: 0,
|
||||
literalValue: globalProviderFilter.provider.name,
|
||||
accent: STAT_ACCENTS[0],
|
||||
href: '/providers',
|
||||
}, 0)}
|
||||
{:else}
|
||||
{@render statCardSnippet({
|
||||
@@ -459,6 +475,7 @@
|
||||
label: 'dashboard.providers',
|
||||
value: filteredProviderCount,
|
||||
accent: STAT_ACCENTS[0],
|
||||
href: '/providers',
|
||||
}, 0)}
|
||||
{/if}
|
||||
{@render statCardSnippet({
|
||||
@@ -467,12 +484,14 @@
|
||||
value: displayActive,
|
||||
suffix: ` / ${displayTotal}`,
|
||||
accent: STAT_ACCENTS[1],
|
||||
href: '/notification-trackers',
|
||||
}, 1)}
|
||||
{@render statCardSnippet({
|
||||
icon: 'mdiTarget',
|
||||
label: 'dashboard.targets',
|
||||
value: displayTargets,
|
||||
accent: STAT_ACCENTS[2],
|
||||
href: '/targets',
|
||||
}, 2)}
|
||||
{#if status?.command_trackers !== undefined}
|
||||
{@render statCardSnippet({
|
||||
@@ -480,6 +499,7 @@
|
||||
label: 'nav.commandTrackers',
|
||||
value: displayCommandTrackers,
|
||||
accent: STAT_ACCENTS[3],
|
||||
href: '/command-trackers',
|
||||
}, 3)}
|
||||
{:else}
|
||||
{@render statCardSnippet({
|
||||
@@ -487,6 +507,8 @@
|
||||
label: 'dashboard.eventsTotal',
|
||||
value: heroSummary?.throughput ?? 0,
|
||||
accent: STAT_ACCENTS[3],
|
||||
href: '#events-section',
|
||||
onclick: scrollToEvents,
|
||||
}, 3)}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -496,7 +518,7 @@
|
||||
<!-- ==================== TWO COL: stream + provider deck ==================== -->
|
||||
<div class="two-col" class:two-col--single={!!globalProviderFilter.id}>
|
||||
<!-- Signal stream -->
|
||||
<section class="panel">
|
||||
<section class="panel" id="events-section">
|
||||
<header class="panel-head">
|
||||
<div>
|
||||
<h2 class="panel-title">{t('dashboard.streamTitle')} <em>{t('dashboard.streamEmphasis')}</em></h2>
|
||||
@@ -646,14 +668,14 @@
|
||||
{:else}
|
||||
<div class="provider-deck">
|
||||
{#each providerDeck as p}
|
||||
<a href="/providers" class="provider-row" style="--accent: {STAT_ACCENTS[p.id % STAT_ACCENTS.length]}">
|
||||
<a href="/providers" class="provider-row" style="--accent: {STAT_ACCENTS[p.id % STAT_ACCENTS.length]}" onclick={(e) => gotoProvider(e, p.id)}>
|
||||
<div class="provider-icon">
|
||||
<MdiIcon name={p.icon} size={20} />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="provider-name truncate">
|
||||
<div class="provider-name">
|
||||
{#if p.armedCount > 0}<span class="aurora-pulse"></span>{:else}<span class="aurora-pulse idle"></span>{/if}
|
||||
{p.name}
|
||||
<span class="truncate min-w-0">{p.name}</span>
|
||||
</div>
|
||||
<div class="provider-sub font-mono">
|
||||
{p.descriptor?.defaultName ?? p.type} · {p.trackerCount} {t('dashboard.trackersShort')}
|
||||
@@ -909,6 +931,7 @@
|
||||
============================================================ */
|
||||
.stat-card {
|
||||
position: relative;
|
||||
display: block;
|
||||
border-radius: 22px;
|
||||
background: var(--color-glass);
|
||||
backdrop-filter: blur(28px) saturate(160%);
|
||||
@@ -918,7 +941,10 @@
|
||||
overflow: hidden;
|
||||
transition: transform 0.25s cubic-bezier(.4,.4,0,1);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.stat-card:hover { text-decoration: none; }
|
||||
.stat-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
@@ -1282,6 +1308,7 @@
|
||||
.provider-meter {
|
||||
text-align: right;
|
||||
min-width: 80px;
|
||||
padding: 4px 4px 4px 0;
|
||||
}
|
||||
.provider-num {
|
||||
font-size: 1rem;
|
||||
@@ -1295,7 +1322,6 @@
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--color-glass-strong);
|
||||
overflow: hidden;
|
||||
}
|
||||
.provider-bar-fill {
|
||||
height: 100%;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { slide } from 'svelte/transition';
|
||||
import { slide, fade } from 'svelte/transition';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
||||
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||
import { t, getLocale } from '$lib/i18n';
|
||||
@@ -35,6 +36,10 @@
|
||||
// Per-bot expandable sections
|
||||
let chats = $state<Record<number, TelegramChat[]>>({});
|
||||
let chatsLoading = $state<Record<number, boolean>>({});
|
||||
// Distinct from chatsLoading: refresh keeps the existing list visible
|
||||
// instead of swapping it for a placeholder, avoiding the disorienting
|
||||
// "everything disappears" flash during Discover.
|
||||
let chatsRefreshing = $state<Record<number, boolean>>({});
|
||||
let expandedSection = $state<Record<number, string>>({});
|
||||
|
||||
// Webhook status per bot
|
||||
@@ -98,12 +103,13 @@
|
||||
}
|
||||
|
||||
async function discoverChats(botId: number) {
|
||||
chatsLoading = { ...chatsLoading, [botId]: true };
|
||||
if (chatsRefreshing[botId]) return;
|
||||
chatsRefreshing = { ...chatsRefreshing, [botId]: true };
|
||||
try {
|
||||
chats = { ...chats, [botId]: await api<TelegramChat[]>(`/telegram-bots/${botId}/chats/discover`, { method: 'POST' }) };
|
||||
snackSuccess(t('telegramBot.chatsDiscovered'));
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
chatsLoading = { ...chatsLoading, [botId]: false };
|
||||
chatsRefreshing = { ...chatsRefreshing, [botId]: false };
|
||||
}
|
||||
|
||||
async function deleteChat(botId: number, chatDbId: number) {
|
||||
@@ -371,66 +377,80 @@
|
||||
<!-- Chats section -->
|
||||
{#if expandedSection[bot.id] === 'chats'}
|
||||
<div class="mt-3 border-t border-[var(--color-border)] pt-3" in:slide>
|
||||
{#if chatsLoading[bot.id]}
|
||||
{#if chatsLoading[bot.id] && !chats[bot.id]}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)]">{t('common.loading')}</p>
|
||||
{:else if (chats[bot.id] || []).length === 0}
|
||||
{:else if (chats[bot.id] || []).length === 0 && !chatsRefreshing[bot.id]}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)]">{t('telegramBot.noChats')}</p>
|
||||
{:else}
|
||||
{@const gridStyle = "display:grid; grid-template-columns:1fr 80px 40px 100px 50px 130px 60px; align-items:center; gap:0.5rem;"}
|
||||
<!-- Header -->
|
||||
<div style="{gridStyle} padding:0.25rem 0.5rem; border-bottom:1px solid var(--color-border);"
|
||||
class="text-[0.65rem] font-semibold uppercase tracking-wide text-[var(--color-muted-foreground)]">
|
||||
<span>{t('telegramBot.chatName')}</span>
|
||||
<span style="text-align:center">{t('telegramBot.chatType')}</span>
|
||||
<span style="text-align:center">{t('telegramBot.chatLang')}</span>
|
||||
<span style="text-align:center">{t('telegramBot.langOverride')}</span>
|
||||
<span style="text-align:center">{t('telegramBot.cmds')}</span>
|
||||
<span style="text-align:center">{t('telegramBot.chatId')}</span>
|
||||
<span></span>
|
||||
</div>
|
||||
<!-- Rows -->
|
||||
{#each chats[bot.id] as chat}
|
||||
<div style={gridStyle}
|
||||
class="text-sm px-2 py-1.5 rounded hover:bg-[var(--color-muted)] cursor-pointer"
|
||||
onclick={(e: MouseEvent) => copyChatId(e, chat.chat_id)}
|
||||
onkeydown={(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); copyChatId(e as unknown as MouseEvent, chat.chat_id); } }}
|
||||
title={t('telegramBot.clickToCopy')}
|
||||
aria-label={t('telegramBot.clickToCopy')}
|
||||
role="button" tabindex="0">
|
||||
<span class="font-medium truncate">{chat.title || chat.username || t('common.unknown')}</span>
|
||||
<span style="text-align:center" class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{chatTypeLabel(chat.type)}</span>
|
||||
<span style="text-align:center" class="text-xs text-[var(--color-muted-foreground)]">{(chat.language_code || '—').toUpperCase()}</span>
|
||||
<div style="justify-self:center" role="presentation" onclick={(e: MouseEvent) => e.stopPropagation()} onkeydown={(e: KeyboardEvent) => e.stopPropagation()}>
|
||||
<EntitySelect
|
||||
items={LANG_ITEMS}
|
||||
value={chat.language_override || ''}
|
||||
size="sm"
|
||||
onselect={(val) => updateChatLanguage(bot.id, chat, String(val ?? ''))}
|
||||
/>
|
||||
</div>
|
||||
<div style="justify-self:center" role="presentation" onclick={(e: MouseEvent) => e.stopPropagation()} onkeydown={(e: KeyboardEvent) => e.stopPropagation()}>
|
||||
<button
|
||||
style="width:28px; height:16px; border-radius:8px; position:relative; transition:background-color 0.2s; background-color:{chat.commands_enabled ? 'var(--color-primary)' : 'var(--color-border)'};"
|
||||
title={t('telegramBot.commandsToggle')}
|
||||
onclick={() => toggleChatCommands(bot.id, chat)}>
|
||||
<span style="position:absolute; top:2px; width:12px; height:12px; border-radius:50%; transition:left 0.2s; left:{chat.commands_enabled ? '14px' : '2px'}; background:{chat.commands_enabled ? 'white' : 'var(--color-muted-foreground)'};" ></span>
|
||||
</button>
|
||||
</div>
|
||||
<span style="text-align:center" class="text-xs text-[var(--color-muted-foreground)] font-mono">{chat.chat_id}</span>
|
||||
<div style="justify-self:end" class="flex items-center gap-1">
|
||||
<IconButton icon="mdiSend" title={t('common.test')} size={14}
|
||||
onclick={(e: MouseEvent) => testChat(e, bot.id, chat.chat_id)}
|
||||
disabled={chatTesting[`${bot.id}_${chat.chat_id}`]} />
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} size={14}
|
||||
onclick={(e: MouseEvent) => { e.stopPropagation(); deleteChat(bot.id, chat.id); }} variant="danger" />
|
||||
</div>
|
||||
<div class="chat-list-wrap" class:is-refreshing={chatsRefreshing[bot.id]}>
|
||||
{#if chatsRefreshing[bot.id]}
|
||||
<div class="chat-shimmer" aria-hidden="true" transition:fade={{ duration: 180 }}></div>
|
||||
{/if}
|
||||
<!-- Header -->
|
||||
<div style="{gridStyle} padding:0.25rem 0.5rem; border-bottom:1px solid var(--color-border);"
|
||||
class="text-[0.65rem] font-semibold uppercase tracking-wide text-[var(--color-muted-foreground)]">
|
||||
<span>{t('telegramBot.chatName')}</span>
|
||||
<span style="text-align:center">{t('telegramBot.chatType')}</span>
|
||||
<span style="text-align:center">{t('telegramBot.chatLang')}</span>
|
||||
<span style="text-align:center">{t('telegramBot.langOverride')}</span>
|
||||
<span style="text-align:center">{t('telegramBot.cmds')}</span>
|
||||
<span style="text-align:center">{t('telegramBot.chatId')}</span>
|
||||
<span></span>
|
||||
</div>
|
||||
{/each}
|
||||
<!-- Rows -->
|
||||
{#each (chats[bot.id] || []) as chat (chat.id)}
|
||||
<div style={gridStyle}
|
||||
class="chat-row text-sm px-2 py-1.5 rounded hover:bg-[var(--color-muted)] cursor-pointer"
|
||||
animate:flip={{ duration: 280 }}
|
||||
in:fade={{ duration: 220, delay: 60 }}
|
||||
out:fade={{ duration: 140 }}
|
||||
onclick={(e: MouseEvent) => copyChatId(e, chat.chat_id)}
|
||||
onkeydown={(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); copyChatId(e as unknown as MouseEvent, chat.chat_id); } }}
|
||||
title={t('telegramBot.clickToCopy')}
|
||||
aria-label={t('telegramBot.clickToCopy')}
|
||||
role="button" tabindex="0">
|
||||
<span class="font-medium truncate">{chat.title || chat.username || t('common.unknown')}</span>
|
||||
<span style="text-align:center" class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{chatTypeLabel(chat.type)}</span>
|
||||
<span style="text-align:center" class="text-xs text-[var(--color-muted-foreground)]">{(chat.language_code || '—').toUpperCase()}</span>
|
||||
<div style="justify-self:center" role="presentation" onclick={(e: MouseEvent) => e.stopPropagation()} onkeydown={(e: KeyboardEvent) => e.stopPropagation()}>
|
||||
<EntitySelect
|
||||
items={LANG_ITEMS}
|
||||
value={chat.language_override || ''}
|
||||
size="sm"
|
||||
onselect={(val) => updateChatLanguage(bot.id, chat, String(val ?? ''))}
|
||||
/>
|
||||
</div>
|
||||
<div style="justify-self:center" role="presentation" onclick={(e: MouseEvent) => e.stopPropagation()} onkeydown={(e: KeyboardEvent) => e.stopPropagation()}>
|
||||
<button
|
||||
style="width:28px; height:16px; border-radius:8px; position:relative; transition:background-color 0.2s; background-color:{chat.commands_enabled ? 'var(--color-primary)' : 'var(--color-border)'};"
|
||||
title={t('telegramBot.commandsToggle')}
|
||||
onclick={() => toggleChatCommands(bot.id, chat)}>
|
||||
<span style="position:absolute; top:2px; width:12px; height:12px; border-radius:50%; transition:left 0.2s; left:{chat.commands_enabled ? '14px' : '2px'}; background:{chat.commands_enabled ? 'white' : 'var(--color-muted-foreground)'};" ></span>
|
||||
</button>
|
||||
</div>
|
||||
<span style="text-align:center" class="text-xs text-[var(--color-muted-foreground)] font-mono">{chat.chat_id}</span>
|
||||
<div style="justify-self:end" class="flex items-center gap-1">
|
||||
<IconButton icon="mdiSend" title={t('common.test')} size={14}
|
||||
onclick={(e: MouseEvent) => testChat(e, bot.id, chat.chat_id)}
|
||||
disabled={chatTesting[`${bot.id}_${chat.chat_id}`]} />
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} size={14}
|
||||
onclick={(e: MouseEvent) => { e.stopPropagation(); deleteChat(bot.id, chat.id); }} variant="danger" />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{#if chatsRefreshing[bot.id] && (chats[bot.id] || []).length === 0}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] py-2 px-2">{t('telegramBot.discoveringChats')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<button onclick={() => discoverChats(bot.id)}
|
||||
class="text-xs text-[var(--color-primary)] hover:underline mt-2 flex items-center gap-1">
|
||||
<MdiIcon name="mdiSync" size={14} />
|
||||
{t('telegramBot.discoverChats')}
|
||||
disabled={chatsRefreshing[bot.id]}
|
||||
class="discover-btn text-xs text-[var(--color-primary)] hover:underline mt-2 flex items-center gap-1 disabled:opacity-70 disabled:cursor-default disabled:no-underline">
|
||||
<span class="discover-icon" class:is-spinning={chatsRefreshing[bot.id]}>
|
||||
<MdiIcon name="mdiSync" size={14} />
|
||||
</span>
|
||||
{chatsRefreshing[bot.id] ? t('telegramBot.discoveringChats') : t('telegramBot.discoverChats')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -553,3 +573,72 @@
|
||||
|
||||
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
||||
|
||||
<style>
|
||||
/* Chat list — smooth refresh state.
|
||||
The list stays mounted during Discover; we only dim it slightly
|
||||
and run a thin shimmer bar across the top so the user sees
|
||||
"refreshing" instead of "everything vanished and came back". */
|
||||
.chat-list-wrap {
|
||||
position: relative;
|
||||
transition: opacity 0.25s ease, filter 0.25s ease;
|
||||
}
|
||||
.chat-list-wrap.is-refreshing {
|
||||
opacity: 0.78;
|
||||
filter: saturate(0.9);
|
||||
}
|
||||
.chat-list-wrap.is-refreshing .chat-row {
|
||||
pointer-events: none;
|
||||
}
|
||||
.chat-shimmer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
overflow: hidden;
|
||||
border-radius: 2px;
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
z-index: 2;
|
||||
}
|
||||
.chat-shimmer::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
color-mix(in srgb, var(--color-primary) 70%, transparent) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
transform: translateX(-100%);
|
||||
animation: chat-shimmer-sweep 1.15s ease-in-out infinite;
|
||||
}
|
||||
@keyframes chat-shimmer-sweep {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
.discover-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.discover-icon.is-spinning {
|
||||
animation: discover-spin 1s linear infinite;
|
||||
}
|
||||
@keyframes discover-spin {
|
||||
to { transform: rotate(-360deg); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.chat-shimmer::after,
|
||||
.discover-icon.is-spinning {
|
||||
animation: none;
|
||||
}
|
||||
.chat-list-wrap {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -110,6 +110,27 @@
|
||||
editing = null;
|
||||
showForm = true;
|
||||
}
|
||||
|
||||
// Re-pick the command-template config when the provider type changes.
|
||||
// The previously-selected id may belong to a different provider type and
|
||||
// would no longer appear in the filtered EntitySelect, leaving it empty.
|
||||
let _prevProviderType = $state('');
|
||||
$effect(() => {
|
||||
if (showForm && form.provider_type && form.provider_type !== _prevProviderType) {
|
||||
_prevProviderType = form.provider_type;
|
||||
if (editing === null) {
|
||||
const currentTpl = cmdTemplateConfigs.find(
|
||||
(c) => c.id === form.command_template_config_id,
|
||||
);
|
||||
if (!currentTpl || currentTpl.provider_type !== form.provider_type) {
|
||||
const first = cmdTemplateConfigs.find(
|
||||
(c) => c.provider_type === form.provider_type,
|
||||
);
|
||||
form.command_template_config_id = first?.id ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
function editConfig(cfg: CommandConfig) {
|
||||
form = {
|
||||
name: cfg.name,
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import JinjaEditor from '$lib/components/JinjaEditor.svelte';
|
||||
import CollapsibleSlot from '$lib/components/CollapsibleSlot.svelte';
|
||||
import Hint from '$lib/components/Hint.svelte';
|
||||
import EntitySelect, { type EntityItem } from '$lib/components/EntitySelect.svelte';
|
||||
import { getLocaleMeta } from '$lib/locales';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
import { highlightFromUrl } from '$lib/highlight';
|
||||
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
||||
@@ -41,6 +44,7 @@
|
||||
}
|
||||
|
||||
let LOCALES = $derived(supportedLocalesCache.items);
|
||||
let primaryLocale = $derived(LOCALES[0] || 'en');
|
||||
|
||||
let allCmdTplConfigs = $state<CmdTemplateConfig[]>([]);
|
||||
let filterText = $state('');
|
||||
@@ -73,7 +77,18 @@
|
||||
});
|
||||
let varsRef = $state<Record<string, any>>({});
|
||||
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 slotFilter = $state('');
|
||||
let showPreviewFor = $state<Set<string>>(new Set());
|
||||
@@ -112,11 +127,40 @@
|
||||
let commandSlots = $derived<SlotDef[]>(
|
||||
allCapabilities[form.provider_type]?.command_slots || []
|
||||
);
|
||||
let filteredCmdSlots = $derived(
|
||||
slotFilter
|
||||
? commandSlots.filter(s => s.name.toLowerCase().includes(slotFilter.toLowerCase()) || s.description.toLowerCase().includes(slotFilter.toLowerCase()))
|
||||
: commandSlots
|
||||
);
|
||||
|
||||
const ERROR_SLOTS = new Set(['rate_limited', 'no_results']);
|
||||
|
||||
/**
|
||||
* Group command slots by purpose so the form mirrors how notification
|
||||
* templates are split (event vs scheduled vs settings).
|
||||
*
|
||||
* commandResponses — primary reply templates (/start, /help, /status, data slots)
|
||||
* commandErrors — fallback messages (rate_limited, no_results)
|
||||
* commandDescriptions — desc_* slots: short menu blurbs in Telegram's command picker
|
||||
* commandUsage — usage_* slots: invocation examples shown by /help
|
||||
*/
|
||||
let commandSlotGroups = $derived([
|
||||
{
|
||||
group: 'commandResponses',
|
||||
slots: commandSlots.filter(s =>
|
||||
!s.name.startsWith('desc_') &&
|
||||
!s.name.startsWith('usage_') &&
|
||||
!ERROR_SLOTS.has(s.name)
|
||||
),
|
||||
},
|
||||
{
|
||||
group: 'commandErrors',
|
||||
slots: commandSlots.filter(s => ERROR_SLOTS.has(s.name)),
|
||||
},
|
||||
{
|
||||
group: 'commandDescriptions',
|
||||
slots: commandSlots.filter(s => s.name.startsWith('desc_')),
|
||||
},
|
||||
{
|
||||
group: 'commandUsage',
|
||||
slots: commandSlots.filter(s => s.name.startsWith('usage_')),
|
||||
},
|
||||
]);
|
||||
|
||||
/** Get slot template for current locale, with fallback. */
|
||||
function getSlotValue(slotName: string): string {
|
||||
@@ -215,7 +259,7 @@
|
||||
if (typesWithCmdSlots.length > 0) form.provider_type = typesWithCmdSlots[0];
|
||||
editing = null;
|
||||
showForm = true;
|
||||
activeLocale = 'en';
|
||||
activeLocale = primaryLocale;
|
||||
slotPreview = {};
|
||||
slotErrors = {};
|
||||
expandedSlots = new Set();
|
||||
@@ -238,7 +282,7 @@
|
||||
};
|
||||
editing = c.id;
|
||||
showForm = true;
|
||||
activeLocale = 'en';
|
||||
activeLocale = primaryLocale;
|
||||
slotPreview = {};
|
||||
slotErrors = {};
|
||||
expandedSlots = new Set();
|
||||
@@ -332,7 +376,7 @@
|
||||
};
|
||||
editing = null;
|
||||
showForm = true;
|
||||
activeLocale = 'en';
|
||||
activeLocale = primaryLocale;
|
||||
slotPreview = {};
|
||||
slotErrors = {};
|
||||
expandedSlots = new Set();
|
||||
@@ -410,89 +454,98 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
||||
<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>
|
||||
|
||||
<!-- Locale tabs -->
|
||||
<div class="flex items-center gap-1 mb-3 border-b border-[var(--color-border)]">
|
||||
{#each LOCALES as loc}
|
||||
<button type="button"
|
||||
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)]'}"
|
||||
onclick={() => { activeLocale = loc; refreshAllPreviews(); }}>
|
||||
{loc.toUpperCase()}
|
||||
</button>
|
||||
{/each}
|
||||
{#if form.provider_type}
|
||||
<button type="button" onclick={resetAllToDefaults}
|
||||
title={t('templateConfig.resetAllToDefaults')}
|
||||
class="ml-auto flex items-center gap-1 text-xs px-2 py-1 rounded-md border border-[var(--color-border)] text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]">
|
||||
<MdiIcon name="mdiRefresh" size={12} />
|
||||
{t('templateConfig.resetAllToDefaults')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Slot filter -->
|
||||
{#if commandSlots.length > 4}
|
||||
<div class="mb-3">
|
||||
<input type="text" bind:value={slotFilter} placeholder={t('templateConfig.filterSlots')}
|
||||
class="w-full px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
<!-- Language picker -->
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-medium text-[var(--color-muted-foreground)] shrink-0">
|
||||
{t('templateConfig.language')}
|
||||
</span>
|
||||
<div class="flex-1 max-w-xs">
|
||||
<EntitySelect
|
||||
items={localeItems}
|
||||
value={activeLocale}
|
||||
size="sm"
|
||||
onselect={(v) => { activeLocale = (v as string) || primaryLocale; refreshAllPreviews(); }}
|
||||
/>
|
||||
</div>
|
||||
{#if form.provider_type}
|
||||
<button type="button" onclick={resetAllToDefaults}
|
||||
title={t('templateConfig.resetAllToDefaults')}
|
||||
class="ml-auto flex items-center gap-1 text-xs px-2 py-1 rounded-md border border-[var(--color-border)] text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]">
|
||||
<MdiIcon name="mdiRefresh" size={12} />
|
||||
{t('templateConfig.resetAllToDefaults')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
{#each filteredCmdSlots as slot}
|
||||
<CollapsibleSlot
|
||||
label={slot.name}
|
||||
description="/{slot.name} — {slot.description}"
|
||||
expanded={expandedSlots.has(slot.name)}
|
||||
status={getSlotStatus(slot.name)}
|
||||
ontoggle={() => toggleSlot(slot.name)}
|
||||
>
|
||||
<div class="flex items-center justify-end gap-2 mb-2">
|
||||
{#if slotPreview[slot.name] && !slotErrors[slot.name]}
|
||||
<button type="button" onclick={() => togglePreview(slot.name)}
|
||||
class="text-xs px-2 py-0.5 rounded-md transition-colors {showPreviewFor.has(slot.name) ? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]' : 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]'}">
|
||||
{t('templateConfig.preview')}
|
||||
<!-- Slot filter -->
|
||||
{#if commandSlots.length > 4}
|
||||
<div>
|
||||
<input type="text" bind:value={slotFilter} placeholder={t('templateConfig.filterSlots')}
|
||||
class="w-full px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each commandSlotGroups.filter(g => g.slots.length > 0) as group}
|
||||
{@const filteredSlots = slotFilter ? group.slots.filter(s => s.name.toLowerCase().includes(slotFilter.toLowerCase()) || s.description.toLowerCase().includes(slotFilter.toLowerCase())) : group.slots}
|
||||
{#if filteredSlots.length > 0}
|
||||
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
||||
<legend class="text-sm font-medium px-1">
|
||||
{t(`cmdTemplateConfig.${group.group}`)}<Hint text={t(`hints.${group.group}`)} />
|
||||
</legend>
|
||||
<div class="space-y-2 mt-2">
|
||||
{#each filteredSlots as slot}
|
||||
<CollapsibleSlot
|
||||
label={slot.name}
|
||||
description="/{slot.name} — {slot.description}"
|
||||
expanded={expandedSlots.has(slot.name)}
|
||||
status={getSlotStatus(slot.name)}
|
||||
ontoggle={() => toggleSlot(slot.name)}
|
||||
>
|
||||
<div class="flex items-center justify-end gap-2 mb-2">
|
||||
{#if slotPreview[slot.name] && !slotErrors[slot.name]}
|
||||
<button type="button" onclick={() => togglePreview(slot.name)}
|
||||
class="text-xs px-2 py-0.5 rounded-md transition-colors {showPreviewFor.has(slot.name) ? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]' : 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]'}">
|
||||
{t('templateConfig.preview')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if getVarsFor(slot.name)}
|
||||
<button type="button" onclick={() => showVarsFor = slot.name}
|
||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templateConfig.variables')}</button>
|
||||
{/if}
|
||||
<button type="button" onclick={() => resetSlotToDefault(slot.name)}
|
||||
title={t('templateConfig.resetToDefault')}
|
||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline">
|
||||
{t('templateConfig.resetToDefault')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if getVarsFor(slot.name)}
|
||||
<button type="button" onclick={() => showVarsFor = slot.name}
|
||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templateConfig.variables')}</button>
|
||||
{/if}
|
||||
<button type="button" onclick={() => resetSlotToDefault(slot.name)}
|
||||
title={t('templateConfig.resetToDefault')}
|
||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline">
|
||||
{t('templateConfig.resetToDefault')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showPreviewFor.has(slot.name) && slotPreview[slot.name] && !slotErrors[slot.name]}
|
||||
<div class="p-2 bg-[var(--color-muted)] rounded text-sm mb-2">
|
||||
<pre class="whitespace-pre-wrap text-xs">{@html sanitizePreview(slotPreview[slot.name])}</pre>
|
||||
</div>
|
||||
{:else}
|
||||
<JinjaEditor
|
||||
value={getSlotValue(slot.name)}
|
||||
onchange={(v: string) => { setSlotValue(slot.name, v); validateSlot(slot.name, v); }}
|
||||
rows={3}
|
||||
errorLine={slotErrorLines[slot.name] || null}
|
||||
variables={getVarsFor(slot.name) || undefined}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if slotErrors[slot.name]}
|
||||
{#if slotErrorTypes[slot.name] === 'undefined'}
|
||||
<p class="mt-1 text-xs" style="color: var(--color-warning-fg);">⚠ {t('common.undefinedVar')}: {slotErrors[slot.name]}</p>
|
||||
{#if showPreviewFor.has(slot.name) && slotPreview[slot.name] && !slotErrors[slot.name]}
|
||||
<div class="p-2 bg-[var(--color-muted)] rounded text-sm mb-2">
|
||||
<pre class="whitespace-pre-wrap text-xs">{@html sanitizePreview(slotPreview[slot.name])}</pre>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">✕ {t('common.syntaxError')}: {slotErrors[slot.name]}{slotErrorLines[slot.name] ? ` (${t('common.line')} ${slotErrorLines[slot.name]})` : ''}</p>
|
||||
<JinjaEditor
|
||||
value={getSlotValue(slot.name)}
|
||||
onchange={(v: string) => { setSlotValue(slot.name, v); validateSlot(slot.name, v); }}
|
||||
rows={3}
|
||||
errorLine={slotErrorLines[slot.name] || null}
|
||||
variables={getVarsFor(slot.name) || undefined}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</CollapsibleSlot>
|
||||
{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{#if slotErrors[slot.name]}
|
||||
{#if slotErrorTypes[slot.name] === 'undefined'}
|
||||
<p class="mt-1 text-xs" style="color: var(--color-warning-fg);">⚠ {t('common.undefinedVar')}: {slotErrors[slot.name]}</p>
|
||||
{:else}
|
||||
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">✕ {t('common.syntaxError')}: {slotErrors[slot.name]}{slotErrorLines[slot.name] ? ` (${t('common.line')} ${slotErrorLines[slot.name]})` : ''}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</CollapsibleSlot>
|
||||
{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<button type="submit" class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||
{editing ? t('common.save') : t('common.create')}
|
||||
|
||||
@@ -113,6 +113,26 @@
|
||||
editing = null;
|
||||
showForm = true;
|
||||
}
|
||||
|
||||
// Re-pick the command config when the provider changes. The previously
|
||||
// selected id may belong to a different provider type and would no longer
|
||||
// appear in the filtered EntitySelect, leaving the selector empty.
|
||||
let _prevProviderId = $state(0);
|
||||
$effect(() => {
|
||||
if (showForm && form.provider_id && form.provider_id !== _prevProviderId) {
|
||||
_prevProviderId = form.provider_id;
|
||||
if (editing === null) {
|
||||
const ptype = providers.find(p => p.id === form.provider_id)?.type || '';
|
||||
if (ptype) {
|
||||
const currentCfg = commandConfigs.find(c => c.id === form.command_config_id);
|
||||
if (!currentCfg || currentCfg.provider_type !== ptype) {
|
||||
const first = commandConfigs.find(c => c.provider_type === ptype);
|
||||
form.command_config_id = first?.id ?? 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
function editTracker(trk: any) {
|
||||
form = {
|
||||
name: trk.name,
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
let trackingConfigs = $derived(trackingConfigsCache.items);
|
||||
let templateConfigs = $derived(templateConfigsCache.items);
|
||||
let collections = $state<Record<string, any>[]>([]);
|
||||
let users = $state<{ id: string; name: string }[]>([]);
|
||||
let showForm = $state(false);
|
||||
let editing = $state<number | null>(null);
|
||||
let collectionFilter = $state('');
|
||||
@@ -167,22 +168,38 @@
|
||||
try { collections = await api(`/providers/${form.provider_id}/collections`); } catch (e) { console.warn('Failed to load collections:', e); collections = []; }
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
if (!form.provider_id) { users = []; return; }
|
||||
// Skip the fetch when the descriptor has no user filters — saves a
|
||||
// pointless round-trip for providers like Immich/Scheduler.
|
||||
const desc = getDescriptor(selectedProviderType);
|
||||
if (!desc?.userFilters || desc.userFilters.length === 0) { users = []; return; }
|
||||
try { users = await api(`/providers/${form.provider_id}/users`); }
|
||||
catch (e) { console.warn('Failed to load users:', e); users = []; }
|
||||
}
|
||||
|
||||
let _prevProviderId = $state(0);
|
||||
$effect(() => {
|
||||
if (showForm && form.provider_id && form.provider_id !== _prevProviderId) {
|
||||
_prevProviderId = form.provider_id;
|
||||
loadCollections();
|
||||
// Auto-select first available tracking/template config for this provider when creating
|
||||
loadUsers();
|
||||
// Re-pick tracking/template configs for the new provider type. The
|
||||
// previously-selected ids may belong to a different provider type
|
||||
// and therefore no longer appear in the filtered EntitySelect list,
|
||||
// which would render the selector as empty.
|
||||
if (editing === null) {
|
||||
const ptype = providers.find(p => p.id === form.provider_id)?.type || '';
|
||||
if (ptype) {
|
||||
if (!form.default_tracking_config_id) {
|
||||
const currentTc = trackingConfigs.find(c => c.id === form.default_tracking_config_id);
|
||||
if (!currentTc || currentTc.provider_type !== ptype) {
|
||||
const first = trackingConfigs.find(c => c.provider_type === ptype);
|
||||
if (first) form.default_tracking_config_id = first.id;
|
||||
form.default_tracking_config_id = first?.id ?? 0;
|
||||
}
|
||||
if (!form.default_template_config_id) {
|
||||
const currentTpl = templateConfigs.find(c => c.id === form.default_template_config_id);
|
||||
if (!currentTpl || currentTpl.provider_type !== ptype) {
|
||||
const first = templateConfigs.find(c => c.provider_type === ptype);
|
||||
if (first) form.default_template_config_id = first.id;
|
||||
form.default_template_config_id = first?.id ?? 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,7 +210,7 @@
|
||||
form = defaultForm();
|
||||
// Auto-select first provider if any
|
||||
if (providers.length > 0) form.provider_id = providers[0].id;
|
||||
editing = null; showForm = true; collections = []; previousCollectionIds = [];
|
||||
editing = null; showForm = true; collections = []; users = []; previousCollectionIds = [];
|
||||
}
|
||||
|
||||
async function edit(trk: Tracker) {
|
||||
@@ -208,7 +225,9 @@
|
||||
};
|
||||
previousCollectionIds = [...(trk.collection_ids || [])];
|
||||
editing = trk.id; showForm = true;
|
||||
if (form.provider_id) await loadCollections();
|
||||
if (form.provider_id) {
|
||||
await Promise.all([loadCollections(), loadUsers()]);
|
||||
}
|
||||
}
|
||||
|
||||
async function save(e: SubmitEvent) {
|
||||
@@ -460,6 +479,7 @@
|
||||
bind:form
|
||||
{providerItems}
|
||||
{collections}
|
||||
{users}
|
||||
bind:collectionFilter
|
||||
trackingConfigItems={trackingConfigs.filter(c => !selectedProviderType || c.provider_type === selectedProviderType).map(c => ({ value: c.id, label: c.name, icon: (c as any).icon || 'mdiCog' }))}
|
||||
templateConfigItems={templateConfigs.filter(c => !selectedProviderType || c.provider_type === selectedProviderType).map(c => ({ value: c.id, label: c.name, icon: (c as any).icon || 'mdiFileDocumentEdit' }))}
|
||||
@@ -499,6 +519,7 @@
|
||||
{:else if !showForm}
|
||||
<div class="space-y-3 stagger-children">
|
||||
{#each notificationTrackers as tracker (tracker.id)}
|
||||
{@const trkDesc = getDescriptor(getProviderType(tracker))}
|
||||
<Card hover entityId={tracker.id}>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -511,7 +532,9 @@
|
||||
<CrossLink href="/providers" icon="mdiServer" label={getProviderName(tracker.provider_id)} entityId={tracker.provider_id} />
|
||||
</div>
|
||||
<p class="text-sm text-[var(--color-muted-foreground)]">
|
||||
{(tracker.collection_ids || []).length} {getCollectionLabel(tracker)} · {t('notificationTracker.every')} {tracker.scan_interval}s · {(tracker.tracker_targets || []).length} {t('notificationTracker.linkedTargets')}
|
||||
{(tracker.collection_ids || []).length} {getCollectionLabel(tracker)} ·
|
||||
{#if !trkDesc?.webhookBased}{t('notificationTracker.every')} {tracker.scan_interval}s ·{/if}
|
||||
{(tracker.tracker_targets || []).length} {t('notificationTracker.linkedTargets')}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 flex-wrap justify-end">
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
};
|
||||
providerItems: { value: number; label: string; icon: string; desc: string }[];
|
||||
collections: any[];
|
||||
users?: { id: string; name: string }[];
|
||||
collectionFilter?: string;
|
||||
trackingConfigItems?: { value: number; label: string; icon: string }[];
|
||||
templateConfigItems?: { value: number; label: string; icon: string }[];
|
||||
@@ -40,6 +41,7 @@
|
||||
form = $bindable(),
|
||||
providerItems,
|
||||
collections,
|
||||
users = [],
|
||||
collectionFilter = $bindable(),
|
||||
trackingConfigItems = [],
|
||||
templateConfigItems = [],
|
||||
@@ -116,6 +118,21 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if descriptor?.userFilters && descriptor.userFilters.length > 0}
|
||||
{@const userItems = users.map(u => ({ value: u.id, label: u.name }))}
|
||||
{#each descriptor.userFilters as uf (uf.key)}
|
||||
<div>
|
||||
<div class="block text-sm font-medium mb-1">{t(uf.label)}</div>
|
||||
<MultiEntitySelect
|
||||
items={userItems.map(i => ({ ...i, icon: uf.icon }))}
|
||||
values={form.filters[uf.key] || []}
|
||||
onchange={(vals) => form.filters = { ...form.filters, [uf.key]: vals }}
|
||||
placeholder={t(uf.placeholder)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if isScheduler}
|
||||
<!-- Schedule type -->
|
||||
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
||||
|
||||
@@ -229,7 +229,7 @@
|
||||
max_media_to_send: c.max_media_to_send ?? 50, max_media_per_group: c.max_media_per_group ?? 10,
|
||||
media_delay: c.media_delay ?? 500, max_asset_size: c.max_asset_size ?? 50,
|
||||
disable_url_preview: c.disable_url_preview ?? false, send_large_photos_as_documents: c.send_large_photos_as_documents ?? false,
|
||||
ai_captions: c.ai_captions ?? false, chat_action: c.chat_action ?? 'typing',
|
||||
ai_captions: c.ai_captions ?? false, chat_action: tgt.chat_action ?? c.chat_action ?? 'typing',
|
||||
// discord/slack
|
||||
username: c.username || '',
|
||||
// ntfy
|
||||
@@ -268,7 +268,7 @@
|
||||
max_media_to_send: form.max_media_to_send, max_media_per_group: form.max_media_per_group,
|
||||
media_delay: form.media_delay, max_asset_size: form.max_asset_size,
|
||||
disable_url_preview: form.disable_url_preview, send_large_photos_as_documents: form.send_large_photos_as_documents,
|
||||
ai_captions: form.ai_captions, chat_action: form.chat_action || undefined,
|
||||
ai_captions: form.ai_captions,
|
||||
};
|
||||
} else if (formType === 'webhook') {
|
||||
config = { ai_captions: form.ai_captions };
|
||||
@@ -284,10 +284,12 @@
|
||||
config = { child_target_ids: form.child_target_ids };
|
||||
}
|
||||
|
||||
const body: Record<string, any> = { name: form.name, icon: form.icon, config };
|
||||
if (formType === 'telegram') body.chat_action = form.chat_action || null;
|
||||
if (editing) {
|
||||
await api(`/targets/${editing}`, { method: 'PUT', body: JSON.stringify({ name: form.name, icon: form.icon, config }) });
|
||||
await api(`/targets/${editing}`, { method: 'PUT', body: JSON.stringify(body) });
|
||||
} else {
|
||||
await api('/targets', { method: 'POST', body: JSON.stringify({ type: formType, name: form.name, icon: form.icon, config }) });
|
||||
await api('/targets', { method: 'POST', body: JSON.stringify({ type: formType, ...body }) });
|
||||
}
|
||||
showForm = false;
|
||||
editing = null;
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import JinjaEditor from '$lib/components/JinjaEditor.svelte';
|
||||
import CollapsibleSlot from '$lib/components/CollapsibleSlot.svelte';
|
||||
import EntitySelect, { type EntityItem } from '$lib/components/EntitySelect.svelte';
|
||||
import { getLocaleMeta } from '$lib/locales';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
import { highlightFromUrl } from '$lib/highlight';
|
||||
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
||||
@@ -71,7 +73,24 @@
|
||||
let showPreviewFor = $state<Set<string>>(new Set());
|
||||
|
||||
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) {
|
||||
const next = new Set(expandedSlots);
|
||||
@@ -272,7 +291,7 @@
|
||||
function openNew() {
|
||||
form = defaultForm();
|
||||
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();
|
||||
}
|
||||
function edit(c: TemplateConfig) {
|
||||
@@ -285,7 +304,7 @@
|
||||
date_format: c.date_format || '%d.%m.%Y, %H:%M UTC',
|
||||
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 = {};
|
||||
expandedSlots = new Set(); showPreviewFor = new Set(); slotFilter = '';
|
||||
setTimeout(() => refreshAllPreviews(), 100);
|
||||
@@ -372,7 +391,7 @@
|
||||
};
|
||||
editing = null;
|
||||
showForm = true;
|
||||
activeLocale = 'en';
|
||||
activeLocale = primaryLocale;
|
||||
slotPreview = {};
|
||||
slotErrors = {};
|
||||
setTimeout(() => refreshAllPreviews(), 100);
|
||||
@@ -447,15 +466,19 @@
|
||||
<IconGridSelect items={previewTargetTypeItems()} bind:value={previewTargetType} columns={2} />
|
||||
</div>
|
||||
|
||||
<!-- Locale tabs -->
|
||||
<div class="flex items-center gap-1 mb-3 border-b border-[var(--color-border)]">
|
||||
{#each LOCALES as loc}
|
||||
<button type="button"
|
||||
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)]'}"
|
||||
onclick={() => { activeLocale = loc; refreshAllPreviews(); }}>
|
||||
{loc.toUpperCase()}
|
||||
</button>
|
||||
{/each}
|
||||
<!-- Language picker -->
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-medium text-[var(--color-muted-foreground)] shrink-0">
|
||||
{t('templateConfig.language')}
|
||||
</span>
|
||||
<div class="flex-1 max-w-xs">
|
||||
<EntitySelect
|
||||
items={localeItems}
|
||||
value={activeLocale}
|
||||
size="sm"
|
||||
onselect={(v) => { activeLocale = (v as string) || primaryLocale; refreshAllPreviews(); }}
|
||||
/>
|
||||
</div>
|
||||
{#if form.provider_type}
|
||||
<button type="button" onclick={resetAllToDefaults}
|
||||
title={t('templateConfig.resetAllToDefaults')}
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(fileURLToPath(new URL('./package.json', import.meta.url)), 'utf8'),
|
||||
);
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(pkg.version),
|
||||
},
|
||||
server: {
|
||||
port: 5175,
|
||||
proxy: {
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "notify-bridge-core"
|
||||
version = "0.6.1"
|
||||
version = "0.6.5"
|
||||
description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
|
||||
@@ -6,12 +6,46 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import FormData
|
||||
|
||||
# Telegram 429 / flood-control retry settings. Telegram returns
|
||||
# ``parameters.retry_after`` for rate limits; we honor it up to a cap so a
|
||||
# pathological value can't park the dispatcher for minutes.
|
||||
_TG_429_MAX_ATTEMPTS = 4
|
||||
_TG_429_MAX_WAIT_S = 60
|
||||
_RETRY_AFTER_RE = re.compile(r"retry after (\d+)", re.IGNORECASE)
|
||||
|
||||
|
||||
def _extract_retry_after(result: dict[str, Any]) -> int | None:
|
||||
"""Return the retry_after seconds from a Telegram error response.
|
||||
|
||||
Prefers the structured ``parameters.retry_after`` field; falls back to
|
||||
parsing the human-readable description (``"Too Many Requests: retry
|
||||
after N"``) which Telegram has been known to return without the
|
||||
structured field on some endpoints.
|
||||
"""
|
||||
params = result.get("parameters") or {}
|
||||
ra = params.get("retry_after")
|
||||
if isinstance(ra, (int, float)) and ra > 0:
|
||||
return int(ra)
|
||||
desc = str(result.get("description", ""))
|
||||
m = _RETRY_AFTER_RE.search(desc)
|
||||
if m:
|
||||
try:
|
||||
return int(m.group(1))
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _is_rate_limited(status: int, result: dict[str, Any]) -> bool:
|
||||
return status == 429 or result.get("error_code") == 429
|
||||
|
||||
from .cache import TelegramFileCache
|
||||
from .media import (
|
||||
TELEGRAM_API_BASE_URL,
|
||||
@@ -193,40 +227,58 @@ class TelegramClient:
|
||||
thumbhash: str | None,
|
||||
) -> NotificationResult:
|
||||
"""Multipart-upload ``data`` to Telegram and cache the returned file_id."""
|
||||
form = FormData()
|
||||
form.add_field("chat_id", chat_id)
|
||||
form.add_field(kind.form_field, data, filename=filename, content_type=content_type)
|
||||
form.add_field("parse_mode", parse_mode)
|
||||
if caption:
|
||||
form.add_field("caption", caption)
|
||||
if reply_to_message_id:
|
||||
form.add_field("reply_parameters", json.dumps({"message_id": reply_to_message_id}))
|
||||
def _build_form() -> FormData:
|
||||
f = FormData()
|
||||
f.add_field("chat_id", chat_id)
|
||||
f.add_field(kind.form_field, data, filename=filename, content_type=content_type)
|
||||
f.add_field("parse_mode", parse_mode)
|
||||
if caption:
|
||||
f.add_field("caption", caption)
|
||||
if reply_to_message_id:
|
||||
f.add_field("reply_parameters", json.dumps({"message_id": reply_to_message_id}))
|
||||
return f
|
||||
|
||||
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/{kind.api_method}"
|
||||
try:
|
||||
async with self._session.post(telegram_url, data=form) as response:
|
||||
result = await response.json()
|
||||
if response.status == 200 and result.get("ok"):
|
||||
res = result.get("result", {})
|
||||
file_id = kind.file_id_from_result(res)
|
||||
if file_id and cache and cache_key:
|
||||
await cache.async_set(
|
||||
cache_key, file_id, kind.cache_type,
|
||||
thumbhash=thumbhash, size=len(data),
|
||||
for attempt in range(1, _TG_429_MAX_ATTEMPTS + 1):
|
||||
try:
|
||||
async with self._session.post(telegram_url, data=_build_form()) as response:
|
||||
result = await response.json()
|
||||
if response.status == 200 and result.get("ok"):
|
||||
res = result.get("result", {})
|
||||
file_id = kind.file_id_from_result(res)
|
||||
if file_id and cache and cache_key:
|
||||
await cache.async_set(
|
||||
cache_key, file_id, kind.cache_type,
|
||||
thumbhash=thumbhash, size=len(data),
|
||||
)
|
||||
return {"success": True, "message_id": res.get("message_id")}
|
||||
|
||||
if _is_rate_limited(response.status, result) and attempt < _TG_429_MAX_ATTEMPTS:
|
||||
retry_after = _extract_retry_after(result) or 1
|
||||
wait_s = min(retry_after + 1, _TG_429_MAX_WAIT_S)
|
||||
_LOGGER.warning(
|
||||
"Telegram %s 429 (retry_after=%ds, attempt %d/%d) bytes=%d — sleeping %ds",
|
||||
kind.api_method, retry_after, attempt, _TG_429_MAX_ATTEMPTS,
|
||||
len(data), wait_s,
|
||||
)
|
||||
return {"success": True, "message_id": res.get("message_id")}
|
||||
await asyncio.sleep(wait_s)
|
||||
continue
|
||||
|
||||
_LOGGER.error(
|
||||
"Telegram %s failed: status=%s code=%s desc=%r bytes=%d",
|
||||
kind.api_method, response.status, result.get("error_code"),
|
||||
result.get("description", "Unknown"), len(data),
|
||||
)
|
||||
return {"success": False, "error": result.get("description", "Unknown Telegram error")}
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.error(
|
||||
"Telegram %s failed: status=%s code=%s desc=%r bytes=%d",
|
||||
kind.api_method, response.status, result.get("error_code"),
|
||||
result.get("description", "Unknown"), len(data),
|
||||
"Telegram %s transport error (bytes=%d): %s",
|
||||
kind.api_method, len(data), err, exc_info=True,
|
||||
)
|
||||
return {"success": False, "error": result.get("description", "Unknown Telegram error")}
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.error(
|
||||
"Telegram %s transport error (bytes=%d): %s",
|
||||
kind.api_method, len(data), err, exc_info=True,
|
||||
)
|
||||
return {"success": False, "error": str(err)}
|
||||
return {"success": False, "error": str(err)}
|
||||
# All attempts exhausted via 429 — should be unreachable, but keep
|
||||
# an explicit error path so we never return None.
|
||||
return {"success": False, "error": "Telegram rate limit: max retries exhausted"}
|
||||
|
||||
async def send_notification(
|
||||
self,
|
||||
@@ -299,12 +351,7 @@ class TelegramClient:
|
||||
send_large_photos_as_documents,
|
||||
)
|
||||
finally:
|
||||
if typing_task:
|
||||
typing_task.cancel()
|
||||
try:
|
||||
await typing_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
await self.stop_keepalive(typing_task)
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
@@ -368,20 +415,53 @@ class TelegramClient:
|
||||
return False
|
||||
|
||||
def start_chat_action_keepalive(self, chat_id: str, action: str = "typing") -> asyncio.Task:
|
||||
"""Repeatedly post ``action`` every 4s until the returned task is cancelled.
|
||||
"""Repeatedly post ``action`` every 4s until stopped.
|
||||
|
||||
Telegram chat actions expire after ~5s, so callers that want the hint
|
||||
to persist through longer work (fetching assets, multi-chunk uploads)
|
||||
need a keep-alive. Cancel the task in a ``finally`` to stop it.
|
||||
need a keep-alive.
|
||||
|
||||
The returned task carries an attached ``stop_event`` (``asyncio.Event``).
|
||||
Stop cleanly via :meth:`stop_keepalive` — setting the event before
|
||||
cancellation prevents the loop from firing one last ``sendChatAction``
|
||||
after the caller's final user-visible message, which would otherwise
|
||||
leave a phantom indicator hanging for ~5s.
|
||||
"""
|
||||
stop_event = asyncio.Event()
|
||||
|
||||
async def action_loop() -> None:
|
||||
try:
|
||||
while True:
|
||||
while not stop_event.is_set():
|
||||
await self.send_chat_action(chat_id, action)
|
||||
await asyncio.sleep(4)
|
||||
try:
|
||||
await asyncio.wait_for(stop_event.wait(), timeout=4)
|
||||
except asyncio.TimeoutError:
|
||||
pass # 4s elapsed, refresh the action
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
return asyncio.create_task(action_loop())
|
||||
|
||||
task: asyncio.Task = asyncio.create_task(action_loop())
|
||||
task.stop_event = stop_event # type: ignore[attr-defined]
|
||||
return task
|
||||
|
||||
@staticmethod
|
||||
async def stop_keepalive(task: asyncio.Task | None) -> None:
|
||||
"""Stop a keepalive task started by :meth:`start_chat_action_keepalive`.
|
||||
|
||||
Sets the attached stop event before cancelling so the loop won't
|
||||
fire another ``sendChatAction`` after the caller's final message
|
||||
landed at Telegram.
|
||||
"""
|
||||
if task is None:
|
||||
return
|
||||
stop_event = getattr(task, "stop_event", None)
|
||||
if stop_event is not None:
|
||||
stop_event.set()
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def _send_photo(
|
||||
self, chat_id: str, url: str | None, caption: str | None = None,
|
||||
@@ -526,12 +606,10 @@ class TelegramClient:
|
||||
all_message_ids.append(result.get("message_id"))
|
||||
continue
|
||||
|
||||
# Multi-item: download all, build form, send media group
|
||||
form = FormData()
|
||||
form.add_field("chat_id", chat_id)
|
||||
if reply_to_message_id and chunk_idx == 0:
|
||||
form.add_field("reply_parameters", json.dumps({"message_id": reply_to_message_id}))
|
||||
|
||||
# Multi-item: download all, build form, send media group.
|
||||
# Attachments are recorded separately so we can rebuild FormData on
|
||||
# 429 retry — aiohttp.FormData is single-use after a request.
|
||||
attachments: list[tuple[str, bytes, str, str]] = [] # (name, data, filename, content_type)
|
||||
media_json = []
|
||||
upload_idx = 0
|
||||
# Track cache info per media_json entry (in order) so we can map
|
||||
@@ -646,7 +724,7 @@ class TelegramClient:
|
||||
attach_name = f"file{upload_idx}"
|
||||
ct = item.get("content_type") or ("image/jpeg" if media_type == "photo" else "video/mp4")
|
||||
ext = "jpg" if media_type == "photo" else "mp4"
|
||||
form.add_field(attach_name, data, filename=f"media_{idx}.{ext}", content_type=ct)
|
||||
attachments.append((attach_name, data, f"media_{idx}.{ext}", ct))
|
||||
mij = {"type": media_type, "media": f"attach://{attach_name}"}
|
||||
upload_idx += 1
|
||||
# Record cache key so we can store file_id from response
|
||||
@@ -674,59 +752,86 @@ class TelegramClient:
|
||||
)
|
||||
continue
|
||||
|
||||
form.add_field("media", json.dumps(media_json))
|
||||
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendMediaGroup"
|
||||
|
||||
try:
|
||||
async with self._session.post(telegram_url, data=form) as response:
|
||||
result = await response.json()
|
||||
if response.status == 200 and result.get("ok"):
|
||||
result_msgs = result.get("result", [])
|
||||
all_message_ids.extend(msg.get("message_id") for msg in result_msgs)
|
||||
def _build_form() -> FormData:
|
||||
f = FormData()
|
||||
f.add_field("chat_id", chat_id)
|
||||
if reply_to_message_id and chunk_idx == 0:
|
||||
f.add_field("reply_parameters", json.dumps({"message_id": reply_to_message_id}))
|
||||
for name, payload, filename, ct in attachments:
|
||||
f.add_field(name, payload, filename=filename, content_type=ct)
|
||||
f.add_field("media", json.dumps(media_json))
|
||||
return f
|
||||
|
||||
chunk_failed_result: dict[str, Any] | None = None
|
||||
for attempt in range(1, _TG_429_MAX_ATTEMPTS + 1):
|
||||
try:
|
||||
async with self._session.post(telegram_url, data=_build_form()) as response:
|
||||
result = await response.json()
|
||||
if response.status == 200 and result.get("ok"):
|
||||
result_msgs = result.get("result", [])
|
||||
all_message_ids.extend(msg.get("message_id") for msg in result_msgs)
|
||||
|
||||
# Cache file_ids from response — map by position
|
||||
cache_entries: list[tuple[str, str, str, str | None, int | None]] = []
|
||||
for i, msg in enumerate(result_msgs):
|
||||
if i >= len(media_cache_info):
|
||||
break
|
||||
info = media_cache_info[i]
|
||||
if info is None:
|
||||
continue # was a cache hit, skip
|
||||
ck, mt, th, sz = info
|
||||
file_id = None
|
||||
if msg.get("photo"):
|
||||
file_id = msg["photo"][-1].get("file_id")
|
||||
elif msg.get("video"):
|
||||
file_id = msg["video"].get("file_id")
|
||||
elif msg.get("document"):
|
||||
file_id = msg["document"].get("file_id")
|
||||
if file_id:
|
||||
cache_entries.append((ck, file_id, mt, th, sz))
|
||||
if cache_entries:
|
||||
# All entries in a chunk share the same cache backend
|
||||
eff_cache = self._get_cache_for_key(cache_entries[0][0], is_asset_cache_key(cache_entries[0][0]))
|
||||
if eff_cache:
|
||||
await eff_cache.async_set_many(cache_entries)
|
||||
break # chunk succeeded
|
||||
|
||||
if _is_rate_limited(response.status, result) and attempt < _TG_429_MAX_ATTEMPTS:
|
||||
retry_after = _extract_retry_after(result) or 1
|
||||
wait_s = min(retry_after + 1, _TG_429_MAX_WAIT_S)
|
||||
_LOGGER.warning(
|
||||
"Telegram sendMediaGroup 429 (retry_after=%ds, attempt %d/%d) chunk=%d/%d items=%d — sleeping %ds",
|
||||
retry_after, attempt, _TG_429_MAX_ATTEMPTS,
|
||||
chunk_idx + 1, len(chunks), len(media_json), wait_s,
|
||||
)
|
||||
await asyncio.sleep(wait_s)
|
||||
continue
|
||||
|
||||
# Cache file_ids from response — map by position
|
||||
cache_entries: list[tuple[str, str, str, str | None, int | None]] = []
|
||||
for i, msg in enumerate(result_msgs):
|
||||
if i >= len(media_cache_info):
|
||||
break
|
||||
info = media_cache_info[i]
|
||||
if info is None:
|
||||
continue # was a cache hit, skip
|
||||
ck, mt, th, sz = info
|
||||
file_id = None
|
||||
if msg.get("photo"):
|
||||
file_id = msg["photo"][-1].get("file_id")
|
||||
elif msg.get("video"):
|
||||
file_id = msg["video"].get("file_id")
|
||||
elif msg.get("document"):
|
||||
file_id = msg["document"].get("file_id")
|
||||
if file_id:
|
||||
cache_entries.append((ck, file_id, mt, th, sz))
|
||||
if cache_entries:
|
||||
# All entries in a chunk share the same cache backend
|
||||
eff_cache = self._get_cache_for_key(cache_entries[0][0], is_asset_cache_key(cache_entries[0][0]))
|
||||
if eff_cache:
|
||||
await eff_cache.async_set_many(cache_entries)
|
||||
else:
|
||||
_LOGGER.error(
|
||||
"Telegram sendMediaGroup failed: status=%s code=%s desc=%r chunk=%d/%d items=%d",
|
||||
response.status, result.get("error_code"),
|
||||
result.get("description", "Unknown"),
|
||||
chunk_idx + 1, len(chunks), len(media_json),
|
||||
)
|
||||
return {
|
||||
chunk_failed_result = {
|
||||
"success": False,
|
||||
"error": result.get("description", "Unknown"),
|
||||
"error_code": result.get("error_code"),
|
||||
"failed_at_chunk": chunk_idx + 1,
|
||||
}
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.error(
|
||||
"Telegram sendMediaGroup transport error on chunk %d/%d (%d items): %s",
|
||||
chunk_idx + 1, len(chunks), len(media_json), err,
|
||||
exc_info=True,
|
||||
)
|
||||
return {"success": False, "error": str(err), "failed_at_chunk": chunk_idx + 1}
|
||||
break
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.error(
|
||||
"Telegram sendMediaGroup transport error on chunk %d/%d (%d items): %s",
|
||||
chunk_idx + 1, len(chunks), len(media_json), err,
|
||||
exc_info=True,
|
||||
)
|
||||
return {"success": False, "error": str(err), "failed_at_chunk": chunk_idx + 1}
|
||||
|
||||
if chunk_failed_result is not None:
|
||||
return chunk_failed_result
|
||||
|
||||
# Distinguish "posted something" from "posted nothing" so the caller
|
||||
# can surface an ERROR when a command produced a caption reply but no
|
||||
|
||||
@@ -150,6 +150,40 @@ class GiteaClient:
|
||||
_LOGGER.warning("Failed to fetch commits for %s/%s: %s", owner, repo, err)
|
||||
return []
|
||||
|
||||
async def get_users(self, limit: int = 200) -> list[dict[str, Any]]:
|
||||
"""List users known to the Gitea instance via /users/search.
|
||||
|
||||
``/users/search`` with an empty ``q`` returns all users the
|
||||
authenticated token can see, paginated. We cap at ``limit`` to avoid
|
||||
unbounded memory on large instances; the picker only needs enough to
|
||||
cover senders that may appear in webhook payloads.
|
||||
"""
|
||||
users: list[dict[str, Any]] = []
|
||||
page = 1
|
||||
per_page = min(50, limit)
|
||||
while len(users) < limit:
|
||||
try:
|
||||
async with self._session.get(
|
||||
f"{self._url}/api/v1/users/search",
|
||||
headers=self._headers,
|
||||
params={"page": str(page), "limit": str(per_page)},
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
_LOGGER.warning("Failed to fetch users: HTTP %s", response.status)
|
||||
break
|
||||
body = await response.json()
|
||||
items = body.get("data", []) if isinstance(body, dict) else body
|
||||
if not items:
|
||||
break
|
||||
users.extend(items)
|
||||
if len(items) < per_page:
|
||||
break
|
||||
page += 1
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.warning("Failed to fetch users: %s", err)
|
||||
break
|
||||
return users[:limit]
|
||||
|
||||
|
||||
class GiteaApiError(Exception):
|
||||
"""Raised when a Gitea API call fails."""
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "notify-bridge-server"
|
||||
version = "0.6.1"
|
||||
version = "0.6.5"
|
||||
description = "Standalone Notify Bridge server — FastAPI REST API with SQLite database"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
|
||||
@@ -12,7 +12,7 @@ import aiohttp
|
||||
|
||||
from ..auth.dependencies import get_current_user
|
||||
from ..database.engine import get_session
|
||||
from ..database.models import ServiceProvider, User
|
||||
from ..database.models import EventLog, ServiceProvider, User
|
||||
from ..services import (
|
||||
make_immich_provider, make_gitea_provider, make_planka_provider,
|
||||
make_nut_provider, make_google_photos_provider, list_provider_collections,
|
||||
@@ -398,6 +398,62 @@ async def list_collections(
|
||||
return await list_provider_collections(provider)
|
||||
|
||||
|
||||
@router.get("/{provider_id}/users")
|
||||
async def list_provider_users(
|
||||
provider_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[dict[str, str]]:
|
||||
"""Return user identities for sender allowlist/blocklist pickers.
|
||||
|
||||
Two sources are merged so the picker is useful both before and after the
|
||||
first webhook arrives:
|
||||
|
||||
- **Provider API** (primary): Gitea's ``/users/search`` returns instance
|
||||
users the api_token can see. Skipped when no api_token is set.
|
||||
- **Past senders** (fallback): distinct ``sender`` values from
|
||||
``EventLog.details`` for this provider, so pre-existing trackers stay
|
||||
filterable even if the API call fails or is unconfigured.
|
||||
"""
|
||||
provider = await _get_user_provider(session, provider_id, user.id)
|
||||
|
||||
users_by_id: dict[str, str] = {}
|
||||
|
||||
# 1. Try the provider API.
|
||||
if provider.type == "gitea" and (provider.config or {}).get("api_token"):
|
||||
from notify_bridge_core.providers.gitea.client import GiteaClient
|
||||
http_session = await get_http_session()
|
||||
client = GiteaClient(
|
||||
http_session,
|
||||
provider.config.get("url", ""),
|
||||
provider.config.get("api_token", ""),
|
||||
)
|
||||
try:
|
||||
for u in await client.get_users():
|
||||
login = u.get("login", "")
|
||||
if isinstance(login, str) and login:
|
||||
users_by_id[login] = u.get("full_name") or login
|
||||
except Exception:
|
||||
_LOGGER.warning("Failed to fetch Gitea users via API", exc_info=True)
|
||||
|
||||
# 2. Merge in past senders (covers users not visible to the API token, or
|
||||
# cases where the API call fails).
|
||||
result = await session.exec(
|
||||
select(EventLog.details).where(EventLog.provider_id == provider.id)
|
||||
)
|
||||
for details in result.all():
|
||||
if not isinstance(details, dict):
|
||||
continue
|
||||
sender = details.get("sender", "")
|
||||
if isinstance(sender, str) and sender and sender not in users_by_id:
|
||||
users_by_id[sender] = sender
|
||||
|
||||
return [
|
||||
{"id": login, "name": name}
|
||||
for login, name in sorted(users_by_id.items(), key=lambda kv: kv[0].lower())
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{provider_id}/albums/{album_id}/shared-links")
|
||||
async def get_album_shared_links(
|
||||
provider_id: int,
|
||||
|
||||
@@ -197,6 +197,21 @@ async def migrate_schema(engine: AsyncEngine) -> None:
|
||||
)
|
||||
logger.info("Added filters column to %s table", tracker_table)
|
||||
|
||||
# Drop legacy batch_duration column from notification_tracker.
|
||||
# The field was removed from the SQLModel class but the column still
|
||||
# exists as NOT NULL in older DBs, so INSERTs from the new code fail
|
||||
# with "NOT NULL constraint failed: notification_tracker.batch_duration".
|
||||
if await _has_table(conn, tracker_table):
|
||||
if await _has_column(conn, tracker_table, "batch_duration"):
|
||||
_assert_ident(tracker_table, "table")
|
||||
await conn.execute(
|
||||
text(f"ALTER TABLE {tracker_table} DROP COLUMN batch_duration")
|
||||
)
|
||||
logger.info(
|
||||
"Dropped legacy batch_duration column from %s table",
|
||||
tracker_table,
|
||||
)
|
||||
|
||||
# Add Gitea tracking flags to tracking_config if missing
|
||||
if await _has_table(conn, "tracking_config"):
|
||||
gitea_flags = [
|
||||
@@ -1376,6 +1391,40 @@ async def migrate_performance_indexes(engine: AsyncEngine) -> None:
|
||||
)
|
||||
|
||||
|
||||
async def migrate_chat_action_to_column(engine: AsyncEngine) -> None:
|
||||
"""Move ``chat_action`` from ``config`` JSON to the dedicated column.
|
||||
|
||||
Earlier versions of the frontend stored ``chat_action`` inside
|
||||
``notification_target.config``; the dedicated ``chat_action`` column
|
||||
was rarely set or held a stale default. The dispatcher's resolver
|
||||
overrode the config value with the (stale) column, so a user's UI
|
||||
choice silently had no effect on outgoing chat actions.
|
||||
|
||||
This backfill takes the config value as authoritative (it's what the
|
||||
UI was writing) and copies it to the column, then strips it from
|
||||
config so the column becomes the single source of truth. Idempotent:
|
||||
a second run finds nothing to migrate.
|
||||
"""
|
||||
async with engine.begin() as conn:
|
||||
if not await _has_table(conn, "notification_target"):
|
||||
return
|
||||
if not await _has_column(conn, "notification_target", "chat_action"):
|
||||
return
|
||||
# Copy config["chat_action"] → column where present.
|
||||
await conn.execute(text(
|
||||
"UPDATE notification_target "
|
||||
"SET chat_action = json_extract(config, '$.chat_action') "
|
||||
"WHERE json_extract(config, '$.chat_action') IS NOT NULL"
|
||||
))
|
||||
# Strip the legacy key so the column is unambiguous going forward.
|
||||
await conn.execute(text(
|
||||
"UPDATE notification_target "
|
||||
"SET config = json_remove(config, '$.chat_action') "
|
||||
"WHERE json_extract(config, '$.chat_action') IS NOT NULL"
|
||||
))
|
||||
logger.info("Migrated chat_action from config JSON to column where present")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema version tracking — lightweight alternative to Alembic while the
|
||||
# hand-rolled idempotent migrations remain the source of truth. Gives
|
||||
|
||||
@@ -75,6 +75,7 @@ async def lifespan(app: FastAPI):
|
||||
migrate_notification_slot_locale,
|
||||
migrate_user_token_version,
|
||||
migrate_performance_indexes,
|
||||
migrate_chat_action_to_column,
|
||||
migrate_schema_version,
|
||||
)
|
||||
from .database.snapshot import snapshot_and_prune
|
||||
@@ -98,6 +99,7 @@ async def lifespan(app: FastAPI):
|
||||
await migrate_notification_slot_locale(engine)
|
||||
await migrate_user_token_version(engine)
|
||||
await migrate_performance_indexes(engine)
|
||||
await migrate_chat_action_to_column(engine)
|
||||
await migrate_schema_version(engine)
|
||||
from .database.seeds import seed_all
|
||||
await seed_all()
|
||||
|
||||
@@ -326,7 +326,11 @@ async def _resolve_target(
|
||||
receivers.append(build_receiver(target.type, dict(r.config), locale))
|
||||
|
||||
target_config = dict(target.config)
|
||||
# Inject chat_action for Telegram targets
|
||||
# chat_action lives on the model column — single source of truth.
|
||||
# Strip any legacy/stale value from config so an old config-stored value
|
||||
# can't shadow the user's UI choice. When the column is unset, leave the
|
||||
# key absent so the dispatcher's "typing" fallback applies.
|
||||
target_config.pop("chat_action", None)
|
||||
if hasattr(target, 'chat_action') and target.chat_action:
|
||||
target_config["chat_action"] = target.chat_action
|
||||
# Inject bot credentials for bot-backed target types
|
||||
|
||||
@@ -378,6 +378,8 @@ async def _load_tracker_jobs() -> None:
|
||||
|
||||
tz = await _load_app_timezone()
|
||||
|
||||
from notify_bridge_core.providers.capabilities import get_capabilities
|
||||
|
||||
for tracker in trackers:
|
||||
job_id = f"tracker_{tracker.id}"
|
||||
if scheduler.get_job(job_id):
|
||||
@@ -386,6 +388,18 @@ async def _load_tracker_jobs() -> None:
|
||||
ptype = provider_types.get(tracker.provider_id, "")
|
||||
filters = tracker.filters or {}
|
||||
|
||||
# Webhook-based providers receive events via inbound HTTP — there is
|
||||
# nothing to poll. Scheduling an interval job for them just wakes up
|
||||
# check_tracker every scan_interval seconds to immediately return,
|
||||
# wasting CPU and DB queries for no work.
|
||||
caps = get_capabilities(ptype) if ptype else None
|
||||
if caps and caps.webhook_based:
|
||||
_LOGGER.debug(
|
||||
"Skipping interval scheduling for webhook tracker %d (%s, type=%s)",
|
||||
tracker.id, tracker.name, ptype,
|
||||
)
|
||||
continue
|
||||
|
||||
# Scheduler providers can use cron triggers
|
||||
if ptype == "scheduler" and filters.get("schedule_type") == "cron":
|
||||
cron_expr = filters.get("cron_expression", "")
|
||||
@@ -450,6 +464,29 @@ def _add_cron_job(
|
||||
)
|
||||
|
||||
|
||||
async def _is_webhook_tracker(tracker_id: int) -> bool:
|
||||
"""Return True iff the tracker's provider type is webhook-based.
|
||||
|
||||
Looks up provider type once via the capabilities registry. Used by
|
||||
``schedule_tracker`` to short-circuit interval scheduling.
|
||||
"""
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
from notify_bridge_core.providers.capabilities import get_capabilities
|
||||
from ..database.engine import get_engine
|
||||
from ..database.models import NotificationTracker, ServiceProvider as ServiceProviderModel
|
||||
|
||||
async with AsyncSession(get_engine()) as session:
|
||||
tracker = await session.get(NotificationTracker, tracker_id)
|
||||
if tracker is None:
|
||||
return False
|
||||
provider = await session.get(ServiceProviderModel, tracker.provider_id)
|
||||
if provider is None:
|
||||
return False
|
||||
caps = get_capabilities(provider.type)
|
||||
return bool(caps and caps.webhook_based)
|
||||
|
||||
|
||||
async def schedule_tracker(
|
||||
tracker_id: int,
|
||||
interval: int,
|
||||
@@ -461,6 +498,10 @@ async def schedule_tracker(
|
||||
``adaptive_max_skip`` mirrors the DB column and is registered with the
|
||||
adaptive module-state so tick-time skip decisions don't re-query the DB.
|
||||
Pass ``None`` or ``0`` to disable back-off for the tracker.
|
||||
|
||||
Webhook-based providers receive events via inbound HTTP and have nothing
|
||||
to poll, so this no-ops for them — preventing scan_interval from creating
|
||||
useless wakeups via the API create/update path.
|
||||
"""
|
||||
scheduler = get_scheduler()
|
||||
job_id = f"tracker_{tracker_id}"
|
||||
@@ -474,6 +515,13 @@ async def schedule_tracker(
|
||||
if scheduler.get_job(job_id):
|
||||
scheduler.remove_job(job_id)
|
||||
|
||||
# Webhook-based providers don't poll — skip job creation entirely.
|
||||
if await _is_webhook_tracker(tracker_id):
|
||||
_LOGGER.debug(
|
||||
"Skipping interval scheduling for webhook tracker %d", tracker_id,
|
||||
)
|
||||
return
|
||||
|
||||
if cron_expression:
|
||||
try:
|
||||
tz = await _load_app_timezone()
|
||||
|
||||
@@ -19,7 +19,6 @@ this module just guarantees every caller gets a properly-wired client.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
from typing import Any, AsyncIterator, Callable
|
||||
|
||||
@@ -144,6 +143,4 @@ async def telegram_chat_action(
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
await client.stop_keepalive(task)
|
||||
|
||||
Reference in New Issue
Block a user