Files
notify-bridge/frontend/src/routes/bots/MatrixBotTab.svelte
T
alexei.dolgolyov 5bd63a2191 feat(frontend): autogenerate entity names from type/provider
Mirror the providers form pattern (defaultName tied to type) across
bots, targets, trackers, actions, and configs. Each form now derives
form.name from the selected type or provider while the user hasn't
manually edited it; switching to edit-mode flips the manualEdited
flag so existing names are preserved.

Defaults: bots → "<Type> Bot"; targets → type label; notification
trackers → "<provider> Tracker"; command trackers → "<provider>
Commands"; actions → "<provider> <Action Type>"; tracking/template/
command/command-template configs → "<descriptor.defaultName>
<Suffix>". TargetForm and TrackerForm grew an optional onnameinput
prop so parents can flag manual edits in subform inputs.
2026-05-07 13:01:52 +03:00

182 lines
7.9 KiB
Svelte

<script lang="ts">
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t, getLocale } from '$lib/i18n';
import { matrixBotsCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.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 { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import Button from '$lib/components/Button.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import type { MatrixBot } from '$lib/types';
let { onreload }: { onreload: () => Promise<void> } = $props();
let matrixBots = $derived(matrixBotsCache.items);
let showMatrixForm = $state(false);
let editingMatrix = $state<number | null>(null);
let matrixSubmitting = $state(false);
let matrixTesting = $state<Record<number, boolean>>({});
let confirmDeleteMatrix = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
let error = $state('');
const defaultMatrixForm = () => ({
name: '', icon: '', homeserver_url: '', access_token: '', display_name: '',
});
let matrixForm = $state(defaultMatrixForm());
let nameManuallyEdited = $state(false);
const DEFAULT_BOT_NAME = 'Matrix Bot';
$effect(() => {
if (showMatrixForm && !nameManuallyEdited && !editingMatrix) {
matrixForm.name = DEFAULT_BOT_NAME;
}
});
function openNewMatrix() { matrixForm = defaultMatrixForm(); nameManuallyEdited = false; editingMatrix = null; showMatrixForm = true; }
function editMatrixBot(bot: MatrixBot) {
matrixForm = {
name: bot.name, icon: bot.icon || '',
homeserver_url: bot.homeserver_url, access_token: '',
display_name: bot.display_name || '',
};
nameManuallyEdited = true;
editingMatrix = bot.id; showMatrixForm = true;
}
async function saveMatrixBot(e: SubmitEvent) {
e.preventDefault(); error = ''; matrixSubmitting = true;
try {
const body = { ...matrixForm };
if (editingMatrix && !body.access_token) delete (body as any).access_token;
if (editingMatrix) {
await api(`/matrix-bots/${editingMatrix}`, { method: 'PUT', body: JSON.stringify(body) });
snackSuccess(t('snack.matrixBotUpdated'));
} else {
await api('/matrix-bots', { method: 'POST', body: JSON.stringify(body) });
snackSuccess(t('snack.matrixBotCreated'));
}
matrixForm = defaultMatrixForm(); nameManuallyEdited = false; showMatrixForm = false; editingMatrix = null; await onreload();
} catch (err: any) { error = err.message; snackError(err.message); }
finally { matrixSubmitting = false; }
}
let blockedBy = $state<BlockedByDetail | null>(null);
function removeMatrix(id: number) {
confirmDeleteMatrix = {
id,
onconfirm: async () => {
try { await api(`/matrix-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.matrixBotDeleted')); }
catch (err: any) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message; snackError(err.message);
}
finally { confirmDeleteMatrix = null; }
}
};
}
async function testMatrixBot(botId: number) {
matrixTesting = { ...matrixTesting, [botId]: true };
try {
const res = await api(`/matrix-bots/${botId}/test?locale=${getLocale()}`, { method: 'POST' });
if (res.success) snackSuccess(t('snack.matrixBotTestOk'));
else snackError(res.error || t('matrixBot.operationFailed'));
} catch (err: any) { snackError(err.message); }
matrixTesting = { ...matrixTesting, [botId]: false };
}
</script>
<PageHeader
title={t('matrixBot.title')}
emphasis={t('matrixBot.titleEmphasis')}
description={t('matrixBot.description')}
crumb="Operators · Bots"
count={matrixBots.length}
countLabel={t('matrixBot.countLabel')}
>
<Button size="sm" onclick={() => { showMatrixForm ? (showMatrixForm = false, editingMatrix = null) : openNewMatrix(); }}>
{showMatrixForm ? t('common.cancel') : t('matrixBot.addBot')}
</Button>
</PageHeader>
{#if showMatrixForm}
<Card class="mb-6">
<ErrorBanner message={error} />
<form onsubmit={saveMatrixBot} class="space-y-3">
<div>
<label for="mbot-name" class="block text-sm font-medium mb-1">{t('matrixBot.name')}</label>
<div class="flex gap-2">
<IconPicker value={matrixForm.icon} onselect={(v: string) => matrixForm.icon = v} />
<input id="mbot-name" bind:value={matrixForm.name} oninput={() => nameManuallyEdited = true} required placeholder={t('matrixBot.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="mbot-hs" class="block text-sm font-medium mb-1">{t('matrixBot.homeserverUrl')}</label>
<input id="mbot-hs" bind:value={matrixForm.homeserver_url} required placeholder="https://matrix.org"
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="mbot-token" class="block text-sm font-medium mb-1">{t('matrixBot.accessToken')}</label>
<input id="mbot-token" bind:value={matrixForm.access_token} type="password"
required={!editingMatrix}
placeholder={editingMatrix ? t('matrixBot.tokenUnchanged') : t('matrixBot.tokenPlaceholder')}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] font-mono" />
</div>
<div>
<label for="mbot-display" class="block text-sm font-medium mb-1">{t('matrixBot.displayName')}</label>
<input id="mbot-display" bind:value={matrixForm.display_name} placeholder="Notify Bridge"
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<button type="submit" disabled={matrixSubmitting}
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">
{matrixSubmitting ? t('common.loading') : (editingMatrix ? t('common.save') : t('matrixBot.addBot'))}
</button>
</form>
</Card>
{/if}
{#if matrixBots.length === 0 && !showMatrixForm}
<Card>
<EmptyState icon="mdiMatrix" message={t('matrixBot.noBots')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
{#each matrixBots as bot}
<Card hover entityId={bot.id}>
<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 || 'mdiMatrix'} 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.homeserver_url}</span>
{#if bot.display_name}
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{bot.display_name}</span>
{/if}
</div>
</div>
<div class="flex items-center gap-1">
<IconButton icon="mdiConnection" title={t('matrixBot.testConnection')} onclick={() => testMatrixBot(bot.id)} disabled={matrixTesting[bot.id]} />
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editMatrixBot(bot)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => removeMatrix(bot.id)} variant="danger" />
</div>
</div>
</Card>
{/each}
</div>
{/if}
<ConfirmModal open={confirmDeleteMatrix !== null} message={t('matrixBot.confirmDelete')}
onconfirm={() => confirmDeleteMatrix?.onconfirm()} oncancel={() => confirmDeleteMatrix = null} />
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />