feat: comprehensive code review fixes — security, performance, quality

Backend security:
- Reject Gitea webhooks when webhook_secret is empty (was silently skipping)
- Add slowapi rate limiting on login (5/min) and setup (3/min) endpoints
- Add CORS middleware with configurable origins
- Mask telegram_webhook_secret in settings API response
- Protect system-owned command template configs from regular user modification
- Increase minimum password length to 8 characters

Backend performance:
- Batch queries in _resolve_command_context (3 queries instead of 3N)
- Concurrent album fetching with asyncio.gather in immich commands
- Singleton Jinja2 SandboxedEnvironment (reuse instead of per-render creation)
- TTLCache for rate limits (bounded memory, auto-eviction)
- Optional aiohttp session reuse in send_reply/send_media_group

Backend code quality:
- Extract dispatch_helpers.py (shared link_data loading + event filtering)
- Extract database/seeds.py from main.py (490 lines → dedicated module)
- Split immich_handler.py (415 lines) into commands/immich/ subpackage
- Replace bare except blocks with logged warnings
- Add per-provider config validation (Pydantic models)
- Truncate command input to 512 chars
- Expose usage_* and desc_* slots in capabilities and variables API

Frontend security:
- CSS.escape() for user-controlled querySelector in highlight.ts
- Client-side password min 8 chars validation on setup and password change

Frontend code quality:
- Replace any types with proper interfaces across top files
- Decompose targets/+page.svelte into TargetForm + ReceiverSection
- Fix $derived.by usage, $state mutation patterns
- Add console.warn to empty catch blocks

Frontend UX:
- Auth redirect via goto() with "Redirecting..." state
- Platform-aware Ctrl/Cmd K keyboard hint
- Remove stat-card hover transform

Frontend accessibility:
- Modal: role=dialog, aria-modal, focus trap, restore focus
- EntitySelect/IconGridSelect: listbox/option roles, aria-selected/disabled
This commit is contained in:
2026-03-23 01:59:51 +03:00
parent 31584c5d31
commit e0bae394ee
78 changed files with 2855 additions and 1658 deletions
+2 -2
View File
@@ -49,8 +49,8 @@ async function doRefreshAccessToken(): Promise<boolean> {
setTokens(data.access_token, data.refresh_token);
return true;
}
} catch {
// ignore
} catch (e) {
console.warn('Token refresh failed:', e);
}
return false;
}
+1 -6
View File
@@ -4,12 +4,7 @@
import { api, setTokens, clearTokens, isAuthenticated } from './api';
import { clearAllCaches } from './stores/caches.svelte';
interface User {
id: number;
username: string;
role: string;
}
import type { User } from './types';
let user = $state<User | null>(null);
let loading = $state(true);
@@ -0,0 +1,10 @@
<script lang="ts">
import MdiIcon from './MdiIcon.svelte';
let { icon = 'mdiInformation', message = '', size = 40 }: { icon?: string; message?: string; size?: number } = $props();
</script>
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
<div style="opacity: 0.4;"><MdiIcon name={icon} {size} /></div>
{#if message}<p class="text-sm">{message}</p>{/if}
</div>
@@ -6,6 +6,8 @@
label: string;
icon?: string;
desc?: string;
disabled?: boolean;
disabledHint?: string;
}
let {
@@ -62,6 +64,7 @@
}
function selectItem(item: EntityItem) {
if (item.disabled) return;
value = item.value || null;
onselect?.(value);
closePalette();
@@ -103,6 +106,8 @@
<!-- Trigger button -->
<button type="button" class="es-trigger" class:es-sm={size === 'sm'} onclick={openPalette}
aria-expanded={open}
aria-haspopup="listbox"
style="opacity: {disabled ? 0.5 : 1}; cursor: {disabled ? 'default' : 'pointer'};">
{#if selected}
{#if selected.icon}
@@ -135,15 +140,19 @@
<kbd class="ep-kbd">ESC</kbd>
</div>
<div class="ep-list" bind:this={listEl}>
<div class="ep-list" bind:this={listEl} role="listbox">
{#if filtered.length === 0}
<div class="ep-empty">No matches</div>
{:else}
{#each filtered as item, i}
<button
class="ep-item"
class:ep-highlight={i === highlightIdx}
class:ep-highlight={i === highlightIdx && !item.disabled}
class:ep-current={String(item.value) === String(value)}
class:ep-disabled={item.disabled}
role="option"
aria-selected={String(item.value) === String(value)}
aria-disabled={item.disabled || undefined}
onclick={() => selectItem(item)}
onmouseenter={() => highlightIdx = i}
type="button"
@@ -152,7 +161,9 @@
<span class="ep-item-icon"><MdiIcon name={item.icon} size={18} /></span>
{/if}
<span class="ep-item-label">{item.label}</span>
{#if item.desc}
{#if item.disabled && item.disabledHint}
<span class="ep-item-hint">{item.disabledHint}</span>
{:else if item.desc}
<span class="ep-item-desc">{item.desc}</span>
{/if}
</button>
@@ -292,6 +303,13 @@
.ep-item:hover, .ep-item.ep-highlight {
background: var(--color-muted);
}
.ep-item.ep-disabled {
opacity: 0.4;
cursor: default;
}
.ep-item.ep-disabled:hover {
background: transparent;
}
.ep-item.ep-current {
border-left-color: var(--color-primary);
}
@@ -319,4 +337,11 @@
text-overflow: ellipsis;
max-width: 40%;
}
.ep-item-hint {
font-size: 0.7rem;
font-style: italic;
color: var(--color-muted-foreground);
white-space: nowrap;
margin-left: auto;
}
</style>
@@ -0,0 +1,262 @@
<script lang="ts">
import { t } from '$lib/i18n';
import MdiIcon from './MdiIcon.svelte';
interface DayData {
date: string;
[eventType: string]: string | number;
}
let { days = [] }: { days: DayData[] } = $props();
const EVENT_TYPES = ['assets_added', 'assets_removed', 'collection_renamed', 'collection_deleted', 'sharing_changed'] as const;
const COLORS: Record<string, string> = {
assets_added: '#059669',
assets_removed: '#ef4444',
collection_renamed: '#6366f1',
collection_deleted: '#dc2626',
sharing_changed: '#f59e0b',
};
const LABELS: Record<string, string> = {
assets_added: 'dashboard.filterAssetsAdded',
assets_removed: 'dashboard.filterAssetsRemoved',
collection_renamed: 'dashboard.filterRenamed',
collection_deleted: 'dashboard.filterDeleted',
sharing_changed: 'dashboard.filterSharingChanged',
};
let tooltip = $state<{ x: number; y: number; text: string } | null>(null);
const maxValue = $derived.by(() => {
let max = 0;
for (const day of days) {
let sum = 0;
for (const et of EVENT_TYPES) {
sum += (day[et] as number) || 0;
}
if (sum > max) max = sum;
}
return Math.max(max, 1);
});
const hasData = $derived(days.some(d => EVENT_TYPES.some(et => (d[et] as number) > 0)));
// Active event types (ones that actually have data)
const activeTypes = $derived(EVENT_TYPES.filter(et => days.some(d => (d[et] as number) > 0)));
function formatDate(dateStr: string): string {
const d = new Date(dateStr + 'T00:00:00');
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
function showTooltip(e: MouseEvent, day: DayData) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const parts: string[] = [];
for (const et of EVENT_TYPES) {
const v = (day[et] as number) || 0;
if (v > 0) parts.push(`${t(LABELS[et])}: ${v} ${v === 1 ? t('dashboard.event') : t('dashboard.events')}`);
}
if (parts.length === 0) parts.push(`0 ${t('dashboard.events')}`);
tooltip = {
x: rect.left + rect.width / 2,
y: rect.top,
text: `${formatDate(day.date)}\n${parts.join('\n')}`,
};
}
function hideTooltip() {
tooltip = null;
}
</script>
<div class="chart-wrapper">
<div class="chart-header">
<h4 class="chart-title">
<MdiIcon name="mdiChartBar" size={18} />
{t('dashboard.eventActivity')}
</h4>
<span class="chart-subtitle">{t('dashboard.last14days')}</span>
</div>
{#if !hasData}
<div class="chart-empty">
<MdiIcon name="mdiChartBoxOutline" size={32} />
<span>{t('dashboard.noChartData')}</span>
</div>
{:else}
<div class="chart-body">
<div class="chart-bars">
{#each days as day, i}
{@const total = EVENT_TYPES.reduce((s, et) => s + ((day[et] as number) || 0), 0)}
<div
class="bar-col"
role="img"
aria-label="{formatDate(day.date)}: {total} {t('dashboard.events')}"
onmouseenter={(e) => showTooltip(e, day)}
onmouseleave={hideTooltip}
>
<div class="bar-stack" style="--max: {maxValue}">
{#each EVENT_TYPES as et}
{@const v = (day[et] as number) || 0}
{#if v > 0}
<div
class="bar-segment"
style="height: {(v / maxValue) * 100}%; background: {COLORS[et]};"
></div>
{/if}
{/each}
</div>
<span class="bar-label">{i % 2 === 0 ? formatDate(day.date) : ''}</span>
</div>
{/each}
</div>
<!-- Legend -->
<div class="chart-legend">
{#each activeTypes as et}
<span class="legend-item">
<span class="legend-dot" style="background: {COLORS[et]};"></span>
{t(LABELS[et])}
</span>
{/each}
</div>
</div>
{/if}
</div>
{#if tooltip}
<div
class="chart-tooltip"
style="position: fixed; left: {tooltip.x}px; top: {tooltip.y}px; z-index: 9999; transform: translate(-50%, -100%) translateY(-8px);"
>
{#each tooltip.text.split('\n') as line}
<div>{line}</div>
{/each}
</div>
{/if}
<style>
.chart-wrapper {
background: var(--color-card);
border: 1px solid var(--color-border);
border-radius: 0.75rem;
padding: 1.25rem;
margin-bottom: 1.5rem;
transition: border-color 0.2s;
}
.chart-wrapper:hover {
border-color: var(--color-primary);
box-shadow: 0 0 16px var(--color-glow);
}
.chart-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.chart-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
font-weight: 600;
margin: 0;
}
.chart-subtitle {
font-size: 0.75rem;
color: var(--color-muted-foreground);
font-family: var(--font-mono);
}
.chart-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 2rem 0;
color: var(--color-muted-foreground);
opacity: 0.5;
font-size: 0.8rem;
}
.chart-body {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.chart-bars {
display: flex;
align-items: flex-end;
gap: 3px;
height: 120px;
}
.bar-col {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
cursor: default;
}
.bar-stack {
flex: 1;
width: 100%;
display: flex;
flex-direction: column-reverse;
align-items: stretch;
justify-content: flex-start;
border-radius: 3px 3px 0 0;
overflow: hidden;
min-height: 0;
}
.bar-segment {
width: 100%;
min-height: 2px;
transition: height 0.5s ease, opacity 0.2s;
opacity: 0.85;
}
.bar-col:hover .bar-segment {
opacity: 1;
}
.bar-label {
font-size: 0.55rem;
color: var(--color-muted-foreground);
margin-top: 4px;
white-space: nowrap;
font-family: var(--font-mono);
}
.chart-legend {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
justify-content: center;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.65rem;
color: var(--color-muted-foreground);
font-family: var(--font-mono);
text-transform: uppercase;
letter-spacing: 0.02em;
}
.legend-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.chart-tooltip {
background: var(--color-card);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
padding: 0.5rem 0.75rem;
font-size: 0.7rem;
font-family: var(--font-mono);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
pointer-events: none;
white-space: nowrap;
line-height: 1.5;
}
</style>
+40
View File
@@ -0,0 +1,40 @@
<script lang="ts">
let { text = '' } = $props<{ text: string }>();
let visible = $state(false);
let tooltipStyle = $state('');
let btnEl: HTMLButtonElement;
function show() {
if (!btnEl) return;
visible = true;
const rect = btnEl.getBoundingClientRect();
const tooltipWidth = 272;
let left = rect.left + rect.width / 2 - tooltipWidth / 2;
if (left < 8) left = 8;
if (left + tooltipWidth > window.innerWidth - 8) left = window.innerWidth - tooltipWidth - 8;
tooltipStyle = `position:fixed; z-index:99999; bottom:${window.innerHeight - rect.top + 8}px; left:${left}px; width:${tooltipWidth}px;`;
}
function hide() {
visible = false;
}
</script>
<button type="button" bind:this={btnEl}
class="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full text-[9px] font-bold leading-none
border border-[var(--color-border)] bg-[var(--color-muted)] text-[var(--color-muted-foreground)]
hover:bg-[var(--color-border)] hover:text-[var(--color-foreground)]
transition-colors cursor-help align-middle ml-2 flex-shrink-0"
onmouseenter={show}
onmouseleave={hide}
onfocus={show}
onblur={hide}
aria-label={text}
tabindex="0"
>?</button>
{#if visible}
<div role="tooltip" style="{tooltipStyle} background:var(--color-card); color:var(--color-foreground); border:1px solid var(--color-border); box-shadow:0 10px 30px rgba(0,0,0,0.3); padding:0.625rem 0.75rem; border-radius:0.5rem; font-size:0.75rem; white-space:normal; line-height:1.625; pointer-events:none;">
{text}
</div>
{/if}
@@ -0,0 +1,65 @@
<script lang="ts">
import MdiIcon from './MdiIcon.svelte';
let { icon, title = '', onclick, disabled = false, variant = 'default', size = 16, class: className = '' } = $props<{
icon: string;
title?: string;
onclick?: (e: MouseEvent) => void;
disabled?: boolean;
variant?: 'default' | 'danger' | 'success';
size?: number;
class?: string;
}>();
</script>
<button type="button" {title} {onclick} {disabled}
class="icon-btn icon-btn-{variant} {className}"
>
<MdiIcon name={icon} {size} />
</button>
<style>
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
border: none;
background: transparent;
cursor: pointer;
transition: all 0.2s ease;
}
.icon-btn:disabled {
opacity: 0.4;
pointer-events: none;
}
.icon-btn-default {
color: var(--color-muted-foreground);
}
.icon-btn-default:hover {
color: var(--color-foreground);
background: var(--color-muted);
}
.icon-btn-danger {
color: var(--color-muted-foreground);
}
.icon-btn-danger:hover {
color: var(--color-destructive);
background: var(--color-error-bg);
box-shadow: 0 0 8px rgba(239, 68, 68, 0.15);
}
.icon-btn-success {
color: var(--color-muted-foreground);
}
.icon-btn-success:hover {
color: var(--color-success-fg);
background: var(--color-success-bg);
box-shadow: 0 0 8px rgba(5, 150, 105, 0.15);
}
</style>
@@ -76,6 +76,8 @@
<button type="button" bind:this={triggerEl} onclick={toggle}
class="icon-grid-trigger {compact ? 'icon-grid-compact' : ''}"
class:disabled
aria-expanded={open}
aria-haspopup="listbox"
style="opacity: {disabled ? 0.5 : 1}; cursor: {disabled ? 'default' : 'pointer'};">
{#if selected}
<span class="icon-grid-trigger-icon"><MdiIcon name={selected.icon} size={compact ? 14 : 18} /></span>
@@ -99,11 +101,13 @@
class="icon-grid-search" type="text" autocomplete="off"
onkeydown={handleKeydown} />
{/if}
<div class="icon-grid" style="grid-template-columns: repeat({columns}, 1fr);">
<div class="icon-grid" style="grid-template-columns: repeat({columns}, 1fr);" role="listbox">
{#each filtered as item}
<button type="button"
class="icon-grid-cell"
class:active={String(item.value) === String(value)}
role="option"
aria-selected={String(item.value) === String(value)}
onclick={() => select(item)}>
<span class="icon-grid-cell-icon"><MdiIcon name={item.icon} size={22} /></span>
<span class="icon-grid-cell-label">{item.label}</span>
@@ -0,0 +1,24 @@
<script lang="ts">
let { lines = 3 } = $props<{ lines?: number }>();
</script>
<div class="space-y-3">
{#each Array(lines) as _, i}
<div class="loading-bar" style="animation-delay: {i * 100}ms;"></div>
{/each}
</div>
<style>
.loading-bar {
height: 4rem;
border-radius: 0.75rem;
background: linear-gradient(90deg, var(--color-muted) 25%, var(--color-border) 50%, var(--color-muted) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
</style>
+158
View File
@@ -0,0 +1,158 @@
<script lang="ts">
import { onMount } from 'svelte';
import MdiIcon from './MdiIcon.svelte';
let { open = false, title = '', onclose, children } = $props<{
open: boolean;
title?: string;
onclose: () => void;
children: import('svelte').Snippet;
}>();
let visible = $state(false);
let panelEl: HTMLDivElement;
let previouslyFocused: HTMLElement | null = null;
const uniqueId = `modal-${Math.random().toString(36).slice(2, 9)}`;
$effect(() => {
if (open) {
previouslyFocused = document.activeElement as HTMLElement | null;
requestAnimationFrame(() => {
visible = true;
// Focus first focusable element inside the modal
requestAnimationFrame(() => {
const focusable = panelEl?.querySelector<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
focusable?.focus();
});
});
} else {
visible = false;
// Restore focus to the previously focused element
if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
previouslyFocused.focus();
previouslyFocused = null;
}
}
});
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onclose();
return;
}
// Focus trap: Tab / Shift+Tab
if (e.key === 'Tab' && panelEl) {
const focusableElements = panelEl.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (focusableElements.length === 0) return;
const first = focusableElements[0];
const last = focusableElements[focusableElements.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
}
function handleBackdropKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') onclose();
}
</script>
<svelte:window onkeydown={open ? handleKeydown : undefined} />
{#if open}
<div
class="modal-backdrop"
class:visible
style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 9999; display: flex; align-items: center; justify-content: center;"
onclick={onclose}
onkeydown={handleBackdropKeydown}
role="presentation"
>
<div
bind:this={panelEl}
class="modal-panel"
class:visible
role="dialog"
aria-modal="true"
aria-labelledby="modal-title-{uniqueId}"
style="background: var(--color-card); border: 1px solid var(--color-border); border-radius: 1rem; width: 100%; max-width: 32rem; max-height: 80vh; margin: 1rem; display: flex; flex-direction: column;"
onclick={(e) => e.stopPropagation()}
>
<div style="display: flex; align-items: center; justify-content: space-between; padding: 1.5rem 1.5rem 1rem;">
<h3 id="modal-title-{uniqueId}" style="font-size: 1.125rem; font-weight: 600;">{title}</h3>
<button class="modal-close" onclick={onclose} aria-label="Close">
<MdiIcon name="mdiClose" size={18} />
</button>
</div>
<div style="padding: 0 1.5rem 1.5rem; overflow-y: auto;">
{@render children()}
</div>
</div>
</div>
{/if}
<style>
.modal-backdrop {
background: rgba(0, 0, 0, 0);
backdrop-filter: blur(0px);
transition: background 0.25s ease, backdrop-filter 0.25s ease;
}
.modal-backdrop.visible {
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}
.modal-panel {
opacity: 0;
transform: translateY(12px) scale(0.97);
transition: opacity 0.25s ease, transform 0.25s ease;
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.12),
0 0 0 1px rgba(255, 255, 255, 0.05) inset;
}
:global([data-theme="dark"]) .modal-panel {
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.4),
0 0 48px var(--color-glow),
0 0 0 1px rgba(255, 255, 255, 0.03) inset;
}
.modal-panel.visible {
opacity: 1;
transform: translateY(0) scale(1);
}
.modal-close {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
border: none;
background: transparent;
color: var(--color-muted-foreground);
cursor: pointer;
transition: all 0.2s ease;
}
.modal-close:hover {
background: var(--color-muted);
color: var(--color-foreground);
}
</style>
@@ -0,0 +1,21 @@
<script lang="ts">
let { title, description = '', children } = $props<{
title: string;
description?: string;
children?: import('svelte').Snippet;
}>();
</script>
<div class="flex items-center justify-between mb-8">
<div class="animate-fade-slide-in">
<h2 class="text-2xl font-semibold tracking-tight">{title}</h2>
{#if description}
<p class="text-sm mt-1.5" style="color: var(--color-muted-foreground);">{description}</p>
{/if}
</div>
{#if children}
<div class="animate-fade-slide-in" style="animation-delay: 60ms;">
{@render children()}
</div>
{/if}
</div>
@@ -40,32 +40,34 @@
}
/** All searchable entity groups. */
const GROUPS = [
{ key: 'providers', label: 'nav.providers', icon: 'mdiServer', href: '/providers',
mapFn: (e: any) => ({ detail: e.type, icon: e.icon || 'mdiServer' }) },
{ key: 'notification_trackers', label: 'nav.notification', icon: 'mdiRadar', href: '/notification-trackers',
mapFn: (e: any) => ({ detail: e.enabled ? 'enabled' : 'disabled', icon: e.icon || 'mdiRadar' }) },
{ key: 'tracking_configs', label: 'nav.trackingConfigs', icon: 'mdiCog', href: '/tracking-configs',
mapFn: (e: any) => ({ detail: e.provider_type, icon: e.icon || 'mdiCog' }) },
{ key: 'template_configs', label: 'nav.templateConfigs', icon: 'mdiFileDocumentEdit', href: '/template-configs',
mapFn: (e: any) => ({ detail: e.provider_type, icon: e.icon || 'mdiFileDocumentEdit' }) },
{ key: 'targets', label: 'nav.targets', icon: 'mdiTarget', href: '/targets',
mapFn: (e: any) => ({ detail: e.type, icon: e.icon || 'mdiTarget' }) },
{ key: 'telegram_bots', label: 'nav.telegram', icon: 'mdiSendCircle', href: '/bots?tab=telegram',
mapFn: (e: any) => ({ detail: `@${e.bot_username || ''}`, icon: e.icon || 'mdiRobot' }) },
{ key: 'email_bots', label: 'nav.email', icon: 'mdiEmailOutline', href: '/bots?tab=email',
mapFn: (e: any) => ({ detail: e.email || '', icon: e.icon || 'mdiEmailOutline' }) },
{ key: 'matrix_bots', label: 'nav.matrix', icon: 'mdiMatrix', href: '/bots?tab=matrix',
mapFn: (e: any) => ({ detail: e.display_name || '', icon: e.icon || 'mdiMatrix' }) },
{ key: 'command_trackers', label: 'nav.commandTrackers', icon: 'mdiConsoleLine', href: '/command-trackers',
mapFn: (e: any) => ({ detail: e.enabled ? 'enabled' : 'disabled', icon: e.icon || 'mdiConsoleLine' }) },
{ key: 'command_configs', label: 'nav.commandConfigs', icon: 'mdiCog', href: '/command-configs',
mapFn: (e: any) => ({ detail: e.provider_type, icon: e.icon || 'mdiCog' }) },
{ key: 'command_template_configs', label: 'nav.cmdTemplateConfigs', icon: 'mdiCodeBracesBox', href: '/command-template-configs',
mapFn: (e: any) => ({ detail: e.provider_type, icon: e.icon || 'mdiCodeBracesBox' }) },
] as const;
type CacheEntity = Record<string, unknown> & { id: number; name: string };
const cacheMap: Record<string, { items: any[] }> = {
const GROUPS: readonly { key: string; label: string; icon: string; href: string; mapFn: (e: CacheEntity) => { detail: string; icon: string } }[] = [
{ key: 'providers', label: 'nav.providers', icon: 'mdiServer', href: '/providers',
mapFn: (e) => ({ detail: String(e.type || ''), icon: String(e.icon || 'mdiServer') }) },
{ key: 'notification_trackers', label: 'nav.notification', icon: 'mdiRadar', href: '/notification-trackers',
mapFn: (e) => ({ detail: e.enabled ? 'enabled' : 'disabled', icon: String(e.icon || 'mdiRadar') }) },
{ key: 'tracking_configs', label: 'nav.trackingConfigs', icon: 'mdiCog', href: '/tracking-configs',
mapFn: (e) => ({ detail: String(e.provider_type || ''), icon: String(e.icon || 'mdiCog') }) },
{ key: 'template_configs', label: 'nav.templateConfigs', icon: 'mdiFileDocumentEdit', href: '/template-configs',
mapFn: (e) => ({ detail: String(e.provider_type || ''), icon: String(e.icon || 'mdiFileDocumentEdit') }) },
{ key: 'targets', label: 'nav.targets', icon: 'mdiTarget', href: '/targets',
mapFn: (e) => ({ detail: String(e.type || ''), icon: String(e.icon || 'mdiTarget') }) },
{ key: 'telegram_bots', label: 'nav.telegram', icon: 'mdiSendCircle', href: '/bots?tab=telegram',
mapFn: (e) => ({ detail: `@${e.bot_username || ''}`, icon: String(e.icon || 'mdiRobot') }) },
{ key: 'email_bots', label: 'nav.email', icon: 'mdiEmailOutline', href: '/bots?tab=email',
mapFn: (e) => ({ detail: String(e.email || ''), icon: String(e.icon || 'mdiEmailOutline') }) },
{ key: 'matrix_bots', label: 'nav.matrix', icon: 'mdiMatrix', href: '/bots?tab=matrix',
mapFn: (e) => ({ detail: String(e.display_name || ''), icon: String(e.icon || 'mdiMatrix') }) },
{ key: 'command_trackers', label: 'nav.commandTrackers', icon: 'mdiConsoleLine', href: '/command-trackers',
mapFn: (e) => ({ detail: e.enabled ? 'enabled' : 'disabled', icon: String(e.icon || 'mdiConsoleLine') }) },
{ key: 'command_configs', label: 'nav.commandConfigs', icon: 'mdiCog', href: '/command-configs',
mapFn: (e) => ({ detail: String(e.provider_type || ''), icon: String(e.icon || 'mdiCog') }) },
{ key: 'command_template_configs', label: 'nav.cmdTemplateConfigs', icon: 'mdiCodeBracesBox', href: '/command-template-configs',
mapFn: (e) => ({ detail: String(e.provider_type || ''), icon: String(e.icon || 'mdiCodeBracesBox') }) },
];
const cacheMap = {
providers: providersCache,
notification_trackers: notificationTrackersCache,
tracking_configs: trackingConfigsCache,
@@ -77,7 +79,7 @@
command_trackers: commandTrackersCache,
command_configs: commandConfigsCache,
command_template_configs: commandTemplateConfigsCache,
};
} as unknown as Record<string, { items: { id: number; name: string; [k: string]: unknown }[] }>;
/** Build flat results from all caches, filtered by query. */
const results = $derived.by(() => {
+150
View File
@@ -0,0 +1,150 @@
<script lang="ts">
import { fly, fade } from 'svelte/transition';
import { getSnacks, removeSnack, type Snack } from '$lib/stores/snackbar.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import { t } from '$lib/i18n';
const snacks = $derived(getSnacks());
let expandedIds = $state<Set<number>>(new Set());
function toggleDetail(id: number) {
const next = new Set(expandedIds);
if (next.has(id)) next.delete(id);
else next.add(id);
expandedIds = next;
}
const iconMap: Record<string, string> = {
success: 'mdiCheckCircle',
error: 'mdiAlertCircle',
info: 'mdiInformation',
warning: 'mdiAlert',
};
const accentMap: Record<string, string> = {
success: '#059669',
error: '#ef4444',
info: '#3b82f6',
warning: '#f59e0b',
};
</script>
{#if snacks.length > 0}
<div
style="position: fixed; left: 50%; transform: translateX(-50%); z-index: 9999; display: flex; flex-direction: column; gap: 0.5rem; width: 90%; max-width: 26rem; pointer-events: none;"
class="snackbar-container"
>
{#each snacks as snack (snack.id)}
<div
in:fly={{ y: 40, duration: 300 }}
out:fade={{ duration: 200 }}
class="snack-item"
style="--snack-accent: {accentMap[snack.type]};"
>
<span class="snack-icon" style="color: {accentMap[snack.type]};">
<MdiIcon name={iconMap[snack.type]} size={18} />
</span>
<div style="flex: 1; min-width: 0;">
<p class="snack-message">{snack.message}</p>
{#if snack.detail}
<button class="snack-detail-toggle" onclick={() => toggleDetail(snack.id)}>
{expandedIds.has(snack.id) ? t('snackbar.hideDetails') : t('snackbar.showDetails')}
</button>
{#if expandedIds.has(snack.id)}
<pre class="snack-detail">{snack.detail}</pre>
{/if}
{/if}
</div>
<button class="snack-close" onclick={() => removeSnack(snack.id)} aria-label="Dismiss">
<MdiIcon name="mdiClose" size={14} />
</button>
</div>
{/each}
</div>
{/if}
<style>
.snackbar-container {
bottom: 5rem;
}
@media (min-width: 768px) {
.snackbar-container {
bottom: 1.5rem;
}
}
.snack-item {
pointer-events: auto;
display: flex;
align-items: flex-start;
gap: 0.625rem;
padding: 0.75rem 1rem;
border-radius: 0.75rem;
border-left: 3px solid var(--snack-accent);
background: var(--color-card);
border-top: 1px solid var(--color-border);
border-right: 1px solid var(--color-border);
border-bottom: 1px solid var(--color-border);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(255, 255, 255, 0.03) inset;
backdrop-filter: blur(12px);
}
:global([data-theme="dark"]) .snack-item {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4), 0 0 16px color-mix(in srgb, var(--snack-accent) 10%, transparent);
}
.snack-icon {
flex-shrink: 0;
margin-top: 1px;
}
.snack-message {
font-size: 0.8rem;
line-height: 1.4;
margin: 0;
color: var(--color-foreground);
}
.snack-detail-toggle {
font-size: 0.7rem;
color: var(--color-muted-foreground);
background: none;
border: none;
padding: 0;
margin-top: 0.25rem;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.snack-detail-toggle:hover {
color: var(--color-foreground);
}
.snack-detail {
font-size: 0.7rem;
font-family: var(--font-mono);
color: var(--color-muted-foreground);
margin: 0.25rem 0 0;
white-space: pre-wrap;
word-break: break-word;
}
.snack-close {
flex-shrink: 0;
background: none;
border: none;
color: var(--color-muted-foreground);
cursor: pointer;
padding: 0.125rem;
border-radius: 0.25rem;
transition: all 0.15s ease;
line-height: 1;
}
.snack-close:hover {
color: var(--color-foreground);
background: var(--color-muted);
}
</style>
+2 -2
View File
@@ -42,7 +42,7 @@ export function highlightFromUrl(): void {
// Wait for DOM to render after loaded=true
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const card = document.querySelector(`[data-entity-id="${id}"]`);
const card = document.querySelector(`[data-entity-id="${CSS.escape(id)}"]`);
if (card) {
_highlightCard(card as HTMLElement);
} else {
@@ -89,7 +89,7 @@ function _waitForCard(id: string): void {
const start = Date.now();
const observer = new MutationObserver(() => {
const card = document.querySelector(`[data-entity-id="${id}"]`);
const card = document.querySelector(`[data-entity-id="${CSS.escape(id)}"]`);
if (card) {
observer.disconnect();
setTimeout(() => _highlightCard(card as HTMLElement), 50);
+3 -2
View File
@@ -47,7 +47,7 @@
"createAccount": "Create account",
"creatingAccount": "Creating account...",
"passwordMismatch": "Passwords do not match",
"passwordTooShort": "Password must be at least 6 characters",
"passwordTooShort": "Password must be at least 8 characters",
"or": "or"
},
"dashboard": {
@@ -753,7 +753,8 @@
"filterByName": "Filter by name...",
"allTypes": "All types",
"allProviders": "All providers",
"noFilterResults": "No items match the current filter."
"noFilterResults": "No items match the current filter.",
"redirecting": "Redirecting..."
},
"gridDesc": {
"sortNone": "No sorting applied",
+63
View File
@@ -0,0 +1,63 @@
/**
* Reactive i18n module using Svelte 5 $state rune.
* Locale changes automatically propagate to all components using t().
*/
import en from './en.json';
import ru from './ru.json';
export type Locale = 'en' | 'ru';
const translations: Record<Locale, Record<string, any>> = { en, ru };
function detectLocale(): Locale {
if (typeof localStorage !== 'undefined') {
const saved = localStorage.getItem('locale') as Locale | null;
if (saved && saved in translations) return saved;
}
if (typeof navigator !== 'undefined') {
const lang = navigator.language.slice(0, 2);
if (lang in translations) return lang as Locale;
}
return 'en';
}
let currentLocale = $state<Locale>(detectLocale());
export function getLocale(): Locale {
return currentLocale;
}
export function setLocale(locale: Locale) {
currentLocale = locale;
if (typeof localStorage !== 'undefined') {
localStorage.setItem('locale', locale);
}
}
export function initLocale() {
// No-op: locale is auto-detected at module load via $state.
// Kept for backward compatibility with existing onMount calls.
}
/**
* Get a translated string by dot-separated key.
* Falls back to English if key not found in current locale.
* Reactive: re-evaluates when currentLocale changes.
*/
export function t(key: string, fallback?: string): string {
return resolve(translations[currentLocale], key)
?? resolve(translations.en, key)
?? fallback
?? key;
}
function resolve(obj: any, path: string): string | undefined {
const parts = path.split('.');
let current = obj;
for (const part of parts) {
if (current == null || typeof current !== 'object') return undefined;
current = current[part];
}
return typeof current === 'string' ? current : undefined;
}
+2
View File
@@ -0,0 +1,2 @@
// Re-export from the .svelte.ts module which supports $state runes
export { t, getLocale, setLocale, initLocale, type Locale } from './index.svelte';
+3 -2
View File
@@ -47,7 +47,7 @@
"createAccount": "Создать аккаунт",
"creatingAccount": "Создание...",
"passwordMismatch": "Пароли не совпадают",
"passwordTooShort": "Пароль должен быть не менее 6 символов",
"passwordTooShort": "Пароль должен быть не менее 8 символов",
"or": "или"
},
"dashboard": {
@@ -753,7 +753,8 @@
"filterByName": "Фильтр по имени...",
"allTypes": "Все типы",
"allProviders": "Все провайдеры",
"noFilterResults": "Нет элементов, соответствующих фильтру."
"noFilterResults": "Нет элементов, соответствующих фильтру.",
"redirecting": "Перенаправление..."
},
"gridDesc": {
"sortNone": "Без сортировки",
+4
View File
@@ -41,6 +41,7 @@ export interface TelegramBot {
webhook_path_id: string;
commands_config: Record<string, any>;
token_preview: string;
update_mode?: string;
created_at: string;
}
@@ -50,6 +51,7 @@ export interface TelegramChat {
title: string;
type: string;
username: string;
language_code?: string;
discovered_at: string;
}
@@ -77,6 +79,7 @@ export interface Tracker {
scan_interval: number;
batch_duration: number;
enabled: boolean;
filters?: Record<string, any>;
tracker_targets: TrackerTarget[];
created_at: string;
}
@@ -222,4 +225,5 @@ export interface DashboardStatus {
targets: number;
total_events: number;
recent_events: EventLog[];
command_trackers?: number;
}
+10 -5
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import '../app.css';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { fade, slide } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
@@ -19,6 +20,7 @@
const theme = getTheme();
let showPasswordForm = $state(false);
let redirecting = $state(false);
let openSearch: (() => void) | undefined;
let pwdCurrent = $state('');
let pwdNew = $state('');
@@ -27,6 +29,7 @@
async function changePassword(e: SubmitEvent) {
e.preventDefault(); pwdMsg = ''; pwdSuccess = false;
if (pwdNew.length < 8) { pwdMsg = t('auth.passwordTooShort'); return; }
try {
await api('/auth/password', { method: 'PUT', body: JSON.stringify({ current_password: pwdCurrent, new_password: pwdNew }) });
pwdMsg = t('common.changePassword');
@@ -38,6 +41,7 @@
}
let collapsed = $state(false);
let isMac = $derived(typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.userAgent));
// Nav counts for badges
let navCounts = $state<Record<string, number>>({});
@@ -153,15 +157,16 @@
try {
const saved = localStorage.getItem('nav_expanded');
if (saved) expandedGroups = JSON.parse(saved);
} catch {}
} catch (e) { console.warn('Failed to parse nav_expanded:', e); }
}
await loadUser();
if (!auth.user && !isAuthPage) {
window.location.href = '/login';
redirecting = true;
goto('/login');
}
// Load nav counts
if (auth.user) {
try { navCounts = await api('/status/counts'); } catch {}
try { navCounts = await api('/status/counts'); } catch (e) { console.warn('Failed to load nav counts:', e); }
}
});
@@ -270,7 +275,7 @@
<MdiIcon name="mdiMagnify" size={16} />
{#if !collapsed}
<span class="flex-1 text-left text-xs">{t('searchPalette.placeholder')}</span>
<kbd class="text-[0.6rem] font-mono px-1 py-0.5 rounded" style="background: var(--color-background); border: 1px solid var(--color-border);">K</kbd>
<kbd class="text-[0.6rem] font-mono px-1 py-0.5 rounded" style="background: var(--color-background); border: 1px solid var(--color-border);">{isMac ? '⌘' : 'Ctrl '}K</kbd>
{/if}
</button>
</div>
@@ -420,7 +425,7 @@
<div class="min-h-screen flex items-center justify-center">
<div class="flex items-center gap-3">
<div class="w-5 h-5 rounded-full border-2 border-[var(--color-primary)] border-t-transparent animate-spin"></div>
<p class="text-sm text-[var(--color-muted-foreground)]">{t('common.loading')}</p>
<p class="text-sm text-[var(--color-muted-foreground)]">{redirecting ? t('common.redirecting') : t('common.loading')}</p>
</div>
</div>
{/if}
+10 -8
View File
@@ -12,13 +12,14 @@
import EntitySelect from '$lib/components/EntitySelect.svelte';
import { eventTypeFilterItems, sortFilterItems } from '$lib/grid-items';
let status = $state<any>(null);
import type { DashboardStatus } from '$lib/types';
let status = $state<DashboardStatus | null>(null);
let providers = $derived(providersCache.items);
const providerFilterItems = $derived([
{ value: '', label: t('dashboard.allProviders'), icon: 'mdiFilterOff' },
...providers.map(p => ({ value: p.id, label: p.name, icon: p.icon || 'mdiServer', desc: p.type })),
]);
let chartDays = $state<any[]>([]);
let chartDays = $state<{ date: string; [eventType: string]: string | number }[]>([]);
let loaded = $state(false);
let error = $state('');
@@ -78,7 +79,7 @@
params.set('limit', String(eventsLimit));
params.set('offset', String(eventsOffset));
const qs = params.toString();
status = await api<any>(`/status${qs ? '?' + qs : ''}`);
status = await api<DashboardStatus>(`/status${qs ? '?' + qs : ''}`);
} catch (err: any) {
error = err.message || t('common.error');
} finally {
@@ -90,9 +91,9 @@
try {
const params = buildFilterParams();
const qs = params.toString();
const chartRes = await api<any>(`/status/chart${qs ? '?' + qs : ''}`);
const chartRes = await api<{ days: { date: string; [k: string]: string | number }[] }>(`/status/chart${qs ? '?' + qs : ''}`);
chartDays = chartRes.days || [];
} catch {}
} catch (e) { console.warn('Failed to load chart data:', e); }
}
// Auto-apply when filter values change (via IconGridSelect bind:value)
@@ -149,13 +150,14 @@
async function loadInitial() {
try {
const [statusRes, , chartRes] = await Promise.all([
api<any>(`/status?limit=${eventsLimit}`),
api<DashboardStatus>(`/status?limit=${eventsLimit}`),
providersCache.fetch(),
api<any>('/status/chart'),
api<{ days: { date: string; [k: string]: string | number }[] }>('/status/chart'),
]);
status = statusRes;
chartDays = chartRes.days || [];
setTimeout(() => {
if (!status) return;
animateCount(0, status.providers, (v) => displayProviders = v);
animateCount(0, status.trackers.active, (v) => displayActive = v);
animateCount(0, status.trackers.total, (v) => displayTotal = v);
@@ -339,7 +341,7 @@
<style>
.stat-card { position: relative; border-radius: 0.75rem; padding: 1px; background: linear-gradient(135deg, var(--accent), transparent 60%, var(--color-border)); transition: all 0.3s ease; }
.stat-card:hover { box-shadow: 0 0 24px color-mix(in srgb, var(--accent) 20%, transparent); transform: translateY(-2px); }
.stat-card:hover { box-shadow: 0 0 24px color-mix(in srgb, var(--accent) 20%, transparent); }
.stat-card-inner { background: var(--color-card); border-radius: calc(0.75rem - 1px); padding: 1.25rem; }
.stat-icon { display: flex; align-items: center; justify-content: center; width: 2.75rem; height: 2.75rem; border-radius: 0.75rem; flex-shrink: 0; }
.stat-value { font-size: 1.75rem; font-weight: 600; line-height: 1.2; animation: countUp 0.5s ease-out both; }
+1 -1
View File
@@ -18,7 +18,7 @@
let error = $state('');
// Global settings (loaded for webhook mode checks)
let settings = $state<any>({});
let settings = $state<Record<string, string>>({});
onMount(load);
async function load() {
+1 -1
View File
@@ -19,7 +19,7 @@
let editingEmail = $state<number | null>(null);
let emailSubmitting = $state(false);
let emailTesting = $state<Record<number, boolean>>({});
let confirmDeleteEmail = $state<any>(null);
let confirmDeleteEmail = $state<EmailBot | null>(null);
let error = $state('');
const defaultEmailForm = () => ({
+1 -1
View File
@@ -19,7 +19,7 @@
let editingMatrix = $state<number | null>(null);
let matrixSubmitting = $state(false);
let matrixTesting = $state<Record<number, boolean>>({});
let confirmDeleteMatrix = $state<any>(null);
let confirmDeleteMatrix = $state<MatrixBot | null>(null);
let error = $state('');
const defaultMatrixForm = () => ({
+27 -22
View File
@@ -11,9 +11,14 @@
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 { TelegramChat } from '$lib/types';
import type { TelegramBot, TelegramChat } from '$lib/types';
let { settings, onreload }: { settings: any; onreload: () => Promise<void> } = $props();
interface CommandTrackerSummary { id: number; name: string; icon?: string; enabled: boolean }
interface ListenerEntry { listener_type: string; listener_id: number }
interface WebhookStatusInfo { url?: string; pending_update_count?: number; last_error_message?: string }
interface ApiResult { success: boolean; error?: string; verified?: boolean }
let { settings, onreload }: { settings: Record<string, string>; onreload: () => Promise<void> } = $props();
let bots = $derived(telegramBotsCache.items);
let showForm = $state(false);
@@ -21,7 +26,7 @@
let form = $state({ name: '', icon: '', token: '' });
let error = $state('');
let submitting = $state(false);
let confirmDelete = $state<any>(null);
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
// Per-bot expandable sections
let chats = $state<Record<number, TelegramChat[]>>({});
@@ -29,17 +34,17 @@
let expandedSection = $state<Record<number, string>>({});
// Webhook status per bot
let webhookStatus = $state<Record<number, any>>({});
let webhookStatus = $state<Record<number, WebhookStatusInfo>>({});
let chatTesting = $state<Record<string, boolean>>({});
let modeChanging = $state<Record<number, boolean>>({});
// Listener status: command trackers using this bot
let botListenerStatus = $state<Record<number, any[]>>({});
let botListenerStatus = $state<Record<number, CommandTrackerSummary[]>>({});
let botListenerLoading = $state<Record<number, boolean>>({});
function openNew() { form = { name: '', icon: '', token: '' }; editing = null; showForm = true; }
function editBot(bot: any) { form = { name: bot.name, icon: bot.icon || '', token: '' }; editing = bot.id; showForm = true; }
function editBot(bot: TelegramBot) { form = { name: bot.name, icon: bot.icon || '', token: '' }; editing = bot.id; showForm = true; }
async function saveBot(e: SubmitEvent) {
e.preventDefault(); error = ''; submitting = true;
@@ -78,14 +83,14 @@
async function loadChats(botId: number) {
chatsLoading = { ...chatsLoading, [botId]: true };
try { chats = { ...chats, [botId]: await api(`/telegram-bots/${botId}/chats`) }; } catch { chats = { ...chats, [botId]: [] }; }
try { chats = { ...chats, [botId]: await api<TelegramChat[]>(`/telegram-bots/${botId}/chats`) }; } catch (e) { console.warn('Failed to load chats:', e); chats = { ...chats, [botId]: [] }; }
chatsLoading = { ...chatsLoading, [botId]: false };
}
async function discoverChats(botId: number) {
chatsLoading = { ...chatsLoading, [botId]: true };
try {
chats = { ...chats, [botId]: await api(`/telegram-bots/${botId}/chats/discover`, { method: 'POST' }) };
chats = { ...chats, [botId]: await api<TelegramChat[]>(`/telegram-bots/${botId}/chats/discover`, { method: 'POST' }) };
snackSuccess(t('telegramBot.chatsDiscovered'));
} catch (err: any) { snackError(err.message); }
chatsLoading = { ...chatsLoading, [botId]: false };
@@ -94,7 +99,7 @@
async function deleteChat(botId: number, chatDbId: number) {
try {
await api(`/telegram-bots/${botId}/chats/${chatDbId}`, { method: 'DELETE' });
chats[botId] = (chats[botId] || []).filter((c: any) => c.id !== chatDbId);
chats[botId] = (chats[botId] || []).filter((c) => c.id !== chatDbId);
snackSuccess(t('telegramBot.chatDeleted'));
} catch (err: any) { snackError(err.message); }
}
@@ -102,24 +107,24 @@
async function loadListenerStatus(botId: number) {
botListenerLoading = { ...botListenerLoading, [botId]: true };
try {
const trackers = await api('/command-trackers');
const matched: any[] = [];
const trackers = await api<CommandTrackerSummary[]>('/command-trackers');
const matched: CommandTrackerSummary[] = [];
for (const trk of trackers) {
try {
const listeners = await api(`/command-trackers/${trk.id}/listeners`);
const hasBot = listeners.some((l: any) => l.listener_type === 'telegram_bot' && l.listener_id === botId);
const listeners = await api<ListenerEntry[]>(`/command-trackers/${trk.id}/listeners`);
const hasBot = listeners.some((l) => l.listener_type === 'telegram_bot' && l.listener_id === botId);
if (hasBot) matched.push(trk);
} catch { /* ignore */ }
} catch (e) { console.warn('Failed to load listeners for tracker:', e); }
}
botListenerStatus = { ...botListenerStatus, [botId]: matched };
} catch { botListenerStatus = { ...botListenerStatus, [botId]: [] }; }
} catch (e) { console.warn('Failed to load listener status:', e); botListenerStatus = { ...botListenerStatus, [botId]: [] }; }
botListenerLoading = { ...botListenerLoading, [botId]: false };
}
async function syncCommands(botId: number) {
modeChanging = { ...modeChanging, [botId]: true };
try {
const res = await api(`/telegram-bots/${botId}/sync-commands`, { method: 'POST' });
const res = await api<ApiResult>(`/telegram-bots/${botId}/sync-commands`, { method: 'POST' });
if (res.success) snackSuccess(t('telegramBot.commandsSynced'));
else snackError(res.error || 'Failed');
} catch (err: any) { snackError(err.message); }
@@ -141,14 +146,14 @@
async function loadWebhookStatus(botId: number) {
try {
webhookStatus = { ...webhookStatus, [botId]: await api(`/telegram-bots/${botId}/webhook/status`) };
} catch { webhookStatus = { ...webhookStatus, [botId]: null }; }
webhookStatus = { ...webhookStatus, [botId]: await api<WebhookStatusInfo>(`/telegram-bots/${botId}/webhook/status`) };
} catch (e) { console.warn('Failed to load webhook status:', e); webhookStatus = { ...webhookStatus, [botId]: {} }; }
}
async function registerWebhook(botId: number) {
modeChanging = { ...modeChanging, [botId]: true };
try {
const res = await api(`/telegram-bots/${botId}/webhook/register`, { method: 'POST' });
const res = await api<ApiResult>(`/telegram-bots/${botId}/webhook/register`, { method: 'POST' });
if (res.success) {
snackSuccess(res.verified ? t('telegramBot.webhookVerified') : t('telegramBot.webhookRegistered'));
await loadWebhookStatus(botId);
@@ -162,7 +167,7 @@
async function unregisterWebhook(botId: number) {
modeChanging = { ...modeChanging, [botId]: true };
try {
const res = await api(`/telegram-bots/${botId}/webhook/unregister`, { method: 'POST' });
const res = await api<ApiResult>(`/telegram-bots/${botId}/webhook/unregister`, { method: 'POST' });
if (res.success) { snackSuccess(t('telegramBot.webhookUnregistered')); await loadWebhookStatus(botId); }
else snackError(res.error || 'Failed');
} catch (err: any) { snackError(err.message); }
@@ -193,7 +198,7 @@
if (chatTesting[key]) return;
chatTesting = { ...chatTesting, [key]: true };
try {
const res = await api(`/telegram-bots/${botId}/chats/${chatId}/test?locale=${getLocale()}`, { method: 'POST' });
const res = await api<ApiResult>(`/telegram-bots/${botId}/chats/${chatId}/test?locale=${getLocale()}`, { method: 'POST' });
if (res.success) snackSuccess(t('snack.targetTestSent'));
else snackError(res.error || 'Failed');
} catch (err: any) { snackError(err.message); }
@@ -398,7 +403,7 @@
{@const ws = webhookStatus[bot.id]}
<span class="text-xs font-mono {ws.url ? 'text-blue-500' : 'text-[var(--color-muted-foreground)]'}">
{ws.url ? t('telegramBot.webhookActive') : t('telegramBot.webhookNotSet')}
{#if ws.pending_update_count > 0}
{#if (ws.pending_update_count ?? 0) > 0}
({ws.pending_update_count} {t('telegramBot.pendingUpdates')})
{/if}
</span>
@@ -17,10 +17,11 @@
import EntitySelect from '$lib/components/EntitySelect.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight';
import type { CommandConfig } from '$lib/types';
function templateName(id: number | null): string {
if (!id) return '';
const tpl = cmdTemplateConfigs.find((c: any) => c.id === id);
const tpl = cmdTemplateConfigs.find((c) => c.id === id);
return tpl?.name || `#${id}`;
}
@@ -33,15 +34,15 @@
));
let cmdTemplateConfigs = $derived(commandTemplateConfigsCache.items);
const templateItems = $derived(cmdTemplateConfigs
.filter((c: any) => c.provider_type === form.provider_type)
.map((c: any) => ({ value: c.id, label: c.name + (c.user_id === 0 ? ' (System)' : ''), icon: c.icon || 'mdiCodeBracesBox', desc: c.provider_type }))
.filter((c) => c.provider_type === form.provider_type)
.map((c) => ({ value: c.id, label: c.name + (c.user_id === 0 ? ' (System)' : ''), icon: c.icon || 'mdiCodeBracesBox', desc: c.provider_type }))
);
let loaded = $state(false);
let showForm = $state(false);
let editing = $state<number | null>(null);
let error = $state('');
let submitting = $state(false);
let confirmDelete = $state<any>(null);
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
// Immich command icons — used as fallback when capabilities don't specify icons
const commandIcons: Record<string, string> = {
@@ -54,7 +55,7 @@
let allCapabilities = $derived(capabilitiesCache.items);
let providerCommands = $derived<{key: string, icon: string}[]>(
(allCapabilities[form.provider_type]?.commands || []).map((c: any) => ({
(allCapabilities[form.provider_type]?.commands || []).map((c: { name: string }) => ({
key: c.name,
icon: commandIcons[c.name] || 'mdiConsole',
}))
@@ -88,12 +89,12 @@
function openNew() {
form = defaultForm();
// Auto-select first matching template for the default provider_type
const match = cmdTemplateConfigs.find((c: any) => c.provider_type === form.provider_type);
const match = cmdTemplateConfigs.find((c) => c.provider_type === form.provider_type);
if (match) form.command_template_config_id = match.id;
editing = null;
showForm = true;
}
function editConfig(cfg: any) {
function editConfig(cfg: CommandConfig) {
form = {
name: cfg.name,
icon: cfg.icon || '',
@@ -132,7 +133,7 @@
finally { submitting = false; }
}
function remove(cfg: any) {
function remove(cfg: CommandConfig) {
confirmDelete = {
id: cfg.id,
onconfirm: async () => {
@@ -11,9 +11,10 @@
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import CrossLink from '$lib/components/CrossLink.svelte';
import EntitySelect from '$lib/components/EntitySelect.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight';
import type { Tracker, TrackingConfig, TemplateConfig } from '$lib/types';
import type { Tracker, TrackerTarget, TrackingConfig, TemplateConfig, NotificationTarget } from '$lib/types';
import TrackerForm from './TrackerForm.svelte';
import LinkedTargetsSection from './LinkedTargetsSection.svelte';
@@ -34,7 +35,7 @@
let targets = $derived(targetsCache.items);
let trackingConfigs = $derived(trackingConfigsCache.items);
let templateConfigs = $derived(templateConfigsCache.items);
let collections = $state<any[]>([]);
let collections = $state<Record<string, any>[]>([]);
let showForm = $state(false);
let editing = $state<number | null>(null);
let collectionFilter = $state('');
@@ -44,7 +45,7 @@
let ttTesting = $state<Record<string, string>>({});
// Shared link validation
let linkWarning = $state<{ albums: any[], providerId: number } | null>(null);
let linkWarning = $state<{ albums: { id: string; name: string; issue: string }[], providerId: number } | null>(null);
let linkCheckLoading = $state(false);
let linkCreating = $state(false);
let previousCollectionIds = $state<string[]>([]);
@@ -83,7 +84,7 @@
];
let testMenuTrackerId = $state<number | null>(null);
let testTypes = $derived(() => {
let testTypes = $derived.by(() => {
if (!testMenuTrackerId) return defaultTestTypes;
const tracker = notificationTrackers.find(t => t.id === testMenuTrackerId);
if (!tracker) return defaultTestTypes;
@@ -98,7 +99,7 @@
loadError = '';
try {
[allNotificationTrackers] = await Promise.all([
api('/notification-trackers'),
api<Tracker[]>('/notification-trackers'),
providersCache.fetch(), targetsCache.fetch(),
trackingConfigsCache.fetch(), templateConfigsCache.fetch(),
]);
@@ -110,7 +111,7 @@
async function loadCollections() {
if (!form.provider_id) return;
try { collections = await api(`/providers/${form.provider_id}/collections`); } catch { collections = []; }
try { collections = await api(`/providers/${form.provider_id}/collections`); } catch (e) { console.warn('Failed to load collections:', e); collections = []; }
}
let _prevProviderId = 0;
@@ -123,7 +124,7 @@
function openNew() { form = defaultForm(); editing = null; showForm = true; collections = []; previousCollectionIds = []; }
async function edit(trk: any) {
async function edit(trk: Tracker) {
form = {
name: trk.name, icon: trk.icon || '', provider_id: trk.provider_id,
collection_ids: [...(trk.collection_ids || [])],
@@ -143,13 +144,14 @@
if (newAlbumIds.length > 0 && form.provider_id) {
linkCheckLoading = true;
try {
const missingAlbums: any[] = [];
const missingAlbums: { id: string; name: string; issue: string }[] = [];
for (const albumId of newAlbumIds) {
const links = await api(`/providers/${form.provider_id}/albums/${albumId}/shared-links`);
const validLink = (links as any[]).find((l: any) => l.is_accessible && !l.is_expired);
interface SharedLink { is_accessible: boolean; is_expired: boolean; has_password: boolean }
const links = await api<SharedLink[]>(`/providers/${form.provider_id}/albums/${albumId}/shared-links`);
const validLink = links.find((l) => l.is_accessible && !l.is_expired);
if (!validLink) {
const album = collections.find(c => c.id === albumId);
const problematicLink = (links as any[]).find((l: any) => l.is_expired || l.has_password);
const problematicLink = links.find((l) => l.is_expired || l.has_password);
missingAlbums.push({
id: albumId,
name: album?.albumName || album?.name || albumId,
@@ -164,7 +166,7 @@
linkCheckLoading = false;
return;
}
} catch { /* Proceed if check fails */ }
} catch (e) { console.warn('Shared link check failed, proceeding:', e); }
linkCheckLoading = false;
}
@@ -210,7 +212,7 @@
await doSave();
}
async function toggle(tracker: any) {
async function toggle(tracker: Tracker) {
if (toggling[tracker.id]) return;
toggling = { ...toggling, [tracker.id]: true };
try {
@@ -220,7 +222,7 @@
} catch (err: any) { snackError(err.message); } finally { toggling = { ...toggling, [tracker.id]: false }; }
}
function startDelete(tracker: any) { confirmDelete = tracker; }
function startDelete(tracker: Tracker) { confirmDelete = tracker; }
async function doDelete() {
if (!confirmDelete) return;
@@ -243,7 +245,7 @@
try {
const d = new Date(dateStr);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
} catch { return ''; }
} catch (e) { console.warn('Date format error:', e); return ''; }
}
// --- Linked Targets helpers ---
@@ -252,7 +254,7 @@
expandedTracker = trackerId;
}
function getProviderType(tracker: any): string {
function getProviderType(tracker: Tracker): string {
const p = providers.find(p => p.id === tracker.provider_id);
return p?.type || '';
}
@@ -262,13 +264,13 @@
return p?.name || `#${id}`;
}
function configsForTracker(tracker: any, configs: (TrackingConfig | TemplateConfig)[]): any[] {
function configsForTracker(tracker: Tracker, configs: (TrackingConfig | TemplateConfig)[]): (TrackingConfig | TemplateConfig)[] {
const pt = getProviderType(tracker);
return pt ? configs.filter((c: any) => c.provider_type === pt) : configs;
return pt ? configs.filter((c) => c.provider_type === pt) : configs;
}
function getUnlinkedTargets(tracker: any): any[] {
const linkedIds = new Set((tracker.tracker_targets || []).map((tt: any) => tt.target_id));
function getUnlinkedTargets(tracker: Tracker): NotificationTarget[] {
const linkedIds = new Set((tracker.tracker_targets || []).map((tt: TrackerTarget) => tt.target_id));
return targets.filter(t => !linkedIds.has(t.id));
}
@@ -302,7 +304,7 @@
} catch (err: any) { snackError(err.message); }
}
async function updateTargetLink(trackerId: number, tt: any, field: string, value: any) {
async function updateTargetLink(trackerId: number, tt: TrackerTarget, field: string, value: string | number | boolean | null) {
try {
await api(`/notification-trackers/${trackerId}/targets/${tt.id}`, {
method: 'PUT',
@@ -331,12 +333,12 @@
const btn = event.currentTarget as HTMLElement;
const rect = btn.getBoundingClientRect();
testMenuStyle = `position:fixed; z-index:9999; top:${rect.bottom + 4}px; right:${window.innerWidth - rect.right}px;`;
testMenuTrackerId = notificationTrackers.find(t => t.tracker_targets?.some((x: any) => String(x.id) === String(ttId)))?.id ?? null;
testMenuTrackerId = notificationTrackers.find(t => t.tracker_targets?.some((x: TrackerTarget) => String(x.id) === String(ttId)))?.id ?? null;
testMenuOpen = testMenuOpen === String(ttId) ? null : String(ttId);
}
function handleTestFromMenu(ttId: number, testType: string) {
const trackerId = notificationTrackers.find(t => t.tracker_targets?.some((x: any) => String(x.id) === String(ttId)))?.id;
const trackerId = notificationTrackers.find(t => t.tracker_targets?.some((x: TrackerTarget) => String(x.id) === String(ttId)))?.id;
if (trackerId) testTrackerTarget(trackerId, ttId, testType);
}
</script>
@@ -451,7 +453,7 @@
{testMenuOpen}
{testMenuStyle}
{ttTesting}
testTypes={testTypes()}
testTypes={testTypes}
ontest={handleTestFromMenu}
onclose={() => testMenuOpen = null}
/>
+1 -1
View File
@@ -20,7 +20,7 @@
e.preventDefault();
error = '';
if (password !== confirmPassword) { error = t('auth.passwordMismatch'); return; }
if (password.length < 6) { error = t('auth.passwordTooShort'); return; }
if (password.length < 8) { error = t('auth.passwordTooShort'); return; }
submitting = true;
try {
await setup(username, password);
+54 -224
View File
@@ -1,6 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { page } from '$app/state';
import { api } from '$lib/api';
import { t, getLocale } from '$lib/i18n';
@@ -8,23 +7,22 @@
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 Hint from '$lib/components/Hint.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import CrossLink from '$lib/components/CrossLink.svelte';
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
import EntitySelect from '$lib/components/EntitySelect.svelte';
import { chatActionItems } from '$lib/grid-items';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight';
import type { NotificationTarget, TargetReceiver, TelegramChat } from '$lib/types';
import TargetForm from './TargetForm.svelte';
import ReceiverSection from './ReceiverSection.svelte';
// ── Helpers ──
function getBotName(target: any): string | null {
function getBotName(target: NotificationTarget): string | null {
if (target.type === 'telegram' && target.config?.bot_id) {
const bot = telegramBots.find(b => b.id === target.config.bot_id);
return bot?.name || null;
@@ -40,14 +38,14 @@
return null;
}
function getBotHref(target: any): string {
function getBotHref(target: NotificationTarget): string {
if (target.type === 'telegram') return '/bots?tab=telegram';
if (target.type === 'email') return '/bots?tab=email';
if (target.type === 'matrix') return '/bots?tab=matrix';
return '/bots?tab=telegram';
}
function getBotEntityId(target: any): number | null {
function getBotEntityId(target: NotificationTarget): number | null {
if (target.type === 'telegram') return target.config?.bot_id || null;
if (target.type === 'email') return target.config?.email_bot_id || null;
if (target.type === 'matrix') return target.config?.matrix_bot_id || null;
@@ -57,7 +55,7 @@
function receiverLabel(target: NotificationTarget, recv: TargetReceiver): string {
const c = recv.config || {};
if (target.type === 'telegram') {
return (recv as any).chat_name || c.chat_id || recv.receiver_key || '?';
return recv.chat_name || c.chat_id || recv.receiver_key || '?';
}
if (target.type === 'email') return c.email || recv.receiver_key || '?';
if (target.type === 'webhook') return c.url || recv.receiver_key || '?';
@@ -173,7 +171,10 @@
async function loadReceiverBotChats(botId: number) {
if (!botId) return;
try { receiverBotChats[botId] = await api(`/telegram-bots/${botId}/chats`); } catch {}
try {
const data = await api<TelegramChat[]>(`/telegram-bots/${botId}/chats`);
receiverBotChats = { ...receiverBotChats, [botId]: data };
} catch (e) { console.warn('Failed to load bot chats:', e); }
}
// ── Target CRUD ──
@@ -223,7 +224,7 @@
if (formType === 'telegram') {
let botToken = form.bot_token;
if (form.bot_id && !botToken) {
const tokenRes = await api(`/telegram-bots/${form.bot_id}/token`);
const tokenRes = await api<{ token: string }>(`/telegram-bots/${form.bot_id}/token`);
botToken = tokenRes.token;
}
config = {
@@ -265,7 +266,7 @@
async function test(id: number) {
try {
const res = await api(`/targets/${id}/test?locale=${getLocale()}`, { method: 'POST' });
const res = await api<{ success: boolean; error?: string }>(`/targets/${id}/test?locale=${getLocale()}`, { method: 'POST' });
if (res.success) snackSuccess(t('snack.targetTestSent'));
else snackError(`Failed: ${res.error}`);
} catch (err: any) { snackError(err.message); }
@@ -317,7 +318,7 @@
const target = allTargets.find(t => t.id === addingReceiverForTarget);
const botId = target?.config?.bot_id || target?.config?.telegram_bot_id;
if (botId && receiverBotChats[botId]) {
const chat = receiverBotChats[botId].find((c: any) => String(c.chat_id) === String(config.chat_id));
const chat = receiverBotChats[botId].find((c: TelegramChat) => String(c.chat_id) === String(config.chat_id));
if (chat) {
config.chat_name = chat.title || chat.username || '';
if (chat.language_code) config.language_code = chat.language_code;
@@ -369,7 +370,7 @@
async function testReceiver(targetId: number, receiverId: number) {
receiverTesting = { ...receiverTesting, [receiverId]: true };
try {
const res = await api(`/targets/${targetId}/receivers/${receiverId}/test?locale=${getLocale()}`, { method: 'POST' });
const res = await api<{ success: boolean; error?: string }>(`/targets/${targetId}/receivers/${receiverId}/test?locale=${getLocale()}`, { method: 'POST' });
if (res.success) snackSuccess(t('snack.targetTestSent'));
else snackError(`Failed: ${res.error}`);
} catch (err: any) { snackError(err.message); }
@@ -391,108 +392,25 @@
{/if}
{#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-4">
{#if !activeType}
<div>
<label class="block text-sm font-medium mb-1">{t('targets.type')}</label>
<IconGridSelect items={typeGridItems} bind:value={formType} columns={4} />
</div>
{/if}
<div>
<label for="tgt-name" class="block text-sm font-medium mb-1">{t('targets.name')}</label>
<div class="flex gap-2">
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
<input id="tgt-name" bind:value={form.name} required placeholder={t('targets.namePlaceholder')} class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
{#if formType === 'telegram'}
<div>
<label class="block text-sm font-medium mb-1">{t('telegramBot.selectBot')}</label>
<EntitySelect items={telegramBotItems} bind:value={form.bot_id} placeholder={t('telegramBot.selectBot')} />
{#if telegramBots.length === 0}
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('telegramBot.noBots')} <a href="/bots?tab=telegram" class="underline">→</a></p>
{/if}
</div>
<div class="border border-[var(--color-border)] rounded-md p-3">
<button type="button" onclick={() => showTelegramSettings = !showTelegramSettings}
class="text-sm font-medium cursor-pointer w-full text-left flex items-center justify-between">
{t('targets.telegramSettings')}
<span class="text-xs transition-transform duration-200" class:rotate-180={showTelegramSettings}>▼</span>
</button>
{#if showTelegramSettings}
<div in:slide={{ duration: 150 }} class="grid grid-cols-2 gap-3 mt-3">
<div>
<label for="tgt-maxmedia" class="block text-xs mb-1">{t('targets.maxMedia')}<Hint text={t('hints.maxMedia')} /></label>
<input id="tgt-maxmedia" type="number" bind:value={form.max_media_to_send} min="0" max="50" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="tgt-groupsize" class="block text-xs mb-1">{t('targets.maxGroupSize')}<Hint text={t('hints.groupSize')} /></label>
<input id="tgt-groupsize" type="number" bind:value={form.max_media_per_group} min="2" max="10" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="tgt-delay" class="block text-xs mb-1">{t('targets.chunkDelay')}<Hint text={t('hints.chunkDelay')} /></label>
<input id="tgt-delay" type="number" bind:value={form.media_delay} min="0" max="60000" step="100" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="tgt-maxsize" class="block text-xs mb-1">{t('targets.maxAssetSize')}<Hint text={t('hints.maxAssetSize')} /></label>
<input id="tgt-maxsize" type="number" bind:value={form.max_asset_size} min="1" max="50" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div class="col-span-2">
<label class="block text-xs mb-1">{t('targets.chatAction')}</label>
<IconGridSelect items={chatActionItems()} bind:value={form.chat_action} columns={4} compact />
</div>
<label class="flex items-center gap-2 text-sm col-span-2"><input type="checkbox" bind:checked={form.disable_url_preview} /> {t('targets.disableUrlPreview')}</label>
<label class="flex items-center gap-2 text-sm col-span-2"><input type="checkbox" bind:checked={form.send_large_photos_as_documents} /> {t('targets.sendLargeAsDocuments')}</label>
</div>
{/if}
</div>
{:else if formType === 'discord' || formType === 'slack'}
<div>
<label for="tgt-user" class="block text-sm font-medium mb-1">{t('targets.overrideUsername')}</label>
<input id="tgt-user" bind:value={form.username} placeholder="Notify Bridge"
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
{:else if formType === 'ntfy'}
<div>
<label for="tgt-ntfy-server" class="block text-sm font-medium mb-1">{t('targets.ntfyServer')}</label>
<input id="tgt-ntfy-server" bind:value={form.server_url} required placeholder="https://ntfy.sh"
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="tgt-ntfy-token" class="block text-sm font-medium mb-1">{t('targets.ntfyToken')}</label>
<input id="tgt-ntfy-token" bind:value={form.auth_token} placeholder={t('targets.ntfyTokenPlaceholder')}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
{:else if formType === 'email'}
<div>
<label class="block text-sm font-medium mb-1">{t('targets.selectEmailBot')}</label>
<EntitySelect items={emailBotItems} bind:value={form.email_bot_id} placeholder={t('targets.selectEmailBot')} />
{#if emailBots.length === 0}
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('emailBot.noBots')} <a href="/bots?tab=email" class="underline">→</a></p>
{/if}
</div>
{:else if formType === 'matrix'}
<div>
<label class="block text-sm font-medium mb-1">{t('targets.selectMatrixBot')}</label>
<EntitySelect items={matrixBotItems} bind:value={form.matrix_bot_id} placeholder={t('targets.selectMatrixBot')} />
{#if matrixBots.length === 0}
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('matrixBot.noBots')} <a href="/bots?tab=matrix" class="underline">→</a></p>
{/if}
</div>
{/if}
{#if formType === 'telegram'}
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.ai_captions} /> {t('targets.aiCaptions')}<Hint text={t('hints.aiCaptions')} /></label>
{/if}
<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('targets.create'))}</button>
</form>
</Card>
</div>
<TargetForm
bind:form
bind:formType
{activeType}
{typeGridItems}
{telegramBotItems}
{emailBotItems}
{matrixBotItems}
chatActionItems={chatActionItems()}
telegramBotCount={telegramBots.length}
emailBotCount={emailBots.length}
matrixBotCount={matrixBots.length}
{editing}
{submitting}
{error}
bind:showTelegramSettings
onsave={save}
ontoggleTelegramSettings={() => showTelegramSettings = !showTelegramSettings}
/>
{/if}
{#if !showForm && allTargets.length > 0}
@@ -522,7 +440,7 @@
<p class="font-medium">{target.name}</p>
{#if !activeType}<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.type}</span>{/if}
{#if (target.receivers || []).length > 0}<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{(target.receivers || []).length} receiver(s)</span>{/if}
{#if getBotName(target)}<CrossLink href={getBotHref(target)} icon="mdiRobot" label={getBotName(target)} entityId={getBotEntityId(target)} />{/if}
{#if getBotName(target)}<CrossLink href={getBotHref(target)} icon="mdiRobot" label={getBotName(target) ?? ''} entityId={getBotEntityId(target)} />{/if}
</div>
</div>
<div class="flex items-center gap-1">
@@ -533,113 +451,25 @@
</div>
<!-- Receivers list -->
<div class="mt-3 pt-3 border-t border-[var(--color-border)]">
<div class="flex items-center justify-between mb-2">
<p class="text-xs font-medium text-[var(--color-muted-foreground)] uppercase tracking-wide">{t('targets.receivers')}</p>
</div>
{#if (target.receivers || []).length === 0 && addingReceiverForTarget !== target.id}
<p class="text-xs text-[var(--color-muted-foreground)] italic mb-2">{t('targets.noReceivers')}</p>
{/if}
{#each target.receivers || [] as recv (recv.id)}
<div class="flex items-center justify-between py-1.5 px-2 rounded-md hover:bg-[var(--color-muted)]" class:opacity-50={!recv.enabled}>
<div class="flex items-center gap-2 min-w-0">
<MdiIcon name={TYPE_ICONS[target.type] || 'mdiTarget'} size={14} />
<span class="text-sm truncate">{receiverLabel(target, recv)}</span>
{#if (recv as any).language_code || recv.config?.language_code}
<span class="text-xs px-1 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{((recv as any).language_code || recv.config.language_code).toUpperCase()}</span>
{/if}
</div>
<div class="flex items-center gap-1">
<IconButton icon="mdiSend" title={t('targets.test')}
onclick={() => testReceiver(target.id, recv.id)}
disabled={receiverTesting[recv.id]} size={16} />
<IconButton
icon={recv.enabled ? 'mdiToggleSwitch' : 'mdiToggleSwitchOff'}
title={recv.enabled ? t('targets.receiverDisabled') : t('targets.receiverEnabled')}
onclick={() => toggleReceiver(target.id, recv)}
size={16}
/>
<IconButton
icon="mdiDelete"
title={t('common.delete')}
onclick={() => confirmDeleteReceiver = { targetId: target.id, receiver: recv }}
variant="danger"
size={16}
/>
</div>
</div>
{/each}
<!-- Inline add-receiver form -->
{#if addingReceiverForTarget === target.id}
<div in:slide={{ duration: 150 }} class="mt-2 p-2 rounded-md border border-[var(--color-border)] bg-[var(--color-background)]">
{#if target.type === 'telegram'}
{@const botId = target.config?.bot_id}
{@const existingKeys = new Set((target.receivers || []).map((r: TargetReceiver) => r.receiver_key))}
{@const chatItems = (receiverBotChats[botId] || []).map((c: any) => ({
value: c.chat_id,
label: c.title || c.username || c.chat_id,
icon: c.type === 'private' ? 'mdiAccount' : c.type === 'channel' ? 'mdiBullhorn' : 'mdiAccountGroup',
desc: `${c.type}${c.language_code ? ' · ' + c.language_code.toUpperCase() : ''} · ${c.chat_id}`,
disabled: existingKeys.has(c.chat_id),
disabledHint: existingKeys.has(c.chat_id) ? t('targets.alreadyAdded') : undefined,
}))}
{#if chatItems.length > 0}
<EntitySelect items={chatItems} bind:value={receiverForm.chat_id} placeholder={t('telegramBot.selectChat')} />
{:else}
<input bind:value={receiverForm.chat_id} placeholder="Chat ID"
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{/if}
{#if botId}
<button type="button" onclick={() => loadReceiverBotChats(botId)}
class="text-xs text-[var(--color-primary)] hover:underline mt-2 flex items-center gap-1">
<MdiIcon name="mdiSync" size={14} />
{t('telegramBot.discoverChats')}
</button>
{/if}
{:else if target.type === 'email'}
<input bind:value={receiverForm.email} type="email" placeholder="recipient@example.com"
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{:else if target.type === 'webhook'}
<input bind:value={receiverForm.url} placeholder="https://..."
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] mb-2" />
<input bind:value={receiverForm.headers} placeholder={'{"Authorization": "Bearer ..."}'}
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]"
style={receiverHeadersError ? 'border-color: var(--color-error-fg)' : ''} />
{#if receiverHeadersError}<p class="text-xs text-[var(--color-error-fg)] mt-1">{receiverHeadersError}</p>{/if}
{:else if target.type === 'discord' || target.type === 'slack'}
<input bind:value={receiverForm.webhook_url}
placeholder={target.type === 'discord' ? 'https://discord.com/api/webhooks/...' : 'https://hooks.slack.com/services/...'}
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{:else if target.type === 'ntfy'}
<input bind:value={receiverForm.topic} placeholder="my-notifications"
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{:else if target.type === 'matrix'}
<input bind:value={receiverForm.room_id} placeholder="!abc123:matrix.org"
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] font-mono" />
{/if}
<div class="flex gap-2 mt-2">
<button type="button" onclick={() => saveReceiver(target.id)} disabled={receiverSubmitting}
class="px-3 py-1 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-xs font-medium hover:opacity-90 disabled:opacity-50">
{receiverSubmitting ? t('common.loading') : t('common.save')}
</button>
<button type="button" onclick={() => addingReceiverForTarget = null}
class="px-3 py-1 border border-[var(--color-border)] rounded-md text-xs hover:bg-[var(--color-muted)]">
{t('targets.cancel')}
</button>
</div>
</div>
{:else}
<button type="button" onclick={() => openReceiverForm(target.id, target.type)}
class="mt-1 flex items-center gap-1 text-xs text-[var(--color-primary)] hover:underline cursor-pointer">
<MdiIcon name="mdiPlus" size={14} />
{t('targets.addReceiver')}
</button>
{/if}
</div>
<ReceiverSection
{target}
typeIcons={TYPE_ICONS}
{addingReceiverForTarget}
bind:receiverForm
{receiverSubmitting}
{receiverHeadersError}
{receiverBotChats}
{receiverTesting}
{receiverLabel}
onopenReceiverForm={openReceiverForm}
onsaveReceiver={saveReceiver}
oncancelReceiver={() => addingReceiverForTarget = null}
ontoggleReceiver={toggleReceiver}
onremoveReceiver={(targetId, recv) => confirmDeleteReceiver = { targetId, receiver: recv }}
ontestReceiver={testReceiver}
onloadBotChats={loadReceiverBotChats}
onchangeReceiverForm={(f) => receiverForm = f}
/>
</Card>
{/each}
</div>
@@ -0,0 +1,156 @@
<script lang="ts">
import { slide } from 'svelte/transition';
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 { NotificationTarget, TargetReceiver, TelegramChat } from '$lib/types';
interface Props {
target: NotificationTarget;
typeIcons: Record<string, string>;
addingReceiverForTarget: number | null;
receiverForm: Record<string, any>;
receiverSubmitting: boolean;
receiverHeadersError: string;
receiverBotChats: Record<number, TelegramChat[]>;
receiverTesting: Record<number, boolean>;
receiverLabel: (target: NotificationTarget, recv: TargetReceiver) => string;
onopenReceiverForm: (targetId: number, targetType: string) => void;
onsaveReceiver: (targetId: number) => void;
oncancelReceiver: () => void;
ontoggleReceiver: (targetId: number, receiver: TargetReceiver) => void;
onremoveReceiver: (targetId: number, receiver: TargetReceiver) => void;
ontestReceiver: (targetId: number, receiverId: number) => void;
onloadBotChats: (botId: number) => void;
onchangeReceiverForm: (form: Record<string, any>) => void;
}
let {
target,
typeIcons,
addingReceiverForTarget,
receiverForm = $bindable(),
receiverSubmitting,
receiverHeadersError,
receiverBotChats,
receiverTesting,
receiverLabel,
onopenReceiverForm,
onsaveReceiver,
oncancelReceiver,
ontoggleReceiver,
onremoveReceiver,
ontestReceiver,
onloadBotChats,
onchangeReceiverForm,
}: Props = $props();
</script>
<div class="mt-3 pt-3 border-t border-[var(--color-border)]">
<div class="flex items-center justify-between mb-2">
<p class="text-xs font-medium text-[var(--color-muted-foreground)] uppercase tracking-wide">{t('targets.receivers')}</p>
</div>
{#if (target.receivers || []).length === 0 && addingReceiverForTarget !== target.id}
<p class="text-xs text-[var(--color-muted-foreground)] italic mb-2">{t('targets.noReceivers')}</p>
{/if}
{#each target.receivers || [] as recv (recv.id)}
<div class="flex items-center justify-between py-1.5 px-2 rounded-md hover:bg-[var(--color-muted)]" class:opacity-50={!recv.enabled}>
<div class="flex items-center gap-2 min-w-0">
<MdiIcon name={typeIcons[target.type] || 'mdiTarget'} size={14} />
<span class="text-sm truncate">{receiverLabel(target, recv)}</span>
{#if (recv as any).language_code || recv.config?.language_code}
<span class="text-xs px-1 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{((recv as any).language_code || recv.config.language_code).toUpperCase()}</span>
{/if}
</div>
<div class="flex items-center gap-1">
<IconButton icon="mdiSend" title={t('targets.test')}
onclick={() => ontestReceiver(target.id, recv.id)}
disabled={receiverTesting[recv.id]} size={16} />
<IconButton
icon={recv.enabled ? 'mdiToggleSwitch' : 'mdiToggleSwitchOff'}
title={recv.enabled ? t('targets.receiverDisabled') : t('targets.receiverEnabled')}
onclick={() => ontoggleReceiver(target.id, recv)}
size={16}
/>
<IconButton
icon="mdiDelete"
title={t('common.delete')}
onclick={() => onremoveReceiver(target.id, recv)}
variant="danger"
size={16}
/>
</div>
</div>
{/each}
<!-- Inline add-receiver form -->
{#if addingReceiverForTarget === target.id}
<div in:slide={{ duration: 150 }} class="mt-2 p-2 rounded-md border border-[var(--color-border)] bg-[var(--color-background)]">
{#if target.type === 'telegram'}
{@const botId = target.config?.bot_id}
{@const existingKeys = new Set((target.receivers || []).map((r: TargetReceiver) => r.receiver_key))}
{@const chatItems = (receiverBotChats[botId] || []).map((c: any) => ({
value: c.chat_id,
label: c.title || c.username || c.chat_id,
icon: c.type === 'private' ? 'mdiAccount' : c.type === 'channel' ? 'mdiBullhorn' : 'mdiAccountGroup',
desc: `${c.type}${c.language_code ? ' · ' + c.language_code.toUpperCase() : ''} · ${c.chat_id}`,
disabled: existingKeys.has(c.chat_id),
disabledHint: existingKeys.has(c.chat_id) ? t('targets.alreadyAdded') : undefined,
}))}
{#if chatItems.length > 0}
<EntitySelect items={chatItems} bind:value={receiverForm.chat_id} placeholder={t('telegramBot.selectChat')} />
{:else}
<input bind:value={receiverForm.chat_id} placeholder="Chat ID"
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{/if}
{#if botId}
<button type="button" onclick={() => onloadBotChats(botId)}
class="text-xs text-[var(--color-primary)] hover:underline mt-2 flex items-center gap-1">
<MdiIcon name="mdiSync" size={14} />
{t('telegramBot.discoverChats')}
</button>
{/if}
{:else if target.type === 'email'}
<input bind:value={receiverForm.email} type="email" placeholder="recipient@example.com"
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{:else if target.type === 'webhook'}
<input bind:value={receiverForm.url} placeholder="https://..."
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] mb-2" />
<input bind:value={receiverForm.headers} placeholder={'{"Authorization": "Bearer ..."}'}
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]"
style={receiverHeadersError ? 'border-color: var(--color-error-fg)' : ''} />
{#if receiverHeadersError}<p class="text-xs text-[var(--color-error-fg)] mt-1">{receiverHeadersError}</p>{/if}
{:else if target.type === 'discord' || target.type === 'slack'}
<input bind:value={receiverForm.webhook_url}
placeholder={target.type === 'discord' ? 'https://discord.com/api/webhooks/...' : 'https://hooks.slack.com/services/...'}
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{:else if target.type === 'ntfy'}
<input bind:value={receiverForm.topic} placeholder="my-notifications"
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{:else if target.type === 'matrix'}
<input bind:value={receiverForm.room_id} placeholder="!abc123:matrix.org"
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] font-mono" />
{/if}
<div class="flex gap-2 mt-2">
<button type="button" onclick={() => onsaveReceiver(target.id)} disabled={receiverSubmitting}
class="px-3 py-1 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-xs font-medium hover:opacity-90 disabled:opacity-50">
{receiverSubmitting ? t('common.loading') : t('common.save')}
</button>
<button type="button" onclick={oncancelReceiver}
class="px-3 py-1 border border-[var(--color-border)] rounded-md text-xs hover:bg-[var(--color-muted)]">
{t('targets.cancel')}
</button>
</div>
</div>
{:else}
<button type="button" onclick={() => onopenReceiverForm(target.id, target.type)}
class="mt-1 flex items-center gap-1 text-xs text-[var(--color-primary)] hover:underline cursor-pointer">
<MdiIcon name="mdiPlus" size={14} />
{t('targets.addReceiver')}
</button>
{/if}
</div>
@@ -0,0 +1,172 @@
<script lang="ts">
import { slide } from 'svelte/transition';
import { t } from '$lib/i18n';
import Card from '$lib/components/Card.svelte';
import IconPicker from '$lib/components/IconPicker.svelte';
import Hint from '$lib/components/Hint.svelte';
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
import EntitySelect from '$lib/components/EntitySelect.svelte';
import type { EntityItem } from '$lib/components/EntitySelect.svelte';
import type { GridItem } from '$lib/components/IconGridSelect.svelte';
interface Props {
form: {
name: string;
icon: string;
bot_id: number;
bot_token: string;
max_media_to_send: number;
max_media_per_group: number;
media_delay: number;
max_asset_size: number;
disable_url_preview: boolean;
send_large_photos_as_documents: boolean;
ai_captions: boolean;
chat_action: string;
username: string;
server_url: string;
auth_token: string;
matrix_bot_id: number;
email_bot_id: number;
};
formType: string;
activeType: string | null;
typeGridItems: GridItem[];
telegramBotItems: EntityItem[];
emailBotItems: EntityItem[];
matrixBotItems: EntityItem[];
chatActionItems: GridItem[];
telegramBotCount: number;
emailBotCount: number;
matrixBotCount: number;
editing: number | null;
submitting: boolean;
error: string;
showTelegramSettings: boolean;
onsave: (e: SubmitEvent) => void;
ontoggleTelegramSettings: () => void;
}
let {
form = $bindable(),
formType = $bindable(),
activeType,
typeGridItems,
telegramBotItems,
emailBotItems,
matrixBotItems,
chatActionItems,
telegramBotCount,
emailBotCount,
matrixBotCount,
editing,
submitting,
error,
showTelegramSettings = $bindable(),
onsave,
ontoggleTelegramSettings,
}: Props = $props();
</script>
<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={onsave} class="space-y-4">
{#if !activeType}
<div>
<label class="block text-sm font-medium mb-1">{t('targets.type')}</label>
<IconGridSelect items={typeGridItems} bind:value={formType} columns={4} />
</div>
{/if}
<div>
<label for="tgt-name" class="block text-sm font-medium mb-1">{t('targets.name')}</label>
<div class="flex gap-2">
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
<input id="tgt-name" bind:value={form.name} required placeholder={t('targets.namePlaceholder')} class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
{#if formType === 'telegram'}
<div>
<label class="block text-sm font-medium mb-1">{t('telegramBot.selectBot')}</label>
<EntitySelect items={telegramBotItems} bind:value={form.bot_id} placeholder={t('telegramBot.selectBot')} />
{#if telegramBotCount === 0}
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('telegramBot.noBots')} <a href="/bots?tab=telegram" class="underline"></a></p>
{/if}
</div>
<div class="border border-[var(--color-border)] rounded-md p-3">
<button type="button" onclick={ontoggleTelegramSettings}
class="text-sm font-medium cursor-pointer w-full text-left flex items-center justify-between">
{t('targets.telegramSettings')}
<span class="text-xs transition-transform duration-200" class:rotate-180={showTelegramSettings}>▼</span>
</button>
{#if showTelegramSettings}
<div in:slide={{ duration: 150 }} class="grid grid-cols-2 gap-3 mt-3">
<div>
<label for="tgt-maxmedia" class="block text-xs mb-1">{t('targets.maxMedia')}<Hint text={t('hints.maxMedia')} /></label>
<input id="tgt-maxmedia" type="number" bind:value={form.max_media_to_send} min="0" max="50" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="tgt-groupsize" class="block text-xs mb-1">{t('targets.maxGroupSize')}<Hint text={t('hints.groupSize')} /></label>
<input id="tgt-groupsize" type="number" bind:value={form.max_media_per_group} min="2" max="10" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="tgt-delay" class="block text-xs mb-1">{t('targets.chunkDelay')}<Hint text={t('hints.chunkDelay')} /></label>
<input id="tgt-delay" type="number" bind:value={form.media_delay} min="0" max="60000" step="100" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="tgt-maxsize" class="block text-xs mb-1">{t('targets.maxAssetSize')}<Hint text={t('hints.maxAssetSize')} /></label>
<input id="tgt-maxsize" type="number" bind:value={form.max_asset_size} min="1" max="50" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div class="col-span-2">
<label class="block text-xs mb-1">{t('targets.chatAction')}</label>
<IconGridSelect items={chatActionItems} bind:value={form.chat_action} columns={4} compact />
</div>
<label class="flex items-center gap-2 text-sm col-span-2"><input type="checkbox" bind:checked={form.disable_url_preview} /> {t('targets.disableUrlPreview')}</label>
<label class="flex items-center gap-2 text-sm col-span-2"><input type="checkbox" bind:checked={form.send_large_photos_as_documents} /> {t('targets.sendLargeAsDocuments')}</label>
</div>
{/if}
</div>
{:else if formType === 'discord' || formType === 'slack'}
<div>
<label for="tgt-user" class="block text-sm font-medium mb-1">{t('targets.overrideUsername')}</label>
<input id="tgt-user" bind:value={form.username} placeholder="Notify Bridge"
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
{:else if formType === 'ntfy'}
<div>
<label for="tgt-ntfy-server" class="block text-sm font-medium mb-1">{t('targets.ntfyServer')}</label>
<input id="tgt-ntfy-server" bind:value={form.server_url} required placeholder="https://ntfy.sh"
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="tgt-ntfy-token" class="block text-sm font-medium mb-1">{t('targets.ntfyToken')}</label>
<input id="tgt-ntfy-token" bind:value={form.auth_token} placeholder={t('targets.ntfyTokenPlaceholder')}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
{:else if formType === 'email'}
<div>
<label class="block text-sm font-medium mb-1">{t('targets.selectEmailBot')}</label>
<EntitySelect items={emailBotItems} bind:value={form.email_bot_id} placeholder={t('targets.selectEmailBot')} />
{#if emailBotCount === 0}
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('emailBot.noBots')} <a href="/bots?tab=email" class="underline"></a></p>
{/if}
</div>
{:else if formType === 'matrix'}
<div>
<label class="block text-sm font-medium mb-1">{t('targets.selectMatrixBot')}</label>
<EntitySelect items={matrixBotItems} bind:value={form.matrix_bot_id} placeholder={t('targets.selectMatrixBot')} />
{#if matrixBotCount === 0}
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('matrixBot.noBots')} <a href="/bots?tab=matrix" class="underline"></a></p>
{/if}
</div>
{/if}
{#if formType === 'telegram'}
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.ai_captions} /> {t('targets.aiCaptions')}<Hint text={t('hints.aiCaptions')} /></label>
{/if}
<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('targets.create'))}</button>
</form>
</Card>
</div>