feat: comprehensive code review fixes + receivers-only architecture
Security:
- Refuse startup with default secret_key in production (was just logging)
- Settings endpoint now requires admin role
- Password validation on initial setup
- DOM-based HTML sanitizer replaces regex in template previews
- Add *.log to .gitignore
Performance & reliability:
- Token refresh deduplication prevents race condition on concurrent 401s
- Theme media query listener registered once (no leak)
- IconPicker uses $derived instead of function call per render
- Snackbar uses single-batch state update instead of while loop
- Replace 11 inline hover handlers with CSS :hover in layout
Architecture - receivers-only:
- Delivery endpoints (chat_id, email, url, room_id, topic) now stored
exclusively in TargetReceiver rows, never in target.config
- Migration extracts existing delivery fields to receiver rows
- Notifier and dispatcher remove all config fallbacks
- Frontend targets page shows receivers list per target with
add/remove/toggle/test per receiver
- Single-receiver test endpoint: POST /targets/{id}/receivers/{id}/test
Code quality:
- Extract AuthLayout.svelte from login/setup (150 lines CSS dedup)
- Split telegram-bots page (754→51 lines + 3 tab components)
- Split notification-trackers page (547→432 lines + 4 components)
- Deduplicate _send_reply into shared handler.send_reply()
- Add locale column to template models, replace name-based detection
- Fix delete_notification_tracker dead protection check
- Fix check_telegram_bot query (filter by type, remove bogus OR)
- Add graceful scheduler shutdown in lifespan
- Consistent /bots?tab=telegram URLs across all nav links
i18n:
- Error page, chat actions, target types, provider types internationalized
- All new receiver UI strings in EN + RU
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* API client with JWT auth for the Notify Bridge backend.
|
||||
*/
|
||||
|
||||
const API_BASE = '/api';
|
||||
|
||||
function getToken(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem('access_token');
|
||||
}
|
||||
|
||||
export function setTokens(access: string, refresh: string) {
|
||||
localStorage.setItem('access_token', access);
|
||||
localStorage.setItem('refresh_token', refresh);
|
||||
}
|
||||
|
||||
export function clearTokens() {
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
}
|
||||
|
||||
export function isAuthenticated(): boolean {
|
||||
return !!getToken();
|
||||
}
|
||||
|
||||
let refreshPromise: Promise<boolean> | null = null;
|
||||
|
||||
async function refreshAccessToken(): Promise<boolean> {
|
||||
if (refreshPromise) return refreshPromise;
|
||||
refreshPromise = doRefreshAccessToken().finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
async function doRefreshAccessToken(): Promise<boolean> {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
if (!refreshToken) return false;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: refreshToken })
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setTokens(data.access_token, data.refresh_token);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function api<T = any>(
|
||||
path: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const token = getToken();
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers as Record<string, string>)
|
||||
};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
let res = await fetch(`${API_BASE}${path}`, { ...options, headers });
|
||||
|
||||
// Try token refresh on 401
|
||||
if (res.status === 401 && token) {
|
||||
const refreshed = await refreshAccessToken();
|
||||
if (refreshed) {
|
||||
headers['Authorization'] = `Bearer ${getToken()}`;
|
||||
res = await fetch(`${API_BASE}${path}`, { ...options, headers });
|
||||
}
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
clearTokens();
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
if (res.status === 204) return undefined as T;
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new Error(err.detail || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let { visible = false, children }: { visible: boolean; children: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
<div class="auth-page">
|
||||
<!-- Animated gradient mesh background -->
|
||||
<div class="auth-bg"></div>
|
||||
<div class="auth-grid"></div>
|
||||
|
||||
<!-- Card -->
|
||||
<div class="auth-card-wrapper" class:visible>
|
||||
<div class="auth-card">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
||||
.auth-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 60% at 20% 30%, var(--color-glow-strong), transparent 60%),
|
||||
radial-gradient(ellipse 60% 80% at 80% 70%, rgba(99, 102, 241, 0.08), transparent 60%),
|
||||
radial-gradient(ellipse 50% 50% at 50% 50%, var(--color-glow), transparent 70%);
|
||||
animation: gradientShift 12s ease-in-out infinite;
|
||||
background-size: 200% 200%;
|
||||
}
|
||||
|
||||
.auth-grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
opacity: 0.3;
|
||||
background-image: radial-gradient(circle at 1px 1px, var(--color-border) 0.5px, transparent 0);
|
||||
background-size: 32px 32px;
|
||||
}
|
||||
|
||||
.auth-card-wrapper {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
max-width: 24rem;
|
||||
padding: 1rem;
|
||||
opacity: 0;
|
||||
transform: translateY(16px) scale(0.98);
|
||||
transition: opacity 0.5s ease-out, transform 0.5s ease-out;
|
||||
}
|
||||
|
||||
.auth-card-wrapper.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background: var(--color-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 1rem;
|
||||
padding: 2rem;
|
||||
box-shadow:
|
||||
0 4px 24px rgba(0, 0, 0, 0.08),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05) inset;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
:global([data-theme="dark"]) .auth-card {
|
||||
box-shadow:
|
||||
0 4px 24px rgba(0, 0, 0, 0.3),
|
||||
0 0 48px var(--color-glow),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.03) inset;
|
||||
}
|
||||
|
||||
:global(.auth-logo-icon) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
border-radius: 1rem;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-foreground);
|
||||
box-shadow: 0 0 24px var(--color-glow-strong);
|
||||
}
|
||||
|
||||
:global(.auth-control-btn) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
background: var(--color-muted);
|
||||
color: var(--color-muted-foreground);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
:global(.auth-control-btn:hover) {
|
||||
color: var(--color-foreground);
|
||||
box-shadow: 0 0 8px var(--color-glow);
|
||||
}
|
||||
|
||||
:global(.auth-label) {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.375rem;
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
:global(.auth-input) {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.625rem;
|
||||
font-size: 0.875rem;
|
||||
background: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
:global(.auth-input:focus) {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px var(--color-glow), 0 0 16px var(--color-glow);
|
||||
}
|
||||
|
||||
:global(.auth-error) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.625rem;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 1rem;
|
||||
background: var(--color-error-bg);
|
||||
color: var(--color-error-fg);
|
||||
}
|
||||
|
||||
:global(.auth-submit) {
|
||||
width: 100%;
|
||||
padding: 0.625rem;
|
||||
border-radius: 0.625rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-foreground);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
:global(.auth-submit:hover:not(:disabled)) {
|
||||
box-shadow: 0 0 24px var(--color-glow-strong);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
:global(.auth-submit:active:not(:disabled)) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
:global(.auth-submit:disabled) {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@keyframes gradientShift {
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
</style>
|
||||
@@ -24,12 +24,12 @@
|
||||
'mdiFileDocument', 'mdiEmail', 'mdiPhone', 'mdiChat', 'mdiShare',
|
||||
];
|
||||
|
||||
function filtered(): string[] {
|
||||
const filtered = $derived.by(() => {
|
||||
const allIcons = getAllMdiNames();
|
||||
if (!search) return popular.filter(p => allIcons.includes(p));
|
||||
const q = search.toLowerCase();
|
||||
return allIcons.filter(k => k.toLowerCase().includes(q)).slice(0, 60);
|
||||
}
|
||||
});
|
||||
|
||||
function toggleOpen() {
|
||||
if (!open && buttonEl) {
|
||||
@@ -81,7 +81,7 @@
|
||||
<button type="button" onclick={() => select('')}
|
||||
class="flex items-center justify-center aspect-square rounded hover:bg-[var(--color-muted)] text-xs text-[var(--color-muted-foreground)]"
|
||||
title="No icon">✕</button>
|
||||
{#each filtered() as iconName}
|
||||
{#each filtered as iconName}
|
||||
<button type="button" onclick={() => select(iconName)}
|
||||
class="flex items-center justify-center aspect-square rounded hover:bg-[var(--color-muted)] {value === iconName ? 'bg-[var(--color-accent)]' : ''}"
|
||||
title={iconName.replace('mdi', '')}>
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
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',
|
||||
{ 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' }) },
|
||||
|
||||
@@ -79,24 +79,24 @@ export const sortFilterItems = (): GridItem[] => [
|
||||
// --- Chat action (Telegram targets) ---
|
||||
|
||||
export const chatActionItems = (): GridItem[] => [
|
||||
{ value: '', icon: 'mdiMinus', label: 'None' },
|
||||
{ value: 'typing', icon: 'mdiKeyboard', label: 'Typing' },
|
||||
{ value: 'upload_photo', icon: 'mdiImagePlus', label: 'Upload Photo' },
|
||||
{ value: 'upload_video', icon: 'mdiVideoPlus', label: 'Upload Video' },
|
||||
{ value: 'upload_document', icon: 'mdiFileUpload', label: 'Upload Doc' },
|
||||
{ value: 'record_video', icon: 'mdiVideo', label: 'Record Video' },
|
||||
{ value: 'record_voice', icon: 'mdiMicrophone', label: 'Record Voice' },
|
||||
{ value: '', icon: 'mdiMinus', label: t('targets.chatActionNone') },
|
||||
{ value: 'typing', icon: 'mdiKeyboard', label: t('targets.chatActionTyping') },
|
||||
{ value: 'upload_photo', icon: 'mdiImagePlus', label: t('targets.chatActionUploadPhoto') },
|
||||
{ value: 'upload_video', icon: 'mdiVideoPlus', label: t('targets.chatActionUploadVideo') },
|
||||
{ value: 'upload_document', icon: 'mdiFileUpload', label: t('targets.chatActionUploadDoc') },
|
||||
{ value: 'record_video', icon: 'mdiVideo', label: t('targets.chatActionRecordVideo') },
|
||||
{ value: 'record_voice', icon: 'mdiMicrophone', label: t('targets.chatActionRecordVoice') },
|
||||
];
|
||||
|
||||
// --- Preview target type ---
|
||||
|
||||
export const previewTargetTypeItems = (): GridItem[] => [
|
||||
{ value: 'telegram', icon: 'mdiSend', label: 'Telegram' },
|
||||
{ value: 'webhook', icon: 'mdiWebhook', label: 'Webhook' },
|
||||
{ value: 'telegram', icon: 'mdiSend', label: t('targets.typeTelegram') },
|
||||
{ value: 'webhook', icon: 'mdiWebhook', label: t('targets.typeWebhook') },
|
||||
];
|
||||
|
||||
// --- Provider type ---
|
||||
|
||||
export const providerTypeItems = (): GridItem[] => [
|
||||
{ value: 'immich', icon: 'mdiCamera', label: 'Immich' },
|
||||
{ value: 'immich', icon: 'mdiCamera', label: t('providers.typeImmich') },
|
||||
];
|
||||
|
||||
@@ -105,6 +105,7 @@
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"checking": "Checking...",
|
||||
"typeImmich": "Immich",
|
||||
"loadError": "Failed to load providers.",
|
||||
"externalDomain": "External Domain",
|
||||
"optional": "optional",
|
||||
@@ -228,6 +229,14 @@
|
||||
"sendLargeAsDocuments": "Send large photos as documents",
|
||||
"chatAction": "Chat action",
|
||||
"chatActionNone": "None (no action)",
|
||||
"chatActionTyping": "Typing",
|
||||
"chatActionUploadPhoto": "Upload Photo",
|
||||
"chatActionUploadVideo": "Upload Video",
|
||||
"chatActionUploadDoc": "Upload Doc",
|
||||
"chatActionRecordVideo": "Record Video",
|
||||
"chatActionRecordVoice": "Record Voice",
|
||||
"typeTelegram": "Telegram",
|
||||
"typeWebhook": "Webhook",
|
||||
"overrideUsername": "Override bot username",
|
||||
"ntfyServer": "ntfy Server URL",
|
||||
"ntfyTopic": "Topic",
|
||||
@@ -236,7 +245,16 @@
|
||||
"selectEmailBot": "Select Email Bot",
|
||||
"selectMatrixBot": "Select Matrix Bot",
|
||||
"recipientEmail": "Recipient Email",
|
||||
"matrixRoomId": "Room ID"
|
||||
"matrixRoomId": "Room ID",
|
||||
"receivers": "Receivers",
|
||||
"noReceivers": "No receivers yet",
|
||||
"addReceiver": "Add Receiver",
|
||||
"receiverAdded": "Receiver added",
|
||||
"receiverDeleted": "Receiver deleted",
|
||||
"receiverUpdated": "Receiver updated",
|
||||
"confirmDeleteReceiver": "Delete this receiver?",
|
||||
"receiverEnabled": "Receiver enabled",
|
||||
"receiverDisabled": "Receiver disabled"
|
||||
},
|
||||
"users": {
|
||||
"title": "Users",
|
||||
@@ -702,6 +720,10 @@
|
||||
"line": "line",
|
||||
"add": "Add"
|
||||
},
|
||||
"error": {
|
||||
"notFound": "Page not found",
|
||||
"goHome": "Go home"
|
||||
},
|
||||
"searchPalette": {
|
||||
"placeholder": "Search entities...",
|
||||
"noResults": "No results found",
|
||||
|
||||
@@ -105,6 +105,7 @@
|
||||
"online": "В сети",
|
||||
"offline": "Не в сети",
|
||||
"checking": "Проверка...",
|
||||
"typeImmich": "Immich",
|
||||
"loadError": "Не удалось загрузить провайдеры.",
|
||||
"externalDomain": "Внешний домен",
|
||||
"optional": "необязательно",
|
||||
@@ -228,6 +229,14 @@
|
||||
"sendLargeAsDocuments": "Отправлять большие фото как документы",
|
||||
"chatAction": "Действие в чате",
|
||||
"chatActionNone": "Нет (без действия)",
|
||||
"chatActionTyping": "Печатает",
|
||||
"chatActionUploadPhoto": "Загрузка фото",
|
||||
"chatActionUploadVideo": "Загрузка видео",
|
||||
"chatActionUploadDoc": "Загрузка документа",
|
||||
"chatActionRecordVideo": "Запись видео",
|
||||
"chatActionRecordVoice": "Запись голоса",
|
||||
"typeTelegram": "Telegram",
|
||||
"typeWebhook": "Вебхук",
|
||||
"overrideUsername": "Переопределить имя бота",
|
||||
"ntfyServer": "URL сервера ntfy",
|
||||
"ntfyTopic": "Тема",
|
||||
@@ -236,7 +245,16 @@
|
||||
"selectEmailBot": "Выберите Email бот",
|
||||
"selectMatrixBot": "Выберите Matrix бот",
|
||||
"recipientEmail": "Email получателя",
|
||||
"matrixRoomId": "ID комнаты"
|
||||
"matrixRoomId": "ID комнаты",
|
||||
"receivers": "Получатели",
|
||||
"noReceivers": "Нет получателей",
|
||||
"addReceiver": "Добавить получателя",
|
||||
"receiverAdded": "Получатель добавлен",
|
||||
"receiverDeleted": "Получатель удалён",
|
||||
"receiverUpdated": "Получатель обновлён",
|
||||
"confirmDeleteReceiver": "Удалить этого получателя?",
|
||||
"receiverEnabled": "Получатель включён",
|
||||
"receiverDisabled": "Получатель отключён"
|
||||
},
|
||||
"users": {
|
||||
"title": "Пользователи",
|
||||
@@ -702,6 +720,10 @@
|
||||
"line": "строка",
|
||||
"add": "Добавить"
|
||||
},
|
||||
"error": {
|
||||
"notFound": "Страница не найдена",
|
||||
"goHome": "На главную"
|
||||
},
|
||||
"searchPalette": {
|
||||
"placeholder": "Поиск объектов...",
|
||||
"noResults": "Ничего не найдено",
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
export type SnackType = 'success' | 'error' | 'info' | 'warning';
|
||||
|
||||
export interface Snack {
|
||||
id: number;
|
||||
type: SnackType;
|
||||
message: string;
|
||||
detail?: string;
|
||||
timeout: number;
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUTS: Record<SnackType, number> = {
|
||||
success: 3000,
|
||||
info: 3000,
|
||||
warning: 4000,
|
||||
error: 5000,
|
||||
};
|
||||
|
||||
const MAX_VISIBLE = 3;
|
||||
|
||||
let nextId = 1;
|
||||
let snacks = $state<Snack[]>([]);
|
||||
const timers = new Map<number, ReturnType<typeof setTimeout>>();
|
||||
|
||||
export function getSnacks(): Snack[] {
|
||||
return snacks;
|
||||
}
|
||||
|
||||
export function addSnack(
|
||||
type: SnackType,
|
||||
message: string,
|
||||
options?: { detail?: string; timeout?: number },
|
||||
): void {
|
||||
const id = nextId++;
|
||||
const timeout = options?.timeout ?? DEFAULT_TIMEOUTS[type];
|
||||
const snack: Snack = { id, type, message, detail: options?.detail, timeout };
|
||||
|
||||
snacks = [snack, ...snacks];
|
||||
|
||||
// Enforce max visible — single batch removal
|
||||
if (snacks.length > MAX_VISIBLE) {
|
||||
const overflow = snacks.slice(MAX_VISIBLE);
|
||||
for (const s of overflow) {
|
||||
const timer = timers.get(s.id);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timers.delete(s.id);
|
||||
}
|
||||
}
|
||||
snacks = snacks.slice(0, MAX_VISIBLE);
|
||||
}
|
||||
|
||||
// Auto-dismiss
|
||||
if (timeout > 0) {
|
||||
timers.set(
|
||||
id,
|
||||
setTimeout(() => removeSnack(id), timeout),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function removeSnack(id: number): void {
|
||||
const timer = timers.get(id);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timers.delete(id);
|
||||
}
|
||||
snacks = snacks.filter((s) => s.id !== id);
|
||||
}
|
||||
|
||||
// Convenience functions
|
||||
export function snackSuccess(message: string): void {
|
||||
addSnack('success', message);
|
||||
}
|
||||
|
||||
export function snackError(message: string, detail?: string): void {
|
||||
addSnack('error', message, { detail });
|
||||
}
|
||||
|
||||
export function snackInfo(message: string): void {
|
||||
addSnack('info', message);
|
||||
}
|
||||
|
||||
export function snackWarning(message: string): void {
|
||||
addSnack('warning', message);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Theme management with Svelte 5 runes.
|
||||
* Supports light, dark, and system preference.
|
||||
*/
|
||||
|
||||
export type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
let theme = $state<Theme>('system');
|
||||
let resolved = $state<'light' | 'dark'>('light');
|
||||
let listenerRegistered = false;
|
||||
|
||||
export function getTheme() {
|
||||
return {
|
||||
get current() { return theme; },
|
||||
get resolved() { return resolved; },
|
||||
get isDark() { return resolved === 'dark'; },
|
||||
};
|
||||
}
|
||||
|
||||
export function setTheme(newTheme: Theme) {
|
||||
theme = newTheme;
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('theme', newTheme);
|
||||
}
|
||||
applyTheme();
|
||||
}
|
||||
|
||||
export function initTheme() {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const saved = localStorage.getItem('theme') as Theme | null;
|
||||
if (saved && ['light', 'dark', 'system'].includes(saved)) {
|
||||
theme = saved;
|
||||
}
|
||||
}
|
||||
applyTheme();
|
||||
|
||||
// Listen for system preference changes (register only once)
|
||||
if (typeof window !== 'undefined' && !listenerRegistered) {
|
||||
listenerRegistered = true;
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||
if (theme === 'system') applyTheme();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function applyTheme() {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
if (theme === 'system') {
|
||||
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
} else {
|
||||
resolved = theme;
|
||||
}
|
||||
|
||||
document.documentElement.setAttribute('data-theme', resolved);
|
||||
}
|
||||
@@ -81,6 +81,17 @@ export interface Tracker {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface TargetReceiver {
|
||||
id: number;
|
||||
target_id: number;
|
||||
name: string;
|
||||
config: Record<string, any>;
|
||||
receiver_key: string;
|
||||
enabled: boolean;
|
||||
chat_name?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface NotificationTarget {
|
||||
id: number;
|
||||
type: string;
|
||||
@@ -88,6 +99,8 @@ export interface NotificationTarget {
|
||||
icon: string;
|
||||
config: Record<string, any>;
|
||||
chat_name?: string;
|
||||
receiver_count: number;
|
||||
receivers: TargetReceiver[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { t } from '$lib/i18n';
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen flex items-center justify-center">
|
||||
<div class="text-center animate-fade-slide-in">
|
||||
<h1 class="text-6xl font-bold text-[var(--color-muted-foreground)] mb-4">{page.status}</h1>
|
||||
<p class="text-lg text-[var(--color-muted-foreground)] mb-8">{page.error?.message || 'Page not found'}</p>
|
||||
<p class="text-lg text-[var(--color-muted-foreground)] mb-8">{page.error?.message || t('error.notFound')}</p>
|
||||
<a href="/" class="px-6 py-3 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-[var(--radius)] font-medium hover:opacity-90 transition-opacity">
|
||||
Go home
|
||||
{t('error.goHome')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
{
|
||||
key: 'nav.bots', icon: 'mdiRobot',
|
||||
children: [
|
||||
{ href: '/bots', key: 'nav.telegram', icon: 'mdiSendCircle', countKey: 'telegram_bots' },
|
||||
{ href: '/bots?tab=telegram', key: 'nav.telegram', icon: 'mdiSendCircle', countKey: 'telegram_bots' },
|
||||
{ href: '/bots?tab=email', key: 'nav.email', icon: 'mdiEmailOutline', countKey: 'email_bots' },
|
||||
{ href: '/bots?tab=matrix', key: 'nav.matrix', icon: 'mdiMatrix', countKey: 'matrix_bots' },
|
||||
],
|
||||
@@ -139,7 +139,7 @@
|
||||
{ href: '/notification-trackers', key: 'nav.notification', icon: 'mdiBellOutline' },
|
||||
{ href: '/command-trackers', key: 'nav.commands', icon: 'mdiConsoleLine' },
|
||||
{ href: '/targets', key: 'nav.targets', icon: 'mdiTarget' },
|
||||
{ href: '/bots', key: 'nav.bots', icon: 'mdiRobot' },
|
||||
{ href: '/bots?tab=telegram', key: 'nav.bots', icon: 'mdiRobot' },
|
||||
]);
|
||||
|
||||
const isAuthPage = $derived(
|
||||
@@ -256,10 +256,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
<button onclick={toggleSidebar}
|
||||
class="flex items-center justify-center w-8 h-8 rounded-lg transition-all duration-200"
|
||||
style="color: var(--color-muted-foreground); background: transparent;"
|
||||
onmouseenter={(e) => { e.currentTarget.style.background = 'var(--color-muted)'; e.currentTarget.style.color = 'var(--color-foreground)'; }}
|
||||
onmouseleave={(e) => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--color-muted-foreground)'; }}
|
||||
class="sidebar-icon-btn flex items-center justify-center w-8 h-8 rounded-lg transition-all duration-200"
|
||||
title={collapsed ? t('common.expand') : t('common.collapse')}>
|
||||
<MdiIcon name={collapsed ? 'mdiChevronRight' : 'mdiChevronLeft'} size={18} />
|
||||
</button>
|
||||
@@ -268,10 +265,7 @@
|
||||
<!-- Search button -->
|
||||
<div class="{collapsed ? 'px-2 py-1.5' : 'px-3 py-1.5'}" style="border-bottom: 1px solid var(--color-border);">
|
||||
<button onclick={() => openSearch?.()}
|
||||
class="flex items-center gap-2 w-full {collapsed ? 'justify-center px-2' : 'px-2.5'} py-1.5 rounded-lg text-sm transition-all duration-200"
|
||||
style="background: var(--color-muted); color: var(--color-muted-foreground); border: 1px solid var(--color-border);"
|
||||
onmouseenter={(e) => { e.currentTarget.style.borderColor = 'var(--color-primary)'; e.currentTarget.style.color = 'var(--color-foreground)'; }}
|
||||
onmouseleave={(e) => { e.currentTarget.style.borderColor = 'var(--color-border)'; e.currentTarget.style.color = 'var(--color-muted-foreground)'; }}
|
||||
class="search-btn flex items-center gap-2 w-full {collapsed ? 'justify-center px-2' : 'px-2.5'} py-1.5 rounded-lg text-sm transition-all duration-200"
|
||||
title={t('searchPalette.placeholder')}>
|
||||
<MdiIcon name="mdiMagnify" size={16} />
|
||||
{#if !collapsed}
|
||||
@@ -288,10 +282,7 @@
|
||||
<!-- Group header -->
|
||||
<button
|
||||
onclick={() => collapsed ? null : toggleGroup(entry.key)}
|
||||
class="nav-item group flex items-center gap-2.5 {collapsed ? 'justify-center px-2' : 'px-3'} py-2 rounded-lg text-sm transition-all duration-200 w-full text-left relative"
|
||||
style="color: {isGroupActive(entry) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'}; background: {isGroupActive(entry) && !expandedGroups[entry.key] ? 'var(--color-sidebar-active)' : 'transparent'}; font-weight: {isGroupActive(entry) ? '500' : '400'};"
|
||||
onmouseenter={(e) => { if (!isGroupActive(entry)) { e.currentTarget.style.background = 'var(--color-muted)'; e.currentTarget.style.color = 'var(--color-foreground)'; } }}
|
||||
onmouseleave={(e) => { if (!isGroupActive(entry)) { e.currentTarget.style.background = isGroupActive(entry) && !expandedGroups[entry.key] ? 'var(--color-sidebar-active)' : 'transparent'; e.currentTarget.style.color = isGroupActive(entry) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'; } }}
|
||||
class="nav-link group flex items-center gap-2.5 {collapsed ? 'justify-center px-2' : 'px-3'} py-2 rounded-lg text-sm transition-all duration-200 w-full text-left relative {isGroupActive(entry) ? 'active' : ''} {isGroupActive(entry) && !expandedGroups[entry.key] ? 'active-bg' : ''}"
|
||||
title={collapsed ? t(entry.key) : ''}
|
||||
>
|
||||
{#if isGroupActive(entry) && !expandedGroups[entry.key]}
|
||||
@@ -311,10 +302,7 @@
|
||||
{#each entry.children as child}
|
||||
<a
|
||||
href={child.href}
|
||||
class="nav-item group flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-sm transition-all duration-200 relative"
|
||||
style="color: {isActive(child.href) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'}; background: {isActive(child.href) ? 'var(--color-sidebar-active)' : 'transparent'}; font-weight: {isActive(child.href) ? '500' : '400'};"
|
||||
onmouseenter={(e) => { if (!isActive(child.href)) { e.currentTarget.style.background = 'var(--color-muted)'; e.currentTarget.style.color = 'var(--color-foreground)'; } }}
|
||||
onmouseleave={(e) => { if (!isActive(child.href)) { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--color-muted-foreground)'; } }}
|
||||
class="nav-link nav-link-child group flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-sm transition-all duration-200 relative {isActive(child.href) ? 'active' : ''}"
|
||||
>
|
||||
{#if isActive(child.href)}
|
||||
<div class="active-indicator" style="position: absolute; left: -13px; top: 50%; transform: translateY(-50%); width: 3px; height: 60%; border-radius: 0 3px 3px 0; background: var(--color-primary); box-shadow: 0 0 8px var(--color-glow-strong);"></div>
|
||||
@@ -332,10 +320,7 @@
|
||||
<!-- Top-level item -->
|
||||
<a
|
||||
href={entry.href}
|
||||
class="nav-item group flex items-center gap-2.5 {collapsed ? 'justify-center px-2' : 'px-3'} py-2 rounded-lg text-sm transition-all duration-200 relative"
|
||||
style="color: {isActive(entry.href) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'}; background: {isActive(entry.href) ? 'var(--color-sidebar-active)' : 'transparent'}; font-weight: {isActive(entry.href) ? '500' : '400'};"
|
||||
onmouseenter={(e) => { if (!isActive(entry.href)) { e.currentTarget.style.background = 'var(--color-muted)'; e.currentTarget.style.color = 'var(--color-foreground)'; } }}
|
||||
onmouseleave={(e) => { if (!isActive(entry.href)) { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--color-muted-foreground)'; } }}
|
||||
class="nav-link group flex items-center gap-2.5 {collapsed ? 'justify-center px-2' : 'px-3'} py-2 rounded-lg text-sm transition-all duration-200 relative {isActive(entry.href) ? 'active' : ''}"
|
||||
title={collapsed ? t(entry.key) : ''}
|
||||
>
|
||||
{#if isActive(entry.href)}
|
||||
@@ -358,18 +343,12 @@
|
||||
<!-- Theme & Language -->
|
||||
<div class="flex {collapsed ? 'flex-col items-center gap-1 p-2' : 'gap-1.5 px-4 py-2.5'}">
|
||||
<button onclick={toggleLocale}
|
||||
class="flex items-center justify-center {collapsed ? 'w-8 h-8' : 'px-2.5 py-1'} rounded-lg text-xs font-medium transition-all duration-200"
|
||||
style="background: var(--color-muted); color: var(--color-muted-foreground);"
|
||||
onmouseenter={(e) => { e.currentTarget.style.color = 'var(--color-foreground)'; e.currentTarget.style.boxShadow = '0 0 8px var(--color-glow)'; }}
|
||||
onmouseleave={(e) => { e.currentTarget.style.color = 'var(--color-muted-foreground)'; e.currentTarget.style.boxShadow = 'none'; }}
|
||||
class="footer-pill flex items-center justify-center {collapsed ? 'w-8 h-8' : 'px-2.5 py-1'} rounded-lg text-xs font-medium transition-all duration-200"
|
||||
title={t('common.language')}>
|
||||
{getLocale().toUpperCase()}
|
||||
</button>
|
||||
<button onclick={cycleTheme}
|
||||
class="flex items-center justify-center {collapsed ? 'w-8 h-8' : 'px-2.5 py-1'} rounded-lg text-xs transition-all duration-200"
|
||||
style="background: var(--color-muted); color: var(--color-muted-foreground);"
|
||||
onmouseenter={(e) => { e.currentTarget.style.color = 'var(--color-foreground)'; e.currentTarget.style.boxShadow = '0 0 8px var(--color-glow)'; }}
|
||||
onmouseleave={(e) => { e.currentTarget.style.color = 'var(--color-muted-foreground)'; e.currentTarget.style.boxShadow = 'none'; }}
|
||||
class="footer-pill flex items-center justify-center {collapsed ? 'w-8 h-8' : 'px-2.5 py-1'} rounded-lg text-xs transition-all duration-200"
|
||||
title={t('common.theme')}>
|
||||
<MdiIcon name={theme.resolved === 'dark' ? 'mdiWeatherNight' : theme.current === 'system' ? 'mdiDesktopTowerMonitor' : 'mdiWeatherSunny'} size={14} />
|
||||
</button>
|
||||
@@ -379,10 +358,7 @@
|
||||
<div class="p-2.5" style="border-top: 1px solid var(--color-border);">
|
||||
{#if collapsed}
|
||||
<button onclick={logout}
|
||||
class="w-full flex justify-center py-2 rounded-lg transition-all duration-200"
|
||||
style="color: var(--color-muted-foreground);"
|
||||
onmouseenter={(e) => { e.currentTarget.style.color = 'var(--color-foreground)'; e.currentTarget.style.background = 'var(--color-muted)'; }}
|
||||
onmouseleave={(e) => { e.currentTarget.style.color = 'var(--color-muted-foreground)'; e.currentTarget.style.background = 'transparent'; }}
|
||||
class="sidebar-icon-btn w-full flex justify-center py-2 rounded-lg transition-all duration-200"
|
||||
title={t('nav.logout')}>
|
||||
<MdiIcon name="mdiLogout" size={16} />
|
||||
</button>
|
||||
@@ -400,19 +376,13 @@
|
||||
</div>
|
||||
</div>
|
||||
<button onclick={logout}
|
||||
class="p-1.5 rounded-lg transition-all duration-200"
|
||||
style="color: var(--color-muted-foreground);"
|
||||
onmouseenter={(e) => { e.currentTarget.style.color = 'var(--color-foreground)'; e.currentTarget.style.background = 'var(--color-muted)'; }}
|
||||
onmouseleave={(e) => { e.currentTarget.style.color = 'var(--color-muted-foreground)'; e.currentTarget.style.background = 'transparent'; }}
|
||||
class="sidebar-icon-btn p-1.5 rounded-lg transition-all duration-200"
|
||||
title={t('nav.logout')}>
|
||||
<MdiIcon name="mdiLogout" size={15} />
|
||||
</button>
|
||||
</div>
|
||||
<button onclick={() => showPasswordForm = true}
|
||||
class="text-[0.7rem] mt-1.5 transition-colors duration-200 flex items-center gap-1"
|
||||
style="color: var(--color-muted-foreground);"
|
||||
onmouseenter={(e) => { e.currentTarget.style.color = 'var(--color-primary)'; }}
|
||||
onmouseleave={(e) => { e.currentTarget.style.color = 'var(--color-muted-foreground)'; }}>
|
||||
class="change-pwd-link text-[0.7rem] mt-1.5 transition-colors duration-200 flex items-center gap-1">
|
||||
<MdiIcon name="mdiKeyVariant" size={12} />
|
||||
{t('common.changePassword')}
|
||||
</button>
|
||||
@@ -472,10 +442,7 @@
|
||||
<p class="text-sm" style="color: var({pwdSuccess ? '--color-success-fg' : '--color-error-fg'});">{pwdMsg}</p>
|
||||
{/if}
|
||||
<button type="submit"
|
||||
class="w-full py-2.5 rounded-lg text-sm font-medium transition-all duration-200"
|
||||
style="background: var(--color-primary); color: var(--color-primary-foreground);"
|
||||
onmouseenter={(e) => { e.currentTarget.style.boxShadow = '0 0 16px var(--color-glow-strong)'; }}
|
||||
onmouseleave={(e) => { e.currentTarget.style.boxShadow = 'none'; }}>
|
||||
class="primary-btn w-full py-2.5 rounded-lg text-sm font-medium transition-all duration-200">
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</form>
|
||||
@@ -488,6 +455,73 @@
|
||||
@media (max-width: 767px) {
|
||||
.mobile-nav { display: flex !important; }
|
||||
}
|
||||
|
||||
/* Sidebar icon button (toggle, logout) */
|
||||
.sidebar-icon-btn {
|
||||
color: var(--color-muted-foreground);
|
||||
background: transparent;
|
||||
}
|
||||
.sidebar-icon-btn:hover {
|
||||
background: var(--color-muted);
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
/* Search button */
|
||||
.search-btn {
|
||||
background: var(--color-muted);
|
||||
color: var(--color-muted-foreground);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
.search-btn:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
/* Nav links (top-level items, group headers, group children) */
|
||||
.nav-link {
|
||||
color: var(--color-muted-foreground);
|
||||
background: transparent;
|
||||
font-weight: 400;
|
||||
}
|
||||
.nav-link:not(.active):hover {
|
||||
background: var(--color-muted);
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
.nav-link.active {
|
||||
color: var(--color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
.nav-link.active-bg {
|
||||
background: var(--color-sidebar-active);
|
||||
}
|
||||
|
||||
/* Footer pill buttons (locale, theme) */
|
||||
.footer-pill {
|
||||
background: var(--color-muted);
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
.footer-pill:hover {
|
||||
color: var(--color-foreground);
|
||||
box-shadow: 0 0 8px var(--color-glow);
|
||||
}
|
||||
|
||||
/* Change password link */
|
||||
.change-pwd-link {
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
.change-pwd-link:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Primary action button (password form submit) */
|
||||
.primary-btn {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-foreground);
|
||||
}
|
||||
.primary-btn:hover {
|
||||
box-shadow: 0 0 16px var(--color-glow-strong);
|
||||
}
|
||||
|
||||
.nav-badge {
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -1,47 +1,25 @@
|
||||
<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';
|
||||
import { t } from '$lib/i18n';
|
||||
import { telegramBotsCache, emailBotsCache, matrixBotsCache } from '$lib/stores/caches.svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
import IconPicker from '$lib/components/IconPicker.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
import { snackSuccess, snackError, snackInfo } from '$lib/stores/snackbar.svelte';
|
||||
import { snackError } from '$lib/stores/snackbar.svelte';
|
||||
import { highlightFromUrl } from '$lib/highlight';
|
||||
import type { TelegramBot, TelegramChat, EmailBot, MatrixBot } from '$lib/types';
|
||||
import TelegramBotTab from './TelegramBotTab.svelte';
|
||||
import EmailBotTab from './EmailBotTab.svelte';
|
||||
import MatrixBotTab from './MatrixBotTab.svelte';
|
||||
|
||||
type BotTab = 'telegram' | 'email' | 'matrix';
|
||||
let activeTab = $derived((page.url.searchParams.get('tab') as BotTab | null) || 'telegram');
|
||||
|
||||
let bots = $derived(telegramBotsCache.items);
|
||||
let emailBots = $derived(emailBotsCache.items);
|
||||
let matrixBots = $derived(matrixBotsCache.items);
|
||||
let loaded = $state(false);
|
||||
let showForm = $state(false);
|
||||
let editing = $state<number | null>(null);
|
||||
let form = $state({ name: '', icon: '', token: '' });
|
||||
let error = $state('');
|
||||
let submitting = $state(false);
|
||||
let confirmDelete = $state<any>(null);
|
||||
|
||||
// Global settings (loaded for webhook mode checks)
|
||||
let settings = $state<any>({});
|
||||
|
||||
// Per-bot expandable sections
|
||||
let chats = $state<Record<number, TelegramChat[]>>({});
|
||||
let chatsLoading = $state<Record<number, boolean>>({});
|
||||
let expandedSection = $state<Record<number, string>>({});
|
||||
|
||||
// Webhook status per bot
|
||||
let webhookStatus = $state<Record<number, any>>({});
|
||||
|
||||
onMount(load);
|
||||
async function load() {
|
||||
try {
|
||||
@@ -54,701 +32,20 @@
|
||||
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
||||
finally { loaded = true; highlightFromUrl(); }
|
||||
}
|
||||
|
||||
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; }
|
||||
|
||||
async function saveBot(e: SubmitEvent) {
|
||||
e.preventDefault(); error = ''; submitting = true;
|
||||
try {
|
||||
if (editing) {
|
||||
await api(`/telegram-bots/${editing}`, { method: 'PUT', body: JSON.stringify({ name: form.name, icon: form.icon }) });
|
||||
snackSuccess(t('snack.botUpdated'));
|
||||
} else {
|
||||
await api('/telegram-bots', { method: 'POST', body: JSON.stringify(form) });
|
||||
snackSuccess(t('snack.botRegistered'));
|
||||
}
|
||||
form = { name: '', icon: '', token: '' }; showForm = false; editing = null; await load();
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
finally { submitting = false; }
|
||||
}
|
||||
|
||||
function remove(id: number) {
|
||||
confirmDelete = {
|
||||
id,
|
||||
onconfirm: async () => {
|
||||
try { await api(`/telegram-bots/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.botDeleted')); }
|
||||
catch (err: any) { error = err.message; snackError(err.message); }
|
||||
finally { confirmDelete = null; }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function toggleSection(botId: number, section: string) {
|
||||
if (expandedSection[botId] === section) {
|
||||
expandedSection = { ...expandedSection, [botId]: '' };
|
||||
return;
|
||||
}
|
||||
expandedSection = { ...expandedSection, [botId]: section };
|
||||
if (section === 'chats') loadChats(botId);
|
||||
}
|
||||
|
||||
async function loadChats(botId: number) {
|
||||
chatsLoading = { ...chatsLoading, [botId]: true };
|
||||
try { chats = { ...chats, [botId]: await api(`/telegram-bots/${botId}/chats`) }; } catch { 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' }) };
|
||||
snackSuccess(t('telegramBot.chatsDiscovered'));
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
chatsLoading = { ...chatsLoading, [botId]: false };
|
||||
}
|
||||
|
||||
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);
|
||||
snackSuccess(t('telegramBot.chatDeleted'));
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
}
|
||||
|
||||
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 botListenerLoading = $state<Record<number, boolean>>({});
|
||||
|
||||
async function loadListenerStatus(botId: number) {
|
||||
botListenerLoading = { ...botListenerLoading, [botId]: true };
|
||||
try {
|
||||
// Load all command trackers and filter for ones that have this bot as a listener
|
||||
const trackers = await api('/command-trackers');
|
||||
const matched: any[] = [];
|
||||
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);
|
||||
if (hasBot) matched.push(trk);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
botListenerStatus = { ...botListenerStatus, [botId]: matched };
|
||||
} catch { 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' });
|
||||
if (res.success) snackSuccess(t('telegramBot.commandsSynced'));
|
||||
else snackError(res.error || 'Failed');
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
modeChanging = { ...modeChanging, [botId]: false };
|
||||
}
|
||||
|
||||
async function switchMode(botId: number, mode: string) {
|
||||
modeChanging = { ...modeChanging, [botId]: true };
|
||||
try {
|
||||
const res = await api(`/telegram-bots/${botId}`, { method: 'PUT', body: JSON.stringify({ update_mode: mode }) });
|
||||
await load();
|
||||
if (mode === 'webhook') {
|
||||
// Load webhook status after switching
|
||||
await loadWebhookStatus(botId);
|
||||
}
|
||||
snackSuccess(t('snack.botUpdated'));
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
modeChanging = { ...modeChanging, [botId]: false };
|
||||
}
|
||||
|
||||
async function loadWebhookStatus(botId: number) {
|
||||
try {
|
||||
webhookStatus = { ...webhookStatus, [botId]: await api(`/telegram-bots/${botId}/webhook/status`) };
|
||||
} catch { webhookStatus = { ...webhookStatus, [botId]: null }; }
|
||||
}
|
||||
|
||||
async function registerWebhook(botId: number) {
|
||||
modeChanging = { ...modeChanging, [botId]: true };
|
||||
try {
|
||||
const res = await api(`/telegram-bots/${botId}/webhook/register`, { method: 'POST' });
|
||||
if (res.success) {
|
||||
snackSuccess(res.verified ? t('telegramBot.webhookVerified') : t('telegramBot.webhookRegistered'));
|
||||
await loadWebhookStatus(botId);
|
||||
} else {
|
||||
snackError(res.error || 'Failed to register webhook');
|
||||
}
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
modeChanging = { ...modeChanging, [botId]: false };
|
||||
}
|
||||
|
||||
async function unregisterWebhook(botId: number) {
|
||||
modeChanging = { ...modeChanging, [botId]: true };
|
||||
try {
|
||||
const res = await api(`/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); }
|
||||
modeChanging = { ...modeChanging, [botId]: false };
|
||||
}
|
||||
|
||||
function copyChatId(e: Event, chatId: string) {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(chatId);
|
||||
snackInfo(`${t('snack.copied')}: ${chatId}`);
|
||||
}
|
||||
|
||||
async function testChat(e: Event, botId: number, chatId: string) {
|
||||
e.stopPropagation();
|
||||
const key = `${botId}_${chatId}`;
|
||||
if (chatTesting[key]) return;
|
||||
chatTesting = { ...chatTesting, [key]: true };
|
||||
try {
|
||||
const res = await api(`/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); }
|
||||
chatTesting = { ...chatTesting, [key]: false };
|
||||
}
|
||||
|
||||
function chatTypeLabel(type: string): string {
|
||||
const map: Record<string, string> = {
|
||||
private: t('telegramBot.private'),
|
||||
group: t('telegramBot.group'),
|
||||
supergroup: t('telegramBot.supergroup'),
|
||||
channel: t('telegramBot.channel'),
|
||||
};
|
||||
return map[type] || type;
|
||||
}
|
||||
|
||||
// --- Email Bot state ---
|
||||
let showEmailForm = $state(false);
|
||||
let editingEmail = $state<number | null>(null);
|
||||
let emailSubmitting = $state(false);
|
||||
let emailTesting = $state<Record<number, boolean>>({});
|
||||
let confirmDeleteEmail = $state<any>(null);
|
||||
const defaultEmailForm = () => ({
|
||||
name: '', icon: '', email: '', smtp_host: '', smtp_port: 587,
|
||||
smtp_username: '', smtp_password: '', smtp_use_tls: true,
|
||||
});
|
||||
let emailForm = $state(defaultEmailForm());
|
||||
|
||||
function openNewEmail() { emailForm = defaultEmailForm(); editingEmail = null; showEmailForm = true; }
|
||||
function editEmailBot(bot: EmailBot) {
|
||||
emailForm = {
|
||||
name: bot.name, icon: bot.icon || '', email: bot.email,
|
||||
smtp_host: bot.smtp_host, smtp_port: bot.smtp_port,
|
||||
smtp_username: bot.smtp_username, smtp_password: '',
|
||||
smtp_use_tls: bot.smtp_use_tls,
|
||||
};
|
||||
editingEmail = bot.id; showEmailForm = true;
|
||||
}
|
||||
|
||||
async function saveEmailBot(e: SubmitEvent) {
|
||||
e.preventDefault(); error = ''; emailSubmitting = true;
|
||||
try {
|
||||
const body = { ...emailForm };
|
||||
if (editingEmail) {
|
||||
// Don't send empty password on update (means "keep current")
|
||||
if (!body.smtp_password) delete (body as any).smtp_password;
|
||||
await api(`/email-bots/${editingEmail}`, { method: 'PUT', body: JSON.stringify(body) });
|
||||
snackSuccess(t('snack.emailBotUpdated'));
|
||||
} else {
|
||||
await api('/email-bots', { method: 'POST', body: JSON.stringify(body) });
|
||||
snackSuccess(t('snack.emailBotCreated'));
|
||||
}
|
||||
emailForm = defaultEmailForm(); showEmailForm = false; editingEmail = null; await load();
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
finally { emailSubmitting = false; }
|
||||
}
|
||||
|
||||
function removeEmail(id: number) {
|
||||
confirmDeleteEmail = {
|
||||
id,
|
||||
onconfirm: async () => {
|
||||
try { await api(`/email-bots/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.emailBotDeleted')); }
|
||||
catch (err: any) { error = err.message; snackError(err.message); }
|
||||
finally { confirmDeleteEmail = null; }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function testEmailBot(botId: number) {
|
||||
emailTesting = { ...emailTesting, [botId]: true };
|
||||
try {
|
||||
const res = await api(`/email-bots/${botId}/test`, { method: 'POST' });
|
||||
if (res.success) snackSuccess(t('snack.emailBotTestSent'));
|
||||
else snackError(res.error || 'Failed');
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
emailTesting = { ...emailTesting, [botId]: false };
|
||||
}
|
||||
|
||||
// --- Matrix Bot state ---
|
||||
let showMatrixForm = $state(false);
|
||||
let editingMatrix = $state<number | null>(null);
|
||||
let matrixSubmitting = $state(false);
|
||||
let matrixTesting = $state<Record<number, boolean>>({});
|
||||
let confirmDeleteMatrix = $state<any>(null);
|
||||
const defaultMatrixForm = () => ({
|
||||
name: '', icon: '', homeserver_url: '', access_token: '', display_name: '',
|
||||
});
|
||||
let matrixForm = $state(defaultMatrixForm());
|
||||
|
||||
function openNewMatrix() { matrixForm = defaultMatrixForm(); 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 || '',
|
||||
};
|
||||
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(); showMatrixForm = false; editingMatrix = null; await load();
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
finally { matrixSubmitting = false; }
|
||||
}
|
||||
|
||||
function removeMatrix(id: number) {
|
||||
confirmDeleteMatrix = {
|
||||
id,
|
||||
onconfirm: async () => {
|
||||
try { await api(`/matrix-bots/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.matrixBotDeleted')); }
|
||||
catch (err: any) { 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`, { method: 'POST' });
|
||||
if (res.success) snackSuccess(t('snack.matrixBotTestOk'));
|
||||
else snackError(res.error || 'Failed');
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
matrixTesting = { ...matrixTesting, [botId]: false };
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !loaded}<Loading />{:else}
|
||||
|
||||
{#if activeTab === 'telegram'}
|
||||
<PageHeader title={t('telegramBot.title')} description={t('telegramBot.description')}>
|
||||
<button onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}
|
||||
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||
{showForm ? t('common.cancel') : t('telegramBot.addBot')}
|
||||
</button>
|
||||
</PageHeader>
|
||||
|
||||
{#if showForm}
|
||||
<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={saveBot} class="space-y-3">
|
||||
<div>
|
||||
<label for="bot-name" class="block text-sm font-medium mb-1">{t('telegramBot.name')}</label>
|
||||
<div class="flex gap-2">
|
||||
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
|
||||
<input id="bot-name" bind:value={form.name} required placeholder={t('telegramBot.namePlaceholder')}
|
||||
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
{#if !editing}
|
||||
<div>
|
||||
<label for="bot-token" class="block text-sm font-medium mb-1">{t('telegramBot.token')}</label>
|
||||
<input id="bot-token" bind:value={form.token} required placeholder={t('telegramBot.tokenPlaceholder')}
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] font-mono" />
|
||||
</div>
|
||||
{/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('telegramBot.addBot'))}
|
||||
</button>
|
||||
</form>
|
||||
</Card>
|
||||
<TelegramBotTab {settings} onreload={load} />
|
||||
{/if}
|
||||
|
||||
{#if bots.length === 0 && !showForm}
|
||||
<Card>
|
||||
<EmptyState icon="mdiRobot" message={t('telegramBot.noBots')} />
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-3 stagger-children">
|
||||
{#each bots 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 || 'mdiRobot'} size={20} /></span>
|
||||
<p class="font-medium">{bot.name}</p>
|
||||
{#if bot.bot_username}
|
||||
<span class="text-xs text-[var(--color-muted-foreground)]">@{bot.bot_username}</span>
|
||||
{/if}
|
||||
<!-- Mode badge -->
|
||||
<span class="text-xs px-1.5 py-0.5 rounded font-mono {bot.update_mode === 'webhook'
|
||||
? 'bg-blue-500/10 text-blue-500'
|
||||
: 'bg-emerald-500/10 text-emerald-500'}">
|
||||
{bot.update_mode === 'webhook' ? t('telegramBot.webhook') : t('telegramBot.polling')}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.token_preview}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editBot(bot)} />
|
||||
<button onclick={() => toggleSection(bot.id, 'chats')}
|
||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1">
|
||||
{t('telegramBot.chats')} {expandedSection[bot.id] === 'chats' ? '▲' : '▼'}
|
||||
</button>
|
||||
<button onclick={() => { toggleSection(bot.id, 'listeners'); if (expandedSection[bot.id] === 'listeners') loadListenerStatus(bot.id); }}
|
||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1">
|
||||
{t('commandTracker.listeners')} {expandedSection[bot.id] === 'listeners' ? '▲' : '▼'}
|
||||
</button>
|
||||
<button onclick={() => syncCommands(bot.id)} disabled={modeChanging[bot.id]}
|
||||
class="text-xs text-[var(--color-primary)] hover:underline px-2 py-1 flex items-center gap-1">
|
||||
<MdiIcon name="mdiSync" size={14} />
|
||||
{t('telegramBot.syncCommands')}
|
||||
</button>
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(bot.id)} variant="danger" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chats section -->
|
||||
{#if expandedSection[bot.id] === 'chats'}
|
||||
<div class="mt-3 border-t border-[var(--color-border)] pt-3" in:slide>
|
||||
{#if chatsLoading[bot.id]}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)]">{t('common.loading')}</p>
|
||||
{:else if (chats[bot.id] || []).length === 0}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)]">{t('telegramBot.noChats')}</p>
|
||||
{:else}
|
||||
<div class="space-y-1">
|
||||
{#each chats[bot.id] as chat}
|
||||
<div class="flex items-center justify-between text-sm px-2 py-1 rounded hover:bg-[var(--color-muted)] cursor-pointer"
|
||||
onclick={(e: MouseEvent) => copyChatId(e, chat.chat_id)}
|
||||
title={t('telegramBot.clickToCopy')}
|
||||
role="button" tabindex="0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">{chat.title || chat.username || 'Unknown'}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{chatTypeLabel(chat.type)}</span>
|
||||
<span class="text-xs text-[var(--color-muted-foreground)] font-mono">{chat.chat_id}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<IconButton icon="mdiSend" title="Test message" size={14}
|
||||
onclick={(e: MouseEvent) => testChat(e, bot.id, chat.chat_id)}
|
||||
disabled={chatTesting[`${bot.id}_${chat.chat_id}`]} />
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} size={14}
|
||||
onclick={(e: MouseEvent) => { e.stopPropagation(); deleteChat(bot.id, chat.id); }} variant="danger" />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<button onclick={() => discoverChats(bot.id)}
|
||||
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>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Listener Status section -->
|
||||
{#if expandedSection[bot.id] === 'listeners'}
|
||||
<div class="mt-3 border-t border-[var(--color-border)] pt-3 space-y-3" in:slide>
|
||||
{#if botListenerLoading[bot.id]}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)]">{t('common.loading')}</p>
|
||||
{:else if (botListenerStatus[bot.id] || []).length === 0}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)]">{t('commandTracker.noListeners')}</p>
|
||||
{:else}
|
||||
<div class="space-y-1">
|
||||
{#each botListenerStatus[bot.id] as trk}
|
||||
<div class="flex items-center justify-between text-sm px-2 py-1 rounded hover:bg-[var(--color-muted)]">
|
||||
<div class="flex items-center gap-2">
|
||||
<MdiIcon name={trk.icon || 'mdiConsoleLine'} size={14} />
|
||||
<span class="font-medium">{trk.name}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded font-mono {trk.enabled
|
||||
? 'bg-emerald-500/10 text-emerald-500'
|
||||
: 'bg-red-500/10 text-red-500'}">
|
||||
{trk.enabled ? t('commandTracker.enabled') : t('commandTracker.disabled')}
|
||||
</span>
|
||||
</div>
|
||||
<a href="/command-trackers" class="text-xs text-[var(--color-primary)] hover:underline">
|
||||
{t('common.edit')}
|
||||
</a>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Update mode -->
|
||||
<div class="border-t border-[var(--color-border)] pt-3">
|
||||
<p class="text-xs font-medium mb-2">{t('telegramBot.updateMode')}</p>
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<div class="flex items-center rounded-md border border-[var(--color-border)] overflow-hidden">
|
||||
<button onclick={() => switchMode(bot.id, 'polling')}
|
||||
disabled={modeChanging[bot.id] || bot.update_mode === 'polling'}
|
||||
class="px-3 py-1 text-xs transition-colors {bot.update_mode === 'polling'
|
||||
? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]'
|
||||
: 'hover:bg-[var(--color-muted)]'} disabled:opacity-70">
|
||||
<MdiIcon name="mdiSync" size={14} />
|
||||
{t('telegramBot.polling')}
|
||||
</button>
|
||||
<button onclick={() => switchMode(bot.id, 'webhook')}
|
||||
disabled={modeChanging[bot.id] || bot.update_mode === 'webhook'}
|
||||
class="px-3 py-1 text-xs transition-colors {bot.update_mode === 'webhook'
|
||||
? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]'
|
||||
: 'hover:bg-[var(--color-muted)]'} disabled:opacity-70">
|
||||
<MdiIcon name="mdiWebhook" size={14} />
|
||||
{t('telegramBot.webhook')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if bot.update_mode === 'polling'}
|
||||
<span class="text-xs text-emerald-500 flex items-center gap-1">
|
||||
<MdiIcon name="mdiCheckCircle" size={14} />
|
||||
{t('telegramBot.pollingActive')}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if bot.update_mode === 'webhook'}
|
||||
<button onclick={() => registerWebhook(bot.id)} disabled={modeChanging[bot.id]}
|
||||
class="px-2 py-1 text-xs border border-[var(--color-border)] rounded-md hover:bg-[var(--color-muted)] disabled:opacity-50">
|
||||
{t('telegramBot.registerWebhook')}
|
||||
</button>
|
||||
<button onclick={() => unregisterWebhook(bot.id)} disabled={modeChanging[bot.id]}
|
||||
class="px-2 py-1 text-xs text-[var(--color-muted-foreground)] hover:underline disabled:opacity-50">
|
||||
{t('telegramBot.unregisterWebhook')}
|
||||
</button>
|
||||
{#if webhookStatus[bot.id]}
|
||||
{@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}
|
||||
({ws.pending_update_count} {t('telegramBot.pendingUpdates')})
|
||||
{/if}
|
||||
</span>
|
||||
{#if ws.last_error_message}
|
||||
<span class="text-xs text-red-500">{t('telegramBot.webhookError')}: {ws.last_error_message}</span>
|
||||
{/if}
|
||||
{:else}
|
||||
<button onclick={() => loadWebhookStatus(bot.id)}
|
||||
class="text-xs text-[var(--color-primary)] hover:underline">
|
||||
{t('telegramBot.webhookStatus')}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if !settings.external_url && bot.update_mode === 'webhook'}
|
||||
<span class="text-xs text-amber-500 flex items-center gap-1">
|
||||
<MdiIcon name="mdiAlert" size={14} />
|
||||
{t('telegramBot.noExternalDomain')}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<ConfirmModal open={confirmDelete !== null} message={t('telegramBot.confirmDelete')}
|
||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||
{/if}
|
||||
|
||||
<!-- ======= Email Bots Section ======= -->
|
||||
{#if activeTab === 'email'}
|
||||
<PageHeader title={t('emailBot.title')} description={t('emailBot.description')}>
|
||||
<button onclick={() => { showEmailForm ? (showEmailForm = false, editingEmail = null) : openNewEmail(); }}
|
||||
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||
{showEmailForm ? t('common.cancel') : t('emailBot.addBot')}
|
||||
</button>
|
||||
</PageHeader>
|
||||
|
||||
{#if showEmailForm}
|
||||
<Card class="mb-6">
|
||||
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
||||
<form onsubmit={saveEmailBot} class="space-y-3">
|
||||
<div>
|
||||
<label for="ebot-name" class="block text-sm font-medium mb-1">{t('emailBot.name')}</label>
|
||||
<div class="flex gap-2">
|
||||
<IconPicker value={emailForm.icon} onselect={(v: string) => emailForm.icon = v} />
|
||||
<input id="ebot-name" bind:value={emailForm.name} required placeholder={t('emailBot.namePlaceholder')}
|
||||
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="ebot-email" class="block text-sm font-medium mb-1">{t('emailBot.email')}</label>
|
||||
<input id="ebot-email" bind:value={emailForm.email} required type="email" placeholder="notify@example.com"
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label for="ebot-host" class="block text-sm font-medium mb-1">{t('emailBot.smtpHost')}</label>
|
||||
<input id="ebot-host" bind:value={emailForm.smtp_host} required placeholder="smtp.gmail.com"
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="ebot-port" class="block text-sm font-medium mb-1">{t('emailBot.smtpPort')}</label>
|
||||
<input id="ebot-port" bind:value={emailForm.smtp_port} type="number" min="1" max="65535"
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label for="ebot-user" class="block text-sm font-medium mb-1">{t('emailBot.smtpUsername')}</label>
|
||||
<input id="ebot-user" bind:value={emailForm.smtp_username} placeholder={t('emailBot.smtpUsernamePlaceholder')}
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="ebot-pass" class="block text-sm font-medium mb-1">{t('emailBot.smtpPassword')}</label>
|
||||
<input id="ebot-pass" bind:value={emailForm.smtp_password} type="password" placeholder={editingEmail ? t('emailBot.passwordUnchanged') : ''}
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" bind:checked={emailForm.smtp_use_tls} />
|
||||
{t('emailBot.useTls')}
|
||||
</label>
|
||||
<button type="submit" disabled={emailSubmitting}
|
||||
class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
|
||||
{emailSubmitting ? t('common.loading') : (editingEmail ? t('common.save') : t('emailBot.addBot'))}
|
||||
</button>
|
||||
</form>
|
||||
</Card>
|
||||
<EmailBotTab onreload={load} />
|
||||
{/if}
|
||||
|
||||
{#if emailBots.length === 0 && !showEmailForm}
|
||||
<Card>
|
||||
<EmptyState icon="mdiEmailOutline" message={t('emailBot.noBots')} />
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-3 stagger-children">
|
||||
{#each emailBots as bot}
|
||||
<Card hover 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 || 'mdiEmailOutline'} size={20} /></span>
|
||||
<p class="font-medium">{bot.name}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-1 flex-wrap">
|
||||
<span class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.email}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{bot.smtp_host}:{bot.smtp_port}</span>
|
||||
{#if bot.smtp_use_tls}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-500">TLS</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<IconButton icon="mdiEmailSend" title={t('emailBot.testConnection')} onclick={() => testEmailBot(bot.id)} disabled={emailTesting[bot.id]} />
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editEmailBot(bot)} />
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => removeEmail(bot.id)} variant="danger" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<ConfirmModal open={confirmDeleteEmail !== null} message={t('emailBot.confirmDelete')}
|
||||
onconfirm={() => confirmDeleteEmail?.onconfirm()} oncancel={() => confirmDeleteEmail = null} />
|
||||
{/if}
|
||||
|
||||
<!-- ======= Matrix Bots Section ======= -->
|
||||
{#if activeTab === 'matrix'}
|
||||
<PageHeader title={t('matrixBot.title')} description={t('matrixBot.description')}>
|
||||
<button onclick={() => { showMatrixForm ? (showMatrixForm = false, editingMatrix = null) : openNewMatrix(); }}
|
||||
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||
{showMatrixForm ? t('common.cancel') : t('matrixBot.addBot')}
|
||||
</button>
|
||||
</PageHeader>
|
||||
|
||||
{#if showMatrixForm}
|
||||
<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={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} 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} />
|
||||
<MatrixBotTab onreload={load} />
|
||||
{/if}
|
||||
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import { emailBotsCache } 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 type { EmailBot } from '$lib/types';
|
||||
|
||||
let { onreload }: { onreload: () => Promise<void> } = $props();
|
||||
|
||||
let emailBots = $derived(emailBotsCache.items);
|
||||
let showEmailForm = $state(false);
|
||||
let editingEmail = $state<number | null>(null);
|
||||
let emailSubmitting = $state(false);
|
||||
let emailTesting = $state<Record<number, boolean>>({});
|
||||
let confirmDeleteEmail = $state<any>(null);
|
||||
let error = $state('');
|
||||
|
||||
const defaultEmailForm = () => ({
|
||||
name: '', icon: '', email: '', smtp_host: '', smtp_port: 587,
|
||||
smtp_username: '', smtp_password: '', smtp_use_tls: true,
|
||||
});
|
||||
let emailForm = $state(defaultEmailForm());
|
||||
|
||||
function openNewEmail() { emailForm = defaultEmailForm(); editingEmail = null; showEmailForm = true; }
|
||||
function editEmailBot(bot: EmailBot) {
|
||||
emailForm = {
|
||||
name: bot.name, icon: bot.icon || '', email: bot.email,
|
||||
smtp_host: bot.smtp_host, smtp_port: bot.smtp_port,
|
||||
smtp_username: bot.smtp_username, smtp_password: '',
|
||||
smtp_use_tls: bot.smtp_use_tls,
|
||||
};
|
||||
editingEmail = bot.id; showEmailForm = true;
|
||||
}
|
||||
|
||||
async function saveEmailBot(e: SubmitEvent) {
|
||||
e.preventDefault(); error = ''; emailSubmitting = true;
|
||||
try {
|
||||
const body = { ...emailForm };
|
||||
if (editingEmail) {
|
||||
if (!body.smtp_password) delete (body as any).smtp_password;
|
||||
await api(`/email-bots/${editingEmail}`, { method: 'PUT', body: JSON.stringify(body) });
|
||||
snackSuccess(t('snack.emailBotUpdated'));
|
||||
} else {
|
||||
await api('/email-bots', { method: 'POST', body: JSON.stringify(body) });
|
||||
snackSuccess(t('snack.emailBotCreated'));
|
||||
}
|
||||
emailForm = defaultEmailForm(); showEmailForm = false; editingEmail = null; await onreload();
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
finally { emailSubmitting = false; }
|
||||
}
|
||||
|
||||
function removeEmail(id: number) {
|
||||
confirmDeleteEmail = {
|
||||
id,
|
||||
onconfirm: async () => {
|
||||
try { await api(`/email-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.emailBotDeleted')); }
|
||||
catch (err: any) { error = err.message; snackError(err.message); }
|
||||
finally { confirmDeleteEmail = null; }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function testEmailBot(botId: number) {
|
||||
emailTesting = { ...emailTesting, [botId]: true };
|
||||
try {
|
||||
const res = await api(`/email-bots/${botId}/test`, { method: 'POST' });
|
||||
if (res.success) snackSuccess(t('snack.emailBotTestSent'));
|
||||
else snackError(res.error || 'Failed');
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
emailTesting = { ...emailTesting, [botId]: false };
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageHeader title={t('emailBot.title')} description={t('emailBot.description')}>
|
||||
<button onclick={() => { showEmailForm ? (showEmailForm = false, editingEmail = null) : openNewEmail(); }}
|
||||
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||
{showEmailForm ? t('common.cancel') : t('emailBot.addBot')}
|
||||
</button>
|
||||
</PageHeader>
|
||||
|
||||
{#if showEmailForm}
|
||||
<Card class="mb-6">
|
||||
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
||||
<form onsubmit={saveEmailBot} class="space-y-3">
|
||||
<div>
|
||||
<label for="ebot-name" class="block text-sm font-medium mb-1">{t('emailBot.name')}</label>
|
||||
<div class="flex gap-2">
|
||||
<IconPicker value={emailForm.icon} onselect={(v: string) => emailForm.icon = v} />
|
||||
<input id="ebot-name" bind:value={emailForm.name} required placeholder={t('emailBot.namePlaceholder')}
|
||||
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="ebot-email" class="block text-sm font-medium mb-1">{t('emailBot.email')}</label>
|
||||
<input id="ebot-email" bind:value={emailForm.email} required type="email" placeholder="notify@example.com"
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label for="ebot-host" class="block text-sm font-medium mb-1">{t('emailBot.smtpHost')}</label>
|
||||
<input id="ebot-host" bind:value={emailForm.smtp_host} required placeholder="smtp.gmail.com"
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="ebot-port" class="block text-sm font-medium mb-1">{t('emailBot.smtpPort')}</label>
|
||||
<input id="ebot-port" bind:value={emailForm.smtp_port} type="number" min="1" max="65535"
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label for="ebot-user" class="block text-sm font-medium mb-1">{t('emailBot.smtpUsername')}</label>
|
||||
<input id="ebot-user" bind:value={emailForm.smtp_username} placeholder={t('emailBot.smtpUsernamePlaceholder')}
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="ebot-pass" class="block text-sm font-medium mb-1">{t('emailBot.smtpPassword')}</label>
|
||||
<input id="ebot-pass" bind:value={emailForm.smtp_password} type="password" placeholder={editingEmail ? t('emailBot.passwordUnchanged') : ''}
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" bind:checked={emailForm.smtp_use_tls} />
|
||||
{t('emailBot.useTls')}
|
||||
</label>
|
||||
<button type="submit" disabled={emailSubmitting}
|
||||
class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
|
||||
{emailSubmitting ? t('common.loading') : (editingEmail ? t('common.save') : t('emailBot.addBot'))}
|
||||
</button>
|
||||
</form>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
{#if emailBots.length === 0 && !showEmailForm}
|
||||
<Card>
|
||||
<EmptyState icon="mdiEmailOutline" message={t('emailBot.noBots')} />
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-3 stagger-children">
|
||||
{#each emailBots as bot}
|
||||
<Card hover 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 || 'mdiEmailOutline'} size={20} /></span>
|
||||
<p class="font-medium">{bot.name}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-1 flex-wrap">
|
||||
<span class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.email}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{bot.smtp_host}:{bot.smtp_port}</span>
|
||||
{#if bot.smtp_use_tls}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-500">TLS</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<IconButton icon="mdiEmailSend" title={t('emailBot.testConnection')} onclick={() => testEmailBot(bot.id)} disabled={emailTesting[bot.id]} />
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editEmailBot(bot)} />
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => removeEmail(bot.id)} variant="danger" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<ConfirmModal open={confirmDeleteEmail !== null} message={t('emailBot.confirmDelete')}
|
||||
onconfirm={() => confirmDeleteEmail?.onconfirm()} oncancel={() => confirmDeleteEmail = null} />
|
||||
@@ -0,0 +1,156 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
import { t } 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 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<any>(null);
|
||||
let error = $state('');
|
||||
|
||||
const defaultMatrixForm = () => ({
|
||||
name: '', icon: '', homeserver_url: '', access_token: '', display_name: '',
|
||||
});
|
||||
let matrixForm = $state(defaultMatrixForm());
|
||||
|
||||
function openNewMatrix() { matrixForm = defaultMatrixForm(); 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 || '',
|
||||
};
|
||||
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(); showMatrixForm = false; editingMatrix = null; await onreload();
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
finally { matrixSubmitting = false; }
|
||||
}
|
||||
|
||||
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) { 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`, { method: 'POST' });
|
||||
if (res.success) snackSuccess(t('snack.matrixBotTestOk'));
|
||||
else snackError(res.error || 'Failed');
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
matrixTesting = { ...matrixTesting, [botId]: false };
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageHeader title={t('matrixBot.title')} description={t('matrixBot.description')}>
|
||||
<button onclick={() => { showMatrixForm ? (showMatrixForm = false, editingMatrix = null) : openNewMatrix(); }}
|
||||
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||
{showMatrixForm ? t('common.cancel') : t('matrixBot.addBot')}
|
||||
</button>
|
||||
</PageHeader>
|
||||
|
||||
{#if showMatrixForm}
|
||||
<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={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} 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} />
|
||||
@@ -0,0 +1,419 @@
|
||||
<script lang="ts">
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api } from '$lib/api';
|
||||
import { t, getLocale } from '$lib/i18n';
|
||||
import { telegramBotsCache } 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, snackInfo } from '$lib/stores/snackbar.svelte';
|
||||
import type { TelegramChat } from '$lib/types';
|
||||
|
||||
let { settings, onreload }: { settings: any; onreload: () => Promise<void> } = $props();
|
||||
|
||||
let bots = $derived(telegramBotsCache.items);
|
||||
let showForm = $state(false);
|
||||
let editing = $state<number | null>(null);
|
||||
let form = $state({ name: '', icon: '', token: '' });
|
||||
let error = $state('');
|
||||
let submitting = $state(false);
|
||||
let confirmDelete = $state<any>(null);
|
||||
|
||||
// Per-bot expandable sections
|
||||
let chats = $state<Record<number, TelegramChat[]>>({});
|
||||
let chatsLoading = $state<Record<number, boolean>>({});
|
||||
let expandedSection = $state<Record<number, string>>({});
|
||||
|
||||
// Webhook status per bot
|
||||
let webhookStatus = $state<Record<number, any>>({});
|
||||
|
||||
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 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; }
|
||||
|
||||
async function saveBot(e: SubmitEvent) {
|
||||
e.preventDefault(); error = ''; submitting = true;
|
||||
try {
|
||||
if (editing) {
|
||||
await api(`/telegram-bots/${editing}`, { method: 'PUT', body: JSON.stringify({ name: form.name, icon: form.icon }) });
|
||||
snackSuccess(t('snack.botUpdated'));
|
||||
} else {
|
||||
await api('/telegram-bots', { method: 'POST', body: JSON.stringify(form) });
|
||||
snackSuccess(t('snack.botRegistered'));
|
||||
}
|
||||
form = { name: '', icon: '', token: '' }; showForm = false; editing = null; await onreload();
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
finally { submitting = false; }
|
||||
}
|
||||
|
||||
function remove(id: number) {
|
||||
confirmDelete = {
|
||||
id,
|
||||
onconfirm: async () => {
|
||||
try { await api(`/telegram-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.botDeleted')); }
|
||||
catch (err: any) { error = err.message; snackError(err.message); }
|
||||
finally { confirmDelete = null; }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function toggleSection(botId: number, section: string) {
|
||||
if (expandedSection[botId] === section) {
|
||||
expandedSection = { ...expandedSection, [botId]: '' };
|
||||
return;
|
||||
}
|
||||
expandedSection = { ...expandedSection, [botId]: section };
|
||||
if (section === 'chats') loadChats(botId);
|
||||
}
|
||||
|
||||
async function loadChats(botId: number) {
|
||||
chatsLoading = { ...chatsLoading, [botId]: true };
|
||||
try { chats = { ...chats, [botId]: await api(`/telegram-bots/${botId}/chats`) }; } catch { 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' }) };
|
||||
snackSuccess(t('telegramBot.chatsDiscovered'));
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
chatsLoading = { ...chatsLoading, [botId]: false };
|
||||
}
|
||||
|
||||
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);
|
||||
snackSuccess(t('telegramBot.chatDeleted'));
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
}
|
||||
|
||||
async function loadListenerStatus(botId: number) {
|
||||
botListenerLoading = { ...botListenerLoading, [botId]: true };
|
||||
try {
|
||||
const trackers = await api('/command-trackers');
|
||||
const matched: any[] = [];
|
||||
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);
|
||||
if (hasBot) matched.push(trk);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
botListenerStatus = { ...botListenerStatus, [botId]: matched };
|
||||
} catch { 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' });
|
||||
if (res.success) snackSuccess(t('telegramBot.commandsSynced'));
|
||||
else snackError(res.error || 'Failed');
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
modeChanging = { ...modeChanging, [botId]: false };
|
||||
}
|
||||
|
||||
async function switchMode(botId: number, mode: string) {
|
||||
modeChanging = { ...modeChanging, [botId]: true };
|
||||
try {
|
||||
await api(`/telegram-bots/${botId}`, { method: 'PUT', body: JSON.stringify({ update_mode: mode }) });
|
||||
await onreload();
|
||||
if (mode === 'webhook') {
|
||||
await loadWebhookStatus(botId);
|
||||
}
|
||||
snackSuccess(t('snack.botUpdated'));
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
modeChanging = { ...modeChanging, [botId]: false };
|
||||
}
|
||||
|
||||
async function loadWebhookStatus(botId: number) {
|
||||
try {
|
||||
webhookStatus = { ...webhookStatus, [botId]: await api(`/telegram-bots/${botId}/webhook/status`) };
|
||||
} catch { webhookStatus = { ...webhookStatus, [botId]: null }; }
|
||||
}
|
||||
|
||||
async function registerWebhook(botId: number) {
|
||||
modeChanging = { ...modeChanging, [botId]: true };
|
||||
try {
|
||||
const res = await api(`/telegram-bots/${botId}/webhook/register`, { method: 'POST' });
|
||||
if (res.success) {
|
||||
snackSuccess(res.verified ? t('telegramBot.webhookVerified') : t('telegramBot.webhookRegistered'));
|
||||
await loadWebhookStatus(botId);
|
||||
} else {
|
||||
snackError(res.error || 'Failed to register webhook');
|
||||
}
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
modeChanging = { ...modeChanging, [botId]: false };
|
||||
}
|
||||
|
||||
async function unregisterWebhook(botId: number) {
|
||||
modeChanging = { ...modeChanging, [botId]: true };
|
||||
try {
|
||||
const res = await api(`/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); }
|
||||
modeChanging = { ...modeChanging, [botId]: false };
|
||||
}
|
||||
|
||||
function copyChatId(e: Event, chatId: string) {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(chatId);
|
||||
snackInfo(`${t('snack.copied')}: ${chatId}`);
|
||||
}
|
||||
|
||||
async function testChat(e: Event, botId: number, chatId: string) {
|
||||
e.stopPropagation();
|
||||
const key = `${botId}_${chatId}`;
|
||||
if (chatTesting[key]) return;
|
||||
chatTesting = { ...chatTesting, [key]: true };
|
||||
try {
|
||||
const res = await api(`/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); }
|
||||
chatTesting = { ...chatTesting, [key]: false };
|
||||
}
|
||||
|
||||
function chatTypeLabel(type: string): string {
|
||||
const map: Record<string, string> = {
|
||||
private: t('telegramBot.private'),
|
||||
group: t('telegramBot.group'),
|
||||
supergroup: t('telegramBot.supergroup'),
|
||||
channel: t('telegramBot.channel'),
|
||||
};
|
||||
return map[type] || type;
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageHeader title={t('telegramBot.title')} description={t('telegramBot.description')}>
|
||||
<button onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}
|
||||
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||
{showForm ? t('common.cancel') : t('telegramBot.addBot')}
|
||||
</button>
|
||||
</PageHeader>
|
||||
|
||||
{#if showForm}
|
||||
<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={saveBot} class="space-y-3">
|
||||
<div>
|
||||
<label for="bot-name" class="block text-sm font-medium mb-1">{t('telegramBot.name')}</label>
|
||||
<div class="flex gap-2">
|
||||
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
|
||||
<input id="bot-name" bind:value={form.name} required placeholder={t('telegramBot.namePlaceholder')}
|
||||
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
{#if !editing}
|
||||
<div>
|
||||
<label for="bot-token" class="block text-sm font-medium mb-1">{t('telegramBot.token')}</label>
|
||||
<input id="bot-token" bind:value={form.token} required placeholder={t('telegramBot.tokenPlaceholder')}
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] font-mono" />
|
||||
</div>
|
||||
{/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('telegramBot.addBot'))}
|
||||
</button>
|
||||
</form>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
{#if bots.length === 0 && !showForm}
|
||||
<Card>
|
||||
<EmptyState icon="mdiRobot" message={t('telegramBot.noBots')} />
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-3 stagger-children">
|
||||
{#each bots 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 || 'mdiRobot'} size={20} /></span>
|
||||
<p class="font-medium">{bot.name}</p>
|
||||
{#if bot.bot_username}
|
||||
<span class="text-xs text-[var(--color-muted-foreground)]">@{bot.bot_username}</span>
|
||||
{/if}
|
||||
<!-- Mode badge -->
|
||||
<span class="text-xs px-1.5 py-0.5 rounded font-mono {bot.update_mode === 'webhook'
|
||||
? 'bg-blue-500/10 text-blue-500'
|
||||
: 'bg-emerald-500/10 text-emerald-500'}">
|
||||
{bot.update_mode === 'webhook' ? t('telegramBot.webhook') : t('telegramBot.polling')}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.token_preview}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editBot(bot)} />
|
||||
<button onclick={() => toggleSection(bot.id, 'chats')}
|
||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1">
|
||||
{t('telegramBot.chats')} {expandedSection[bot.id] === 'chats' ? '▲' : '▼'}
|
||||
</button>
|
||||
<button onclick={() => { toggleSection(bot.id, 'listeners'); if (expandedSection[bot.id] === 'listeners') loadListenerStatus(bot.id); }}
|
||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1">
|
||||
{t('commandTracker.listeners')} {expandedSection[bot.id] === 'listeners' ? '▲' : '▼'}
|
||||
</button>
|
||||
<button onclick={() => syncCommands(bot.id)} disabled={modeChanging[bot.id]}
|
||||
class="text-xs text-[var(--color-primary)] hover:underline px-2 py-1 flex items-center gap-1">
|
||||
<MdiIcon name="mdiSync" size={14} />
|
||||
{t('telegramBot.syncCommands')}
|
||||
</button>
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(bot.id)} variant="danger" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chats section -->
|
||||
{#if expandedSection[bot.id] === 'chats'}
|
||||
<div class="mt-3 border-t border-[var(--color-border)] pt-3" in:slide>
|
||||
{#if chatsLoading[bot.id]}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)]">{t('common.loading')}</p>
|
||||
{:else if (chats[bot.id] || []).length === 0}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)]">{t('telegramBot.noChats')}</p>
|
||||
{:else}
|
||||
<div class="space-y-1">
|
||||
{#each chats[bot.id] as chat}
|
||||
<div class="flex items-center justify-between text-sm px-2 py-1 rounded hover:bg-[var(--color-muted)] cursor-pointer"
|
||||
onclick={(e: MouseEvent) => copyChatId(e, chat.chat_id)}
|
||||
title={t('telegramBot.clickToCopy')}
|
||||
role="button" tabindex="0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">{chat.title || chat.username || 'Unknown'}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{chatTypeLabel(chat.type)}</span>
|
||||
<span class="text-xs text-[var(--color-muted-foreground)] font-mono">{chat.chat_id}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<IconButton icon="mdiSend" title="Test message" size={14}
|
||||
onclick={(e: MouseEvent) => testChat(e, bot.id, chat.chat_id)}
|
||||
disabled={chatTesting[`${bot.id}_${chat.chat_id}`]} />
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} size={14}
|
||||
onclick={(e: MouseEvent) => { e.stopPropagation(); deleteChat(bot.id, chat.id); }} variant="danger" />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<button onclick={() => discoverChats(bot.id)}
|
||||
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>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Listener Status section -->
|
||||
{#if expandedSection[bot.id] === 'listeners'}
|
||||
<div class="mt-3 border-t border-[var(--color-border)] pt-3 space-y-3" in:slide>
|
||||
{#if botListenerLoading[bot.id]}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)]">{t('common.loading')}</p>
|
||||
{:else if (botListenerStatus[bot.id] || []).length === 0}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)]">{t('commandTracker.noListeners')}</p>
|
||||
{:else}
|
||||
<div class="space-y-1">
|
||||
{#each botListenerStatus[bot.id] as trk}
|
||||
<div class="flex items-center justify-between text-sm px-2 py-1 rounded hover:bg-[var(--color-muted)]">
|
||||
<div class="flex items-center gap-2">
|
||||
<MdiIcon name={trk.icon || 'mdiConsoleLine'} size={14} />
|
||||
<span class="font-medium">{trk.name}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded font-mono {trk.enabled
|
||||
? 'bg-emerald-500/10 text-emerald-500'
|
||||
: 'bg-red-500/10 text-red-500'}">
|
||||
{trk.enabled ? t('commandTracker.enabled') : t('commandTracker.disabled')}
|
||||
</span>
|
||||
</div>
|
||||
<a href="/command-trackers" class="text-xs text-[var(--color-primary)] hover:underline">
|
||||
{t('common.edit')}
|
||||
</a>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Update mode -->
|
||||
<div class="border-t border-[var(--color-border)] pt-3">
|
||||
<p class="text-xs font-medium mb-2">{t('telegramBot.updateMode')}</p>
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<div class="flex items-center rounded-md border border-[var(--color-border)] overflow-hidden">
|
||||
<button onclick={() => switchMode(bot.id, 'polling')}
|
||||
disabled={modeChanging[bot.id] || bot.update_mode === 'polling'}
|
||||
class="px-3 py-1 text-xs transition-colors {bot.update_mode === 'polling'
|
||||
? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]'
|
||||
: 'hover:bg-[var(--color-muted)]'} disabled:opacity-70">
|
||||
<MdiIcon name="mdiSync" size={14} />
|
||||
{t('telegramBot.polling')}
|
||||
</button>
|
||||
<button onclick={() => switchMode(bot.id, 'webhook')}
|
||||
disabled={modeChanging[bot.id] || bot.update_mode === 'webhook'}
|
||||
class="px-3 py-1 text-xs transition-colors {bot.update_mode === 'webhook'
|
||||
? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]'
|
||||
: 'hover:bg-[var(--color-muted)]'} disabled:opacity-70">
|
||||
<MdiIcon name="mdiWebhook" size={14} />
|
||||
{t('telegramBot.webhook')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if bot.update_mode === 'polling'}
|
||||
<span class="text-xs text-emerald-500 flex items-center gap-1">
|
||||
<MdiIcon name="mdiCheckCircle" size={14} />
|
||||
{t('telegramBot.pollingActive')}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if bot.update_mode === 'webhook'}
|
||||
<button onclick={() => registerWebhook(bot.id)} disabled={modeChanging[bot.id]}
|
||||
class="px-2 py-1 text-xs border border-[var(--color-border)] rounded-md hover:bg-[var(--color-muted)] disabled:opacity-50">
|
||||
{t('telegramBot.registerWebhook')}
|
||||
</button>
|
||||
<button onclick={() => unregisterWebhook(bot.id)} disabled={modeChanging[bot.id]}
|
||||
class="px-2 py-1 text-xs text-[var(--color-muted-foreground)] hover:underline disabled:opacity-50">
|
||||
{t('telegramBot.unregisterWebhook')}
|
||||
</button>
|
||||
{#if webhookStatus[bot.id]}
|
||||
{@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}
|
||||
({ws.pending_update_count} {t('telegramBot.pendingUpdates')})
|
||||
{/if}
|
||||
</span>
|
||||
{#if ws.last_error_message}
|
||||
<span class="text-xs text-red-500">{t('telegramBot.webhookError')}: {ws.last_error_message}</span>
|
||||
{/if}
|
||||
{:else}
|
||||
<button onclick={() => loadWebhookStatus(bot.id)}
|
||||
class="text-xs text-[var(--color-primary)] hover:underline">
|
||||
{t('telegramBot.webhookStatus')}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if !settings.external_url && bot.update_mode === 'webhook'}
|
||||
<span class="text-xs text-amber-500 flex items-center gap-1">
|
||||
<MdiIcon name="mdiAlert" size={14} />
|
||||
{t('telegramBot.noExternalDomain')}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<ConfirmModal open={confirmDelete !== null} message={t('telegramBot.confirmDelete')}
|
||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||
@@ -263,7 +263,7 @@
|
||||
<div class="flex items-center justify-between text-sm px-2 py-1 rounded hover:bg-[var(--color-muted)]">
|
||||
<div class="flex items-center gap-2">
|
||||
<MdiIcon name="mdiRobot" size={14} />
|
||||
<CrossLink href="/bots" icon="mdiRobot" label={listener.name || listener.listener_type} entityId={listener.listener_id} />
|
||||
<CrossLink href="/bots?tab=telegram" icon="mdiRobot" label={listener.name || listener.listener_type} entityId={listener.listener_id} />
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-500 font-mono">{listener.listener_type}</span>
|
||||
</div>
|
||||
<IconButton icon="mdiClose" title={t('commandTracker.removeListener')} size={14}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { t, getLocale, setLocale } from '$lib/i18n';
|
||||
import { initTheme, getTheme, setTheme, type Theme } from '$lib/theme.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import AuthLayout from '$lib/components/AuthLayout.svelte';
|
||||
|
||||
const theme = getTheme();
|
||||
let username = $state('');
|
||||
@@ -37,235 +38,53 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="auth-page">
|
||||
<!-- Animated gradient mesh background -->
|
||||
<div class="auth-bg"></div>
|
||||
<div class="auth-grid"></div>
|
||||
|
||||
<!-- Login card -->
|
||||
<div class="auth-card-wrapper" class:visible={mounted}>
|
||||
<div class="auth-card">
|
||||
<!-- Controls -->
|
||||
<div class="flex justify-end gap-1.5 mb-6">
|
||||
<button onclick={() => { setLocale(getLocale() === 'en' ? 'ru' : 'en'); }}
|
||||
class="auth-control-btn">
|
||||
{getLocale().toUpperCase()}
|
||||
</button>
|
||||
<button onclick={() => { const o: Theme[] = ['light','dark','system']; setTheme(o[(o.indexOf(theme.current)+1)%3]); }}
|
||||
class="auth-control-btn">
|
||||
<MdiIcon name={theme.resolved === 'dark' ? 'mdiWeatherNight' : 'mdiWeatherSunny'} size={13} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Logo / title -->
|
||||
<div class="text-center mb-8">
|
||||
<div class="auth-logo-icon">
|
||||
<MdiIcon name="mdiLan" size={28} />
|
||||
</div>
|
||||
<h1 class="text-xl font-semibold mt-4 tracking-tight">
|
||||
<span style="color: var(--color-primary);">Notify</span> Bridge
|
||||
</h1>
|
||||
<p class="text-sm mt-1" style="color: var(--color-muted-foreground);">{t('auth.signInTitle')}</p>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="auth-error animate-fade-slide-in">
|
||||
<MdiIcon name="mdiAlertCircle" size={16} />
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={handleSubmit} class="space-y-4">
|
||||
<div>
|
||||
<label for="username" class="auth-label">{t('auth.username')}</label>
|
||||
<input id="username" type="text" bind:value={username} required
|
||||
class="auth-input" placeholder="admin" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="auth-label">{t('auth.password')}</label>
|
||||
<input id="password" type="password" bind:value={password} required
|
||||
class="auth-input" />
|
||||
</div>
|
||||
<button type="submit" disabled={submitting} class="auth-submit">
|
||||
{#if submitting}
|
||||
<div class="w-4 h-4 rounded-full border-2 border-current border-t-transparent animate-spin"></div>
|
||||
{/if}
|
||||
{submitting ? t('auth.signingIn') : t('auth.signIn')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<AuthLayout visible={mounted}>
|
||||
<!-- Controls -->
|
||||
<div class="flex justify-end gap-1.5 mb-6">
|
||||
<button onclick={() => { setLocale(getLocale() === 'en' ? 'ru' : 'en'); }}
|
||||
class="auth-control-btn">
|
||||
{getLocale().toUpperCase()}
|
||||
</button>
|
||||
<button onclick={() => { const o: Theme[] = ['light','dark','system']; setTheme(o[(o.indexOf(theme.current)+1)%3]); }}
|
||||
class="auth-control-btn">
|
||||
<MdiIcon name={theme.resolved === 'dark' ? 'mdiWeatherNight' : 'mdiWeatherSunny'} size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--color-background);
|
||||
}
|
||||
<!-- Logo / title -->
|
||||
<div class="text-center mb-8">
|
||||
<div class="auth-logo-icon">
|
||||
<MdiIcon name="mdiLan" size={28} />
|
||||
</div>
|
||||
<h1 class="text-xl font-semibold mt-4 tracking-tight">
|
||||
<span style="color: var(--color-primary);">Notify</span> Bridge
|
||||
</h1>
|
||||
<p class="text-sm mt-1" style="color: var(--color-muted-foreground);">{t('auth.signInTitle')}</p>
|
||||
</div>
|
||||
|
||||
.auth-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 60% at 20% 30%, var(--color-glow-strong), transparent 60%),
|
||||
radial-gradient(ellipse 60% 80% at 80% 70%, rgba(99, 102, 241, 0.08), transparent 60%),
|
||||
radial-gradient(ellipse 50% 50% at 50% 50%, var(--color-glow), transparent 70%);
|
||||
animation: gradientShift 12s ease-in-out infinite;
|
||||
background-size: 200% 200%;
|
||||
}
|
||||
{#if error}
|
||||
<div class="auth-error animate-fade-slide-in">
|
||||
<MdiIcon name="mdiAlertCircle" size={16} />
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
.auth-grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
opacity: 0.3;
|
||||
background-image: radial-gradient(circle at 1px 1px, var(--color-border) 0.5px, transparent 0);
|
||||
background-size: 32px 32px;
|
||||
}
|
||||
|
||||
.auth-card-wrapper {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
max-width: 24rem;
|
||||
padding: 1rem;
|
||||
opacity: 0;
|
||||
transform: translateY(16px) scale(0.98);
|
||||
transition: opacity 0.5s ease-out, transform 0.5s ease-out;
|
||||
}
|
||||
|
||||
.auth-card-wrapper.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background: var(--color-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 1rem;
|
||||
padding: 2rem;
|
||||
box-shadow:
|
||||
0 4px 24px rgba(0, 0, 0, 0.08),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05) inset;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
:global([data-theme="dark"]) .auth-card {
|
||||
box-shadow:
|
||||
0 4px 24px rgba(0, 0, 0, 0.3),
|
||||
0 0 48px var(--color-glow),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.03) inset;
|
||||
}
|
||||
|
||||
.auth-logo-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
border-radius: 1rem;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-foreground);
|
||||
box-shadow: 0 0 24px var(--color-glow-strong);
|
||||
}
|
||||
|
||||
.auth-control-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
background: var(--color-muted);
|
||||
color: var(--color-muted-foreground);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.auth-control-btn:hover {
|
||||
color: var(--color-foreground);
|
||||
box-shadow: 0 0 8px var(--color-glow);
|
||||
}
|
||||
|
||||
.auth-label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.375rem;
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
.auth-input {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.625rem;
|
||||
font-size: 0.875rem;
|
||||
background: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.auth-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px var(--color-glow), 0 0 16px var(--color-glow);
|
||||
}
|
||||
|
||||
.auth-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.625rem;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 1rem;
|
||||
background: var(--color-error-bg);
|
||||
color: var(--color-error-fg);
|
||||
}
|
||||
|
||||
.auth-submit {
|
||||
width: 100%;
|
||||
padding: 0.625rem;
|
||||
border-radius: 0.625rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-foreground);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.auth-submit:hover:not(:disabled) {
|
||||
box-shadow: 0 0 24px var(--color-glow-strong);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.auth-submit:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.auth-submit:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@keyframes gradientShift {
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
</style>
|
||||
<form onsubmit={handleSubmit} class="space-y-4">
|
||||
<div>
|
||||
<label for="username" class="auth-label">{t('auth.username')}</label>
|
||||
<input id="username" type="text" bind:value={username} required
|
||||
class="auth-input" placeholder="admin" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="auth-label">{t('auth.password')}</label>
|
||||
<input id="password" type="password" bind:value={password} required
|
||||
class="auth-input" />
|
||||
</div>
|
||||
<button type="submit" disabled={submitting} class="auth-submit">
|
||||
{#if submitting}
|
||||
<div class="w-4 h-4 rounded-full border-2 border-current border-t-transparent animate-spin"></div>
|
||||
{/if}
|
||||
{submitting ? t('auth.signingIn') : t('auth.signIn')}
|
||||
</button>
|
||||
</form>
|
||||
</AuthLayout>
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api } from '$lib/api';
|
||||
import { t, getLocale } from '$lib/i18n';
|
||||
import { providersCache, targetsCache, trackingConfigsCache, templateConfigsCache } from '$lib/stores/caches.svelte';
|
||||
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 Modal from '$lib/components/Modal.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 EntitySelect from '$lib/components/EntitySelect.svelte';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
import { highlightFromUrl } from '$lib/highlight';
|
||||
import type { Tracker, ServiceProvider, NotificationTarget, TrackingConfig, TemplateConfig } from '$lib/types';
|
||||
import type { Tracker, TrackingConfig, TemplateConfig } from '$lib/types';
|
||||
|
||||
import TrackerForm from './TrackerForm.svelte';
|
||||
import LinkedTargetsSection from './LinkedTargetsSection.svelte';
|
||||
import SharedLinkModal from './SharedLinkModal.svelte';
|
||||
import TestMenu from './TestMenu.svelte';
|
||||
|
||||
let loaded = $state(false);
|
||||
let loadError = $state('');
|
||||
@@ -35,7 +35,6 @@
|
||||
let submitting = $state(false);
|
||||
let confirmDelete = $state<Tracker | null>(null);
|
||||
let toggling = $state<Record<number, boolean>>({});
|
||||
// Per tracker-target test state (keyed by `${ttId}_${testType}`)
|
||||
let ttTesting = $state<Record<string, string>>({});
|
||||
|
||||
// Shared link validation
|
||||
@@ -52,15 +51,26 @@
|
||||
let form = $state(defaultForm());
|
||||
let error = $state('');
|
||||
|
||||
// Linked targets management (inline in tracker detail)
|
||||
// Linked targets management
|
||||
let expandedTracker = $state<number | null>(null);
|
||||
let linkedTargets = $state<Record<number, any[]>>({});
|
||||
let addingTarget = $state<Record<number, boolean>>({});
|
||||
let newLinkTargetId = $state<Record<number, number>>({});
|
||||
let newLinkTrackingConfigId = $state<Record<number, number>>({});
|
||||
let newLinkTemplateConfigId = $state<Record<number, number>>({});
|
||||
|
||||
// Test menu
|
||||
let testMenuOpen = $state<string | null>(null);
|
||||
let testMenuStyle = $state('');
|
||||
|
||||
const testTypes = [
|
||||
{ key: 'basic', icon: 'mdiSend', labelKey: 'notificationTracker.testBasic' },
|
||||
{ key: 'periodic', icon: 'mdiCalendarClock', labelKey: 'notificationTracker.testPeriodic' },
|
||||
{ key: 'scheduled', icon: 'mdiImageMultiple', labelKey: 'notificationTracker.testScheduled' },
|
||||
{ key: 'memory', icon: 'mdiHistory', labelKey: 'notificationTracker.testMemory' },
|
||||
];
|
||||
|
||||
onMount(load);
|
||||
|
||||
async function load() {
|
||||
loadError = '';
|
||||
try {
|
||||
@@ -74,12 +84,12 @@
|
||||
snackError(loadError);
|
||||
} finally { loaded = true; highlightFromUrl(); }
|
||||
}
|
||||
|
||||
async function loadCollections() {
|
||||
if (!form.provider_id) return;
|
||||
try { collections = await api(`/providers/${form.provider_id}/collections`); } catch { collections = []; }
|
||||
}
|
||||
|
||||
// Auto-load collections when provider changes via EntitySelect
|
||||
let _prevProviderId = 0;
|
||||
$effect(() => {
|
||||
if (showForm && form.provider_id && form.provider_id !== _prevProviderId) {
|
||||
@@ -89,6 +99,7 @@
|
||||
});
|
||||
|
||||
function openNew() { form = defaultForm(); editing = null; showForm = true; collections = []; previousCollectionIds = []; }
|
||||
|
||||
async function edit(trk: any) {
|
||||
form = {
|
||||
name: trk.name, icon: trk.icon || '', provider_id: trk.provider_id,
|
||||
@@ -104,7 +115,6 @@
|
||||
e.preventDefault(); error = '';
|
||||
if (submitting) return;
|
||||
|
||||
// Check shared links for newly added albums
|
||||
const newAlbumIds = form.collection_ids.filter(id => !previousCollectionIds.includes(id));
|
||||
if (newAlbumIds.length > 0 && form.provider_id) {
|
||||
linkCheckLoading = true;
|
||||
@@ -128,7 +138,7 @@
|
||||
if (missingAlbums.length > 0) {
|
||||
linkWarning = { albums: missingAlbums, providerId: form.provider_id };
|
||||
linkCheckLoading = false;
|
||||
return; // Show warning, don't save yet
|
||||
return;
|
||||
}
|
||||
} catch { /* Proceed if check fails */ }
|
||||
linkCheckLoading = false;
|
||||
@@ -175,6 +185,7 @@
|
||||
linkWarning = null;
|
||||
await doSave();
|
||||
}
|
||||
|
||||
async function toggle(tracker: any) {
|
||||
if (toggling[tracker.id]) return;
|
||||
toggling = { ...toggling, [tracker.id]: true };
|
||||
@@ -184,7 +195,9 @@
|
||||
snackSuccess(tracker.enabled ? t('snack.trackerPaused') : t('snack.trackerResumed'));
|
||||
} catch (err: any) { snackError(err.message); } finally { toggling = { ...toggling, [tracker.id]: false }; }
|
||||
}
|
||||
|
||||
function startDelete(tracker: any) { confirmDelete = tracker; }
|
||||
|
||||
async function doDelete() {
|
||||
if (!confirmDelete) return;
|
||||
try {
|
||||
@@ -194,39 +207,13 @@
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
confirmDelete = null;
|
||||
}
|
||||
let testMenuOpen = $state<string | null>(null);
|
||||
let testMenuStyle = $state('');
|
||||
|
||||
const testTypes = [
|
||||
{ key: 'basic', icon: 'mdiSend', labelKey: 'notificationTracker.testBasic' },
|
||||
{ key: 'periodic', icon: 'mdiCalendarClock', labelKey: 'notificationTracker.testPeriodic' },
|
||||
{ key: 'scheduled', icon: 'mdiImageMultiple', labelKey: 'notificationTracker.testScheduled' },
|
||||
{ key: 'memory', icon: 'mdiHistory', labelKey: 'notificationTracker.testMemory' },
|
||||
];
|
||||
|
||||
async function testTrackerTarget(trackerId: number, ttId: number, testType: string) {
|
||||
testMenuOpen = null;
|
||||
const key = `${ttId}_${testType}`;
|
||||
if (ttTesting[key]) return;
|
||||
ttTesting = { ...ttTesting, [key]: testType };
|
||||
try {
|
||||
await api(`/notification-trackers/${trackerId}/targets/${ttId}/test/${testType}?locale=${getLocale()}`, { method: 'POST' });
|
||||
snackSuccess(t('snack.targetTestSent'));
|
||||
} catch (err: any) {
|
||||
snackError(err.message);
|
||||
} finally {
|
||||
ttTesting = { ...ttTesting, [key]: '' };
|
||||
}
|
||||
function toggleCollection(collectionId: string) {
|
||||
form.collection_ids = form.collection_ids.includes(collectionId)
|
||||
? form.collection_ids.filter(id => id !== collectionId)
|
||||
: [...form.collection_ids, collectionId];
|
||||
}
|
||||
|
||||
function openTestMenu(ttId: number, event: MouseEvent) {
|
||||
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;`;
|
||||
testMenuOpen = testMenuOpen === String(ttId) ? null : String(ttId);
|
||||
}
|
||||
function toggleCollection(collectionId: string) { form.collection_ids = form.collection_ids.includes(collectionId) ? form.collection_ids.filter(id => id !== collectionId) : [...form.collection_ids, collectionId]; }
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
if (!dateStr) return '';
|
||||
try {
|
||||
@@ -235,11 +222,10 @@
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
// --- Linked Targets Management ---
|
||||
// --- Linked Targets helpers ---
|
||||
function toggleExpand(trackerId: number) {
|
||||
if (expandedTracker === trackerId) { expandedTracker = null; return; }
|
||||
expandedTracker = trackerId;
|
||||
// tracker_targets already loaded in tracker response
|
||||
}
|
||||
|
||||
function getProviderType(tracker: any): string {
|
||||
@@ -301,6 +287,33 @@
|
||||
await load();
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
}
|
||||
|
||||
async function testTrackerTarget(trackerId: number, ttId: number, testType: string) {
|
||||
testMenuOpen = null;
|
||||
const key = `${ttId}_${testType}`;
|
||||
if (ttTesting[key]) return;
|
||||
ttTesting = { ...ttTesting, [key]: testType };
|
||||
try {
|
||||
await api(`/notification-trackers/${trackerId}/targets/${ttId}/test/${testType}?locale=${getLocale()}`, { method: 'POST' });
|
||||
snackSuccess(t('snack.targetTestSent'));
|
||||
} catch (err: any) {
|
||||
snackError(err.message);
|
||||
} finally {
|
||||
ttTesting = { ...ttTesting, [key]: '' };
|
||||
}
|
||||
}
|
||||
|
||||
function openTestMenu(ttId: number, event: MouseEvent) {
|
||||
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;`;
|
||||
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;
|
||||
if (trackerId) testTrackerTarget(trackerId, ttId, testType);
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageHeader title={t('notificationTracker.title')} description={t('notificationTracker.description')}>
|
||||
@@ -317,58 +330,19 @@
|
||||
<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3">{loadError}</div>
|
||||
</Card>
|
||||
{:else if showForm}
|
||||
<div in:slide={{ duration: 200 }}>
|
||||
<Card class="mb-6">
|
||||
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
||||
<form onsubmit={save} class="space-y-4">
|
||||
<div>
|
||||
<label for="trk-name" class="block text-sm font-medium mb-1">{t('notificationTracker.name')}</label>
|
||||
<div class="flex gap-2">
|
||||
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
|
||||
<input id="trk-name" bind:value={form.name} required placeholder={t('notificationTracker.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 class="block text-sm font-medium mb-1">{t('notificationTracker.server')}</label>
|
||||
<EntitySelect items={providerItems} bind:value={form.provider_id} placeholder={t('notificationTracker.selectServer')} />
|
||||
</div>
|
||||
{#if collections.length > 0}
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{t('notificationTracker.albums')} ({collections.length})</label>
|
||||
<input type="text" bind:value={collectionFilter} placeholder="Filter..."
|
||||
class="w-full px-3 py-1.5 mb-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
<div class="max-h-56 overflow-y-auto border border-[var(--color-border)] rounded-md p-2 space-y-1">
|
||||
{#each collections.filter(a => !collectionFilter || (a.albumName || a.name || '').toLowerCase().includes(collectionFilter.toLowerCase())) as col}
|
||||
<label class="flex items-center justify-between text-sm cursor-pointer hover:bg-[var(--color-muted)] px-2 py-1 rounded">
|
||||
<span class="flex items-center gap-2">
|
||||
<input type="checkbox" checked={form.collection_ids.includes(col.id)} onchange={() => toggleCollection(col.id)} />
|
||||
{col.albumName || col.name} <span class="text-[var(--color-muted-foreground)]">({col.assetCount ?? col.asset_count ?? 0})</span>
|
||||
</span>
|
||||
{#if col.updatedAt || col.updated_at}
|
||||
<span class="text-xs text-[var(--color-muted-foreground)] whitespace-nowrap ml-2">{formatDate(col.updatedAt || col.updated_at)}</span>
|
||||
{/if}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label for="trk-interval" class="block text-sm font-medium mb-1">{t('notificationTracker.scanInterval')}<Hint text={t('hints.scanInterval')} /></label>
|
||||
<input id="trk-interval" type="number" bind:value={form.scan_interval} min="10" max="3600" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="trk-batch" class="block text-sm font-medium mb-1">{t('notificationTracker.batchDuration')}<Hint text={t('hints.batchDuration')} /></label>
|
||||
<input id="trk-batch" type="number" bind:value={form.batch_duration} min="0" max="3600" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={submitting || linkCheckLoading} 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">
|
||||
{#if linkCheckLoading}{t('notificationTracker.checkingLinks')}{:else}{editing ? t('common.save') : t('notificationTracker.createTracker')}{/if}
|
||||
</button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
<TrackerForm
|
||||
bind:form
|
||||
{providerItems}
|
||||
{collections}
|
||||
bind:collectionFilter
|
||||
{editing}
|
||||
{submitting}
|
||||
{linkCheckLoading}
|
||||
{error}
|
||||
onsave={save}
|
||||
ontoggleCollection={toggleCollection}
|
||||
{formatDate}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if loaded && !loadError}
|
||||
@@ -388,7 +362,7 @@
|
||||
<span class="text-xs px-1.5 py-0.5 rounded {tracker.enabled ? 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]' : 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
|
||||
{tracker.enabled ? t('notificationTracker.active') : t('notificationTracker.paused')}
|
||||
</span>
|
||||
t <CrossLink href="/providers" icon="mdiServer" label={getProviderName(tracker.provider_id)} entityId={tracker.provider_id} />
|
||||
<CrossLink href="/providers" icon="mdiServer" label={getProviderName(tracker.provider_id)} entityId={tracker.provider_id} />
|
||||
</div>
|
||||
<p class="text-sm text-[var(--color-muted-foreground)]">
|
||||
{(tracker.collection_ids || []).length} {t('notificationTracker.albums_count')} · {t('notificationTracker.every')} {tracker.scan_interval}s · {(tracker.tracker_targets || []).length} {t('notificationTracker.linkedTargets')}
|
||||
@@ -406,76 +380,26 @@ t <CrossLink href="/providers" icon="mdiServer" label={getProviderName(trac
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Linked Targets Section -->
|
||||
{#if expandedTracker === tracker.id}
|
||||
<div class="mt-3 border-t border-[var(--color-border)] pt-3 space-y-2" in:slide>
|
||||
{#if (tracker.tracker_targets || []).length === 0}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)]">{t('notificationTracker.noLinkedTargets')}</p>
|
||||
{:else}
|
||||
{#each tracker.tracker_targets as tt}
|
||||
<div class="flex items-center justify-between text-sm px-2 py-1.5 rounded bg-[var(--color-muted)]/30">
|
||||
<div class="flex items-center gap-2">
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={tt.target_icon || (tt.target_type === 'telegram' ? 'mdiSend' : 'mdiWebhook')} size={16} /></span>
|
||||
<span class="font-medium">{tt.target_name || `Target #${tt.target_id}`}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{tt.target_type}</span>
|
||||
{#if !tt.enabled}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-warning-bg)] text-[var(--color-warning-fg)]">{t('notificationTracker.paused')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-wrap justify-end">
|
||||
<select value={tt.tracking_config_id || 0}
|
||||
onchange={(e: Event) => updateTargetLink(tracker.id, tt, 'tracking_config_id', Number((e.target as HTMLSelectElement).value) || null)}
|
||||
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
|
||||
<option value={0}>— {t('trackingConfig.title')} —</option>
|
||||
{#each configsForTracker(tracker, trackingConfigs) as tc}<option value={tc.id}>{tc.name}</option>{/each}
|
||||
</select>
|
||||
<select value={tt.template_config_id || 0}
|
||||
onchange={(e: Event) => updateTargetLink(tracker.id, tt, 'template_config_id', Number((e.target as HTMLSelectElement).value) || null)}
|
||||
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
|
||||
<option value={0}>— {t('templateConfig.title')} —</option>
|
||||
{#each configsForTracker(tracker, templateConfigs) as tc}<option value={tc.id}>{tc.name}</option>{/each}
|
||||
</select>
|
||||
<div class="relative">
|
||||
<IconButton icon="mdiDotsVertical" size={14} title={t('common.test')}
|
||||
onclick={(e: MouseEvent) => openTestMenu(tt.id, e)}
|
||||
disabled={Object.keys(ttTesting).some(k => k.startsWith(`${tt.id}_`) && ttTesting[k])} />
|
||||
</div>
|
||||
<IconButton icon={tt.enabled ? 'mdiPause' : 'mdiPlay'} size={14}
|
||||
title={tt.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')}
|
||||
onclick={() => updateTargetLink(tracker.id, tt, 'enabled', !tt.enabled)} />
|
||||
<IconButton icon="mdiClose" size={14} title={t('common.delete')}
|
||||
onclick={() => removeTargetLink(tracker.id, tt.id)} variant="danger" />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<!-- Add target link -->
|
||||
{#if getUnlinkedTargets(tracker).length > 0}
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<select bind:value={newLinkTargetId[tracker.id]}
|
||||
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)] flex-1">
|
||||
<option value={0}>— {t('notificationTracker.addTarget')} —</option>
|
||||
{#each getUnlinkedTargets(tracker) as tgt}<option value={tgt.id}>{tgt.name} ({tgt.type})</option>{/each}
|
||||
</select>
|
||||
<select bind:value={newLinkTrackingConfigId[tracker.id]}
|
||||
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
|
||||
<option value={0}>— {t('trackingConfig.title')} —</option>
|
||||
{#each configsForTracker(tracker, trackingConfigs) as tc}<option value={tc.id}>{tc.name}</option>{/each}
|
||||
</select>
|
||||
<select bind:value={newLinkTemplateConfigId[tracker.id]}
|
||||
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
|
||||
<option value={0}>— {t('templateConfig.title')} —</option>
|
||||
{#each configsForTracker(tracker, templateConfigs) as tc}<option value={tc.id}>{tc.name}</option>{/each}
|
||||
</select>
|
||||
<button onclick={() => addTargetLink(tracker.id)}
|
||||
disabled={!newLinkTargetId[tracker.id] || addingTarget[tracker.id]}
|
||||
class="text-xs px-3 py-1 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded hover:opacity-90 disabled:opacity-50">
|
||||
{t('common.add')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<LinkedTargetsSection
|
||||
{tracker}
|
||||
{trackingConfigs}
|
||||
{templateConfigs}
|
||||
unlinkedTargets={getUnlinkedTargets(tracker)}
|
||||
newLinkTargetId={newLinkTargetId[tracker.id] || 0}
|
||||
newLinkTrackingConfigId={newLinkTrackingConfigId[tracker.id] || 0}
|
||||
newLinkTemplateConfigId={newLinkTemplateConfigId[tracker.id] || 0}
|
||||
addingTarget={addingTarget[tracker.id] || false}
|
||||
{ttTesting}
|
||||
configsForTracker={(configs) => configsForTracker(tracker, configs)}
|
||||
onupdateLink={(tt, field, value) => updateTargetLink(tracker.id, tt, field, value)}
|
||||
onremoveLink={(ttId) => removeTargetLink(tracker.id, ttId)}
|
||||
onaddLink={() => addTargetLink(tracker.id)}
|
||||
onopenTestMenu={openTestMenu}
|
||||
onchangeNewTarget={(v) => newLinkTargetId = { ...newLinkTargetId, [tracker.id]: v }}
|
||||
onchangeNewTrackingConfig={(v) => newLinkTrackingConfigId = { ...newLinkTrackingConfigId, [tracker.id]: v }}
|
||||
onchangeNewTemplateConfig={(v) => newLinkTemplateConfigId = { ...newLinkTemplateConfigId, [tracker.id]: v }}
|
||||
/>
|
||||
{/if}
|
||||
</Card>
|
||||
{/each}
|
||||
@@ -483,61 +407,22 @@ t <CrossLink href="/providers" icon="mdiServer" label={getProviderName(trac
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if testMenuOpen}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div style="position:fixed; top:0; left:0; right:0; bottom:0; z-index:9998;"
|
||||
onclick={() => testMenuOpen = null}
|
||||
onkeydown={(e) => { if (e.key === 'Escape') testMenuOpen = null; }}>
|
||||
</div>
|
||||
<div style="{testMenuStyle} background:var(--color-card); border:1px solid var(--color-border); border-radius:0.5rem; box-shadow:0 10px 25px rgba(0,0,0,0.3); padding:0.25rem; min-width:10rem;">
|
||||
{#each testTypes as tt}
|
||||
{@const trackerId = notificationTrackers.find(t => t.tracker_targets?.some((x: any) => String(x.id) === testMenuOpen))?.id}
|
||||
<button
|
||||
onclick={() => trackerId && testTrackerTarget(trackerId, Number(testMenuOpen), tt.key)}
|
||||
disabled={!!ttTesting[`${testMenuOpen}_${tt.key}`]}
|
||||
class="flex items-center gap-2 w-full px-3 py-1.5 text-sm rounded hover:bg-[var(--color-muted)] transition-colors disabled:opacity-50 text-left">
|
||||
<MdiIcon name={tt.icon} size={14} />
|
||||
{t(tt.labelKey)}
|
||||
{#if ttTesting[`${testMenuOpen}_${tt.key}`]}
|
||||
<span class="ml-auto text-xs text-[var(--color-muted-foreground)]">...</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<TestMenu
|
||||
{testMenuOpen}
|
||||
{testMenuStyle}
|
||||
{ttTesting}
|
||||
{testTypes}
|
||||
ontest={handleTestFromMenu}
|
||||
onclose={() => testMenuOpen = null}
|
||||
/>
|
||||
|
||||
<Modal open={linkWarning !== null} title={t('notificationTracker.missingLinksTitle')} onclose={() => { linkWarning = null; }}>
|
||||
{#if linkWarning}
|
||||
<p class="text-sm mb-3" style="color: var(--color-muted-foreground);">
|
||||
{t('notificationTracker.missingLinksDesc')}
|
||||
</p>
|
||||
<div class="space-y-1.5 mb-4 max-h-40 overflow-y-auto">
|
||||
{#each linkWarning.albums as album}
|
||||
<div class="flex items-center justify-between text-sm px-2 py-1.5 rounded bg-[var(--color-muted)]/30">
|
||||
<span class="font-medium">{album.name}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded {album.issue === 'expired' ? 'bg-[var(--color-error-bg)] text-[var(--color-error-fg)]' : album.issue === 'password-protected' ? 'bg-[var(--color-warning-bg)] text-[var(--color-warning-fg)]' : 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
|
||||
{album.issue === 'expired' ? t('notificationTracker.expired') : album.issue === 'password-protected' ? t('notificationTracker.passwordProtected') : t('notificationTracker.noLink')}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="text-xs mb-4" style="color: var(--color-muted-foreground);">
|
||||
<MdiIcon name="mdiInformation" size={14} /> {t('notificationTracker.linksNote')}
|
||||
</p>
|
||||
<div class="flex items-center gap-2 justify-end">
|
||||
<button onclick={dismissLinkWarning}
|
||||
class="px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md hover:bg-[var(--color-muted)]">
|
||||
{t('notificationTracker.saveWithoutLinks')}
|
||||
</button>
|
||||
{#if linkWarning.albums.some(a => a.issue === 'missing')}
|
||||
<button onclick={autoCreateLinks} disabled={linkCreating}
|
||||
class="px-3 py-1.5 text-sm bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md hover:opacity-90 disabled:opacity-50">
|
||||
{linkCreating ? t('common.loading') : t('notificationTracker.createLinks').replace('{count}', String(linkWarning.albums.filter(a => a.issue === 'missing').length))}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</Modal>
|
||||
<SharedLinkModal
|
||||
{linkWarning}
|
||||
{linkCreating}
|
||||
onclose={() => { linkWarning = null; }}
|
||||
onautoCreate={autoCreateLinks}
|
||||
ondismiss={dismissLinkWarning}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
open={!!confirmDelete}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
<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 type { Tracker, NotificationTarget, TrackingConfig, TemplateConfig } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
tracker: Tracker;
|
||||
trackingConfigs: TrackingConfig[];
|
||||
templateConfigs: TemplateConfig[];
|
||||
unlinkedTargets: NotificationTarget[];
|
||||
newLinkTargetId: number;
|
||||
newLinkTrackingConfigId: number;
|
||||
newLinkTemplateConfigId: number;
|
||||
addingTarget: boolean;
|
||||
ttTesting: Record<string, string>;
|
||||
configsForTracker: (configs: (TrackingConfig | TemplateConfig)[]) => any[];
|
||||
onupdateLink: (tt: any, field: string, value: any) => void;
|
||||
onremoveLink: (ttId: number) => void;
|
||||
onaddLink: () => void;
|
||||
onopenTestMenu: (ttId: number, event: MouseEvent) => void;
|
||||
onchangeNewTarget: (value: number) => void;
|
||||
onchangeNewTrackingConfig: (value: number) => void;
|
||||
onchangeNewTemplateConfig: (value: number) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
tracker,
|
||||
trackingConfigs,
|
||||
templateConfigs,
|
||||
unlinkedTargets,
|
||||
newLinkTargetId,
|
||||
newLinkTrackingConfigId,
|
||||
newLinkTemplateConfigId,
|
||||
addingTarget,
|
||||
ttTesting,
|
||||
configsForTracker,
|
||||
onupdateLink,
|
||||
onremoveLink,
|
||||
onaddLink,
|
||||
onopenTestMenu,
|
||||
onchangeNewTarget,
|
||||
onchangeNewTrackingConfig,
|
||||
onchangeNewTemplateConfig,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="mt-3 border-t border-[var(--color-border)] pt-3 space-y-2" in:slide>
|
||||
{#if (tracker.tracker_targets || []).length === 0}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)]">{t('notificationTracker.noLinkedTargets')}</p>
|
||||
{:else}
|
||||
{#each tracker.tracker_targets as tt}
|
||||
<div class="flex items-center justify-between text-sm px-2 py-1.5 rounded bg-[var(--color-muted)]/30">
|
||||
<div class="flex items-center gap-2">
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={tt.target_icon || (tt.target_type === 'telegram' ? 'mdiSend' : 'mdiWebhook')} size={16} /></span>
|
||||
<span class="font-medium">{tt.target_name || `Target #${tt.target_id}`}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{tt.target_type}</span>
|
||||
{#if !tt.enabled}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-warning-bg)] text-[var(--color-warning-fg)]">{t('notificationTracker.paused')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-wrap justify-end">
|
||||
<select value={tt.tracking_config_id || 0}
|
||||
onchange={(e: Event) => onupdateLink(tt, 'tracking_config_id', Number((e.target as HTMLSelectElement).value) || null)}
|
||||
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
|
||||
<option value={0}>— {t('trackingConfig.title')} —</option>
|
||||
{#each configsForTracker(trackingConfigs) as tc}<option value={tc.id}>{tc.name}</option>{/each}
|
||||
</select>
|
||||
<select value={tt.template_config_id || 0}
|
||||
onchange={(e: Event) => onupdateLink(tt, 'template_config_id', Number((e.target as HTMLSelectElement).value) || null)}
|
||||
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
|
||||
<option value={0}>— {t('templateConfig.title')} —</option>
|
||||
{#each configsForTracker(templateConfigs) as tc}<option value={tc.id}>{tc.name}</option>{/each}
|
||||
</select>
|
||||
<div class="relative">
|
||||
<IconButton icon="mdiDotsVertical" size={14} title={t('common.test')}
|
||||
onclick={(e: MouseEvent) => onopenTestMenu(tt.id, e)}
|
||||
disabled={Object.keys(ttTesting).some(k => k.startsWith(`${tt.id}_`) && ttTesting[k])} />
|
||||
</div>
|
||||
<IconButton icon={tt.enabled ? 'mdiPause' : 'mdiPlay'} size={14}
|
||||
title={tt.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')}
|
||||
onclick={() => onupdateLink(tt, 'enabled', !tt.enabled)} />
|
||||
<IconButton icon="mdiClose" size={14} title={t('common.delete')}
|
||||
onclick={() => onremoveLink(tt.id)} variant="danger" />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<!-- Add target link -->
|
||||
{#if unlinkedTargets.length > 0}
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<select value={newLinkTargetId}
|
||||
onchange={(e: Event) => onchangeNewTarget(Number((e.target as HTMLSelectElement).value))}
|
||||
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)] flex-1">
|
||||
<option value={0}>— {t('notificationTracker.addTarget')} —</option>
|
||||
{#each unlinkedTargets as tgt}<option value={tgt.id}>{tgt.name} ({tgt.type})</option>{/each}
|
||||
</select>
|
||||
<select value={newLinkTrackingConfigId}
|
||||
onchange={(e: Event) => onchangeNewTrackingConfig(Number((e.target as HTMLSelectElement).value))}
|
||||
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
|
||||
<option value={0}>— {t('trackingConfig.title')} —</option>
|
||||
{#each configsForTracker(trackingConfigs) as tc}<option value={tc.id}>{tc.name}</option>{/each}
|
||||
</select>
|
||||
<select value={newLinkTemplateConfigId}
|
||||
onchange={(e: Event) => onchangeNewTemplateConfig(Number((e.target as HTMLSelectElement).value))}
|
||||
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
|
||||
<option value={0}>— {t('templateConfig.title')} —</option>
|
||||
{#each configsForTracker(templateConfigs) as tc}<option value={tc.id}>{tc.name}</option>{/each}
|
||||
</select>
|
||||
<button onclick={onaddLink}
|
||||
disabled={!newLinkTargetId || addingTarget}
|
||||
class="text-xs px-3 py-1 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded hover:opacity-90 disabled:opacity-50">
|
||||
{t('common.add')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
|
||||
interface Props {
|
||||
linkWarning: { albums: any[]; providerId: number } | null;
|
||||
linkCreating: boolean;
|
||||
onclose: () => void;
|
||||
onautoCreate: () => void;
|
||||
ondismiss: () => void;
|
||||
}
|
||||
|
||||
let { linkWarning, linkCreating, onclose, onautoCreate, ondismiss }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Modal open={linkWarning !== null} title={t('notificationTracker.missingLinksTitle')} onclose={onclose}>
|
||||
{#if linkWarning}
|
||||
<p class="text-sm mb-3" style="color: var(--color-muted-foreground);">
|
||||
{t('notificationTracker.missingLinksDesc')}
|
||||
</p>
|
||||
<div class="space-y-1.5 mb-4 max-h-40 overflow-y-auto">
|
||||
{#each linkWarning.albums as album}
|
||||
<div class="flex items-center justify-between text-sm px-2 py-1.5 rounded bg-[var(--color-muted)]/30">
|
||||
<span class="font-medium">{album.name}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded {album.issue === 'expired' ? 'bg-[var(--color-error-bg)] text-[var(--color-error-fg)]' : album.issue === 'password-protected' ? 'bg-[var(--color-warning-bg)] text-[var(--color-warning-fg)]' : 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
|
||||
{album.issue === 'expired' ? t('notificationTracker.expired') : album.issue === 'password-protected' ? t('notificationTracker.passwordProtected') : t('notificationTracker.noLink')}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="text-xs mb-4" style="color: var(--color-muted-foreground);">
|
||||
<MdiIcon name="mdiInformation" size={14} /> {t('notificationTracker.linksNote')}
|
||||
</p>
|
||||
<div class="flex items-center gap-2 justify-end">
|
||||
<button onclick={ondismiss}
|
||||
class="px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md hover:bg-[var(--color-muted)]">
|
||||
{t('notificationTracker.saveWithoutLinks')}
|
||||
</button>
|
||||
{#if linkWarning.albums.some(a => a.issue === 'missing')}
|
||||
<button onclick={onautoCreate} disabled={linkCreating}
|
||||
class="px-3 py-1.5 text-sm bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md hover:opacity-90 disabled:opacity-50">
|
||||
{linkCreating ? t('common.loading') : t('notificationTracker.createLinks').replace('{count}', String(linkWarning.albums.filter(a => a.issue === 'missing').length))}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</Modal>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
|
||||
interface Props {
|
||||
testMenuOpen: string | null;
|
||||
testMenuStyle: string;
|
||||
ttTesting: Record<string, string>;
|
||||
testTypes: { key: string; icon: string; labelKey: string }[];
|
||||
ontest: (ttId: number, testType: string) => void;
|
||||
onclose: () => void;
|
||||
}
|
||||
|
||||
let { testMenuOpen, testMenuStyle, ttTesting, testTypes, ontest, onclose }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if testMenuOpen}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div style="position:fixed; top:0; left:0; right:0; bottom:0; z-index:9998;"
|
||||
onclick={onclose}
|
||||
onkeydown={(e) => { if (e.key === 'Escape') onclose(); }}>
|
||||
</div>
|
||||
<div style="{testMenuStyle} background:var(--color-card); border:1px solid var(--color-border); border-radius:0.5rem; box-shadow:0 10px 25px rgba(0,0,0,0.3); padding:0.25rem; min-width:10rem;">
|
||||
{#each testTypes as tt}
|
||||
<button
|
||||
onclick={() => ontest(Number(testMenuOpen), tt.key)}
|
||||
disabled={!!ttTesting[`${testMenuOpen}_${tt.key}`]}
|
||||
class="flex items-center gap-2 w-full px-3 py-1.5 text-sm rounded hover:bg-[var(--color-muted)] transition-colors disabled:opacity-50 text-left">
|
||||
<MdiIcon name={tt.icon} size={14} />
|
||||
{t(tt.labelKey)}
|
||||
{#if ttTesting[`${testMenuOpen}_${tt.key}`]}
|
||||
<span class="ml-auto text-xs text-[var(--color-muted-foreground)]">...</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,96 @@
|
||||
<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 EntitySelect from '$lib/components/EntitySelect.svelte';
|
||||
|
||||
interface Props {
|
||||
form: {
|
||||
name: string;
|
||||
icon: string;
|
||||
provider_id: number;
|
||||
collection_ids: string[];
|
||||
scan_interval: number;
|
||||
batch_duration: number;
|
||||
};
|
||||
providerItems: { value: number; label: string; icon: string; desc: string }[];
|
||||
collections: any[];
|
||||
collectionFilter: string;
|
||||
editing: number | null;
|
||||
submitting: boolean;
|
||||
linkCheckLoading: boolean;
|
||||
error: string;
|
||||
onsave: (e: SubmitEvent) => void;
|
||||
ontoggleCollection: (collectionId: string) => void;
|
||||
formatDate: (dateStr: string) => string;
|
||||
}
|
||||
|
||||
let {
|
||||
form = $bindable(),
|
||||
providerItems,
|
||||
collections,
|
||||
collectionFilter = $bindable(),
|
||||
editing,
|
||||
submitting,
|
||||
linkCheckLoading,
|
||||
error,
|
||||
onsave,
|
||||
ontoggleCollection,
|
||||
formatDate,
|
||||
}: 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">
|
||||
<div>
|
||||
<label for="trk-name" class="block text-sm font-medium mb-1">{t('notificationTracker.name')}</label>
|
||||
<div class="flex gap-2">
|
||||
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
|
||||
<input id="trk-name" bind:value={form.name} required placeholder={t('notificationTracker.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 class="block text-sm font-medium mb-1">{t('notificationTracker.server')}</label>
|
||||
<EntitySelect items={providerItems} bind:value={form.provider_id} placeholder={t('notificationTracker.selectServer')} />
|
||||
</div>
|
||||
{#if collections.length > 0}
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{t('notificationTracker.albums')} ({collections.length})</label>
|
||||
<input type="text" bind:value={collectionFilter} placeholder="Filter..."
|
||||
class="w-full px-3 py-1.5 mb-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
<div class="max-h-56 overflow-y-auto border border-[var(--color-border)] rounded-md p-2 space-y-1">
|
||||
{#each collections.filter(a => !collectionFilter || (a.albumName || a.name || '').toLowerCase().includes(collectionFilter.toLowerCase())) as col}
|
||||
<label class="flex items-center justify-between text-sm cursor-pointer hover:bg-[var(--color-muted)] px-2 py-1 rounded">
|
||||
<span class="flex items-center gap-2">
|
||||
<input type="checkbox" checked={form.collection_ids.includes(col.id)} onchange={() => ontoggleCollection(col.id)} />
|
||||
{col.albumName || col.name} <span class="text-[var(--color-muted-foreground)]">({col.assetCount ?? col.asset_count ?? 0})</span>
|
||||
</span>
|
||||
{#if col.updatedAt || col.updated_at}
|
||||
<span class="text-xs text-[var(--color-muted-foreground)] whitespace-nowrap ml-2">{formatDate(col.updatedAt || col.updated_at)}</span>
|
||||
{/if}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label for="trk-interval" class="block text-sm font-medium mb-1">{t('notificationTracker.scanInterval')}<Hint text={t('hints.scanInterval')} /></label>
|
||||
<input id="trk-interval" type="number" bind:value={form.scan_interval} min="10" max="3600" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="trk-batch" class="block text-sm font-medium mb-1">{t('notificationTracker.batchDuration')}<Hint text={t('hints.batchDuration')} /></label>
|
||||
<input id="trk-batch" type="number" bind:value={form.batch_duration} min="0" max="3600" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={submitting || linkCheckLoading} 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">
|
||||
{#if linkCheckLoading}{t('notificationTracker.checkingLinks')}{:else}{editing ? t('common.save') : t('notificationTracker.createTracker')}{/if}
|
||||
</button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -5,6 +5,7 @@
|
||||
import { t } from '$lib/i18n';
|
||||
import { initTheme } from '$lib/theme.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import AuthLayout from '$lib/components/AuthLayout.svelte';
|
||||
|
||||
let username = $state('admin');
|
||||
let password = $state('');
|
||||
@@ -29,201 +30,42 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="auth-page">
|
||||
<div class="auth-bg"></div>
|
||||
<div class="auth-grid"></div>
|
||||
|
||||
<div class="auth-card-wrapper" class:visible={mounted}>
|
||||
<div class="auth-card">
|
||||
<div class="text-center mb-8">
|
||||
<div class="auth-logo-icon">
|
||||
<MdiIcon name="mdiShieldAccount" size={28} />
|
||||
</div>
|
||||
<h1 class="text-xl font-semibold mt-4 tracking-tight">
|
||||
<span style="color: var(--color-primary);">Notify</span> Bridge
|
||||
</h1>
|
||||
<p class="text-sm mt-1" style="color: var(--color-muted-foreground);">{t('auth.setupDescription')}</p>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="auth-error animate-fade-slide-in">
|
||||
<MdiIcon name="mdiAlertCircle" size={16} />
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={handleSubmit} class="space-y-4">
|
||||
<div>
|
||||
<label for="username" class="auth-label">{t('auth.username')}</label>
|
||||
<input id="username" type="text" bind:value={username} required class="auth-input" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="auth-label">{t('auth.password')}</label>
|
||||
<input id="password" type="password" bind:value={password} required class="auth-input" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="confirm" class="auth-label">{t('auth.confirmPassword')}</label>
|
||||
<input id="confirm" type="password" bind:value={confirmPassword} required class="auth-input" />
|
||||
</div>
|
||||
<button type="submit" disabled={submitting} class="auth-submit">
|
||||
{#if submitting}
|
||||
<div class="w-4 h-4 rounded-full border-2 border-current border-t-transparent animate-spin"></div>
|
||||
{/if}
|
||||
{submitting ? t('auth.creatingAccount') : t('auth.createAccount')}
|
||||
</button>
|
||||
</form>
|
||||
<AuthLayout visible={mounted}>
|
||||
<div class="text-center mb-8">
|
||||
<div class="auth-logo-icon">
|
||||
<MdiIcon name="mdiShieldAccount" size={28} />
|
||||
</div>
|
||||
<h1 class="text-xl font-semibold mt-4 tracking-tight">
|
||||
<span style="color: var(--color-primary);">Notify</span> Bridge
|
||||
</h1>
|
||||
<p class="text-sm mt-1" style="color: var(--color-muted-foreground);">{t('auth.setupDescription')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--color-background);
|
||||
}
|
||||
{#if error}
|
||||
<div class="auth-error animate-fade-slide-in">
|
||||
<MdiIcon name="mdiAlertCircle" size={16} />
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
.auth-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 60% at 20% 30%, var(--color-glow-strong), transparent 60%),
|
||||
radial-gradient(ellipse 60% 80% at 80% 70%, rgba(99, 102, 241, 0.08), transparent 60%),
|
||||
radial-gradient(ellipse 50% 50% at 50% 50%, var(--color-glow), transparent 70%);
|
||||
animation: gradientShift 12s ease-in-out infinite;
|
||||
background-size: 200% 200%;
|
||||
}
|
||||
|
||||
.auth-grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
opacity: 0.3;
|
||||
background-image: radial-gradient(circle at 1px 1px, var(--color-border) 0.5px, transparent 0);
|
||||
background-size: 32px 32px;
|
||||
}
|
||||
|
||||
.auth-card-wrapper {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
max-width: 24rem;
|
||||
padding: 1rem;
|
||||
opacity: 0;
|
||||
transform: translateY(16px) scale(0.98);
|
||||
transition: opacity 0.5s ease-out, transform 0.5s ease-out;
|
||||
}
|
||||
|
||||
.auth-card-wrapper.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background: var(--color-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 1rem;
|
||||
padding: 2rem;
|
||||
box-shadow:
|
||||
0 4px 24px rgba(0, 0, 0, 0.08),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05) inset;
|
||||
}
|
||||
|
||||
:global([data-theme="dark"]) .auth-card {
|
||||
box-shadow:
|
||||
0 4px 24px rgba(0, 0, 0, 0.3),
|
||||
0 0 48px var(--color-glow),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.03) inset;
|
||||
}
|
||||
|
||||
.auth-logo-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
border-radius: 1rem;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-foreground);
|
||||
box-shadow: 0 0 24px var(--color-glow-strong);
|
||||
}
|
||||
|
||||
.auth-label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.375rem;
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
.auth-input {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.625rem;
|
||||
font-size: 0.875rem;
|
||||
background: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.auth-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px var(--color-glow), 0 0 16px var(--color-glow);
|
||||
}
|
||||
|
||||
.auth-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.625rem;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 1rem;
|
||||
background: var(--color-error-bg);
|
||||
color: var(--color-error-fg);
|
||||
}
|
||||
|
||||
.auth-submit {
|
||||
width: 100%;
|
||||
padding: 0.625rem;
|
||||
border-radius: 0.625rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-foreground);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.auth-submit:hover:not(:disabled) {
|
||||
box-shadow: 0 0 24px var(--color-glow-strong);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.auth-submit:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.auth-submit:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@keyframes gradientShift {
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
</style>
|
||||
<form onsubmit={handleSubmit} class="space-y-4">
|
||||
<div>
|
||||
<label for="username" class="auth-label">{t('auth.username')}</label>
|
||||
<input id="username" type="text" bind:value={username} required class="auth-input" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="auth-label">{t('auth.password')}</label>
|
||||
<input id="password" type="password" bind:value={password} required class="auth-input" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="confirm" class="auth-label">{t('auth.confirmPassword')}</label>
|
||||
<input id="confirm" type="password" bind:value={confirmPassword} required class="auth-input" />
|
||||
</div>
|
||||
<button type="submit" disabled={submitting} class="auth-submit">
|
||||
{#if submitting}
|
||||
<div class="w-4 h-4 rounded-full border-2 border-current border-t-transparent animate-spin"></div>
|
||||
{/if}
|
||||
{submitting ? t('auth.creatingAccount') : t('auth.createAccount')}
|
||||
</button>
|
||||
</form>
|
||||
</AuthLayout>
|
||||
|
||||
@@ -20,7 +20,9 @@
|
||||
import { chatActionItems } from '$lib/grid-items';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
import { highlightFromUrl } from '$lib/highlight';
|
||||
import type { NotificationTarget, TelegramBot, TelegramChat, EmailBot, MatrixBot } from '$lib/types';
|
||||
import type { NotificationTarget, TargetReceiver, TelegramChat } from '$lib/types';
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
function getBotName(target: any): string | null {
|
||||
if (target.type === 'telegram' && target.config?.bot_id) {
|
||||
@@ -39,10 +41,10 @@
|
||||
}
|
||||
|
||||
function getBotHref(target: any): string {
|
||||
if (target.type === 'telegram') return '/bots';
|
||||
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';
|
||||
return '/bots?tab=telegram';
|
||||
}
|
||||
|
||||
function getBotEntityId(target: any): number | null {
|
||||
@@ -52,6 +54,24 @@
|
||||
return null;
|
||||
}
|
||||
|
||||
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 || '?';
|
||||
}
|
||||
if (target.type === 'email') return c.email || recv.receiver_key || '?';
|
||||
if (target.type === 'webhook') return c.url || recv.receiver_key || '?';
|
||||
if (target.type === 'discord' || target.type === 'slack') {
|
||||
const url = c.webhook_url || recv.receiver_key || '';
|
||||
return url.length > 50 ? url.substring(0, 50) + '...' : url || '?';
|
||||
}
|
||||
if (target.type === 'ntfy') return c.topic || recv.receiver_key || '?';
|
||||
if (target.type === 'matrix') return c.room_id || recv.receiver_key || '?';
|
||||
return recv.receiver_key || '?';
|
||||
}
|
||||
|
||||
// ── Constants ──
|
||||
|
||||
const ALL_TYPES = ['telegram', 'webhook', 'email', 'discord', 'slack', 'ntfy', 'matrix'] as const;
|
||||
type TargetType = typeof ALL_TYPES[number];
|
||||
const TYPE_ICONS: Record<string, string> = {
|
||||
@@ -69,6 +89,8 @@
|
||||
label: tt.charAt(0).toUpperCase() + tt.slice(1),
|
||||
})));
|
||||
|
||||
// ── Derived state ──
|
||||
|
||||
let allTargets = $derived(targetsCache.items);
|
||||
let activeType = $derived(page.url.searchParams.get('type') as TargetType | null);
|
||||
let targets = $derived(activeType ? allTargets.filter(t => t.type === activeType) : allTargets);
|
||||
@@ -78,39 +100,56 @@
|
||||
const telegramBotItems = $derived(telegramBots.map(b => ({ value: b.id, label: b.name, icon: b.icon || 'mdiRobot', desc: b.bot_username ? `@${b.bot_username}` : '' })));
|
||||
const emailBotItems = $derived(emailBots.map(b => ({ value: b.id, label: b.name, icon: b.icon || 'mdiEmailOutline', desc: b.email })));
|
||||
const matrixBotItems = $derived(matrixBots.map(b => ({ value: b.id, label: b.name, icon: b.icon || 'mdiMatrix', desc: b.display_name || b.homeserver_url })));
|
||||
let botChats = $state<Record<number, TelegramChat[]>>({});
|
||||
|
||||
// ── Target form state ──
|
||||
|
||||
let showForm = $state(false);
|
||||
let editing = $state<number | null>(null);
|
||||
let formType = $state<TargetType>('telegram');
|
||||
const defaultForm = () => ({ name: '', icon: '', bot_id: 0, chat_id: '', bot_token: '', url: '', headers: '',
|
||||
const defaultForm = () => ({
|
||||
name: '', icon: '', bot_id: 0, bot_token: '',
|
||||
max_media_to_send: 50, max_media_per_group: 10, media_delay: 500, max_asset_size: 50,
|
||||
disable_url_preview: false, send_large_photos_as_documents: false, ai_captions: false, chat_action: 'typing',
|
||||
// Discord/Slack
|
||||
webhook_url: '', username: '',
|
||||
// ntfy
|
||||
server_url: 'https://ntfy.sh', topic: '', auth_token: '', priority: 3,
|
||||
// Discord/Slack shared settings
|
||||
username: '',
|
||||
// ntfy shared settings
|
||||
server_url: 'https://ntfy.sh', auth_token: '',
|
||||
// Matrix
|
||||
matrix_bot_id: 0, room_id: '',
|
||||
matrix_bot_id: 0,
|
||||
// Email
|
||||
email_bot_id: 0, email: '',
|
||||
email_bot_id: 0,
|
||||
});
|
||||
let form = $state(defaultForm());
|
||||
let error = $state('');
|
||||
let headersError = $state('');
|
||||
let loaded = $state(false);
|
||||
let submitting = $state(false);
|
||||
let loadError = $state('');
|
||||
let showTelegramSettings = $state(false);
|
||||
let confirmDelete = $state<NotificationTarget | null>(null);
|
||||
|
||||
// ── Receiver inline form state ──
|
||||
|
||||
let addingReceiverForTarget = $state<number | null>(null);
|
||||
let receiverForm = $state<Record<string, any>>({});
|
||||
let receiverSubmitting = $state(false);
|
||||
let receiverBotChats = $state<Record<number, TelegramChat[]>>({});
|
||||
let receiverHeadersError = $state('');
|
||||
let confirmDeleteReceiver = $state<{ targetId: number; receiver: TargetReceiver } | null>(null);
|
||||
let receiverTesting = $state<Record<number, boolean>>({});
|
||||
|
||||
// ── Effects ──
|
||||
|
||||
// Reset form when switching target type tabs
|
||||
$effect(() => {
|
||||
activeType; // track
|
||||
showForm = false;
|
||||
editing = null;
|
||||
error = '';
|
||||
addingReceiverForTarget = null;
|
||||
});
|
||||
|
||||
// ── Data loading ──
|
||||
|
||||
onMount(load);
|
||||
async function load() {
|
||||
try {
|
||||
@@ -119,53 +158,59 @@
|
||||
emailBotsCache.fetch(), matrixBotsCache.fetch(),
|
||||
]);
|
||||
loadError = '';
|
||||
} catch (err: any) { loadError = err.message || t('common.loadError'); snackError(loadError); } finally { loaded = true; highlightFromUrl(); }
|
||||
}
|
||||
|
||||
async function loadBotChats() {
|
||||
if (!form.bot_id) return;
|
||||
try { botChats[form.bot_id] = await api(`/telegram-bots/${form.bot_id}/chats`); } catch {}
|
||||
}
|
||||
|
||||
// Auto-load chats when bot changes via EntitySelect
|
||||
let _prevBotId = 0;
|
||||
$effect(() => {
|
||||
if (showForm && form.bot_id && form.bot_id !== _prevBotId) {
|
||||
_prevBotId = form.bot_id;
|
||||
loadBotChats();
|
||||
} catch (err: any) {
|
||||
loadError = err.message || t('common.loadError');
|
||||
snackError(loadError);
|
||||
} finally {
|
||||
loaded = true;
|
||||
highlightFromUrl();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function openNew() { form = defaultForm(); formType = activeType || 'telegram'; editing = null; showTelegramSettings = false; showForm = true; }
|
||||
async function edit(tgt: any) {
|
||||
formType = tgt.type;
|
||||
async function loadReceiverBotChats(botId: number) {
|
||||
if (!botId) return;
|
||||
try { receiverBotChats[botId] = await api(`/telegram-bots/${botId}/chats`); } catch {}
|
||||
}
|
||||
|
||||
// ── Target CRUD ──
|
||||
|
||||
function openNew() {
|
||||
form = defaultForm();
|
||||
formType = activeType || 'telegram';
|
||||
editing = null;
|
||||
showTelegramSettings = false;
|
||||
showForm = true;
|
||||
}
|
||||
|
||||
async function edit(tgt: NotificationTarget) {
|
||||
formType = tgt.type as TargetType;
|
||||
const c = tgt.config || {};
|
||||
form = {
|
||||
name: tgt.name, icon: tgt.icon || '',
|
||||
// telegram
|
||||
bot_id: c.bot_id || 0, bot_token: '', chat_id: c.chat_id || '',
|
||||
bot_id: c.bot_id || 0, bot_token: '',
|
||||
max_media_to_send: c.max_media_to_send ?? 50, max_media_per_group: c.max_media_per_group ?? 10,
|
||||
media_delay: c.media_delay ?? 500, max_asset_size: c.max_asset_size ?? 50,
|
||||
disable_url_preview: c.disable_url_preview ?? false, send_large_photos_as_documents: c.send_large_photos_as_documents ?? false,
|
||||
ai_captions: c.ai_captions ?? false, chat_action: c.chat_action ?? 'typing',
|
||||
// webhook
|
||||
url: c.url || '', headers: '',
|
||||
// discord/slack
|
||||
webhook_url: c.webhook_url || '', username: c.username || '',
|
||||
username: c.username || '',
|
||||
// ntfy
|
||||
server_url: c.server_url || 'https://ntfy.sh', topic: c.topic || '',
|
||||
auth_token: c.auth_token || '', priority: c.priority ?? 3,
|
||||
server_url: c.server_url || 'https://ntfy.sh',
|
||||
auth_token: c.auth_token || '',
|
||||
// email
|
||||
email_bot_id: c.email_bot_id || 0, email: c.email || '',
|
||||
email_bot_id: c.email_bot_id || 0,
|
||||
// matrix
|
||||
matrix_bot_id: c.matrix_bot_id || 0, room_id: c.room_id || '',
|
||||
matrix_bot_id: c.matrix_bot_id || 0,
|
||||
};
|
||||
editing = tgt.id; showTelegramSettings = false; showForm = true;
|
||||
if (form.bot_id) await loadBotChats();
|
||||
editing = tgt.id;
|
||||
showTelegramSettings = false;
|
||||
showForm = true;
|
||||
}
|
||||
|
||||
async function save(e: SubmitEvent) {
|
||||
e.preventDefault(); error = ''; headersError = '';
|
||||
e.preventDefault();
|
||||
error = '';
|
||||
if (submitting) return;
|
||||
submitting = true;
|
||||
try {
|
||||
@@ -177,38 +222,43 @@
|
||||
const tokenRes = await api(`/telegram-bots/${form.bot_id}/token`);
|
||||
botToken = tokenRes.token;
|
||||
}
|
||||
config = { ...(botToken ? { bot_token: botToken } : {}), chat_id: form.chat_id,
|
||||
config = {
|
||||
...(botToken ? { bot_token: botToken } : {}),
|
||||
bot_id: form.bot_id || undefined,
|
||||
max_media_to_send: form.max_media_to_send, max_media_per_group: form.max_media_per_group,
|
||||
media_delay: form.media_delay, max_asset_size: form.max_asset_size,
|
||||
disable_url_preview: form.disable_url_preview, send_large_photos_as_documents: form.send_large_photos_as_documents,
|
||||
ai_captions: form.ai_captions, chat_action: form.chat_action || undefined };
|
||||
ai_captions: form.ai_captions, chat_action: form.chat_action || undefined,
|
||||
};
|
||||
} else if (formType === 'webhook') {
|
||||
let parsedHeaders = {};
|
||||
if (form.headers) {
|
||||
try { parsedHeaders = JSON.parse(form.headers); }
|
||||
catch { headersError = t('common.headersInvalid'); return; }
|
||||
}
|
||||
config = { url: form.url, headers: parsedHeaders, ai_captions: form.ai_captions };
|
||||
config = { ai_captions: form.ai_captions };
|
||||
} else if (formType === 'discord' || formType === 'slack') {
|
||||
config = { webhook_url: form.webhook_url, username: form.username || undefined };
|
||||
config = { username: form.username || undefined };
|
||||
} else if (formType === 'ntfy') {
|
||||
config = { server_url: form.server_url, topic: form.topic, auth_token: form.auth_token || undefined };
|
||||
config = { server_url: form.server_url, auth_token: form.auth_token || undefined };
|
||||
} else if (formType === 'email') {
|
||||
config = { email_bot_id: form.email_bot_id, email: form.email };
|
||||
config = { email_bot_id: form.email_bot_id };
|
||||
} else if (formType === 'matrix') {
|
||||
config = { matrix_bot_id: form.matrix_bot_id, room_id: form.room_id };
|
||||
config = { matrix_bot_id: form.matrix_bot_id };
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
await api(`/targets/${editing}`, { method: 'PUT', body: JSON.stringify({ name: form.name, icon: form.icon, config }) });
|
||||
} else {
|
||||
await api('/targets', { method: 'POST', body: JSON.stringify({ type: formType, name: form.name, icon: form.icon, config }) });
|
||||
}
|
||||
showForm = false; editing = null; await load();
|
||||
showForm = false;
|
||||
editing = null;
|
||||
await load();
|
||||
snackSuccess(t('snack.targetSaved'));
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
finally { submitting = false; }
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
snackError(err.message);
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function test(id: number) {
|
||||
try {
|
||||
const res = await api(`/targets/${id}/test?locale=${getLocale()}`, { method: 'POST' });
|
||||
@@ -216,9 +266,98 @@
|
||||
else snackError(`Failed: ${res.error}`);
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
}
|
||||
|
||||
async function remove(id: number) {
|
||||
try { await api(`/targets/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.targetDeleted')); }
|
||||
catch (err: any) { error = err.message; snackError(err.message); }
|
||||
try {
|
||||
await api(`/targets/${id}`, { method: 'DELETE' });
|
||||
await load();
|
||||
snackSuccess(t('snack.targetDeleted'));
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
snackError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Receiver CRUD ──
|
||||
|
||||
function openReceiverForm(targetId: number, targetType: string) {
|
||||
addingReceiverForTarget = targetId;
|
||||
receiverHeadersError = '';
|
||||
if (targetType === 'telegram') {
|
||||
receiverForm = { chat_id: '' };
|
||||
// Load bot chats for the target's bot
|
||||
const tgt = allTargets.find(t => t.id === targetId);
|
||||
const botId = tgt?.config?.bot_id;
|
||||
if (botId && !receiverBotChats[botId]) loadReceiverBotChats(botId);
|
||||
} else if (targetType === 'email') {
|
||||
receiverForm = { email: '' };
|
||||
} else if (targetType === 'webhook') {
|
||||
receiverForm = { url: '', headers: '' };
|
||||
} else if (targetType === 'discord' || targetType === 'slack') {
|
||||
receiverForm = { webhook_url: '' };
|
||||
} else if (targetType === 'ntfy') {
|
||||
receiverForm = { topic: '' };
|
||||
} else if (targetType === 'matrix') {
|
||||
receiverForm = { room_id: '' };
|
||||
}
|
||||
}
|
||||
|
||||
async function saveReceiver(targetId: number) {
|
||||
if (receiverSubmitting) return;
|
||||
receiverSubmitting = true;
|
||||
receiverHeadersError = '';
|
||||
try {
|
||||
const config = { ...receiverForm };
|
||||
// Parse headers JSON for webhook
|
||||
if ('headers' in config && typeof config.headers === 'string') {
|
||||
if (config.headers) {
|
||||
try { config.headers = JSON.parse(config.headers); }
|
||||
catch { receiverHeadersError = t('common.headersInvalid'); return; }
|
||||
} else {
|
||||
delete config.headers;
|
||||
}
|
||||
}
|
||||
await api(`/targets/${targetId}/receivers`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name: '', config }),
|
||||
});
|
||||
addingReceiverForTarget = null;
|
||||
await load();
|
||||
snackSuccess(t('targets.receiverAdded'));
|
||||
} catch (err: any) {
|
||||
snackError(err.message);
|
||||
} finally {
|
||||
receiverSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleReceiver(targetId: number, receiver: TargetReceiver) {
|
||||
try {
|
||||
await api(`/targets/${targetId}/receivers/${receiver.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ enabled: !receiver.enabled }),
|
||||
});
|
||||
await load();
|
||||
snackSuccess(receiver.enabled ? t('targets.receiverDisabled') : t('targets.receiverEnabled'));
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
}
|
||||
|
||||
async function removeReceiver(targetId: number, receiverId: number) {
|
||||
try {
|
||||
await api(`/targets/${targetId}/receivers/${receiverId}`, { method: 'DELETE' });
|
||||
await load();
|
||||
snackSuccess(t('targets.receiverDeleted'));
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
}
|
||||
|
||||
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' });
|
||||
if (res.success) snackSuccess(t('snack.targetTestSent'));
|
||||
else snackError(`Failed: ${res.error}`);
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
finally { receiverTesting = { ...receiverTesting, [receiverId]: false }; }
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -258,32 +397,10 @@
|
||||
<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" class="underline">→</a></p>
|
||||
<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>
|
||||
|
||||
{#if form.bot_id}
|
||||
<div>
|
||||
<label for="tgt-chat" class="block text-sm font-medium mb-1">{t('telegramBot.selectChat')}</label>
|
||||
{#if (botChats[form.bot_id] || []).length > 0}
|
||||
<select id="tgt-chat" bind:value={form.chat_id} required
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value="">— {t('telegramBot.selectChat')} —</option>
|
||||
{#each botChats[form.bot_id] as chat}
|
||||
<option value={chat.chat_id}>{chat.title || chat.username || 'Unknown'} ({chat.type}) [{chat.chat_id}]</option>
|
||||
{/each}
|
||||
</select>
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">
|
||||
<button type="button" onclick={loadBotChats} class="hover:underline">{t('telegramBot.refreshChats')}</button>
|
||||
</p>
|
||||
{:else}
|
||||
<input id="tgt-chat" bind:value={form.chat_id} required placeholder="Chat ID"
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('telegramBot.noChats')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<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">
|
||||
@@ -317,22 +434,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if formType === 'webhook'}
|
||||
<div>
|
||||
<label for="tgt-url" class="block text-sm font-medium mb-1">{t('targets.webhookUrl')}</label>
|
||||
<input id="tgt-url" bind:value={form.url} required placeholder="https://..." 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-headers" class="block text-sm font-medium mb-1">Headers (JSON)</label>
|
||||
<input id="tgt-headers" bind:value={form.headers} placeholder={'{"Authorization": "Bearer ..."}'} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" style={headersError ? 'border-color: var(--color-error-fg)' : ''} />
|
||||
{#if headersError}<p class="text-xs text-[var(--color-error-fg)] mt-1">{headersError}</p>{/if}
|
||||
</div>
|
||||
{:else if formType === 'discord' || formType === 'slack'}
|
||||
<div>
|
||||
<label for="tgt-wh" class="block text-sm font-medium mb-1">{formType === 'discord' ? 'Discord' : 'Slack'} Webhook URL</label>
|
||||
<input id="tgt-wh" bind:value={form.webhook_url} required placeholder={formType === 'discord' ? 'https://discord.com/api/webhooks/...' : 'https://hooks.slack.com/services/...'}
|
||||
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-user" class="block text-sm font-medium mb-1">{t('targets.overrideUsername')}</label>
|
||||
<input id="tgt-user" bind:value={form.username} placeholder="Notify Bridge"
|
||||
@@ -344,11 +446,6 @@
|
||||
<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-topic" class="block text-sm font-medium mb-1">{t('targets.ntfyTopic')}</label>
|
||||
<input id="tgt-ntfy-topic" bind:value={form.topic} required placeholder="my-notifications"
|
||||
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')}
|
||||
@@ -359,27 +456,17 @@
|
||||
<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" class="underline">→</a></p>
|
||||
<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>
|
||||
<div>
|
||||
<label for="tgt-email" class="block text-sm font-medium mb-1">{t('targets.recipientEmail')}</label>
|
||||
<input id="tgt-email" bind:value={form.email} required type="email" placeholder="recipient@example.com"
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</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" class="underline">→</a></p>
|
||||
<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>
|
||||
<div>
|
||||
<label for="tgt-room" class="block text-sm font-medium mb-1">{t('targets.matrixRoomId')}</label>
|
||||
<input id="tgt-room" bind:value={form.room_id} required placeholder="!abc123:matrix.org"
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] font-mono" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if formType === 'telegram'}
|
||||
@@ -398,35 +485,18 @@
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-3 stagger-children">
|
||||
{#each targets as target}
|
||||
{#each targets as target (target.id)}
|
||||
<Card hover entityId={target.id}>
|
||||
<!-- Target header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={target.icon || TYPE_ICONS[target.type] || 'mdiTarget'} size={20} /></span>
|
||||
<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.receiver_count}<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.receiver_count} receiver(s)</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}
|
||||
</div>
|
||||
<p class="text-sm text-[var(--color-muted-foreground)]">
|
||||
{#if target.type === 'telegram'}
|
||||
Chat: {#if target.chat_name}{target.chat_name} <span class="font-mono text-xs">({target.config?.chat_id})</span>{:else}{target.config?.chat_id || '***'}{/if}
|
||||
{#if target.config?.chat_action}
|
||||
<span class="text-xs px-1.5 py-0.5 ml-1 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.config.chat_action}</span>
|
||||
{/if}
|
||||
{:else if target.type === 'webhook'}
|
||||
{target.config?.url || ''}
|
||||
{:else if target.type === 'discord' || target.type === 'slack'}
|
||||
{target.config?.webhook_url ? target.config.webhook_url.substring(0, 50) + '...' : ''}
|
||||
{:else if target.type === 'ntfy'}
|
||||
{target.config?.server_url || 'ntfy.sh'} / {target.config?.topic || ''}
|
||||
{:else if target.type === 'email'}
|
||||
{target.config?.email || ''}
|
||||
{:else if target.type === 'matrix'}
|
||||
{target.config?.room_id || ''}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(target)} />
|
||||
@@ -434,6 +504,109 @@
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => confirmDelete = target} variant="danger" />
|
||||
</div>
|
||||
</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>
|
||||
</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 chatItems = (receiverBotChats[botId] || []).map((c: TelegramChat) => ({
|
||||
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.chat_id}`,
|
||||
}))}
|
||||
{#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>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -447,3 +620,10 @@
|
||||
onconfirm={() => { if (confirmDelete) { remove(confirmDelete.id); confirmDelete = null; } }}
|
||||
oncancel={() => confirmDelete = null}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
open={!!confirmDeleteReceiver}
|
||||
message={t('targets.confirmDeleteReceiver')}
|
||||
onconfirm={() => { if (confirmDeleteReceiver) { removeReceiver(confirmDeleteReceiver.targetId, confirmDeleteReceiver.receiver.id); confirmDeleteReceiver = null; } }}
|
||||
oncancel={() => confirmDeleteReceiver = null}
|
||||
/>
|
||||
|
||||
@@ -190,18 +190,41 @@
|
||||
}
|
||||
|
||||
function sanitizePreview(html: string): string {
|
||||
// Allow only Telegram-safe HTML tags, escape everything else
|
||||
return html
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
// Restore allowed tags — only http(s) URLs for <a> to prevent javascript: XSS
|
||||
.replace(/<a href="(https?:\/\/[^"]*)">/g, '<a href="$1" target="_blank" rel="noopener noreferrer">')
|
||||
.replace(/<\/a>/g, '</a>')
|
||||
.replace(/<b>/g, '<b>').replace(/<\/b>/g, '</b>')
|
||||
.replace(/<i>/g, '<i>').replace(/<\/i>/g, '</i>')
|
||||
.replace(/<code>/g, '<code>').replace(/<\/code>/g, '</code>')
|
||||
.replace(/<pre>/g, '<pre>').replace(/<\/pre>/g, '</pre>');
|
||||
// DOM-based sanitizer: parse HTML, walk tree, keep only safe elements
|
||||
const ALLOWED_TAGS = new Set(['B', 'I', 'CODE', 'PRE', 'A', 'BR']);
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
function walkNodes(parent: Node, target: Node) {
|
||||
for (const node of Array.from(parent.childNodes)) {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
target.appendChild(document.createTextNode(node.textContent || ''));
|
||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const el = node as Element;
|
||||
if (ALLOWED_TAGS.has(el.tagName)) {
|
||||
const safe = document.createElement(el.tagName);
|
||||
if (el.tagName === 'A') {
|
||||
const href = el.getAttribute('href') || '';
|
||||
if (/^https?:\/\//i.test(href)) {
|
||||
safe.setAttribute('href', href);
|
||||
safe.setAttribute('target', '_blank');
|
||||
safe.setAttribute('rel', 'noopener noreferrer');
|
||||
}
|
||||
}
|
||||
walkNodes(el, safe);
|
||||
target.appendChild(safe);
|
||||
} else {
|
||||
// Unwrap: keep text content of disallowed tags
|
||||
walkNodes(el, target);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walkNodes(doc.body, fragment);
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.appendChild(fragment);
|
||||
return wrapper.innerHTML;
|
||||
}
|
||||
|
||||
function remove(id: number) {
|
||||
|
||||
Reference in New Issue
Block a user