feat: locale-aware command templates, debounced auto-sync, entity pickers

- Locale-aware templates: CommandTemplateSlot now has a locale column,
  allowing each slot to have per-language variants (EN/RU). Templates
  are resolved at runtime from the Telegram user's language_code.

- Merged system configs: "Default Commands (EN)" and "(RU)" merged
  into a single "Default Commands" config with locale-aware slots.
  Migration handles existing data automatically.

- Configurable command descriptions: hardcoded COMMAND_DESCRIPTIONS
  replaced with desc_* template slots (desc_status, desc_help, etc.)
  that users can customize per locale. setMyCommands registers all
  locales explicitly.

- Removed locale from CommandConfig: no longer needed since locale
  is derived from the Telegram user's language at runtime.

- Debounced command auto-sync: after command config/tracker changes,
  affected bots are marked dirty and synced after a 30s debounce
  window. Manual "Sync with Telegram" button still works.

- Entity pickers in LinkedTargetsSection: replaced 6 plain <select>
  elements with EntitySelect components (search, icons, keyboard nav).
  Added onselect callback and size="sm" props to EntitySelect.
This commit is contained in:
2026-03-22 03:14:51 +03:00
parent 751097b347
commit 1167d138a3
47 changed files with 604 additions and 230 deletions
@@ -15,6 +15,8 @@
allowNone = false,
noneLabel = '—',
disabled = false,
size = 'default',
onselect,
}: {
items: EntityItem[];
value: string | number | null;
@@ -22,6 +24,8 @@
allowNone?: boolean;
noneLabel?: string;
disabled?: boolean;
size?: 'sm' | 'default';
onselect?: (value: string | number | null) => void;
} = $props();
let open = $state(false);
@@ -59,6 +63,7 @@
function selectItem(item: EntityItem) {
value = item.value || null;
onselect?.(value);
closePalette();
}
@@ -97,7 +102,7 @@
</script>
<!-- Trigger button -->
<button type="button" class="es-trigger" onclick={openPalette}
<button type="button" class="es-trigger" class:es-sm={size === 'sm'} onclick={openPalette}
style="opacity: {disabled ? 0.5 : 1}; cursor: {disabled ? 'default' : 'pointer'};">
{#if selected}
{#if selected.icon}
@@ -174,6 +179,11 @@
text-align: left;
cursor: pointer;
}
.es-trigger.es-sm {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
gap: 0.375rem;
}
.es-trigger:hover {
border-color: var(--color-primary);
}
+1 -1
View File
@@ -201,7 +201,7 @@ export interface CommandTemplateConfig {
name: string;
description: string;
icon: string;
slots: Record<string, string>;
slots: Record<string, Record<string, string>>;
created_at: string;
}
@@ -13,7 +13,7 @@
import IconButton from '$lib/components/IconButton.svelte';
import CrossLink from '$lib/components/CrossLink.svelte';
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
import { providerTypeItems, localeItems, responseModeItems } from '$lib/grid-items';
import { providerTypeItems, responseModeItems } from '$lib/grid-items';
import EntitySelect from '$lib/components/EntitySelect.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight';
@@ -59,7 +59,6 @@
icon: '',
provider_type: 'immich',
enabled_commands: ['help', 'status', 'albums', 'events', 'latest', 'random', 'favorites', 'summary', 'memory'] as string[],
locale: 'en',
response_mode: 'media',
default_count: 5,
rate_limits: { search: 30, default: 10 },
@@ -92,7 +91,6 @@
icon: cfg.icon || '',
provider_type: cfg.provider_type || 'immich',
enabled_commands: [...(cfg.enabled_commands || [])],
locale: cfg.locale || 'en',
response_mode: cfg.response_mode || 'media',
default_count: cfg.default_count ?? 5,
rate_limits: { search: cfg.rate_limits?.search ?? 30, default: cfg.rate_limits?.default ?? 10 },
@@ -192,11 +190,7 @@
<EntitySelect items={templateItems} bind:value={form.command_template_config_id} placeholder={t('commandConfig.responseTemplate')} />
</div>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div>
<label class="block text-xs mb-1">{t('commandConfig.locale')}</label>
<IconGridSelect items={localeItems()} bind:value={form.locale} columns={2} />
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
<div>
<label class="block text-xs mb-1">{t('commandConfig.responseMode')}</label>
<IconGridSelect items={responseModeItems(t)} bind:value={form.response_mode} columns={2} />
@@ -244,7 +238,6 @@
<span class="text-xs px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-500 font-mono">
{(cfg.enabled_commands || []).length} {t('commandConfig.commands')}
</span>
<span class="text-xs text-[var(--color-muted-foreground)]">{cfg.locale?.toUpperCase()}</span>
</div>
<div class="flex items-center gap-2 mt-0.5">
<span class="text-xs text-[var(--color-muted-foreground)]">
@@ -26,7 +26,7 @@
name: string;
description: string;
icon: string;
slots: Record<string, string>;
slots: Record<string, Record<string, string>>;
created_at: string;
}
@@ -35,6 +35,8 @@
description: string;
}
const LOCALES = ['en', 'ru'] as const;
let configs = $state<CmdTemplateConfig[]>([]);
let loaded = $state(false);
let showForm = $state(false);
@@ -48,6 +50,7 @@
let validateTimers: Record<string, ReturnType<typeof setTimeout>> = {};
let varsRef = $state<Record<string, any>>({});
let showVarsFor = $state<string | null>(null);
let activeLocale = $state<string>('en');
// Provider capabilities
let allCapabilities = $state<Record<string, any>>({});
@@ -61,10 +64,21 @@
name: '',
description: '',
icon: '',
slots: {} as Record<string, string>,
slots: {} as Record<string, Record<string, string>>,
});
let form = $state(defaultForm());
/** Get slot template for current locale, with fallback. */
function getSlotValue(slotName: string): string {
return form.slots[slotName]?.[activeLocale] || '';
}
/** Set slot template for current locale. */
function setSlotValue(slotName: string, value: string) {
if (!form.slots[slotName]) form.slots[slotName] = {};
form.slots[slotName][activeLocale] = value;
}
onMount(load);
async function load() {
@@ -122,7 +136,7 @@
function refreshAllPreviews() {
for (const slot of commandSlots) {
const template = form.slots[slot.name] || '';
const template = getSlotValue(slot.name);
if (template) validateSlot(slot.name, template, true);
}
}
@@ -131,20 +145,27 @@
form = defaultForm();
editing = null;
showForm = true;
activeLocale = 'en';
slotPreview = {};
slotErrors = {};
}
function edit(c: CmdTemplateConfig) {
// Deep copy nested slots
const slotsCopy: Record<string, Record<string, string>> = {};
for (const [k, v] of Object.entries(c.slots)) {
slotsCopy[k] = { ...v };
}
form = {
provider_type: c.provider_type,
name: c.name,
description: c.description || '',
icon: c.icon || '',
slots: { ...c.slots },
slots: slotsCopy,
};
editing = c.id;
showForm = true;
activeLocale = 'en';
slotPreview = {};
slotErrors = {};
setTimeout(() => refreshAllPreviews(), 100);
@@ -170,15 +191,20 @@
}
function clone(c: CmdTemplateConfig) {
const slotsCopy: Record<string, Record<string, string>> = {};
for (const [k, v] of Object.entries(c.slots)) {
slotsCopy[k] = { ...v };
}
form = {
provider_type: c.provider_type,
name: `${c.name} (Copy)`,
description: c.description || '',
icon: c.icon || '',
slots: { ...c.slots },
slots: slotsCopy,
};
editing = null;
showForm = true;
activeLocale = 'en';
slotPreview = {};
slotErrors = {};
setTimeout(() => refreshAllPreviews(), 100);
@@ -245,8 +271,20 @@
<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-3">{t('cmdTemplateConfig.commandResponsesHint')}</p>
<div class="space-y-3 mt-2">
<p class="text-xs text-[var(--color-muted-foreground)] mb-2">{t('cmdTemplateConfig.commandResponsesHint')}</p>
<!-- Locale tabs -->
<div class="flex 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}
</div>
<div class="space-y-3">
{#each commandSlots as slot}
<div>
<div class="flex items-center justify-between mb-1">
@@ -257,8 +295,8 @@
{/if}
</div>
<JinjaEditor
value={form.slots[slot.name] || ''}
onchange={(v: string) => { form.slots[slot.name] = v; validateSlot(slot.name, v); }}
value={getSlotValue(slot.name)}
onchange={(v: string) => { setSlotValue(slot.name, v); validateSlot(slot.name, v); }}
rows={3}
errorLine={slotErrorLines[slot.name] || null}
/>
@@ -3,6 +3,8 @@
import { t } from '$lib/i18n';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import EntitySelect from '$lib/components/EntitySelect.svelte';
import type { EntityItem } from '$lib/components/EntitySelect.svelte';
import type { Tracker, NotificationTarget, TrackingConfig, TemplateConfig } from '$lib/types';
interface Props {
@@ -44,6 +46,23 @@
onchangeNewTrackingConfig,
onchangeNewTemplateConfig,
}: Props = $props();
function toItems(configs: any[]): EntityItem[] {
return configsForTracker(configs).map(c => ({
value: c.id,
label: c.name,
icon: c.icon || '',
}));
}
const trackingConfigItems = $derived(toItems(trackingConfigs));
const templateConfigItems = $derived(toItems(templateConfigs));
const targetItems = $derived<EntityItem[]>(unlinkedTargets.map(tgt => ({
value: tgt.id,
label: tgt.name,
icon: tgt.icon || (tgt.type === 'telegram' ? 'mdiSend' : 'mdiWebhook'),
desc: tgt.type,
})));
</script>
<div class="mt-3 border-t border-[var(--color-border)] pt-3 space-y-2" in:slide>
@@ -61,18 +80,16 @@
{/if}
</div>
<div class="flex items-center gap-2 flex-wrap justify-end">
<select value={tt.tracking_config_id || 0}
onchange={(e: Event) => onupdateLink(tt, 'tracking_config_id', Number((e.target as HTMLSelectElement).value) || null)}
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value={0}> {t('trackingConfig.title')} —</option>
{#each configsForTracker(trackingConfigs) as tc}<option value={tc.id}>{tc.name}</option>{/each}
</select>
<select value={tt.template_config_id || 0}
onchange={(e: Event) => onupdateLink(tt, 'template_config_id', Number((e.target as HTMLSelectElement).value) || null)}
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value={0}> {t('templateConfig.title')} —</option>
{#each configsForTracker(templateConfigs) as tc}<option value={tc.id}>{tc.name}</option>{/each}
</select>
<div class="min-w-[140px]">
<EntitySelect items={trackingConfigItems} value={tt.tracking_config_id}
placeholder={'— ' + t('trackingConfig.title') + ' —'} size="sm" allowNone noneLabel={'— ' + t('trackingConfig.title') + ' —'}
onselect={(v) => onupdateLink(tt, 'tracking_config_id', Number(v) || null)} />
</div>
<div class="min-w-[140px]">
<EntitySelect items={templateConfigItems} value={tt.template_config_id}
placeholder={'— ' + t('templateConfig.title') + ' —'} size="sm" allowNone noneLabel={'— ' + t('templateConfig.title') + ' —'}
onselect={(v) => onupdateLink(tt, 'template_config_id', Number(v) || null)} />
</div>
<div class="relative">
<IconButton icon="mdiDotsVertical" size={14} title={t('common.test')}
onclick={(e: MouseEvent) => onopenTestMenu(tt.id, e)}
@@ -91,24 +108,21 @@
<!-- Add target link -->
{#if unlinkedTargets.length > 0}
<div class="flex items-center gap-2 mt-2">
<select value={newLinkTargetId}
onchange={(e: Event) => onchangeNewTarget(Number((e.target as HTMLSelectElement).value))}
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)] flex-1">
<option value={0}> {t('notificationTracker.addTarget')} —</option>
{#each unlinkedTargets as tgt}<option value={tgt.id}>{tgt.name} ({tgt.type})</option>{/each}
</select>
<select value={newLinkTrackingConfigId}
onchange={(e: Event) => onchangeNewTrackingConfig(Number((e.target as HTMLSelectElement).value))}
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value={0}> {t('trackingConfig.title')} —</option>
{#each configsForTracker(trackingConfigs) as tc}<option value={tc.id}>{tc.name}</option>{/each}
</select>
<select value={newLinkTemplateConfigId}
onchange={(e: Event) => onchangeNewTemplateConfig(Number((e.target as HTMLSelectElement).value))}
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value={0}> {t('templateConfig.title')} —</option>
{#each configsForTracker(templateConfigs) as tc}<option value={tc.id}>{tc.name}</option>{/each}
</select>
<div class="flex-1 min-w-[140px]">
<EntitySelect items={targetItems} value={newLinkTargetId || null}
placeholder={'— ' + t('notificationTracker.addTarget') + ' —'} size="sm"
onselect={(v) => onchangeNewTarget(Number(v) || 0)} />
</div>
<div class="min-w-[140px]">
<EntitySelect items={trackingConfigItems} value={newLinkTrackingConfigId || null}
placeholder={'— ' + t('trackingConfig.title') + ' —'} size="sm" allowNone noneLabel={'— ' + t('trackingConfig.title') + ' —'}
onselect={(v) => onchangeNewTrackingConfig(Number(v) || 0)} />
</div>
<div class="min-w-[140px]">
<EntitySelect items={templateConfigItems} value={newLinkTemplateConfigId || null}
placeholder={'— ' + t('templateConfig.title') + ' —'} size="sm" allowNone noneLabel={'— ' + t('templateConfig.title') + ' —'}
onselect={(v) => onchangeNewTemplateConfig(Number(v) || 0)} />
</div>
<button onclick={onaddLink}
disabled={!newLinkTargetId || addingTarget}
class="text-xs px-3 py-1 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded hover:opacity-90 disabled:opacity-50">