feat: provider-strict configs, slot-based templates, broadcast targets, email bots, command templates
Major architectural improvements: - Provider-type enforcement: configs validated against provider type at assignment - TemplateConfig migrated to slot-based pattern (TemplateSlot child table) - Broadcast targets: TargetReceiver child table for multi-receiver dispatch - EmailBot: first-class email sender entity with SMTP config, test connection - CommandTemplateConfig: generic slot-based command response templates - Provider capability registry: dynamic slot/event/command definitions per provider - CommandTracker play/pause button matches NotificationTracker style
This commit is contained in:
@@ -13,6 +13,7 @@
|
||||
"targets": "Targets",
|
||||
"commandConfigs": "Cmd Configs",
|
||||
"commandTrackers": "Cmd Trackers",
|
||||
"cmdTemplateConfigs": "Cmd Templates",
|
||||
"users": "Users",
|
||||
"settings": "Settings",
|
||||
"logout": "Logout"
|
||||
@@ -478,6 +479,36 @@
|
||||
"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."
|
||||
},
|
||||
"emailBot": {
|
||||
"title": "Email Bots",
|
||||
"description": "SMTP email senders for notifications",
|
||||
"addBot": "Add Email Bot",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "Family Notifications",
|
||||
"email": "From Email",
|
||||
"smtpHost": "SMTP Host",
|
||||
"smtpPort": "Port",
|
||||
"smtpUsername": "Username",
|
||||
"smtpUsernamePlaceholder": "Same as email or app password",
|
||||
"smtpPassword": "Password",
|
||||
"passwordUnchanged": "(unchanged)",
|
||||
"useTls": "Use TLS/SSL",
|
||||
"testConnection": "Send test email",
|
||||
"noBots": "No email bots yet.",
|
||||
"confirmDelete": "Delete this email bot?"
|
||||
},
|
||||
"cmdTemplateConfig": {
|
||||
"title": "Command Templates",
|
||||
"description": "Customize command response messages with Jinja2 templates",
|
||||
"newConfig": "New Config",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "Default Immich Commands",
|
||||
"descriptionPlaceholder": "e.g. Custom response formats for family bot",
|
||||
"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."
|
||||
},
|
||||
"commandConfig": {
|
||||
"title": "Command Configs",
|
||||
"description": "Define command settings for Telegram bot interactions",
|
||||
@@ -558,7 +589,13 @@
|
||||
"commandTrackerEnabled": "Command tracker enabled",
|
||||
"commandTrackerDisabled": "Command tracker disabled",
|
||||
"listenerAdded": "Listener added",
|
||||
"listenerRemoved": "Listener removed"
|
||||
"listenerRemoved": "Listener removed",
|
||||
"cmdTemplateSaved": "Command template saved",
|
||||
"cmdTemplateDeleted": "Command template deleted",
|
||||
"emailBotCreated": "Email bot created",
|
||||
"emailBotUpdated": "Email bot updated",
|
||||
"emailBotDeleted": "Email bot deleted",
|
||||
"emailBotTestSent": "Test email sent successfully"
|
||||
},
|
||||
"common": {
|
||||
"loading": "Loading...",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"targets": "Получатели",
|
||||
"commandConfigs": "Конф. команд",
|
||||
"commandTrackers": "Трекеры команд",
|
||||
"cmdTemplateConfigs": "Шаблоны команд",
|
||||
"users": "Пользователи",
|
||||
"settings": "Настройки",
|
||||
"logout": "Выход"
|
||||
@@ -478,6 +479,36 @@
|
||||
"botLocale": "Язык описаний команд в меню Telegram и ответов бота.",
|
||||
"rateLimits": "Кулдаун в секундах между использованиями команд в каждом чате. 0 = без ограничений."
|
||||
},
|
||||
"emailBot": {
|
||||
"title": "Email боты",
|
||||
"description": "SMTP отправители для уведомлений по email",
|
||||
"addBot": "Добавить Email бот",
|
||||
"name": "Название",
|
||||
"namePlaceholder": "Семейные уведомления",
|
||||
"email": "Email отправителя",
|
||||
"smtpHost": "SMTP сервер",
|
||||
"smtpPort": "Порт",
|
||||
"smtpUsername": "Имя пользователя",
|
||||
"smtpUsernamePlaceholder": "Как email или пароль приложения",
|
||||
"smtpPassword": "Пароль",
|
||||
"passwordUnchanged": "(без изменений)",
|
||||
"useTls": "Использовать TLS/SSL",
|
||||
"testConnection": "Отправить тестовое письмо",
|
||||
"noBots": "Email ботов пока нет.",
|
||||
"confirmDelete": "Удалить этот email бот?"
|
||||
},
|
||||
"cmdTemplateConfig": {
|
||||
"title": "Шаблоны команд",
|
||||
"description": "Настройте ответы команд с помощью Jinja2 шаблонов",
|
||||
"newConfig": "Новый шаблон",
|
||||
"name": "Название",
|
||||
"namePlaceholder": "Команды Immich по умолчанию",
|
||||
"descriptionPlaceholder": "Например, пользовательские форматы ответов",
|
||||
"noConfigs": "Шаблонов команд пока нет.",
|
||||
"confirmDelete": "Удалить этот шаблон команд?",
|
||||
"commandResponses": "Ответы команд",
|
||||
"commandResponsesHint": "Оставьте слот пустым, чтобы использовать ответ по умолчанию."
|
||||
},
|
||||
"commandConfig": {
|
||||
"title": "Конфигурации команд",
|
||||
"description": "Настройки команд для взаимодействия с Telegram-ботами",
|
||||
@@ -558,7 +589,13 @@
|
||||
"commandTrackerEnabled": "Трекер команд включён",
|
||||
"commandTrackerDisabled": "Трекер команд отключён",
|
||||
"listenerAdded": "Слушатель добавлен",
|
||||
"listenerRemoved": "Слушатель удалён"
|
||||
"listenerRemoved": "Слушатель удалён",
|
||||
"cmdTemplateSaved": "Шаблон команд сохранён",
|
||||
"cmdTemplateDeleted": "Шаблон команд удалён",
|
||||
"emailBotCreated": "Email бот создан",
|
||||
"emailBotUpdated": "Email бот обновлён",
|
||||
"emailBotDeleted": "Email бот удалён",
|
||||
"emailBotTestSent": "Тестовое письмо отправлено"
|
||||
},
|
||||
"common": {
|
||||
"loading": "Загрузка...",
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
/** Shared TypeScript interfaces for API entities. */
|
||||
|
||||
export interface ServiceProvider {
|
||||
id: number;
|
||||
type: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
config: Record<string, any>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface EmailBot {
|
||||
id: number;
|
||||
name: string;
|
||||
icon: string;
|
||||
email: string;
|
||||
smtp_host: string;
|
||||
smtp_port: number;
|
||||
smtp_username: string;
|
||||
smtp_password: string;
|
||||
smtp_use_tls: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface TelegramBot {
|
||||
id: number;
|
||||
name: string;
|
||||
icon: string;
|
||||
bot_username: string;
|
||||
bot_id: number;
|
||||
webhook_path_id: string;
|
||||
commands_config: Record<string, any>;
|
||||
token_preview: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface TelegramChat {
|
||||
id: number;
|
||||
chat_id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
username: string;
|
||||
discovered_at: string;
|
||||
}
|
||||
|
||||
export interface TrackerTarget {
|
||||
id: number;
|
||||
target_id: number;
|
||||
target_name: string | null;
|
||||
target_type: string | null;
|
||||
target_icon: string | null;
|
||||
tracking_config_id: number | null;
|
||||
template_config_id: number | null;
|
||||
enabled: boolean;
|
||||
quiet_hours_start: string | null;
|
||||
quiet_hours_end: string | null;
|
||||
commands_config: Record<string, any> | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Tracker {
|
||||
id: number;
|
||||
name: string;
|
||||
icon: string;
|
||||
provider_id: number;
|
||||
collection_ids: string[];
|
||||
scan_interval: number;
|
||||
batch_duration: number;
|
||||
enabled: boolean;
|
||||
tracker_targets: TrackerTarget[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface NotificationTarget {
|
||||
id: number;
|
||||
type: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
config: Record<string, any>;
|
||||
chat_name?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface TrackingConfig {
|
||||
id: number;
|
||||
provider_type: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
track_assets_added: boolean;
|
||||
track_assets_removed: boolean;
|
||||
track_collection_renamed: boolean;
|
||||
track_collection_deleted: boolean;
|
||||
track_sharing_changed: boolean;
|
||||
track_images: boolean;
|
||||
track_videos: boolean;
|
||||
notify_favorites_only: boolean;
|
||||
include_tags: boolean;
|
||||
include_asset_details: boolean;
|
||||
max_assets_to_show: number;
|
||||
assets_order_by: string;
|
||||
assets_order: string;
|
||||
periodic_enabled: boolean;
|
||||
periodic_interval_days: number;
|
||||
periodic_start_date: string;
|
||||
periodic_times: string;
|
||||
scheduled_enabled: boolean;
|
||||
scheduled_times: string;
|
||||
scheduled_collection_mode: string;
|
||||
scheduled_limit: number;
|
||||
scheduled_favorite_only: boolean;
|
||||
scheduled_asset_type: string;
|
||||
scheduled_min_rating: number;
|
||||
scheduled_order_by: string;
|
||||
scheduled_order: string;
|
||||
memory_enabled: boolean;
|
||||
memory_source: string;
|
||||
memory_times: string;
|
||||
memory_collection_mode: string;
|
||||
memory_limit: number;
|
||||
memory_favorite_only: boolean;
|
||||
memory_asset_type: string;
|
||||
memory_min_rating: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface TemplateConfig {
|
||||
id: number;
|
||||
user_id: number;
|
||||
provider_type: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
slots: Record<string, string>;
|
||||
date_format: string;
|
||||
date_only_format: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface EventLog {
|
||||
id: number;
|
||||
event_type: string;
|
||||
collection_id: string;
|
||||
collection_name: string;
|
||||
tracker_name: string;
|
||||
provider_name: string;
|
||||
provider_id: number | null;
|
||||
assets_count: number;
|
||||
details: Record<string, any>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
role: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface DashboardStatus {
|
||||
providers: number;
|
||||
trackers: { total: number; active: number };
|
||||
targets: number;
|
||||
total_events: number;
|
||||
recent_events: EventLog[];
|
||||
}
|
||||
@@ -46,6 +46,7 @@
|
||||
{ href: '/targets', key: 'nav.targets', icon: 'mdiTarget' },
|
||||
{ href: '/command-trackers', key: 'nav.commandTrackers', icon: 'mdiConsoleLine' },
|
||||
{ href: '/command-configs', key: 'nav.commandConfigs', icon: 'mdiCog' },
|
||||
{ href: '/command-template-configs', key: 'nav.cmdTemplateConfigs', icon: 'mdiCodeBracesBox' },
|
||||
];
|
||||
const navItems = $derived(auth.isAdmin
|
||||
? [...baseNavItems, { href: '/users', key: 'nav.users', icon: 'mdiAccountGroup' }, { href: '/settings', key: 'nav.settings', icon: 'mdiCogOutline' }]
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
import IconPicker from '$lib/components/IconPicker.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import JinjaEditor from '$lib/components/JinjaEditor.svelte';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
|
||||
interface CmdTemplateConfig {
|
||||
id: number;
|
||||
user_id: number;
|
||||
provider_type: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
slots: Record<string, string>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface SlotDef {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
let configs = $state<CmdTemplateConfig[]>([]);
|
||||
let loaded = $state(false);
|
||||
let showForm = $state(false);
|
||||
let editing = $state<number | null>(null);
|
||||
let error = $state('');
|
||||
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
|
||||
let slotPreview = $state<Record<string, string>>({});
|
||||
let slotErrors = $state<Record<string, string>>({});
|
||||
let slotErrorLines = $state<Record<string, number | null>>({});
|
||||
let slotErrorTypes = $state<Record<string, string>>({});
|
||||
let validateTimers: Record<string, ReturnType<typeof setTimeout>> = {};
|
||||
|
||||
// Load command slot definitions from capabilities
|
||||
let commandSlots = $state<SlotDef[]>([]);
|
||||
|
||||
const defaultForm = () => ({
|
||||
provider_type: 'immich',
|
||||
name: '',
|
||||
description: '',
|
||||
icon: '',
|
||||
slots: {} as Record<string, string>,
|
||||
});
|
||||
let form = $state(defaultForm());
|
||||
|
||||
onMount(load);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [cfgs, caps] = await Promise.all([
|
||||
api('/command-template-configs'),
|
||||
api('/providers/capabilities/immich'),
|
||||
]);
|
||||
configs = cfgs;
|
||||
commandSlots = caps.command_slots || [];
|
||||
} catch (err: any) {
|
||||
error = err.message || t('common.loadError');
|
||||
snackError(error);
|
||||
} finally {
|
||||
loaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
function validateSlot(slotKey: string, template: string, immediate = false) {
|
||||
if (validateTimers[slotKey]) clearTimeout(validateTimers[slotKey]);
|
||||
if (!template) {
|
||||
slotErrors = { ...slotErrors, [slotKey]: '' };
|
||||
slotErrorLines = { ...slotErrorLines, [slotKey]: null };
|
||||
slotErrorTypes = { ...slotErrorTypes, [slotKey]: '' };
|
||||
const { [slotKey]: _, ...rest } = slotPreview;
|
||||
slotPreview = rest;
|
||||
return;
|
||||
}
|
||||
|
||||
const doValidate = async () => {
|
||||
try {
|
||||
const res = await api('/command-template-configs/preview-raw', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ template }),
|
||||
});
|
||||
slotErrors = { ...slotErrors, [slotKey]: res.error || '' };
|
||||
slotErrorLines = { ...slotErrorLines, [slotKey]: res.error_line || null };
|
||||
slotErrorTypes = { ...slotErrorTypes, [slotKey]: res.error_type || '' };
|
||||
if (res.rendered) {
|
||||
slotPreview = { ...slotPreview, [slotKey]: res.rendered };
|
||||
} else {
|
||||
const { [slotKey]: _, ...rest } = slotPreview;
|
||||
slotPreview = rest;
|
||||
}
|
||||
} catch {
|
||||
slotErrors = { ...slotErrors, [slotKey]: '' };
|
||||
}
|
||||
};
|
||||
if (immediate) doValidate();
|
||||
else validateTimers[slotKey] = setTimeout(doValidate, 800);
|
||||
}
|
||||
|
||||
function refreshAllPreviews() {
|
||||
for (const slot of commandSlots) {
|
||||
const template = form.slots[slot.name] || '';
|
||||
if (template) validateSlot(slot.name, template, true);
|
||||
}
|
||||
}
|
||||
|
||||
function openNew() {
|
||||
form = defaultForm();
|
||||
editing = null;
|
||||
showForm = true;
|
||||
slotPreview = {};
|
||||
slotErrors = {};
|
||||
}
|
||||
|
||||
function edit(c: CmdTemplateConfig) {
|
||||
form = {
|
||||
provider_type: c.provider_type,
|
||||
name: c.name,
|
||||
description: c.description || '',
|
||||
icon: c.icon || '',
|
||||
slots: { ...c.slots },
|
||||
};
|
||||
editing = c.id;
|
||||
showForm = true;
|
||||
slotPreview = {};
|
||||
slotErrors = {};
|
||||
setTimeout(() => refreshAllPreviews(), 100);
|
||||
}
|
||||
|
||||
async function save(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
error = '';
|
||||
try {
|
||||
if (editing) {
|
||||
await api(`/command-template-configs/${editing}`, { method: 'PUT', body: JSON.stringify(form) });
|
||||
} else {
|
||||
await api('/command-template-configs', { method: 'POST', body: JSON.stringify(form) });
|
||||
}
|
||||
showForm = false;
|
||||
editing = null;
|
||||
await load();
|
||||
snackSuccess(t('snack.cmdTemplateSaved'));
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
snackError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function clone(c: CmdTemplateConfig) {
|
||||
form = {
|
||||
provider_type: c.provider_type,
|
||||
name: `${c.name} (Copy)`,
|
||||
description: c.description || '',
|
||||
icon: c.icon || '',
|
||||
slots: { ...c.slots },
|
||||
};
|
||||
editing = null;
|
||||
showForm = true;
|
||||
slotPreview = {};
|
||||
slotErrors = {};
|
||||
setTimeout(() => refreshAllPreviews(), 100);
|
||||
}
|
||||
|
||||
function remove(id: number) {
|
||||
confirmDelete = {
|
||||
id,
|
||||
onconfirm: async () => {
|
||||
try {
|
||||
await api(`/command-template-configs/${id}`, { method: 'DELETE' });
|
||||
await load();
|
||||
snackSuccess(t('snack.cmdTemplateDeleted'));
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
snackError(err.message);
|
||||
} finally {
|
||||
confirmDelete = null;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageHeader title={t('cmdTemplateConfig.title')} description={t('cmdTemplateConfig.description')}>
|
||||
<button onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}
|
||||
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||
{showForm ? t('common.cancel') : t('cmdTemplateConfig.newConfig')}
|
||||
</button>
|
||||
</PageHeader>
|
||||
|
||||
{#if !loaded}<Loading />{:else}
|
||||
|
||||
{#if showForm}
|
||||
<div in:slide={{ duration: 200 }}>
|
||||
<Card class="mb-6">
|
||||
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
||||
<form onsubmit={save} class="space-y-5">
|
||||
<div>
|
||||
<label for="ct-name" class="block text-sm font-medium mb-1">{t('cmdTemplateConfig.name')}</label>
|
||||
<div class="flex gap-2">
|
||||
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
|
||||
<input id="ct-name" bind:value={form.name} required placeholder={t('cmdTemplateConfig.namePlaceholder')}
|
||||
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="ct-desc" class="block text-sm font-medium mb-1">{t('common.description')}</label>
|
||||
<input id="ct-desc" bind:value={form.description} placeholder={t('cmdTemplateConfig.descriptionPlaceholder')}
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{#each commandSlots as slot}
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<label class="text-xs text-[var(--color-muted-foreground)]">/{slot.name}</label>
|
||||
<span class="text-xs text-[var(--color-muted-foreground)]">{slot.description}</span>
|
||||
</div>
|
||||
<JinjaEditor
|
||||
value={form.slots[slot.name] || ''}
|
||||
onchange={(v: string) => { form.slots[slot.name] = v; validateSlot(slot.name, v); }}
|
||||
rows={3}
|
||||
errorLine={slotErrorLines[slot.name] || null}
|
||||
/>
|
||||
{#if slotErrors[slot.name]}
|
||||
{#if slotErrorTypes[slot.name] === 'undefined'}
|
||||
<p class="mt-1 text-xs" style="color: #d97706;">⚠ {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}
|
||||
{#if slotPreview[slot.name] && !slotErrors[slot.name]}
|
||||
<div class="mt-1 p-2 bg-[var(--color-muted)] rounded text-sm">
|
||||
<pre class="whitespace-pre-wrap text-xs">{slotPreview[slot.name]}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<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')}
|
||||
</button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if configs.length === 0 && !showForm}
|
||||
<Card>
|
||||
<EmptyState icon="mdiConsoleLine" message={t('cmdTemplateConfig.noConfigs')} />
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-3 stagger-children">
|
||||
{#each configs as config}
|
||||
<Card hover>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={config.icon || 'mdiConsoleLine'} size={20} /></span>
|
||||
<p class="font-medium">{config.name}</p>
|
||||
{#if config.user_id === 0}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">System</span>
|
||||
{/if}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{Object.keys(config.slots).length} slots</span>
|
||||
</div>
|
||||
{#if config.description}
|
||||
<p class="text-sm text-[var(--color-muted-foreground)] mt-1">{config.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-1 ml-4">
|
||||
<IconButton icon="mdiContentCopy" title={t('common.clone')} onclick={() => clone(config)} />
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(config)} />
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(config.id)} variant="danger" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{/if}
|
||||
|
||||
<ConfirmModal open={confirmDelete !== null} message={t('cmdTemplateConfig.confirmDelete')}
|
||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||
@@ -210,11 +210,6 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" bind:checked={form.enabled} />
|
||||
{t('commandTracker.enabled')}
|
||||
</label>
|
||||
|
||||
<button type="submit" disabled={submitting}
|
||||
class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
|
||||
{submitting ? t('common.loading') : (editing ? t('common.save') : t('common.create'))}
|
||||
@@ -252,10 +247,7 @@
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editTracker(trk)} />
|
||||
<button onclick={() => toggleEnabled(trk)} disabled={toggling[trk.id]}
|
||||
class="text-xs px-2 py-1 rounded-md border border-[var(--color-border)] hover:bg-[var(--color-muted)] disabled:opacity-50">
|
||||
{trk.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')}
|
||||
</button>
|
||||
<IconButton icon={trk.enabled ? 'mdiPause' : 'mdiPlay'} title={trk.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')} onclick={() => toggleEnabled(trk)} disabled={toggling[trk.id]} />
|
||||
<button onclick={() => toggleListeners(trk.id)}
|
||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1">
|
||||
{t('commandTracker.listeners')} {expandedTracker === trk.id ? '▲' : '▼'}
|
||||
|
||||
@@ -227,6 +227,16 @@
|
||||
// tracker_targets already loaded in tracker response
|
||||
}
|
||||
|
||||
function getProviderType(tracker: any): string {
|
||||
const p = providers.find(p => p.id === tracker.provider_id);
|
||||
return p?.type || '';
|
||||
}
|
||||
|
||||
function configsForTracker(tracker: any, configs: (TrackingConfig | TemplateConfig)[]): any[] {
|
||||
const pt = getProviderType(tracker);
|
||||
return pt ? configs.filter((c: any) => c.provider_type === pt) : configs;
|
||||
}
|
||||
|
||||
function getUnlinkedTargets(tracker: any): any[] {
|
||||
const linkedIds = new Set((tracker.tracker_targets || []).map((tt: any) => tt.target_id));
|
||||
return targets.filter(t => !linkedIds.has(t.id));
|
||||
@@ -399,13 +409,13 @@
|
||||
onchange={(e: Event) => updateTargetLink(tracker.id, 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 trackingConfigs as tc}<option value={tc.id}>{tc.name}</option>{/each}
|
||||
{#each configsForTracker(tracker, trackingConfigs) as tc}<option value={tc.id}>{tc.name}</option>{/each}
|
||||
</select>
|
||||
<select value={tt.template_config_id || 0}
|
||||
onchange={(e: Event) => updateTargetLink(tracker.id, 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 templateConfigs as tc}<option value={tc.id}>{tc.name}</option>{/each}
|
||||
{#each configsForTracker(tracker, templateConfigs) as tc}<option value={tc.id}>{tc.name}</option>{/each}
|
||||
</select>
|
||||
<div class="relative">
|
||||
<IconButton icon="mdiDotsVertical" size={14} title={t('common.test')}
|
||||
@@ -433,12 +443,12 @@
|
||||
<select bind:value={newLinkTrackingConfigId[tracker.id]}
|
||||
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 trackingConfigs as tc}<option value={tc.id}>{tc.name}</option>{/each}
|
||||
{#each configsForTracker(tracker, trackingConfigs) as tc}<option value={tc.id}>{tc.name}</option>{/each}
|
||||
</select>
|
||||
<select bind:value={newLinkTemplateConfigId[tracker.id]}
|
||||
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 templateConfigs as tc}<option value={tc.id}>{tc.name}</option>{/each}
|
||||
{#each configsForTracker(tracker, templateConfigs) as tc}<option value={tc.id}>{tc.name}</option>{/each}
|
||||
</select>
|
||||
<button onclick={() => addTargetLink(tracker.id)}
|
||||
disabled={!newLinkTargetId[tracker.id] || addingTarget[tracker.id]}
|
||||
|
||||
@@ -12,9 +12,10 @@
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
import { snackSuccess, snackError, snackInfo } from '$lib/stores/snackbar.svelte';
|
||||
import type { TelegramBot, TelegramChat } from '$lib/types';
|
||||
import type { TelegramBot, TelegramChat, EmailBot } from '$lib/types';
|
||||
|
||||
let bots = $state<TelegramBot[]>([]);
|
||||
let emailBots = $state<EmailBot[]>([]);
|
||||
let loaded = $state(false);
|
||||
let showForm = $state(false);
|
||||
let editing = $state<number | null>(null);
|
||||
@@ -37,9 +38,10 @@
|
||||
onMount(load);
|
||||
async function load() {
|
||||
try {
|
||||
[bots, settings] = await Promise.all([
|
||||
[bots, settings, emailBots] = await Promise.all([
|
||||
api('/telegram-bots'),
|
||||
api('/settings'),
|
||||
api('/email-bots'),
|
||||
]);
|
||||
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
||||
finally { loaded = true; }
|
||||
@@ -214,6 +216,68 @@
|
||||
};
|
||||
return map[type] || type;
|
||||
}
|
||||
|
||||
// --- Email Bot state ---
|
||||
let showEmailForm = $state(false);
|
||||
let editingEmail = $state<number | null>(null);
|
||||
let emailSubmitting = $state(false);
|
||||
let emailTesting = $state<Record<number, boolean>>({});
|
||||
let confirmDeleteEmail = $state<any>(null);
|
||||
const defaultEmailForm = () => ({
|
||||
name: '', icon: '', email: '', smtp_host: '', smtp_port: 587,
|
||||
smtp_username: '', smtp_password: '', smtp_use_tls: true,
|
||||
});
|
||||
let emailForm = $state(defaultEmailForm());
|
||||
|
||||
function openNewEmail() { emailForm = defaultEmailForm(); editingEmail = null; showEmailForm = true; }
|
||||
function editEmailBot(bot: EmailBot) {
|
||||
emailForm = {
|
||||
name: bot.name, icon: bot.icon || '', email: bot.email,
|
||||
smtp_host: bot.smtp_host, smtp_port: bot.smtp_port,
|
||||
smtp_username: bot.smtp_username, smtp_password: '',
|
||||
smtp_use_tls: bot.smtp_use_tls,
|
||||
};
|
||||
editingEmail = bot.id; showEmailForm = true;
|
||||
}
|
||||
|
||||
async function saveEmailBot(e: SubmitEvent) {
|
||||
e.preventDefault(); error = ''; emailSubmitting = true;
|
||||
try {
|
||||
const body = { ...emailForm };
|
||||
if (editingEmail) {
|
||||
// Don't send empty password on update (means "keep current")
|
||||
if (!body.smtp_password) delete (body as any).smtp_password;
|
||||
await api(`/email-bots/${editingEmail}`, { method: 'PUT', body: JSON.stringify(body) });
|
||||
snackSuccess(t('snack.emailBotUpdated'));
|
||||
} else {
|
||||
await api('/email-bots', { method: 'POST', body: JSON.stringify(body) });
|
||||
snackSuccess(t('snack.emailBotCreated'));
|
||||
}
|
||||
emailForm = defaultEmailForm(); showEmailForm = false; editingEmail = null; await load();
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
finally { emailSubmitting = false; }
|
||||
}
|
||||
|
||||
function removeEmail(id: number) {
|
||||
confirmDeleteEmail = {
|
||||
id,
|
||||
onconfirm: async () => {
|
||||
try { await api(`/email-bots/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.emailBotDeleted')); }
|
||||
catch (err: any) { error = err.message; snackError(err.message); }
|
||||
finally { confirmDeleteEmail = null; }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function testEmailBot(botId: number) {
|
||||
emailTesting = { ...emailTesting, [botId]: true };
|
||||
try {
|
||||
const res = await api(`/email-bots/${botId}/test`, { method: 'POST' });
|
||||
if (res.success) snackSuccess(t('snack.emailBotTestSent'));
|
||||
else snackError(res.error || 'Failed');
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
emailTesting = { ...emailTesting, [botId]: false };
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageHeader title={t('telegramBot.title')} description={t('telegramBot.description')}>
|
||||
@@ -434,7 +498,106 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ======= Email Bots Section ======= -->
|
||||
<div class="mt-8">
|
||||
<PageHeader title={t('emailBot.title')} description={t('emailBot.description')}>
|
||||
<button onclick={() => { showEmailForm ? (showEmailForm = false, editingEmail = null) : openNewEmail(); }}
|
||||
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||
{showEmailForm ? t('common.cancel') : t('emailBot.addBot')}
|
||||
</button>
|
||||
</PageHeader>
|
||||
|
||||
{#if showEmailForm}
|
||||
<Card class="mb-6">
|
||||
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
||||
<form onsubmit={saveEmailBot} class="space-y-3">
|
||||
<div>
|
||||
<label for="ebot-name" class="block text-sm font-medium mb-1">{t('emailBot.name')}</label>
|
||||
<div class="flex gap-2">
|
||||
<IconPicker value={emailForm.icon} onselect={(v: string) => emailForm.icon = v} />
|
||||
<input id="ebot-name" bind:value={emailForm.name} required placeholder={t('emailBot.namePlaceholder')}
|
||||
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="ebot-email" class="block text-sm font-medium mb-1">{t('emailBot.email')}</label>
|
||||
<input id="ebot-email" bind:value={emailForm.email} required type="email" placeholder="notify@example.com"
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label for="ebot-host" class="block text-sm font-medium mb-1">{t('emailBot.smtpHost')}</label>
|
||||
<input id="ebot-host" bind:value={emailForm.smtp_host} required placeholder="smtp.gmail.com"
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="ebot-port" class="block text-sm font-medium mb-1">{t('emailBot.smtpPort')}</label>
|
||||
<input id="ebot-port" bind:value={emailForm.smtp_port} type="number" min="1" max="65535"
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label for="ebot-user" class="block text-sm font-medium mb-1">{t('emailBot.smtpUsername')}</label>
|
||||
<input id="ebot-user" bind:value={emailForm.smtp_username} placeholder={t('emailBot.smtpUsernamePlaceholder')}
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="ebot-pass" class="block text-sm font-medium mb-1">{t('emailBot.smtpPassword')}</label>
|
||||
<input id="ebot-pass" bind:value={emailForm.smtp_password} type="password" placeholder={editingEmail ? t('emailBot.passwordUnchanged') : ''}
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" bind:checked={emailForm.smtp_use_tls} />
|
||||
{t('emailBot.useTls')}
|
||||
</label>
|
||||
<button type="submit" disabled={emailSubmitting}
|
||||
class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
|
||||
{emailSubmitting ? t('common.loading') : (editingEmail ? t('common.save') : t('emailBot.addBot'))}
|
||||
</button>
|
||||
</form>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
{#if emailBots.length === 0 && !showEmailForm}
|
||||
<Card>
|
||||
<EmptyState icon="mdiEmailOutline" message={t('emailBot.noBots')} />
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-3 stagger-children">
|
||||
{#each emailBots as bot}
|
||||
<Card hover>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={bot.icon || 'mdiEmailOutline'} size={20} /></span>
|
||||
<p class="font-medium">{bot.name}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-1 flex-wrap">
|
||||
<span class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.email}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{bot.smtp_host}:{bot.smtp_port}</span>
|
||||
{#if bot.smtp_use_tls}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-500">TLS</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<IconButton icon="mdiEmailSend" title={t('emailBot.testConnection')} onclick={() => testEmailBot(bot.id)} disabled={emailTesting[bot.id]} />
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editEmailBot(bot)} />
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => removeEmail(bot.id)} variant="danger" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{/if}
|
||||
|
||||
<ConfirmModal open={confirmDelete !== null} message={t('telegramBot.confirmDelete')}
|
||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||
|
||||
<ConfirmModal open={confirmDeleteEmail !== null} message={t('emailBot.confirmDelete')}
|
||||
onconfirm={() => confirmDeleteEmail?.onconfirm()} oncancel={() => confirmDeleteEmail = null} />
|
||||
|
||||
@@ -39,8 +39,8 @@
|
||||
const res = await api('/template-configs/preview-date-format', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
date_format: (form as any).date_format,
|
||||
date_only_format: (form as any).date_only_format,
|
||||
date_format: form.date_format,
|
||||
date_only_format: form.date_only_format,
|
||||
}),
|
||||
});
|
||||
dateFormatPreview = res;
|
||||
@@ -63,7 +63,7 @@
|
||||
|
||||
const doValidate = async () => {
|
||||
try {
|
||||
const res = await api('/template-configs/preview-raw', { method: 'POST', body: JSON.stringify({ template, target_type: previewTargetType, date_format: (form as any).date_format, date_only_format: (form as any).date_only_format }) });
|
||||
const res = await api('/template-configs/preview-raw', { method: 'POST', body: JSON.stringify({ template, target_type: previewTargetType, date_format: form.date_format, date_only_format: form.date_only_format }) });
|
||||
slotErrors = { ...slotErrors, [slotKey]: res.error || '' };
|
||||
slotErrorLines = { ...slotErrorLines, [slotKey]: res.error_line || null };
|
||||
slotErrorTypes = { ...slotErrorTypes, [slotKey]: res.error_type || '' };
|
||||
@@ -86,8 +86,9 @@
|
||||
function refreshAllPreviews() {
|
||||
for (const group of templateSlots) {
|
||||
for (const slot of group.slots) {
|
||||
const template = (form as any)[slot.key];
|
||||
if (template && slot.key !== 'date_format' && slot.key !== 'date_only_format') {
|
||||
if (slot.isDateFormat) continue;
|
||||
const template = form.slots[slot.key] || '';
|
||||
if (template) {
|
||||
validateSlot(slot.key, template, true);
|
||||
}
|
||||
}
|
||||
@@ -97,14 +98,7 @@
|
||||
|
||||
const defaultForm = () => ({
|
||||
provider_type: 'immich', name: '', description: '', icon: '',
|
||||
message_assets_added: '',
|
||||
message_assets_removed: '',
|
||||
message_collection_renamed: '',
|
||||
message_collection_deleted: '',
|
||||
message_sharing_changed: '',
|
||||
periodic_summary_message: '',
|
||||
scheduled_assets_message: '',
|
||||
memory_mode_message: '',
|
||||
slots: {} as Record<string, string>,
|
||||
date_format: '%d.%m.%Y, %H:%M UTC',
|
||||
date_only_format: '%d.%m.%Y',
|
||||
});
|
||||
@@ -125,8 +119,8 @@
|
||||
{ key: 'memory_mode_message', label: 'memoryMode', rows: 6 },
|
||||
]},
|
||||
{ group: 'settings', slots: [
|
||||
{ key: 'date_format', label: 'dateFormat', rows: 1 },
|
||||
{ key: 'date_only_format', label: 'dateOnlyFormat', rows: 1 },
|
||||
{ key: 'date_format', label: 'dateFormat', rows: 1, isDateFormat: true },
|
||||
{ key: 'date_only_format', label: 'dateOnlyFormat', rows: 1, isDateFormat: true },
|
||||
]},
|
||||
];
|
||||
|
||||
@@ -142,8 +136,17 @@
|
||||
}
|
||||
|
||||
function openNew() { form = defaultForm(); editing = null; showForm = true; slotPreview = {}; slotErrors = {}; dateFormatPreview = {}; refreshDateFormatPreview(); }
|
||||
function edit(c: any) {
|
||||
form = { ...defaultForm(), ...c }; editing = c.id; showForm = true;
|
||||
function edit(c: TemplateConfig) {
|
||||
form = {
|
||||
provider_type: c.provider_type,
|
||||
name: c.name,
|
||||
description: c.description || '',
|
||||
icon: c.icon || '',
|
||||
slots: { ...c.slots },
|
||||
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;
|
||||
slotPreview = {}; slotErrors = {}; dateFormatPreview = {};
|
||||
setTimeout(() => refreshAllPreviews(), 100);
|
||||
}
|
||||
@@ -158,11 +161,16 @@
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
}
|
||||
|
||||
function clone(c: any) {
|
||||
form = { ...defaultForm(), ...c, name: `${c.name} (Copy)`, description: c.description || '' };
|
||||
delete (form as any).id;
|
||||
delete (form as any).user_id;
|
||||
delete (form as any).created_at;
|
||||
function clone(c: TemplateConfig) {
|
||||
form = {
|
||||
provider_type: c.provider_type,
|
||||
name: `${c.name} (Copy)`,
|
||||
description: c.description || '',
|
||||
icon: c.icon || '',
|
||||
slots: { ...c.slots },
|
||||
date_format: c.date_format || '%d.%m.%Y, %H:%M UTC',
|
||||
date_only_format: c.date_only_format || '%d.%m.%Y',
|
||||
};
|
||||
editing = null;
|
||||
showForm = true;
|
||||
slotPreview = {};
|
||||
@@ -249,9 +257,9 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if slot.key === 'date_format' || slot.key === 'date_only_format'}
|
||||
<input bind:value={(form as any)[slot.key]}
|
||||
oninput={() => { clearTimeout(validateTimers['_fmt']); validateTimers['_fmt'] = setTimeout(refreshAllPreviews, 600); refreshDateFormatPreview(); }}
|
||||
{#if slot.isDateFormat}
|
||||
<input value={(form as any)[slot.key]}
|
||||
oninput={(e: Event) => { (form as any)[slot.key] = (e.target as HTMLInputElement).value; clearTimeout(validateTimers['_fmt']); validateTimers['_fmt'] = setTimeout(refreshAllPreviews, 600); refreshDateFormatPreview(); }}
|
||||
class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] font-mono" />
|
||||
{#if dateFormatPreview[slot.key]}
|
||||
<p class="mt-1 text-xs font-mono" style="color: var(--color-muted-foreground);">{t('templateConfig.preview')}: <span style="color: var(--color-foreground);">{dateFormatPreview[slot.key]}</span></p>
|
||||
@@ -259,7 +267,7 @@
|
||||
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">{t('templateConfig.invalidFormat')}</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<JinjaEditor value={(form as any)[slot.key] || ''} onchange={(v: string) => { (form as any)[slot.key] = v; validateSlot(slot.key, v); }} rows={slot.rows || 3} errorLine={slotErrorLines[slot.key] || null} />
|
||||
<JinjaEditor value={form.slots[slot.key] || ''} onchange={(v: string) => { form.slots[slot.key] = v; validateSlot(slot.key, v); }} rows={slot.rows || 3} errorLine={slotErrorLines[slot.key] || null} />
|
||||
{#if slotErrors[slot.key]}
|
||||
{#if slotErrorTypes[slot.key] === 'undefined'}
|
||||
<p class="mt-1 text-xs" style="color: #d97706;">⚠ {t('common.undefinedVar')}: {slotErrors[slot.key]}</p>
|
||||
|
||||
Reference in New Issue
Block a user