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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user