8651767112
Adds bot commands for the bridge_self provider so operators can inspect and manage bridge health from chat: /status, /thresholds, /reset, /health. Includes Jinja2 templates for both locales, seed data, capability slots, and a handler that exposes pending deferred backlog plus per-counter reset. Also adds .claude/skills/ for project-scoped graph-aware skills.
716 lines
26 KiB
Svelte
716 lines
26 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { slide } from 'svelte/transition';
|
|
import { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
|
|
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
|
import { t } from '$lib/i18n';
|
|
import { sanitizePreview } from '$lib/sanitize';
|
|
import { commandTemplateConfigsCache, supportedLocalesCache } from '$lib/stores/caches.svelte';
|
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
|
import Button from '$lib/components/Button.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 IconGridSelect from '$lib/components/IconGridSelect.svelte';
|
|
import { providerTypeItems as providerTypeItemsFn, providerTypeFilterItems } from '$lib/grid-items';
|
|
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';
|
|
import { getDescriptor } from '$lib/providers';
|
|
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
|
|
|
|
interface CmdTemplateConfig {
|
|
id: number;
|
|
user_id: number;
|
|
provider_type: string;
|
|
name: string;
|
|
description: string;
|
|
icon: string;
|
|
slots: Record<string, Record<string, string>>;
|
|
created_at: string;
|
|
}
|
|
|
|
interface SlotDef {
|
|
name: string;
|
|
description: string;
|
|
}
|
|
|
|
let LOCALES = $derived(supportedLocalesCache.items);
|
|
let primaryLocale = $derived(LOCALES[0] || 'en');
|
|
|
|
let allCmdTplConfigs = $state<CmdTemplateConfig[]>([]);
|
|
let filterText = $state('');
|
|
let filterType = $state('');
|
|
let effectiveType = $derived(globalProviderFilter.providerType || filterType);
|
|
let configs = $derived(allCmdTplConfigs.filter(c =>
|
|
(!filterText || c.name.toLowerCase().includes(filterText.toLowerCase())) &&
|
|
(!effectiveType || c.provider_type === effectiveType)
|
|
));
|
|
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 confirmReset = $state<{
|
|
kind: 'slot' | 'all';
|
|
slotKey?: string;
|
|
message: string;
|
|
} | 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>> = {};
|
|
/** Clean up validate timers on unmount */
|
|
onMount(() => {
|
|
return () => {
|
|
for (const timer of Object.values(validateTimers)) clearTimeout(timer);
|
|
};
|
|
});
|
|
let varsRef = $state<Record<string, any>>({});
|
|
let showVarsFor = $state<string | null>(null);
|
|
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());
|
|
|
|
function toggleSlot(key: string) {
|
|
const next = new Set(expandedSlots);
|
|
if (next.has(key)) next.delete(key); else next.add(key);
|
|
expandedSlots = next;
|
|
}
|
|
|
|
function togglePreview(key: string) {
|
|
const next = new Set(showPreviewFor);
|
|
if (next.has(key)) next.delete(key); else next.add(key);
|
|
showPreviewFor = next;
|
|
}
|
|
|
|
function getSlotStatus(key: string): 'empty' | 'valid' | 'error' | 'warning' {
|
|
if (slotErrors[key] && slotErrorTypes[key] !== 'undefined') return 'error';
|
|
if (slotErrors[key] && slotErrorTypes[key] === 'undefined') return 'warning';
|
|
if (getSlotValue(key)) return 'valid';
|
|
return 'empty';
|
|
}
|
|
|
|
const defaultForm = () => ({
|
|
provider_type: '',
|
|
name: '',
|
|
description: '',
|
|
icon: '',
|
|
slots: {} as Record<string, Record<string, string>>,
|
|
});
|
|
let form = $state(defaultForm());
|
|
let nameManuallyEdited = $state(false);
|
|
|
|
$effect(() => {
|
|
if (showForm && !nameManuallyEdited && !editing) {
|
|
const desc = getDescriptor(form.provider_type);
|
|
form.name = desc ? `${desc.defaultName} Command Templates` : 'Command Templates';
|
|
}
|
|
});
|
|
|
|
// Provider capabilities
|
|
let allCapabilities = $state<Record<string, any>>({});
|
|
let providerTypes = $derived(Object.keys(allCapabilities));
|
|
let commandSlots = $derived<SlotDef[]>(
|
|
allCapabilities[form.provider_type]?.command_slots || []
|
|
);
|
|
|
|
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 {
|
|
return form.slots[slotName]?.[activeLocale] || '';
|
|
}
|
|
|
|
/** Resolve variable reference for a slot, preferring provider-specific over shared. */
|
|
function getVarsFor(slotName: string) {
|
|
const providerVars = varsRef[form.provider_type];
|
|
return providerVars?.[slotName] ?? varsRef[slotName];
|
|
}
|
|
|
|
let modalVars = $derived(showVarsFor ? getVarsFor(showVarsFor) : null);
|
|
|
|
/** Set slot template for current locale (immutable update). */
|
|
function setSlotValue(slotName: string, value: string) {
|
|
form.slots = {
|
|
...form.slots,
|
|
[slotName]: { ...(form.slots[slotName] || {}), [activeLocale]: value }
|
|
};
|
|
}
|
|
|
|
onMount(load);
|
|
|
|
const headerPills = $derived.by(() => {
|
|
const pills: Array<{ label: string; tone: 'sky' }> = [];
|
|
const types = new Set(configs.map(c => c.provider_type)).size;
|
|
if (types > 0) pills.push({ label: `${types} ${types === 1 ? t('providers.typeSingular') : t('providers.typePlural')}`, tone: 'sky' });
|
|
return pills;
|
|
});
|
|
|
|
async function load() {
|
|
try {
|
|
const [cfgs, caps, vars] = await Promise.all([
|
|
commandTemplateConfigsCache.fetch(true),
|
|
api('/providers/capabilities'),
|
|
api('/command-template-configs/variables'),
|
|
supportedLocalesCache.fetch(),
|
|
]);
|
|
allCmdTplConfigs = cfgs;
|
|
allCapabilities = caps;
|
|
varsRef = vars;
|
|
} catch (err: unknown) {
|
|
error = errMsg(err, t('common.loadError'));
|
|
snackError(error);
|
|
} finally {
|
|
loaded = true;
|
|
highlightFromUrl();
|
|
}
|
|
}
|
|
|
|
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 = getSlotValue(slot.name);
|
|
if (template) validateSlot(slot.name, template, true);
|
|
}
|
|
}
|
|
|
|
function cmdTemplateConfigTiles(config: CmdTemplateConfig): MetaTile[] {
|
|
const tiles: MetaTile[] = [];
|
|
tiles.push({
|
|
icon: 'mdiServer',
|
|
label: config.provider_type,
|
|
tone: 'lavender',
|
|
mono: true,
|
|
});
|
|
const slotCount = Object.keys(config.slots || {}).length;
|
|
tiles.push({
|
|
icon: 'mdiViewGridOutline',
|
|
value: String(slotCount),
|
|
label: t('templateConfig.slots'),
|
|
tone: slotCount > 0 ? 'sky' : 'default',
|
|
});
|
|
const locales = new Set<string>();
|
|
for (const s of Object.values(config.slots || {})) {
|
|
for (const loc of Object.keys(s || {})) locales.add(loc);
|
|
}
|
|
if (locales.size > 0) {
|
|
tiles.push({
|
|
icon: 'mdiTranslate',
|
|
value: String(locales.size),
|
|
label: locales.size === 1 ? t('locales.label') : t('locales.labelPlural'),
|
|
hint: [...locales].sort().join(', '),
|
|
tone: 'mint',
|
|
});
|
|
}
|
|
if (config.user_id === 0) {
|
|
tiles.push({
|
|
icon: 'mdiShieldStarOutline',
|
|
label: t('common.system'),
|
|
tone: 'orchid',
|
|
});
|
|
}
|
|
return tiles;
|
|
}
|
|
|
|
function openNew() {
|
|
form = defaultForm();
|
|
const typesWithCmdSlots = providerTypes.filter(t => (allCapabilities[t]?.command_slots?.length || 0) > 0);
|
|
if (typesWithCmdSlots.length > 0) form.provider_type = typesWithCmdSlots[0];
|
|
nameManuallyEdited = false;
|
|
editing = null;
|
|
showForm = true;
|
|
activeLocale = primaryLocale;
|
|
slotPreview = {};
|
|
slotErrors = {};
|
|
expandedSlots = new Set();
|
|
showPreviewFor = new Set();
|
|
slotFilter = '';
|
|
}
|
|
|
|
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: slotsCopy,
|
|
};
|
|
nameManuallyEdited = true;
|
|
editing = c.id;
|
|
showForm = true;
|
|
activeLocale = primaryLocale;
|
|
slotPreview = {};
|
|
slotErrors = {};
|
|
expandedSlots = new Set();
|
|
showPreviewFor = new Set();
|
|
slotFilter = '';
|
|
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: unknown) {
|
|
const m = errMsg(err);
|
|
error = m;
|
|
snackError(m);
|
|
}
|
|
}
|
|
|
|
function resetSlotToDefault(slotKey: string) {
|
|
if (!form.provider_type) return;
|
|
confirmReset = {
|
|
kind: 'slot',
|
|
slotKey,
|
|
message: t('templateConfig.resetSlotConfirm').replace('{locale}', activeLocale.toUpperCase()),
|
|
};
|
|
}
|
|
|
|
function resetAllToDefaults() {
|
|
if (!form.provider_type) return;
|
|
confirmReset = {
|
|
kind: 'all',
|
|
message: t('templateConfig.resetAllConfirm').replace(/\{locale\}/g, activeLocale.toUpperCase()),
|
|
};
|
|
}
|
|
|
|
async function performReset() {
|
|
if (!confirmReset || !form.provider_type) return;
|
|
const { kind, slotKey } = confirmReset;
|
|
confirmReset = null;
|
|
try {
|
|
if (kind === 'slot' && slotKey) {
|
|
const res = await api<Record<string, Record<string, string>>>(
|
|
`/command-template-configs/defaults?provider_type=${encodeURIComponent(form.provider_type)}&slot_name=${encodeURIComponent(slotKey)}&locale=${encodeURIComponent(activeLocale)}`,
|
|
);
|
|
const text = res?.[slotKey]?.[activeLocale];
|
|
if (!text) {
|
|
snackError(t('templateConfig.resetNoDefault'));
|
|
return;
|
|
}
|
|
setSlotValue(slotKey, text);
|
|
validateSlot(slotKey, text, true);
|
|
} else {
|
|
const res = await api<Record<string, Record<string, string>>>(
|
|
`/command-template-configs/defaults?provider_type=${encodeURIComponent(form.provider_type)}&locale=${encodeURIComponent(activeLocale)}`,
|
|
);
|
|
const nextSlots = { ...form.slots };
|
|
for (const [key, localeMap] of Object.entries(res || {})) {
|
|
const text = localeMap?.[activeLocale];
|
|
if (text === undefined) continue;
|
|
nextSlots[key] = { ...(nextSlots[key] || {}), [activeLocale]: text };
|
|
}
|
|
form.slots = nextSlots;
|
|
refreshAllPreviews();
|
|
}
|
|
snackSuccess(t('templateConfig.resetApplied'));
|
|
} catch (err: unknown) {
|
|
snackError(errMsg(err));
|
|
}
|
|
}
|
|
|
|
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: slotsCopy,
|
|
};
|
|
editing = null;
|
|
showForm = true;
|
|
activeLocale = primaryLocale;
|
|
slotPreview = {};
|
|
slotErrors = {};
|
|
expandedSlots = new Set();
|
|
showPreviewFor = new Set();
|
|
slotFilter = '';
|
|
setTimeout(() => refreshAllPreviews(), 100);
|
|
}
|
|
|
|
let blockedBy = $state<BlockedByDetail | null>(null);
|
|
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: unknown) {
|
|
const bb = getBlockedBy(err);
|
|
if (bb) { blockedBy = bb; return; }
|
|
const m = errMsg(err);
|
|
error = m;
|
|
snackError(m);
|
|
} finally {
|
|
confirmDelete = null;
|
|
}
|
|
},
|
|
};
|
|
}
|
|
</script>
|
|
|
|
<PageHeader
|
|
title={t('cmdTemplateConfig.title')}
|
|
emphasis={t('cmdTemplateConfig.titleEmphasis')}
|
|
description={t('cmdTemplateConfig.description')}
|
|
crumb={t('crumbs.routingCommands')}
|
|
count={configs.length}
|
|
countLabel={t('cmdTemplateConfig.countLabel')}
|
|
pills={headerPills}
|
|
>
|
|
<Button size="sm" onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}>
|
|
{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} oninput={() => nameManuallyEdited = true} 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>
|
|
|
|
{#if !editing}
|
|
<div>
|
|
<div class="block text-sm font-medium mb-1">{t('templateConfig.providerType')}</div>
|
|
<IconGridSelect items={providerTypeItemsFn()} bind:value={form.provider_type} columns={2} />
|
|
</div>
|
|
{:else}
|
|
<div>
|
|
<span class="block text-sm font-medium mb-1">{t('templateConfig.providerType')}</span>
|
|
<span class="text-sm text-[var(--color-muted-foreground)]">{allCapabilities[form.provider_type]?.display_name || form.provider_type}</span>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- 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>
|
|
|
|
<!-- 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>
|
|
</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>
|
|
{: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')}
|
|
</button>
|
|
</form>
|
|
</Card>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if !showForm && allCmdTplConfigs.length > 0}
|
|
<div class="flex items-center gap-2 mb-3">
|
|
<input type="text" bind:value={filterText} placeholder={t('common.filterByName')}
|
|
class="flex-1 px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
|
{#if !globalProviderFilter.id}
|
|
<div class="w-48">
|
|
<IconGridSelect items={providerTypeFilterItems()} bind:value={filterType} columns={2} compact />
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if allCmdTplConfigs.length === 0 && !showForm}
|
|
<Card>
|
|
<EmptyState icon="mdiConsoleLine" message={t('cmdTemplateConfig.noConfigs')} />
|
|
</Card>
|
|
{:else if configs.length === 0 && !showForm}
|
|
<Card>
|
|
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
|
|
</Card>
|
|
{:else}
|
|
<div class="list-stack stagger-children">
|
|
{#each configs as config}
|
|
<Card hover entityId={config.id}>
|
|
<div class="list-row">
|
|
<div class="list-row__identity">
|
|
<div class="flex items-center gap-2 min-w-0">
|
|
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={config.icon || 'mdiConsoleLine'} size={20} /></span>
|
|
<p class="font-medium truncate">{config.name}</p>
|
|
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] shrink-0">{config.provider_type}</span>
|
|
{#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)] shrink-0">{t('common.system')}</span>
|
|
{/if}
|
|
</div>
|
|
{#if config.description}
|
|
<p class="text-sm text-[var(--color-muted-foreground)] mt-1 list-row__secondary">{config.description}</p>
|
|
{/if}
|
|
</div>
|
|
<MetaStrip tiles={cmdTemplateConfigTiles(config)} />
|
|
<div class="list-row__actions">
|
|
<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} />
|
|
|
|
<ConfirmModal open={confirmReset !== null}
|
|
title={t('templateConfig.resetToDefault')}
|
|
message={confirmReset?.message || ''}
|
|
confirmLabel={confirmReset?.kind === 'all' ? t('templateConfig.resetAllToDefaults') : t('templateConfig.resetToDefault')}
|
|
confirmIcon="mdiRefresh"
|
|
onconfirm={performReset}
|
|
oncancel={() => confirmReset = null} />
|
|
|
|
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
|
|
|
<!-- Variables reference modal -->
|
|
<Modal open={showVarsFor !== null} title="{t('templateConfig.variables')}: /{showVarsFor || ''}" onclose={() => showVarsFor = null}>
|
|
{#if showVarsFor && modalVars}
|
|
<p class="text-sm text-[var(--color-muted-foreground)] mb-3">{modalVars.description}</p>
|
|
<div class="space-y-1">
|
|
<p class="text-xs font-medium mb-1">{t('templateConfig.variables')}:</p>
|
|
{#each Object.entries(modalVars.variables || {}) as [name, desc]}
|
|
<div class="flex items-start gap-2 text-sm">
|
|
<code class="text-xs bg-[var(--color-muted)] px-1 py-0.5 rounded font-mono whitespace-nowrap">{'{{ ' + name + ' }}'}</code>
|
|
<span class="text-xs text-[var(--color-muted-foreground)]">{desc}</span>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{#each [
|
|
['asset_fields', 'asset', 'Asset fields'],
|
|
['album_fields', 'album', 'Album fields'],
|
|
['command_fields', 'cmd', 'Command fields'],
|
|
['event_fields', 'event', 'Event fields'],
|
|
['repo_fields', 'repo', 'Repository fields'],
|
|
['issue_fields', 'issue', 'Issue fields'],
|
|
['pr_fields', 'pr', 'Pull request fields'],
|
|
['commit_fields', 'c', 'Commit fields'],
|
|
['board_fields', 'board', 'Board fields'],
|
|
['card_fields', 'card', 'Card fields'],
|
|
['list_fields', 'lst', 'List fields'],
|
|
['device_fields', 'd', 'Device fields'],
|
|
] as [fieldKey, prefix, title]}
|
|
{#if modalVars[fieldKey]}
|
|
<div class="mt-3 pt-3 border-t border-[var(--color-border)]">
|
|
<p class="text-xs font-medium mb-1">{title} <span class="font-normal text-[var(--color-muted-foreground)]">(use {prefix}.field)</span>:</p>
|
|
{#each Object.entries(modalVars[fieldKey]) as [name, desc]}
|
|
<div class="flex items-start gap-2 text-sm">
|
|
<code class="text-xs bg-[var(--color-muted)] px-1 py-0.5 rounded font-mono whitespace-nowrap">{'{{ ' + prefix + '.' + name + ' }}'}</code>
|
|
<span class="text-xs text-[var(--color-muted-foreground)]">{desc}</span>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
{/each}
|
|
{/if}
|
|
</Modal>
|