fix: comprehensive API/UI review — 26 bug fixes and improvements
Backend: - Scheduler lifecycle sync: create/update/delete tracker now syncs APScheduler jobs - Test-periodic/test-memory endpoints render actual Jinja2 templates with sample data - Cascade cleanup on tracker delete (TrackerState removed, EventLog nullified) - Fix user_id=0 FK violation for system-owned TemplateConfig (removed FK constraint) - Fix API key leak: only attach x-api-key header for internal provider URLs - Validate config ownership in tracker_targets create/update - Fix _response() double-emit of created_at in template/tracking configs - Add per-target-link test endpoints (test, test-periodic, test-memory) Frontend: - Fix orphaned provider on test exception in providers/new - Add submitting guard + disabled state to targets save button - Move test buttons from tracker card to per-target-link rows - Fix Svelte 5 async $state reactivity (spread reassignment for all Record mutations) - i18n for dashboard timeAgo and event type badges (EN + RU) - Add required attribute to chat select dropdown in targets - Fix font CSS vars to prioritize imported DM Sans / JetBrains Mono - Standardize empty states with centered icon + text across all 6 list pages - Add stagger-children animation class to all list containers - Fix slide transition duration consistency (200ms everywhere) - Standardize border-radius to rounded-md across all form inputs - Fix providers/new page structure (h2 + mb-8 spacing) - Fix tracker card action row overflow (flex-wrap justify-end) - JinjaEditor dark mode reactivity (recreate editor on theme change) - Add aria-labels to mobile nav items - Make ConfirmModal confirm button label/icon configurable - Remove double error reporting on providers page - Add telegram bot edit functionality (name editing via PUT) - i18n for External Domain label on provider forms Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -31,8 +31,8 @@
|
||||
--color-glow-strong: rgba(13, 148, 136, 0.3);
|
||||
--color-sidebar: #ffffff;
|
||||
--color-sidebar-active: rgba(13, 148, 136, 0.08);
|
||||
--font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-mono: ui-monospace, 'Cascadia Code', 'Consolas', monospace;
|
||||
--font-sans: 'DM Sans', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, 'Cascadia Code', 'Consolas', monospace;
|
||||
--radius: 0.625rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<script lang="ts">
|
||||
import Modal from './Modal.svelte';
|
||||
import MdiIcon from './MdiIcon.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
let { open = false, title = '', message = '', confirmLabel = '', confirmIcon = 'mdiDelete', onconfirm, oncancel } = $props<{
|
||||
open: boolean;
|
||||
title?: string;
|
||||
message?: string;
|
||||
confirmLabel?: string;
|
||||
confirmIcon?: string;
|
||||
onconfirm: () => void;
|
||||
oncancel: () => void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<Modal {open} title={title || t('common.confirm')} onclose={oncancel}>
|
||||
<div class="flex items-start gap-3 mb-5">
|
||||
<div class="flex items-center justify-center w-9 h-9 rounded-full flex-shrink-0"
|
||||
style="background: var(--color-error-bg); color: var(--color-error-fg);">
|
||||
<MdiIcon name="mdiAlertCircle" size={20} />
|
||||
</div>
|
||||
<p class="text-sm mt-1.5" style="color: var(--color-muted-foreground);">{message}</p>
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button onclick={oncancel}
|
||||
class="confirm-btn-cancel">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onclick={onconfirm}
|
||||
class="confirm-btn-delete">
|
||||
<MdiIcon name={confirmIcon} size={15} />
|
||||
{confirmLabel || t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.confirm-btn-cancel {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
border: 1px solid var(--color-border);
|
||||
background: transparent;
|
||||
color: var(--color-foreground);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.confirm-btn-cancel:hover {
|
||||
background: var(--color-muted);
|
||||
}
|
||||
|
||||
.confirm-btn-delete {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
background: var(--color-destructive);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.confirm-btn-delete:hover {
|
||||
box-shadow: 0 0 16px rgba(239, 68, 68, 0.3);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,162 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { EditorView, Decoration, placeholder as cmPlaceholder, type DecorationSet } from '@codemirror/view';
|
||||
import { EditorState, StateField, StateEffect } from '@codemirror/state';
|
||||
import { StreamLanguage } from '@codemirror/language';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import { getTheme } from '$lib/theme.svelte';
|
||||
|
||||
let { value = '', onchange, rows = 6, placeholder = '', errorLine = null } = $props<{
|
||||
value: string;
|
||||
onchange: (val: string) => void;
|
||||
rows?: number;
|
||||
placeholder?: string;
|
||||
errorLine?: number | null;
|
||||
}>();
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let view: EditorView;
|
||||
const theme = getTheme();
|
||||
|
||||
// Error line highlight effect and field
|
||||
const setErrorLine = StateEffect.define<number | null>();
|
||||
const errorLineField = StateField.define<DecorationSet>({
|
||||
create() { return Decoration.none; },
|
||||
update(decorations, tr) {
|
||||
for (const e of tr.effects) {
|
||||
if (e.is(setErrorLine)) {
|
||||
if (e.value === null) return Decoration.none;
|
||||
const lineNum = e.value;
|
||||
if (lineNum < 1 || lineNum > tr.state.doc.lines) return Decoration.none;
|
||||
const line = tr.state.doc.line(lineNum);
|
||||
return Decoration.set([
|
||||
Decoration.line({ class: 'cm-error-line' }).range(line.from),
|
||||
]);
|
||||
}
|
||||
}
|
||||
return decorations;
|
||||
},
|
||||
provide: f => EditorView.decorations.from(f),
|
||||
});
|
||||
|
||||
// Simple Jinja2 stream parser for syntax highlighting
|
||||
const jinjaLang = StreamLanguage.define({
|
||||
token(stream) {
|
||||
if (stream.match('{#')) {
|
||||
stream.skipTo('#}') && stream.match('#}');
|
||||
return 'comment';
|
||||
}
|
||||
if (stream.match('{{')) {
|
||||
while (!stream.eol()) {
|
||||
if (stream.match('}}')) return 'variableName';
|
||||
stream.next();
|
||||
}
|
||||
return 'variableName';
|
||||
}
|
||||
if (stream.match('{%')) {
|
||||
while (!stream.eol()) {
|
||||
if (stream.match('%}')) return 'keyword';
|
||||
stream.next();
|
||||
}
|
||||
return 'keyword';
|
||||
}
|
||||
while (stream.next()) {
|
||||
if (stream.peek() === '{') break;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
const extensions = [
|
||||
jinjaLang,
|
||||
errorLineField,
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
onchange(update.state.doc.toString());
|
||||
}
|
||||
}),
|
||||
EditorView.lineWrapping,
|
||||
EditorView.theme({
|
||||
'&': { fontSize: '13px', fontFamily: "'Consolas', 'Monaco', 'Courier New', monospace" },
|
||||
'.cm-content': { minHeight: `${rows * 1.5}em`, padding: '8px' },
|
||||
'.cm-editor': { borderRadius: '0.375rem', border: '1px solid var(--color-border)' },
|
||||
'.cm-focused': { outline: '2px solid var(--color-primary)', outlineOffset: '0px' },
|
||||
'.cm-error-line': { backgroundColor: 'rgba(239, 68, 68, 0.2)', outline: '1px solid rgba(239, 68, 68, 0.4)' },
|
||||
'.ͼc': { color: '#e879f9' },
|
||||
'.ͼd': { color: '#38bdf8' },
|
||||
'.ͼ5': { color: '#6b7280' },
|
||||
}),
|
||||
];
|
||||
|
||||
if (theme.isDark) {
|
||||
extensions.push(oneDark);
|
||||
}
|
||||
|
||||
if (placeholder) {
|
||||
extensions.push(cmPlaceholder(placeholder));
|
||||
}
|
||||
|
||||
view = new EditorView({
|
||||
state: EditorState.create({ doc: value, extensions }),
|
||||
parent: container,
|
||||
});
|
||||
|
||||
return () => view.destroy();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (view && view.state.doc.toString() !== value) {
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: value },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (view) {
|
||||
view.dispatch({ effects: setErrorLine.of(errorLine ?? null) });
|
||||
}
|
||||
});
|
||||
|
||||
// Recreate editor when theme changes
|
||||
let lastIsDark: boolean | undefined;
|
||||
$effect(() => {
|
||||
const isDark = theme.isDark;
|
||||
if (lastIsDark !== undefined && lastIsDark !== isDark && view) {
|
||||
const currentDoc = view.state.doc.toString();
|
||||
view.destroy();
|
||||
|
||||
const extensions = [
|
||||
jinjaLang,
|
||||
errorLineField,
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
onchange(update.state.doc.toString());
|
||||
}
|
||||
}),
|
||||
EditorView.lineWrapping,
|
||||
EditorView.theme({
|
||||
'&': { fontSize: '13px', fontFamily: "'Consolas', 'Monaco', 'Courier New', monospace" },
|
||||
'.cm-content': { minHeight: `${rows * 1.5}em`, padding: '8px' },
|
||||
'.cm-editor': { borderRadius: '0.375rem', border: '1px solid var(--color-border)' },
|
||||
'.cm-focused': { outline: '2px solid var(--color-primary)', outlineOffset: '0px' },
|
||||
'.cm-error-line': { backgroundColor: 'rgba(239, 68, 68, 0.2)', outline: '1px solid rgba(239, 68, 68, 0.4)' },
|
||||
'.ͼc': { color: '#e879f9' },
|
||||
'.ͼd': { color: '#38bdf8' },
|
||||
'.ͼ5': { color: '#6b7280' },
|
||||
}),
|
||||
];
|
||||
if (isDark) extensions.push(oneDark);
|
||||
if (placeholder) extensions.push(cmPlaceholder(placeholder));
|
||||
|
||||
view = new EditorView({
|
||||
state: EditorState.create({ doc: currentDoc, extensions }),
|
||||
parent: container,
|
||||
});
|
||||
}
|
||||
lastIsDark = isDark;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div bind:this={container}></div>
|
||||
@@ -0,0 +1,448 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "Notify Bridge",
|
||||
"tagline": "Service notifications"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"providers": "Providers",
|
||||
"trackers": "Trackers",
|
||||
"trackingConfigs": "Tracking",
|
||||
"templateConfigs": "Templates",
|
||||
"telegramBots": "Bots",
|
||||
"targets": "Targets",
|
||||
"users": "Users",
|
||||
"logout": "Logout"
|
||||
},
|
||||
"auth": {
|
||||
"signIn": "Sign in",
|
||||
"signInTitle": "Sign in to your account",
|
||||
"signingIn": "Signing in...",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"confirmPassword": "Confirm password",
|
||||
"setupTitle": "Welcome",
|
||||
"setupDescription": "Create your admin account to get started",
|
||||
"createAccount": "Create account",
|
||||
"creatingAccount": "Creating account...",
|
||||
"passwordMismatch": "Passwords do not match",
|
||||
"passwordTooShort": "Password must be at least 6 characters",
|
||||
"or": "or"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"description": "Overview of your Notify Bridge setup",
|
||||
"providers": "Providers",
|
||||
"activeTrackers": "Active Trackers",
|
||||
"targets": "Targets",
|
||||
"recentEvents": "Recent Events",
|
||||
"noEvents": "No events yet. Create a tracker to start monitoring.",
|
||||
"loading": "Loading...",
|
||||
"justNow": "just now",
|
||||
"minutesAgo": "{n}m ago",
|
||||
"hoursAgo": "{n}h ago",
|
||||
"daysAgo": "{n}d ago",
|
||||
"assetsAdded": "assets added",
|
||||
"assetsRemoved": "assets removed",
|
||||
"collectionRenamed": "collection renamed",
|
||||
"collectionDeleted": "collection deleted",
|
||||
"sharingChanged": "sharing changed"
|
||||
},
|
||||
"providers": {
|
||||
"title": "Providers",
|
||||
"description": "Manage service provider connections",
|
||||
"addProvider": "Add Provider",
|
||||
"cancel": "Cancel",
|
||||
"type": "Provider Type",
|
||||
"name": "Name",
|
||||
"url": "Provider URL",
|
||||
"urlPlaceholder": "http://provider:2283",
|
||||
"apiKey": "API Key",
|
||||
"apiKeyKeep": "API Key (leave empty to keep current)",
|
||||
"connecting": "Connecting...",
|
||||
"noProviders": "No providers configured yet.",
|
||||
"delete": "Delete",
|
||||
"confirmDelete": "Delete this provider?",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"checking": "Checking...",
|
||||
"loadError": "Failed to load providers.",
|
||||
"externalDomain": "External Domain",
|
||||
"optional": "optional"
|
||||
},
|
||||
"trackers": {
|
||||
"title": "Trackers",
|
||||
"description": "Monitor albums for changes",
|
||||
"newTracker": "New Tracker",
|
||||
"cancel": "Cancel",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "Family photos tracker",
|
||||
"server": "Provider",
|
||||
"selectServer": "Select provider...",
|
||||
"albums": "Albums",
|
||||
"eventTypes": "Event Types",
|
||||
"notificationTargets": "Notification Targets",
|
||||
"scanInterval": "Scan Interval (seconds)",
|
||||
"createTracker": "Create Tracker",
|
||||
"noTrackers": "No trackers yet. Add a provider first, then create a tracker.",
|
||||
"active": "Active",
|
||||
"paused": "Paused",
|
||||
"pause": "Pause",
|
||||
"resume": "Resume",
|
||||
"delete": "Delete",
|
||||
"confirmDelete": "Delete this tracker?",
|
||||
"albums_count": "album(s)",
|
||||
"every": "every",
|
||||
"trackImages": "Track images",
|
||||
"trackVideos": "Track videos",
|
||||
"favoritesOnly": "Favorites only",
|
||||
"includePeople": "Include people in notifications",
|
||||
"includeAssetDetails": "Include asset details",
|
||||
"maxAssetsToShow": "Max assets to show",
|
||||
"sortBy": "Sort by",
|
||||
"sortOrder": "Sort order",
|
||||
"sortNone": "Original order",
|
||||
"sortDate": "Date",
|
||||
"sortRating": "Rating",
|
||||
"sortName": "Name",
|
||||
"sortRandom": "Random",
|
||||
"ascending": "Ascending",
|
||||
"descending": "Descending",
|
||||
"quietHoursStart": "Quiet hours start",
|
||||
"quietHoursEnd": "Quiet hours end",
|
||||
"batchDuration": "Batch duration (seconds)",
|
||||
"linkedTargets": "targets",
|
||||
"noLinkedTargets": "No targets linked. Add a target below.",
|
||||
"addTarget": "Add target"
|
||||
},
|
||||
"templates": {
|
||||
"title": "Templates",
|
||||
"description": "Jinja2 message templates for notifications",
|
||||
"newTemplate": "New Template",
|
||||
"cancel": "Cancel",
|
||||
"name": "Name",
|
||||
"body": "Template Body (Jinja2)",
|
||||
"variables": "Variables",
|
||||
"preview": "Preview",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"confirmDelete": "Delete this template?",
|
||||
"create": "Create Template",
|
||||
"update": "Update Template",
|
||||
"noTemplates": "No templates yet. A default template will be used if none is configured.",
|
||||
"eventType": "Event type",
|
||||
"allEvents": "All events",
|
||||
"assetsAdded": "Assets added",
|
||||
"assetsRemoved": "Assets removed",
|
||||
"albumRenamed": "Album renamed",
|
||||
"albumDeleted": "Album deleted"
|
||||
},
|
||||
"targets": {
|
||||
"title": "Targets",
|
||||
"description": "Notification destinations (Telegram, webhooks)",
|
||||
"addTarget": "Add Target",
|
||||
"cancel": "Cancel",
|
||||
"type": "Type",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "My notifications",
|
||||
"botToken": "Bot Token",
|
||||
"chatId": "Chat ID",
|
||||
"webhookUrl": "Webhook URL",
|
||||
"create": "Add Target",
|
||||
"test": "Test",
|
||||
"delete": "Delete",
|
||||
"confirmDelete": "Delete this target?",
|
||||
"noTargets": "No notification targets configured yet.",
|
||||
"testSent": "Test sent successfully!",
|
||||
"aiCaptions": "Enable AI captions",
|
||||
"telegramSettings": "Telegram Settings",
|
||||
"maxMedia": "Max media to send",
|
||||
"maxGroupSize": "Max group size",
|
||||
"chunkDelay": "Delay between groups (ms)",
|
||||
"maxAssetSize": "Max asset size (MB)",
|
||||
"videoWarning": "Video size warning",
|
||||
"disableUrlPreview": "Disable link previews",
|
||||
"sendLargeAsDocuments": "Send large photos as documents"
|
||||
},
|
||||
"users": {
|
||||
"title": "Users",
|
||||
"description": "Manage user accounts (admin only)",
|
||||
"addUser": "Add User",
|
||||
"cancel": "Cancel",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"role": "Role",
|
||||
"roleUser": "User",
|
||||
"roleAdmin": "Admin",
|
||||
"create": "Create User",
|
||||
"delete": "Delete",
|
||||
"confirmDelete": "Delete this user?",
|
||||
"joined": "joined"
|
||||
},
|
||||
"telegramBot": {
|
||||
"title": "Telegram Bots",
|
||||
"description": "Register and manage Telegram bots",
|
||||
"addBot": "Add Bot",
|
||||
"name": "Display name",
|
||||
"namePlaceholder": "Family notifications bot",
|
||||
"token": "Bot Token",
|
||||
"tokenPlaceholder": "123456:ABC-DEF...",
|
||||
"noBots": "No bots registered yet.",
|
||||
"chats": "Chats",
|
||||
"noChats": "No chats found. Send a message to the bot first.",
|
||||
"refreshChats": "Refresh",
|
||||
"selectBot": "Select bot",
|
||||
"selectChat": "Select chat",
|
||||
"private": "Private",
|
||||
"group": "Group",
|
||||
"supergroup": "Supergroup",
|
||||
"channel": "Channel",
|
||||
"confirmDelete": "Delete this bot?",
|
||||
"commands": "Commands",
|
||||
"enabledCommands": "Enabled Commands",
|
||||
"defaultCount": "Default result count",
|
||||
"responseMode": "Response mode",
|
||||
"modeMedia": "Media (send photos)",
|
||||
"modeText": "Text (send links)",
|
||||
"botLocale": "Bot language",
|
||||
"rateLimits": "Rate Limits",
|
||||
"rateSearch": "Search cooldown",
|
||||
"rateFind": "Find cooldown",
|
||||
"rateDefault": "Default cooldown",
|
||||
"syncCommands": "Sync to Telegram",
|
||||
"discoverChats": "Discover chats from Telegram",
|
||||
"clickToCopy": "Click to copy chat ID",
|
||||
"chatsDiscovered": "Chats discovered",
|
||||
"chatDeleted": "Chat removed"
|
||||
},
|
||||
"trackingConfig": {
|
||||
"title": "Tracking Configs",
|
||||
"description": "Define what events and assets to react to",
|
||||
"newConfig": "New Config",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "Default tracking",
|
||||
"noConfigs": "No tracking configs yet.",
|
||||
"eventTracking": "Event Tracking",
|
||||
"assetsAdded": "Assets added",
|
||||
"assetsRemoved": "Assets removed",
|
||||
"albumRenamed": "Album renamed",
|
||||
"albumDeleted": "Album deleted",
|
||||
"sharingChanged": "Sharing changed",
|
||||
"trackImages": "Track images",
|
||||
"trackVideos": "Track videos",
|
||||
"favoritesOnly": "Favorites only",
|
||||
"assetDisplay": "Asset Display",
|
||||
"includePeople": "Include people",
|
||||
"includeDetails": "Include asset details",
|
||||
"maxAssets": "Max assets to show",
|
||||
"sortBy": "Sort by",
|
||||
"sortOrder": "Sort order",
|
||||
"periodicSummary": "Periodic Summary",
|
||||
"enabled": "Enabled",
|
||||
"intervalDays": "Interval (days)",
|
||||
"startDate": "Start date",
|
||||
"times": "Times (HH:MM)",
|
||||
"scheduledAssets": "Scheduled Assets",
|
||||
"albumMode": "Album mode",
|
||||
"limit": "Limit",
|
||||
"assetType": "Asset type",
|
||||
"minRating": "Min rating",
|
||||
"memoryMode": "Memory Mode (On This Day)",
|
||||
"test": "Test",
|
||||
"confirmDelete": "Delete this tracking config?",
|
||||
"sortNone": "None",
|
||||
"sortDate": "Date",
|
||||
"sortRating": "Rating",
|
||||
"sortName": "Name",
|
||||
"orderDesc": "Descending",
|
||||
"orderAsc": "Ascending",
|
||||
"albumModePerAlbum": "Per album",
|
||||
"albumModeCombined": "Combined",
|
||||
"albumModeRandom": "Random",
|
||||
"assetTypeAll": "All",
|
||||
"assetTypePhoto": "Photo",
|
||||
"assetTypeVideo": "Video"
|
||||
},
|
||||
"templateConfig": {
|
||||
"title": "Template Configs",
|
||||
"description": "Define how notification messages are formatted",
|
||||
"newConfig": "New Config",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "Default EN",
|
||||
"descriptionPlaceholder": "e.g. English templates for family notifications",
|
||||
"noConfigs": "No template configs yet.",
|
||||
"eventMessages": "Event Messages",
|
||||
"assetsAdded": "Assets added",
|
||||
"assetsRemoved": "Assets removed",
|
||||
"albumRenamed": "Album renamed",
|
||||
"albumDeleted": "Album deleted",
|
||||
"sharingChanged": "Sharing changed",
|
||||
"assetFormatting": "Asset Formatting",
|
||||
"imageTemplate": "Image item",
|
||||
"videoTemplate": "Video item",
|
||||
"assetsWrapper": "Assets wrapper",
|
||||
"moreMessage": "More message",
|
||||
"peopleFormat": "People format",
|
||||
"dateLocation": "Date & Location",
|
||||
"dateFormat": "Date format",
|
||||
"commonDate": "Common date",
|
||||
"uniqueDate": "Per-asset date",
|
||||
"locationFormat": "Location format",
|
||||
"commonLocation": "Common location",
|
||||
"uniqueLocation": "Per-asset location",
|
||||
"favoriteIndicator": "Favorite indicator",
|
||||
"scheduledMessages": "Scheduled Messages",
|
||||
"periodicSummary": "Periodic summary",
|
||||
"periodicAlbum": "Per-album item",
|
||||
"scheduledAssets": "Scheduled assets",
|
||||
"memoryMode": "Memory mode",
|
||||
"settings": "Settings",
|
||||
"previewAs": "Preview as",
|
||||
"preview": "Preview",
|
||||
"variables": "Variables",
|
||||
"assetFields": "Asset fields (in {% for asset in added_assets %})",
|
||||
"albumFields": "Album fields (in {% for album in albums %})",
|
||||
"confirmDelete": "Delete this template config?"
|
||||
},
|
||||
"templateVars": {
|
||||
"message_assets_added": { "description": "Notification when new assets are added to an album" },
|
||||
"message_assets_removed": { "description": "Notification when assets are removed from an album" },
|
||||
"message_album_renamed": { "description": "Notification when an album is renamed" },
|
||||
"message_album_deleted": { "description": "Notification when an album is deleted" },
|
||||
"periodic_summary_message": { "description": "Periodic album summary (scheduler not yet implemented)" },
|
||||
"scheduled_assets_message": { "description": "Scheduled asset delivery (scheduler not yet implemented)" },
|
||||
"memory_mode_message": { "description": "\"On This Day\" memories (scheduler not yet implemented)" },
|
||||
"album_id": "Album ID (UUID)",
|
||||
"album_name": "Album name",
|
||||
"album_url": "Public share URL (empty if not shared)",
|
||||
"added_count": "Number of assets added",
|
||||
"removed_count": "Number of assets removed",
|
||||
"change_type": "Type of change (assets_added, assets_removed, album_renamed, album_deleted)",
|
||||
"people": "Detected people names (list, use {{ people | join(', ') }})",
|
||||
"added_assets": "List of asset dicts (use {% for asset in added_assets %})",
|
||||
"removed_assets": "List of removed asset IDs (strings)",
|
||||
"shared": "Whether album is shared (boolean)",
|
||||
"target_type": "Target type: 'telegram' or 'webhook'",
|
||||
"has_videos": "Whether added assets contain videos (boolean)",
|
||||
"has_photos": "Whether added assets contain photos (boolean)",
|
||||
"old_name": "Previous album name (rename events)",
|
||||
"new_name": "New album name (rename events)",
|
||||
"old_shared": "Was album shared before rename (boolean)",
|
||||
"new_shared": "Is album shared after rename (boolean)",
|
||||
"albums": "List of album dicts (use {% for album in albums %})",
|
||||
"assets": "List of asset dicts (use {% for asset in assets %})",
|
||||
"date": "Current date string",
|
||||
"asset_id": "Asset ID (UUID)",
|
||||
"asset_filename": "Original filename",
|
||||
"asset_type": "IMAGE or VIDEO",
|
||||
"asset_created_at": "Creation date/time (ISO 8601)",
|
||||
"asset_owner": "Owner display name",
|
||||
"asset_owner_id": "Owner user ID",
|
||||
"asset_description": "User or EXIF description",
|
||||
"asset_people": "People detected in this asset (list)",
|
||||
"asset_is_favorite": "Whether asset is favorited (boolean)",
|
||||
"asset_rating": "Star rating (1-5 or null)",
|
||||
"asset_latitude": "GPS latitude (float or null)",
|
||||
"asset_longitude": "GPS longitude (float or null)",
|
||||
"asset_city": "City name",
|
||||
"asset_state": "State/region name",
|
||||
"asset_country": "Country name",
|
||||
"asset_url": "Public viewer URL (if shared)",
|
||||
"asset_download_url": "Direct download URL (if shared)",
|
||||
"asset_photo_url": "Preview image URL (images only, if shared)",
|
||||
"asset_playback_url": "Video playback URL (videos only, if shared)",
|
||||
"album_name_field": "Album name (in album list)",
|
||||
"album_asset_count": "Total assets in album",
|
||||
"album_url_field": "Album share URL",
|
||||
"album_shared": "Whether album is shared"
|
||||
},
|
||||
"hints": {
|
||||
"periodicSummary": "Sends a scheduled summary of all tracked albums at specified times. Great for daily/weekly digests.",
|
||||
"scheduledAssets": "Sends random or selected photos from tracked albums on a schedule. Like a daily photo pick.",
|
||||
"memoryMode": "\"On This Day\" — sends photos taken on this date in previous years. Nostalgic flashbacks.",
|
||||
"favoritesOnly": "Only include assets marked as favorites.",
|
||||
"maxAssets": "Maximum number of asset details to include in a single notification message.",
|
||||
"periodicStartDate": "The reference date for calculating periodic intervals. Summaries are sent every N days from this date.",
|
||||
"times": "Time(s) of day to send notifications, in HH:MM format. Use commas for multiple times: 09:00,18:00",
|
||||
"albumMode": "Per album: separate notification per album. Combined: one notification with all albums. Random: pick one album randomly.",
|
||||
"minRating": "Only include assets with at least this star rating (0 = no filter).",
|
||||
"eventMessages": "Templates for real-time event notifications. Use {variables} for dynamic content.",
|
||||
"assetFormatting": "How individual assets are formatted within notification messages.",
|
||||
"dateLocation": "Date and location formatting in notifications. Uses strftime syntax for dates.",
|
||||
"scheduledMessages": "Templates for periodic summaries, scheduled photo picks, and On This Day memories.",
|
||||
"aiCaptions": "Use Claude AI to generate a natural-language caption for notifications instead of the template.",
|
||||
"maxMedia": "Maximum number of photos/videos to attach per notification (0 = text only).",
|
||||
"groupSize": "Telegram media groups can contain 2-10 items. Larger batches are split into chunks.",
|
||||
"chunkDelay": "Delay in milliseconds between sending media chunks. Prevents Telegram rate limiting.",
|
||||
"maxAssetSize": "Skip assets larger than this size in MB. Telegram limits files to 50 MB.",
|
||||
"trackingConfig": "Controls which events trigger notifications and how assets are filtered.",
|
||||
"templateConfig": "Controls the message format. Uses default templates if not set.",
|
||||
"scanInterval": "How often to poll the provider for changes, in seconds. Lower = faster detection but more API calls.",
|
||||
"batchDuration": "Time to accumulate changes before dispatching notifications. 0 = send immediately.",
|
||||
"defaultCount": "How many results to return when the user doesn't specify a count (1-20).",
|
||||
"responseMode": "Media: send actual photos. Text: send filenames/links only. Media mode uses more bandwidth.",
|
||||
"botLocale": "Language for command descriptions in Telegram's menu and bot response messages.",
|
||||
"rateLimits": "Cooldown in seconds between uses of each command category per chat. 0 = no limit."
|
||||
},
|
||||
"snack": {
|
||||
"providerSaved": "Provider saved",
|
||||
"providerDeleted": "Provider deleted",
|
||||
"trackerCreated": "Tracker created",
|
||||
"trackerUpdated": "Tracker updated",
|
||||
"trackerDeleted": "Tracker deleted",
|
||||
"trackerPaused": "Tracker paused",
|
||||
"trackerResumed": "Tracker resumed",
|
||||
"targetSaved": "Target saved",
|
||||
"targetDeleted": "Target deleted",
|
||||
"targetTestSent": "Test notification sent",
|
||||
"templateSaved": "Template config saved",
|
||||
"templateDeleted": "Template config deleted",
|
||||
"trackingConfigSaved": "Tracking config saved",
|
||||
"trackingConfigDeleted": "Tracking config deleted",
|
||||
"botRegistered": "Bot registered",
|
||||
"botDeleted": "Bot deleted",
|
||||
"userCreated": "User created",
|
||||
"userDeleted": "User deleted",
|
||||
"passwordChanged": "Password changed",
|
||||
"copied": "Copied to clipboard",
|
||||
"genericError": "Something went wrong",
|
||||
"commandsSaved": "Commands config saved",
|
||||
"commandsSynced": "Commands synced to Telegram",
|
||||
"targetLinked": "Target linked",
|
||||
"targetUnlinked": "Target unlinked",
|
||||
"botUpdated": "Bot updated"
|
||||
},
|
||||
"common": {
|
||||
"loading": "Loading...",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"description": "Description",
|
||||
"close": "Close",
|
||||
"confirm": "Confirm",
|
||||
"error": "Error",
|
||||
"success": "Success",
|
||||
"none": "None",
|
||||
"noneDefault": "None (default)",
|
||||
"loadError": "Failed to load data",
|
||||
"headersInvalid": "Invalid JSON",
|
||||
"language": "Language",
|
||||
"theme": "Theme",
|
||||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
"system": "System",
|
||||
"test": "Test",
|
||||
"create": "Create",
|
||||
"changePassword": "Change Password",
|
||||
"currentPassword": "Current password",
|
||||
"newPassword": "New password",
|
||||
"passwordChanged": "Password changed successfully",
|
||||
"expand": "Expand",
|
||||
"collapse": "Collapse",
|
||||
"syntaxError": "Syntax error",
|
||||
"undefinedVar": "Unknown variable",
|
||||
"line": "line",
|
||||
"add": "Add"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "Notify Bridge",
|
||||
"tagline": "Уведомления о сервисах"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Главная",
|
||||
"providers": "Провайдеры",
|
||||
"trackers": "Трекеры",
|
||||
"trackingConfigs": "Отслеживание",
|
||||
"templateConfigs": "Шаблоны",
|
||||
"telegramBots": "Боты",
|
||||
"targets": "Получатели",
|
||||
"users": "Пользователи",
|
||||
"logout": "Выход"
|
||||
},
|
||||
"auth": {
|
||||
"signIn": "Войти",
|
||||
"signInTitle": "Вход в аккаунт",
|
||||
"signingIn": "Вход...",
|
||||
"username": "Имя пользователя",
|
||||
"password": "Пароль",
|
||||
"confirmPassword": "Подтвердите пароль",
|
||||
"setupTitle": "Добро пожаловать",
|
||||
"setupDescription": "Создайте учётную запись администратора",
|
||||
"createAccount": "Создать аккаунт",
|
||||
"creatingAccount": "Создание...",
|
||||
"passwordMismatch": "Пароли не совпадают",
|
||||
"passwordTooShort": "Пароль должен быть не менее 6 символов",
|
||||
"or": "или"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Главная",
|
||||
"description": "Обзор настроек Notify Bridge",
|
||||
"providers": "Провайдеры",
|
||||
"activeTrackers": "Активные трекеры",
|
||||
"targets": "Получатели",
|
||||
"recentEvents": "Последние события",
|
||||
"noEvents": "Событий пока нет. Создайте трекер для отслеживания.",
|
||||
"loading": "Загрузка...",
|
||||
"justNow": "только что",
|
||||
"minutesAgo": "{n} мин назад",
|
||||
"hoursAgo": "{n} ч назад",
|
||||
"daysAgo": "{n} д назад",
|
||||
"assetsAdded": "добавлены файлы",
|
||||
"assetsRemoved": "удалены файлы",
|
||||
"collectionRenamed": "альбом переименован",
|
||||
"collectionDeleted": "альбом удалён",
|
||||
"sharingChanged": "изменение доступа"
|
||||
},
|
||||
"providers": {
|
||||
"title": "Провайдеры",
|
||||
"description": "Управление подключениями к сервисам",
|
||||
"addProvider": "Добавить провайдер",
|
||||
"cancel": "Отмена",
|
||||
"type": "Тип провайдера",
|
||||
"name": "Название",
|
||||
"url": "URL провайдера",
|
||||
"urlPlaceholder": "http://provider:2283",
|
||||
"apiKey": "API ключ",
|
||||
"apiKeyKeep": "API ключ (оставьте пустым, чтобы сохранить текущий)",
|
||||
"connecting": "Подключение...",
|
||||
"noProviders": "Провайдеры не настроены.",
|
||||
"delete": "Удалить",
|
||||
"confirmDelete": "Удалить этот провайдер?",
|
||||
"online": "В сети",
|
||||
"offline": "Не в сети",
|
||||
"checking": "Проверка...",
|
||||
"loadError": "Не удалось загрузить провайдеры.",
|
||||
"externalDomain": "Внешний домен",
|
||||
"optional": "необязательно"
|
||||
},
|
||||
"trackers": {
|
||||
"title": "Трекеры",
|
||||
"description": "Отслеживание изменений в альбомах",
|
||||
"newTracker": "Новый трекер",
|
||||
"cancel": "Отмена",
|
||||
"name": "Название",
|
||||
"namePlaceholder": "Трекер семейных фото",
|
||||
"server": "Провайдер",
|
||||
"selectServer": "Выберите провайдер...",
|
||||
"albums": "Альбомы",
|
||||
"eventTypes": "Типы событий",
|
||||
"notificationTargets": "Получатели уведомлений",
|
||||
"scanInterval": "Интервал проверки (секунды)",
|
||||
"createTracker": "Создать трекер",
|
||||
"noTrackers": "Трекеров пока нет. Сначала добавьте провайдер, затем создайте трекер.",
|
||||
"active": "Активен",
|
||||
"paused": "Приостановлен",
|
||||
"pause": "Пауза",
|
||||
"resume": "Возобновить",
|
||||
"delete": "Удалить",
|
||||
"confirmDelete": "Удалить этот трекер?",
|
||||
"albums_count": "альбом(ов)",
|
||||
"every": "каждые",
|
||||
"trackImages": "Отслеживать фото",
|
||||
"trackVideos": "Отслеживать видео",
|
||||
"favoritesOnly": "Только избранные",
|
||||
"includePeople": "Включать людей в уведомления",
|
||||
"includeAssetDetails": "Включать детали файлов",
|
||||
"maxAssetsToShow": "Макс. файлов в уведомлении",
|
||||
"sortBy": "Сортировка",
|
||||
"sortOrder": "Порядок",
|
||||
"sortNone": "Исходный порядок",
|
||||
"sortDate": "Дата",
|
||||
"sortRating": "Рейтинг",
|
||||
"sortName": "Имя",
|
||||
"sortRandom": "Случайный",
|
||||
"ascending": "По возрастанию",
|
||||
"descending": "По убыванию",
|
||||
"quietHoursStart": "Тихие часы начало",
|
||||
"quietHoursEnd": "Тихие часы конец",
|
||||
"batchDuration": "Длительность пакета (секунды)",
|
||||
"linkedTargets": "получатели",
|
||||
"noLinkedTargets": "Нет привязанных получателей. Добавьте получателя ниже.",
|
||||
"addTarget": "Добавить получателя"
|
||||
},
|
||||
"templates": {
|
||||
"title": "Шаблоны",
|
||||
"description": "Шаблоны сообщений Jinja2 для уведомлений",
|
||||
"newTemplate": "Новый шаблон",
|
||||
"cancel": "Отмена",
|
||||
"name": "Название",
|
||||
"body": "Текст шаблона (Jinja2)",
|
||||
"variables": "Переменные",
|
||||
"preview": "Предпросмотр",
|
||||
"edit": "Редактировать",
|
||||
"delete": "Удалить",
|
||||
"confirmDelete": "Удалить этот шаблон?",
|
||||
"create": "Создать шаблон",
|
||||
"update": "Обновить шаблон",
|
||||
"noTemplates": "Шаблонов пока нет. Без шаблона будет использован шаблон по умолчанию.",
|
||||
"eventType": "Тип события",
|
||||
"allEvents": "Все события",
|
||||
"assetsAdded": "Добавлены файлы",
|
||||
"assetsRemoved": "Удалены файлы",
|
||||
"albumRenamed": "Альбом переименован",
|
||||
"albumDeleted": "Альбом удалён"
|
||||
},
|
||||
"targets": {
|
||||
"title": "Получатели",
|
||||
"description": "Адреса уведомлений (Telegram, вебхуки)",
|
||||
"addTarget": "Добавить получателя",
|
||||
"cancel": "Отмена",
|
||||
"type": "Тип",
|
||||
"name": "Название",
|
||||
"namePlaceholder": "Мои уведомления",
|
||||
"botToken": "Токен бота",
|
||||
"chatId": "ID чата",
|
||||
"webhookUrl": "URL вебхука",
|
||||
"create": "Добавить",
|
||||
"test": "Тест",
|
||||
"delete": "Удалить",
|
||||
"confirmDelete": "Удалить этого получателя?",
|
||||
"noTargets": "Получатели уведомлений не настроены.",
|
||||
"testSent": "Тестовое уведомление отправлено!",
|
||||
"aiCaptions": "Включить AI подписи",
|
||||
"telegramSettings": "Настройки Telegram",
|
||||
"maxMedia": "Макс. медиафайлов",
|
||||
"maxGroupSize": "Макс. размер группы",
|
||||
"chunkDelay": "Задержка между группами (мс)",
|
||||
"maxAssetSize": "Макс. размер файла (МБ)",
|
||||
"videoWarning": "Предупреждение о размере видео",
|
||||
"disableUrlPreview": "Отключить превью ссылок",
|
||||
"sendLargeAsDocuments": "Отправлять большие фото как документы"
|
||||
},
|
||||
"users": {
|
||||
"title": "Пользователи",
|
||||
"description": "Управление аккаунтами (только админ)",
|
||||
"addUser": "Добавить пользователя",
|
||||
"cancel": "Отмена",
|
||||
"username": "Имя пользователя",
|
||||
"password": "Пароль",
|
||||
"role": "Роль",
|
||||
"roleUser": "Пользователь",
|
||||
"roleAdmin": "Администратор",
|
||||
"create": "Создать",
|
||||
"delete": "Удалить",
|
||||
"confirmDelete": "Удалить этого пользователя?",
|
||||
"joined": "зарегистрирован"
|
||||
},
|
||||
"telegramBot": {
|
||||
"title": "Telegram боты",
|
||||
"description": "Регистрация и управление Telegram ботами",
|
||||
"addBot": "Добавить бота",
|
||||
"name": "Отображаемое имя",
|
||||
"namePlaceholder": "Бот семейных уведомлений",
|
||||
"token": "Токен бота",
|
||||
"tokenPlaceholder": "123456:ABC-DEF...",
|
||||
"noBots": "Ботов пока нет.",
|
||||
"chats": "Чаты",
|
||||
"noChats": "Чатов не найдено. Сначала отправьте сообщение боту.",
|
||||
"refreshChats": "Обновить",
|
||||
"selectBot": "Выберите бота",
|
||||
"selectChat": "Выберите чат",
|
||||
"private": "Личный",
|
||||
"group": "Группа",
|
||||
"supergroup": "Супергруппа",
|
||||
"channel": "Канал",
|
||||
"confirmDelete": "Удалить этого бота?",
|
||||
"commands": "Команды",
|
||||
"enabledCommands": "Включённые команды",
|
||||
"defaultCount": "Кол-во результатов",
|
||||
"responseMode": "Режим ответа",
|
||||
"modeMedia": "Медиа (отправка фото)",
|
||||
"modeText": "Текст (ссылки)",
|
||||
"botLocale": "Язык бота",
|
||||
"rateLimits": "Ограничения частоты",
|
||||
"rateSearch": "Кулдаун поиска",
|
||||
"rateFind": "Кулдаун поиска файлов",
|
||||
"rateDefault": "Кулдаун по умолчанию",
|
||||
"syncCommands": "Синхронизировать с Telegram",
|
||||
"discoverChats": "Обнаружить чаты из Telegram",
|
||||
"clickToCopy": "Нажмите, чтобы скопировать ID чата",
|
||||
"chatsDiscovered": "Чаты обнаружены",
|
||||
"chatDeleted": "Чат удалён"
|
||||
},
|
||||
"trackingConfig": {
|
||||
"title": "Конфигурации отслеживания",
|
||||
"description": "Определите, на какие события и файлы реагировать",
|
||||
"newConfig": "Новая конфигурация",
|
||||
"name": "Название",
|
||||
"namePlaceholder": "Основное отслеживание",
|
||||
"noConfigs": "Конфигураций отслеживания пока нет.",
|
||||
"eventTracking": "Отслеживание событий",
|
||||
"assetsAdded": "Добавлены файлы",
|
||||
"assetsRemoved": "Удалены файлы",
|
||||
"albumRenamed": "Альбом переименован",
|
||||
"albumDeleted": "Альбом удалён",
|
||||
"sharingChanged": "Изменение доступа",
|
||||
"trackImages": "Фото",
|
||||
"trackVideos": "Видео",
|
||||
"favoritesOnly": "Только избранные",
|
||||
"assetDisplay": "Отображение файлов",
|
||||
"includePeople": "Включать людей",
|
||||
"includeDetails": "Включать детали",
|
||||
"maxAssets": "Макс. файлов",
|
||||
"sortBy": "Сортировка",
|
||||
"sortOrder": "Порядок",
|
||||
"periodicSummary": "Периодическая сводка",
|
||||
"enabled": "Включено",
|
||||
"intervalDays": "Интервал (дни)",
|
||||
"startDate": "Дата начала",
|
||||
"times": "Время (ЧЧ:ММ)",
|
||||
"scheduledAssets": "Запланированные фото",
|
||||
"albumMode": "Режим альбомов",
|
||||
"limit": "Лимит",
|
||||
"assetType": "Тип файлов",
|
||||
"minRating": "Мин. рейтинг",
|
||||
"memoryMode": "Воспоминания (В этот день)",
|
||||
"test": "Тест",
|
||||
"confirmDelete": "Удалить эту конфигурацию отслеживания?",
|
||||
"sortNone": "Нет",
|
||||
"sortDate": "Дата",
|
||||
"sortRating": "Рейтинг",
|
||||
"sortName": "Имя",
|
||||
"orderDesc": "По убыванию",
|
||||
"orderAsc": "По возрастанию",
|
||||
"albumModePerAlbum": "По альбомам",
|
||||
"albumModeCombined": "Объединённый",
|
||||
"albumModeRandom": "Случайный",
|
||||
"assetTypeAll": "Все",
|
||||
"assetTypePhoto": "Фото",
|
||||
"assetTypeVideo": "Видео"
|
||||
},
|
||||
"templateConfig": {
|
||||
"title": "Конфигурации шаблонов",
|
||||
"description": "Определите формат уведомлений",
|
||||
"newConfig": "Новая конфигурация",
|
||||
"name": "Название",
|
||||
"namePlaceholder": "По умолчанию RU",
|
||||
"descriptionPlaceholder": "напр. Русские шаблоны для семейных уведомлений",
|
||||
"noConfigs": "Конфигураций шаблонов пока нет.",
|
||||
"eventMessages": "Сообщения о событиях",
|
||||
"assetsAdded": "Добавлены файлы",
|
||||
"assetsRemoved": "Удалены файлы",
|
||||
"albumRenamed": "Альбом переименован",
|
||||
"albumDeleted": "Альбом удалён",
|
||||
"sharingChanged": "Изменение доступа",
|
||||
"assetFormatting": "Форматирование файлов",
|
||||
"imageTemplate": "Шаблон фото",
|
||||
"videoTemplate": "Шаблон видео",
|
||||
"assetsWrapper": "Обёртка списка",
|
||||
"moreMessage": "Сообщение \"ещё\"",
|
||||
"peopleFormat": "Формат людей",
|
||||
"dateLocation": "Дата и место",
|
||||
"dateFormat": "Формат даты",
|
||||
"commonDate": "Общая дата",
|
||||
"uniqueDate": "Дата файла",
|
||||
"locationFormat": "Формат места",
|
||||
"commonLocation": "Общее место",
|
||||
"uniqueLocation": "Место файла",
|
||||
"favoriteIndicator": "Индикатор избранного",
|
||||
"scheduledMessages": "Запланированные сообщения",
|
||||
"periodicSummary": "Периодическая сводка",
|
||||
"periodicAlbum": "Элемент альбома",
|
||||
"scheduledAssets": "Запланированные фото",
|
||||
"memoryMode": "Воспоминания",
|
||||
"settings": "Настройки",
|
||||
"previewAs": "Предпросмотр как",
|
||||
"preview": "Предпросмотр",
|
||||
"variables": "Переменные",
|
||||
"assetFields": "Поля файла (в {% for asset in added_assets %})",
|
||||
"albumFields": "Поля альбома (в {% for album in albums %})",
|
||||
"confirmDelete": "Удалить эту конфигурацию шаблона?"
|
||||
},
|
||||
"templateVars": {
|
||||
"message_assets_added": { "description": "Уведомление о добавлении файлов в альбом" },
|
||||
"message_assets_removed": { "description": "Уведомление об удалении файлов из альбома" },
|
||||
"message_album_renamed": { "description": "Уведомление о переименовании альбома" },
|
||||
"message_album_deleted": { "description": "Уведомление об удалении альбома" },
|
||||
"periodic_summary_message": { "description": "Периодическая сводка альбомов (планировщик не реализован)" },
|
||||
"scheduled_assets_message": { "description": "Запланированная подборка фото (планировщик не реализован)" },
|
||||
"memory_mode_message": { "description": "«В этот день» — воспоминания (планировщик не реализован)" },
|
||||
"album_id": "ID альбома (UUID)",
|
||||
"album_name": "Название альбома",
|
||||
"album_url": "Публичная ссылка (пусто, если не расшарен)",
|
||||
"added_count": "Количество добавленных файлов",
|
||||
"removed_count": "Количество удалённых файлов",
|
||||
"change_type": "Тип изменения (assets_added, assets_removed, album_renamed, album_deleted)",
|
||||
"people": "Обнаруженные люди (список, {{ people | join(', ') }})",
|
||||
"added_assets": "Список файлов ({% for asset in added_assets %})",
|
||||
"removed_assets": "Список ID удалённых файлов (строки)",
|
||||
"shared": "Общий альбом (boolean)",
|
||||
"target_type": "Тип получателя: 'telegram' или 'webhook'",
|
||||
"has_videos": "Содержат ли добавленные файлы видео (boolean)",
|
||||
"has_photos": "Содержат ли добавленные файлы фото (boolean)",
|
||||
"old_name": "Прежнее название альбома (при переименовании)",
|
||||
"new_name": "Новое название альбома (при переименовании)",
|
||||
"old_shared": "Был ли общим до переименования (boolean)",
|
||||
"new_shared": "Является ли общим после переименования (boolean)",
|
||||
"albums": "Список альбомов ({% for album in albums %})",
|
||||
"assets": "Список файлов ({% for asset in assets %})",
|
||||
"date": "Текущая дата",
|
||||
"asset_id": "ID файла (UUID)",
|
||||
"asset_filename": "Имя файла",
|
||||
"asset_type": "IMAGE или VIDEO",
|
||||
"asset_created_at": "Дата создания (ISO 8601)",
|
||||
"asset_owner": "Имя владельца",
|
||||
"asset_owner_id": "ID владельца",
|
||||
"asset_description": "Описание (EXIF или пользовательское)",
|
||||
"asset_people": "Люди на этом файле (список)",
|
||||
"asset_is_favorite": "В избранном (boolean)",
|
||||
"asset_rating": "Рейтинг (1-5 или null)",
|
||||
"asset_latitude": "GPS широта (float или null)",
|
||||
"asset_longitude": "GPS долгота (float или null)",
|
||||
"asset_city": "Город",
|
||||
"asset_state": "Регион",
|
||||
"asset_country": "Страна",
|
||||
"asset_url": "Ссылка для просмотра (если расшарен)",
|
||||
"asset_download_url": "Ссылка для скачивания (если расшарен)",
|
||||
"asset_photo_url": "URL превью (только фото, если расшарен)",
|
||||
"asset_playback_url": "URL видео (только видео, если расшарен)",
|
||||
"album_name_field": "Название альбома (в списке альбомов)",
|
||||
"album_asset_count": "Всего файлов в альбоме",
|
||||
"album_url_field": "Ссылка на альбом",
|
||||
"album_shared": "Общий альбом"
|
||||
},
|
||||
"hints": {
|
||||
"periodicSummary": "Отправляет плановую сводку по всем отслеживаемым альбомам в указанное время. Подходит для ежедневных/еженедельных дайджестов.",
|
||||
"scheduledAssets": "Отправляет случайные или выбранные фото из альбомов по расписанию. Как ежедневная подборка фото.",
|
||||
"memoryMode": "\"В этот день\" — отправляет фото, сделанные в этот день в прошлые годы. Ностальгические воспоминания.",
|
||||
"favoritesOnly": "Включать только ассеты, отмеченные как избранные.",
|
||||
"maxAssets": "Максимальное количество ассетов в одном уведомлении.",
|
||||
"periodicStartDate": "Опорная дата для расчёта интервалов. Сводки отправляются каждые N дней от этой даты.",
|
||||
"times": "Время отправки уведомлений в формате ЧЧ:ММ. Для нескольких значений через запятую: 09:00,18:00",
|
||||
"albumMode": "По альбому: отдельное уведомление для каждого. Объединённый: одно уведомление со всеми. Случайный: выбирается один альбом.",
|
||||
"minRating": "Включать только ассеты с рейтингом не ниже указанного (0 = без фильтра).",
|
||||
"eventMessages": "Шаблоны уведомлений о событиях в реальном времени. Используйте {переменные} для динамического контента.",
|
||||
"assetFormatting": "Форматирование отдельных ассетов в сообщениях уведомлений.",
|
||||
"dateLocation": "Форматирование даты и местоположения. Использует синтаксис strftime для дат.",
|
||||
"scheduledMessages": "Шаблоны для периодических сводок, подборок фото и воспоминаний «В этот день».",
|
||||
"aiCaptions": "Использовать Claude AI для генерации описания уведомления вместо шаблона.",
|
||||
"maxMedia": "Максимальное количество фото/видео в одном уведомлении (0 = только текст).",
|
||||
"groupSize": "Медиагруппы Telegram содержат 2-10 элементов. Большие пакеты разбиваются на части.",
|
||||
"chunkDelay": "Задержка в миллисекундах между отправкой порций медиа. Предотвращает ограничение Telegram.",
|
||||
"maxAssetSize": "Пропускать файлы больше указанного размера в МБ. Лимит Telegram — 50 МБ.",
|
||||
"trackingConfig": "Управляет тем, какие события вызывают уведомления и как фильтруются ассеты.",
|
||||
"templateConfig": "Управляет форматом сообщений. Используются шаблоны по умолчанию, если не задано.",
|
||||
"scanInterval": "Как часто опрашивать провайдер на предмет изменений (в секундах). Меньше = быстрее обнаружение, но больше запросов к API.",
|
||||
"batchDuration": "Время накопления изменений перед отправкой уведомлений. 0 = отправлять сразу.",
|
||||
"defaultCount": "Сколько результатов возвращать, если пользователь не указал количество (1-20).",
|
||||
"responseMode": "Медиа: отправка фото. Текст: только имена файлов/ссылки. Медиа-режим использует больше трафика.",
|
||||
"botLocale": "Язык описаний команд в меню Telegram и ответов бота.",
|
||||
"rateLimits": "Кулдаун в секундах между использованиями команд в каждом чате. 0 = без ограничений."
|
||||
},
|
||||
"snack": {
|
||||
"providerSaved": "Провайдер сохранён",
|
||||
"providerDeleted": "Провайдер удалён",
|
||||
"trackerCreated": "Трекер создан",
|
||||
"trackerUpdated": "Трекер обновлён",
|
||||
"trackerDeleted": "Трекер удалён",
|
||||
"trackerPaused": "Трекер приостановлен",
|
||||
"trackerResumed": "Трекер возобновлён",
|
||||
"targetSaved": "Цель сохранена",
|
||||
"targetDeleted": "Цель удалена",
|
||||
"targetTestSent": "Тестовое уведомление отправлено",
|
||||
"templateSaved": "Шаблон сохранён",
|
||||
"templateDeleted": "Шаблон удалён",
|
||||
"trackingConfigSaved": "Конфигурация сохранена",
|
||||
"trackingConfigDeleted": "Конфигурация удалена",
|
||||
"botRegistered": "Бот зарегистрирован",
|
||||
"botDeleted": "Бот удалён",
|
||||
"userCreated": "Пользователь создан",
|
||||
"userDeleted": "Пользователь удалён",
|
||||
"passwordChanged": "Пароль изменён",
|
||||
"copied": "Скопировано",
|
||||
"genericError": "Что-то пошло не так",
|
||||
"commandsSaved": "Конфигурация команд сохранена",
|
||||
"commandsSynced": "Команды синхронизированы с Telegram",
|
||||
"targetLinked": "Получатель привязан",
|
||||
"targetUnlinked": "Получатель отвязан",
|
||||
"botUpdated": "Бот обновлён"
|
||||
},
|
||||
"common": {
|
||||
"loading": "Загрузка...",
|
||||
"save": "Сохранить",
|
||||
"cancel": "Отмена",
|
||||
"delete": "Удалить",
|
||||
"edit": "Редактировать",
|
||||
"description": "Описание",
|
||||
"close": "Закрыть",
|
||||
"confirm": "Подтвердить",
|
||||
"error": "Ошибка",
|
||||
"success": "Успешно",
|
||||
"none": "Нет",
|
||||
"noneDefault": "Нет (по умолчанию)",
|
||||
"loadError": "Не удалось загрузить данные",
|
||||
"headersInvalid": "Невалидный JSON",
|
||||
"language": "Язык",
|
||||
"theme": "Тема",
|
||||
"light": "Светлая",
|
||||
"dark": "Тёмная",
|
||||
"system": "Системная",
|
||||
"test": "Тест",
|
||||
"create": "Создать",
|
||||
"changePassword": "Сменить пароль",
|
||||
"currentPassword": "Текущий пароль",
|
||||
"newPassword": "Новый пароль",
|
||||
"passwordChanged": "Пароль успешно изменён",
|
||||
"expand": "Развернуть",
|
||||
"collapse": "Свернуть",
|
||||
"syntaxError": "Ошибка синтаксиса",
|
||||
"undefinedVar": "Неизвестная переменная",
|
||||
"line": "строка",
|
||||
"add": "Добавить"
|
||||
}
|
||||
}
|
||||
@@ -229,13 +229,13 @@
|
||||
<!-- Mobile bottom nav -->
|
||||
<nav class="mobile-nav" style="position: fixed; bottom: 0; left: 0; right: 0; z-index: 50; background: var(--color-sidebar); border-top: 1px solid var(--color-border); display: none; justify-content: space-around; padding: 0.375rem 0; backdrop-filter: blur(12px);">
|
||||
{#each navItems.slice(0, 5) as item}
|
||||
<a href={item.href}
|
||||
<a href={item.href} aria-label={t(item.key)}
|
||||
class="flex flex-col items-center gap-0.5 px-2 py-1.5 text-xs rounded-lg transition-all duration-200"
|
||||
style="color: {isActive(item.href) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'};">
|
||||
<MdiIcon name={item.icon} size={20} />
|
||||
</a>
|
||||
{/each}
|
||||
<button onclick={logout}
|
||||
<button onclick={logout} aria-label={t('nav.logout')}
|
||||
class="flex flex-col items-center gap-0.5 px-2 py-1.5 text-xs" style="color: var(--color-muted-foreground);">
|
||||
<MdiIcon name="mdiLogout" size={20} />
|
||||
</button>
|
||||
|
||||
@@ -31,17 +31,7 @@
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const [providers, trackers, targets] = await Promise.all([
|
||||
api<any[]>('/providers'),
|
||||
api<any[]>('/trackers'),
|
||||
api<any[]>('/targets'),
|
||||
]);
|
||||
status = {
|
||||
providers: providers.length,
|
||||
trackers: { active: trackers.filter((t: any) => t.enabled).length, total: trackers.length },
|
||||
targets: targets.length,
|
||||
recent_events: [],
|
||||
};
|
||||
status = await api<any>('/status');
|
||||
setTimeout(() => {
|
||||
animateCount(0, status.providers, (v) => displayProviders = v);
|
||||
animateCount(0, status.trackers.active, (v) => displayActive = v);
|
||||
@@ -64,13 +54,21 @@
|
||||
function timeAgo(dateStr: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
if (mins < 1) return t('dashboard.justNow');
|
||||
if (mins < 60) return t('dashboard.minutesAgo').replace('{n}', String(mins));
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return `${Math.floor(hours / 24)}d ago`;
|
||||
if (hours < 24) return t('dashboard.hoursAgo').replace('{n}', String(hours));
|
||||
return t('dashboard.daysAgo').replace('{n}', String(Math.floor(hours / 24)));
|
||||
}
|
||||
|
||||
const eventLabels: Record<string, string> = {
|
||||
assets_added: 'dashboard.assetsAdded',
|
||||
assets_removed: 'dashboard.assetsRemoved',
|
||||
collection_renamed: 'dashboard.collectionRenamed',
|
||||
collection_deleted: 'dashboard.collectionDeleted',
|
||||
sharing_changed: 'dashboard.sharingChanged',
|
||||
};
|
||||
|
||||
const eventIcons: Record<string, string> = {
|
||||
assets_added: 'mdiImagePlus', assets_removed: 'mdiImageMinus',
|
||||
collection_renamed: 'mdiRename', collection_deleted: 'mdiDeleteAlert', sharing_changed: 'mdiShareVariant',
|
||||
@@ -137,7 +135,7 @@
|
||||
<MdiIcon name={eventIcons[event.event_type] || 'mdiBell'} size={16} />
|
||||
</span>
|
||||
<span class="text-sm font-medium truncate">{event.collection_name}</span>
|
||||
<span class="event-badge">{event.event_type.replace('_', ' ')}</span>
|
||||
<span class="event-badge">{t(eventLabels[event.event_type] || event.event_type)}</span>
|
||||
</div>
|
||||
<span class="text-xs whitespace-nowrap font-mono" style="color: var(--color-muted-foreground);">{timeAgo(event.created_at)}</span>
|
||||
</div>
|
||||
|
||||
@@ -1,96 +1,208 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
import IconPicker from '$lib/components/IconPicker.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
|
||||
let providers = $state<any[]>([]);
|
||||
let showForm = $state(false);
|
||||
let editing = $state<number | null>(null);
|
||||
let form = $state({ name: 'Immich', type: 'immich', url: '', api_key: '', external_domain: '', icon: '' });
|
||||
let error = $state('');
|
||||
let loadError = $state('');
|
||||
let submitting = $state(false);
|
||||
let loaded = $state(false);
|
||||
let confirmDelete = $state<any>(null);
|
||||
|
||||
let deleteTarget = $state<any>(null);
|
||||
let health = $state<Record<number, boolean | null>>({});
|
||||
|
||||
onMount(async () => {
|
||||
await loadProviders();
|
||||
});
|
||||
|
||||
async function loadProviders() {
|
||||
onMount(load);
|
||||
async function load() {
|
||||
try {
|
||||
providers = await api('/providers');
|
||||
loadError = '';
|
||||
} catch (err: any) {
|
||||
snackError(err.message);
|
||||
} finally {
|
||||
loaded = true;
|
||||
loadError = err.message || t('providers.loadError');
|
||||
} finally { loaded = true; }
|
||||
// Ping all providers in background
|
||||
for (const p of providers) {
|
||||
health = { ...health, [p.id]: null };
|
||||
api(`/providers/${p.id}/test`, { method: 'POST' })
|
||||
.then((r: any) => { health = { ...health, [p.id]: r.ok }; })
|
||||
.catch(() => { health = { ...health, [p.id]: false }; });
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteProvider() {
|
||||
if (!deleteTarget) return;
|
||||
function openNew() {
|
||||
form = { name: 'Immich', type: 'immich', url: '', api_key: '', external_domain: '', icon: '' };
|
||||
editing = null; showForm = true;
|
||||
}
|
||||
function edit(p: any) {
|
||||
const cfg = p.config || {};
|
||||
form = { name: p.name, type: p.type, url: cfg.url || '', api_key: '', external_domain: cfg.external_domain || '', icon: p.icon || '' };
|
||||
editing = p.id; showForm = true;
|
||||
}
|
||||
|
||||
async function save(e: SubmitEvent) {
|
||||
e.preventDefault(); error = ''; submitting = true;
|
||||
try {
|
||||
await api(`/providers/${deleteTarget.id}`, { method: 'DELETE' });
|
||||
snackSuccess(t('snack.providerDeleted'));
|
||||
deleteTarget = null;
|
||||
await loadProviders();
|
||||
} catch (err: any) {
|
||||
snackError(err.message);
|
||||
}
|
||||
const config: any = { url: form.url };
|
||||
if (form.api_key) config.api_key = form.api_key;
|
||||
if (form.external_domain) config.external_domain = form.external_domain;
|
||||
if (editing) {
|
||||
await api(`/providers/${editing}`, { method: 'PUT', body: JSON.stringify({ name: form.name, icon: form.icon, config }) });
|
||||
} else {
|
||||
config.api_key = form.api_key; // required on create
|
||||
await api('/providers', { method: 'POST', body: JSON.stringify({ type: form.type, name: form.name, icon: form.icon, config }) });
|
||||
}
|
||||
showForm = false; editing = null; await load();
|
||||
snackSuccess(t('snack.providerSaved'));
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
submitting = false;
|
||||
}
|
||||
|
||||
function startDelete(provider: any) { confirmDelete = provider; }
|
||||
async function doDelete() {
|
||||
if (!confirmDelete) return;
|
||||
const id = confirmDelete.id;
|
||||
confirmDelete = null;
|
||||
try { await api(`/providers/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.providerDeleted')); }
|
||||
catch (err: any) { error = err.message; snackError(err.message); }
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageHeader title={t('providers.title')} description={t('providers.description')}>
|
||||
<a href="/providers/new"
|
||||
class="flex items-center gap-2 px-4 py-2 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'; }}>
|
||||
<MdiIcon name="mdiPlus" size={16} />
|
||||
{t('providers.addProvider')}
|
||||
</a>
|
||||
<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('providers.cancel') : t('providers.addProvider')}
|
||||
</button>
|
||||
</PageHeader>
|
||||
|
||||
{#if !loaded}
|
||||
<Loading />
|
||||
{:else if providers.length === 0}
|
||||
{:else}
|
||||
|
||||
{#if loadError}
|
||||
<Card class="mb-6">
|
||||
<div class="flex items-center gap-2 text-sm" style="color: var(--color-error-fg);">
|
||||
<MdiIcon name="mdiAlertCircle" size={18} />
|
||||
{loadError}
|
||||
</div>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
{#if showForm}
|
||||
<div in:slide={{ duration: 200 }}>
|
||||
<Card class="mb-6">
|
||||
{#if error}
|
||||
<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>
|
||||
{/if}
|
||||
<form onsubmit={save} class="space-y-3">
|
||||
<div>
|
||||
<label for="prv-type" class="block text-sm font-medium mb-1">{t('providers.type')}</label>
|
||||
<select id="prv-type" bind:value={form.type} disabled={!!editing}
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] disabled:opacity-60">
|
||||
<option value="immich">Immich</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="prv-name" class="block text-sm font-medium mb-1">{t('providers.name')}</label>
|
||||
<div class="flex gap-2">
|
||||
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
|
||||
<input id="prv-name" bind:value={form.name} required 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="prv-url" class="block text-sm font-medium mb-1">{t('providers.url')}</label>
|
||||
<input id="prv-url" bind:value={form.url} required placeholder={t('providers.urlPlaceholder')} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="prv-key" class="block text-sm font-medium mb-1">{editing ? t('providers.apiKeyKeep') : t('providers.apiKey')}</label>
|
||||
<input id="prv-key" bind:value={form.api_key} type="password" required={!editing} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="prv-ext" class="block text-sm font-medium mb-1">{t('providers.externalDomain')} <span class="text-xs text-[var(--color-muted-foreground)]">({t('providers.optional')})</span></label>
|
||||
<input id="prv-ext" bind:value={form.external_domain} placeholder="https://photos.example.com" 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={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('providers.connecting') : (editing ? t('common.save') : t('providers.addProvider'))}
|
||||
</button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if providers.length === 0 && !showForm}
|
||||
<Card>
|
||||
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
|
||||
<div style="opacity: 0.4;">
|
||||
<MdiIcon name="mdiServer" size={40} />
|
||||
</div>
|
||||
<div style="opacity: 0.4;"><MdiIcon name="mdiServer" size={40} /></div>
|
||||
<p class="text-sm">{t('providers.noProviders')}</p>
|
||||
</div>
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 stagger-children">
|
||||
<div class="space-y-3 stagger-children">
|
||||
{#each providers as provider}
|
||||
<Card hover>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-lg"
|
||||
style="background: var(--color-primary); color: var(--color-primary-foreground); opacity: 0.9;">
|
||||
<MdiIcon name={provider.icon || 'mdiServer'} size={20} />
|
||||
</div>
|
||||
<div class="health-dot {health[provider.id] === true ? 'online' : health[provider.id] === false ? 'offline' : 'checking'}"></div>
|
||||
{#if provider.icon}
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={provider.icon} size={20} /></span>
|
||||
{/if}
|
||||
<div>
|
||||
<h3 class="font-medium text-sm">{provider.name}</h3>
|
||||
<p class="text-xs capitalize" style="color: var(--color-muted-foreground);">{provider.type}</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="font-medium">{provider.name}</p>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{provider.type}</span>
|
||||
</div>
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{provider.config?.url || ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
<IconButton icon="mdiDelete" variant="danger" title={t('providers.delete')}
|
||||
onclick={() => deleteTarget = provider} />
|
||||
<div class="flex items-center gap-1">
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(provider)} />
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => startDelete(provider)} variant="danger" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<ConfirmModal
|
||||
open={!!deleteTarget}
|
||||
title={t('providers.confirmDelete')}
|
||||
message={deleteTarget?.name || ''}
|
||||
onconfirm={deleteProvider}
|
||||
oncancel={() => deleteTarget = null}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<ConfirmModal open={!!confirmDelete} message={t('providers.confirmDelete')}
|
||||
onconfirm={doDelete} oncancel={() => confirmDelete = null} />
|
||||
|
||||
<style>
|
||||
.health-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.health-dot.online {
|
||||
background: #059669;
|
||||
box-shadow: 0 0 8px rgba(5, 150, 105, 0.4);
|
||||
}
|
||||
.health-dot.offline {
|
||||
background: #ef4444;
|
||||
box-shadow: 0 0 8px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
.health-dot.checking {
|
||||
background: #f59e0b;
|
||||
animation: pulseCheck 1.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulseCheck {
|
||||
0%, 100% { box-shadow: 0 0 4px rgba(245, 158, 11, 0.3); }
|
||||
50% { box-shadow: 0 0 12px rgba(245, 158, 11, 0.6); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,133 +1,111 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n/index.svelte.ts';
|
||||
import { api } from '$lib/api.ts';
|
||||
import { api } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import IconPicker from '$lib/components/IconPicker.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
|
||||
let providerType = $state('immich');
|
||||
let name = $state('');
|
||||
let icon = $state('');
|
||||
let url = $state('');
|
||||
let apiKey = $state('');
|
||||
let externalDomain = $state('');
|
||||
let error = $state('');
|
||||
let testing = $state(false);
|
||||
let testResult = $state<{ ok: boolean; message: string } | null>(null);
|
||||
let saving = $state(false);
|
||||
|
||||
async function testConnection() {
|
||||
if (!url || !apiKey) {
|
||||
error = 'URL and API Key are required';
|
||||
return;
|
||||
}
|
||||
testing = true;
|
||||
testResult = null;
|
||||
error = '';
|
||||
async function testAndSave() {
|
||||
if (!url || !apiKey) { error = 'URL and API Key are required'; return; }
|
||||
testing = true; error = '';
|
||||
let createdId: number | null = null;
|
||||
try {
|
||||
// Save first to get an ID, then test
|
||||
const provider = await api.post<any>('/providers', {
|
||||
type: providerType,
|
||||
name: name || 'Immich',
|
||||
config: { url, api_key: apiKey, external_domain: externalDomain || undefined },
|
||||
const provider = await api('/providers', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ type: providerType, name: name || 'Immich', icon, config: { url, api_key: apiKey, external_domain: externalDomain || undefined } }),
|
||||
});
|
||||
testResult = await api.post<{ ok: boolean; message: string }>(`/providers/${provider.id}/test`);
|
||||
if (!testResult.ok) {
|
||||
// Clean up failed provider
|
||||
await api.delete(`/providers/${provider.id}`);
|
||||
createdId = provider.id;
|
||||
const result = await api(`/providers/${provider.id}/test`, { method: 'POST' });
|
||||
if (!result.ok) {
|
||||
await api(`/providers/${provider.id}`, { method: 'DELETE' }).catch(() => {});
|
||||
createdId = null;
|
||||
error = result.message || 'Connection test failed';
|
||||
snackError(error);
|
||||
} else {
|
||||
// Success — redirect to providers list
|
||||
snackSuccess(t('snack.providerSaved'));
|
||||
window.location.href = '/providers';
|
||||
return;
|
||||
}
|
||||
} catch (e: any) {
|
||||
error = e.message || 'Test failed';
|
||||
} finally {
|
||||
testing = false;
|
||||
if (createdId) await api(`/providers/${createdId}`, { method: 'DELETE' }).catch(() => {});
|
||||
error = e.message || 'Test failed'; snackError(error);
|
||||
}
|
||||
finally { testing = false; }
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!url || !apiKey) {
|
||||
error = 'URL and API Key are required';
|
||||
return;
|
||||
}
|
||||
saving = true;
|
||||
error = '';
|
||||
async function saveWithoutTest() {
|
||||
if (!url || !apiKey) { error = 'URL and API Key are required'; return; }
|
||||
saving = true; error = '';
|
||||
try {
|
||||
await api.post('/providers', {
|
||||
type: providerType,
|
||||
name: name || 'Immich',
|
||||
config: { url, api_key: apiKey, external_domain: externalDomain || undefined },
|
||||
await api('/providers', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ type: providerType, name: name || 'Immich', icon, config: { url, api_key: apiKey, external_domain: externalDomain || undefined } }),
|
||||
});
|
||||
snackSuccess(t('snack.providerSaved'));
|
||||
window.location.href = '/providers';
|
||||
} catch (e: any) {
|
||||
error = e.message || 'Save failed';
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
} catch (e: any) { error = e.message || 'Save failed'; snackError(error); }
|
||||
finally { saving = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-6 max-w-2xl mx-auto">
|
||||
<div class="mb-6">
|
||||
<a href="/providers" class="text-sm text-muted-foreground hover:text-foreground">← Back to Providers</a>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<a href="/providers" class="text-sm text-[var(--color-muted-foreground)] hover:underline">← {t('providers.title')}</a>
|
||||
</div>
|
||||
|
||||
<h1 class="text-2xl font-bold mb-6">{t('provider.addProvider')}</h1>
|
||||
<h2 class="text-xl font-semibold mb-8">{t('providers.addProvider')}</h2>
|
||||
|
||||
<div class="bg-card rounded-xl border border-border p-6 space-y-5">
|
||||
<!-- Provider Type -->
|
||||
<Card>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-muted-foreground mb-1">Provider Type</label>
|
||||
<select bind:value={providerType} class="w-full px-3 py-2 border border-border rounded-[var(--radius)] bg-background">
|
||||
<option value="immich">{t('provider.immich')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-muted-foreground mb-1">Name</label>
|
||||
<input type="text" bind:value={name} placeholder="My Immich Server" class="w-full px-3 py-2 border border-border rounded-[var(--radius)]" />
|
||||
<label for="prv-name" class="block text-sm font-medium mb-1">{t('providers.name')}</label>
|
||||
<div class="flex gap-2">
|
||||
<IconPicker value={icon} onselect={(v: string) => icon = v} />
|
||||
<input id="prv-name" bind:value={name} placeholder="My Immich Server" class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if providerType === 'immich'}
|
||||
<!-- Immich URL -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-muted-foreground mb-1">Server URL <span class="text-destructive">*</span></label>
|
||||
<input type="url" bind:value={url} placeholder="http://192.168.1.100:2283" class="w-full px-3 py-2 border border-border rounded-[var(--radius)]" required />
|
||||
<label for="prv-url" class="block text-sm font-medium mb-1">{t('providers.url')}</label>
|
||||
<input id="prv-url" type="url" bind:value={url} required placeholder={t('providers.urlPlaceholder')} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
|
||||
<!-- API Key -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-muted-foreground mb-1">API Key <span class="text-destructive">*</span></label>
|
||||
<input type="password" bind:value={apiKey} placeholder="Your Immich API key" autocomplete="off" class="w-full px-3 py-2 border border-border rounded-[var(--radius)]" required />
|
||||
<label for="prv-key" class="block text-sm font-medium mb-1">{t('providers.apiKey')}</label>
|
||||
<input id="prv-key" type="password" bind:value={apiKey} required autocomplete="off" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
|
||||
<!-- External Domain -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-muted-foreground mb-1">External Domain <span class="text-muted-foreground font-normal">(optional)</span></label>
|
||||
<input type="url" bind:value={externalDomain} placeholder="https://photos.example.com" class="w-full px-3 py-2 border border-border rounded-[var(--radius)]" />
|
||||
<p class="text-xs text-muted-foreground mt-1">Public-facing URL for notification links. Falls back to server URL.</p>
|
||||
<label for="prv-ext" class="block text-sm font-medium mb-1">{t('providers.externalDomain')} <span class="text-xs text-[var(--color-muted-foreground)]">({t('providers.optional')})</span></label>
|
||||
<input id="prv-ext" type="url" bind:value={externalDomain} placeholder="https://photos.example.com" 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">Public-facing URL for notification links. Falls back to server URL.</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<p class="text-sm text-destructive">{error}</p>
|
||||
{/if}
|
||||
|
||||
{#if testResult}
|
||||
<div class="p-3 rounded-lg {testResult.ok ? 'bg-success-bg text-success-fg' : 'bg-error-bg text-error-fg'}">
|
||||
{testResult.message}
|
||||
</div>
|
||||
<p class="text-sm text-[var(--color-error-fg)]">{error}</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-3 pt-2">
|
||||
<button onclick={testConnection} disabled={testing || saving} class="px-5 py-2.5 bg-primary text-primary-foreground rounded-[var(--radius)] font-medium hover:opacity-90 transition-opacity disabled:opacity-50">
|
||||
{testing ? 'Testing...' : 'Test & Save'}
|
||||
<button onclick={testAndSave} disabled={testing || saving}
|
||||
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">
|
||||
{testing ? t('providers.connecting') : 'Test & Save'}
|
||||
</button>
|
||||
<button onclick={handleSave} disabled={testing || saving} class="px-5 py-2.5 bg-muted text-foreground rounded-[var(--radius)] font-medium hover:bg-accent transition-colors disabled:opacity-50">
|
||||
{saving ? 'Saving...' : 'Save without testing'}
|
||||
<button onclick={saveWithoutTest} disabled={testing || saving}
|
||||
class="px-4 py-2 bg-[var(--color-muted)] text-[var(--color-foreground)] rounded-md text-sm font-medium hover:opacity-80 disabled:opacity-50">
|
||||
{saving ? t('common.loading') : 'Save without testing'}
|
||||
</button>
|
||||
<a href="/providers" class="px-5 py-2.5 bg-muted text-muted-foreground rounded-[var(--radius)] font-medium hover:bg-accent transition-colors">
|
||||
<a href="/providers" class="px-4 py-2 bg-[var(--color-muted)] text-[var(--color-muted-foreground)] rounded-md text-sm font-medium hover:opacity-80">
|
||||
{t('common.cancel')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -1,26 +1,227 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
import IconPicker from '$lib/components/IconPicker.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import Hint from '$lib/components/Hint.svelte';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
|
||||
let targets = $state<any[]>([]);
|
||||
let bots = $state<any[]>([]);
|
||||
let botChats = $state<Record<number, any[]>>({});
|
||||
let showForm = $state(false);
|
||||
let editing = $state<number | null>(null);
|
||||
let formType = $state<'telegram' | 'webhook'>('telegram');
|
||||
const defaultForm = () => ({ name: '', icon: '', bot_id: 0, chat_id: '', bot_token: '', url: '', headers: '',
|
||||
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 });
|
||||
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<any>(null);
|
||||
|
||||
onMount(async () => {
|
||||
try { targets = await api('/targets'); } catch {}
|
||||
loaded = true;
|
||||
});
|
||||
onMount(load);
|
||||
async function load() {
|
||||
try {
|
||||
[targets, bots] = await Promise.all([api('/targets'), api('/telegram-bots')]);
|
||||
loadError = '';
|
||||
} catch (err: any) { loadError = err.message || t('common.loadError'); snackError(loadError); } finally { loaded = true; }
|
||||
}
|
||||
|
||||
async function loadBotChats() {
|
||||
if (!form.bot_id) return;
|
||||
try { botChats[form.bot_id] = await api(`/telegram-bots/${form.bot_id}/chats`); } catch {}
|
||||
}
|
||||
|
||||
function openNew() { form = defaultForm(); formType = 'telegram'; editing = null; showTelegramSettings = false; showForm = true; }
|
||||
async function edit(tgt: any) {
|
||||
formType = tgt.type;
|
||||
const c = tgt.config || {};
|
||||
form = {
|
||||
name: tgt.name, icon: tgt.icon || '', bot_id: c.bot_id || 0, bot_token: '', chat_id: c.chat_id || '', url: c.url || '', headers: '',
|
||||
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,
|
||||
};
|
||||
editing = tgt.id; showTelegramSettings = false; showForm = true;
|
||||
if (form.bot_id) await loadBotChats();
|
||||
}
|
||||
|
||||
async function save(e: SubmitEvent) {
|
||||
e.preventDefault(); error = ''; headersError = '';
|
||||
if (submitting) return;
|
||||
submitting = true;
|
||||
try {
|
||||
let botToken = form.bot_token;
|
||||
if (formType === 'telegram' && form.bot_id && !botToken) {
|
||||
const tokenRes = await api(`/telegram-bots/${form.bot_id}/token`);
|
||||
botToken = tokenRes.token;
|
||||
}
|
||||
let parsedHeaders = {};
|
||||
if (formType === 'webhook' && form.headers) {
|
||||
try { parsedHeaders = JSON.parse(form.headers); }
|
||||
catch { headersError = t('common.headersInvalid'); return; }
|
||||
}
|
||||
const config = formType === 'telegram'
|
||||
? { ...(botToken ? { bot_token: botToken } : {}), chat_id: form.chat_id,
|
||||
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 }
|
||||
: { url: form.url, headers: parsedHeaders, ai_captions: form.ai_captions };
|
||||
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();
|
||||
snackSuccess(t('snack.targetSaved'));
|
||||
} 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`, { method: 'POST' });
|
||||
if (res.success) snackSuccess(t('snack.targetTestSent'));
|
||||
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); }
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageHeader title={t('targets.title')} description={t('targets.description')} />
|
||||
<PageHeader title={t('targets.title')} description={t('targets.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('targets.cancel') : t('targets.addTarget')}
|
||||
</button>
|
||||
</PageHeader>
|
||||
|
||||
{#if !loaded}
|
||||
<Loading />
|
||||
{:else if targets.length === 0}
|
||||
{#if !loaded}<Loading />{:else}
|
||||
|
||||
{#if loadError}
|
||||
<div class="mb-4 p-3 rounded-md text-sm bg-[var(--color-error-bg)] text-[var(--color-error-fg)]">{loadError}</div>
|
||||
{/if}
|
||||
|
||||
{#if showForm}
|
||||
<div in:slide={{ duration: 200 }}>
|
||||
<Card class="mb-6">
|
||||
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
||||
<form onsubmit={save} class="space-y-4">
|
||||
<div>
|
||||
<span class="block text-sm font-medium mb-1">{t('targets.type')}</span>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex items-center gap-1 text-sm"><input type="radio" bind:group={formType} value="telegram" /> Telegram</label>
|
||||
<label class="flex items-center gap-1 text-sm"><input type="radio" bind:group={formType} value="webhook" /> Webhook</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="tgt-name" class="block text-sm font-medium mb-1">{t('targets.name')}</label>
|
||||
<div class="flex gap-2">
|
||||
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
|
||||
<input id="tgt-name" bind:value={form.name} required placeholder={t('targets.namePlaceholder')} class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
{#if formType === 'telegram'}
|
||||
<div>
|
||||
<label for="tgt-bot" class="block text-sm font-medium mb-1">{t('telegramBot.selectBot')}</label>
|
||||
<select id="tgt-bot" bind:value={form.bot_id} onchange={loadBotChats} required
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value={0} disabled>— {t('telegramBot.selectBot')} —</option>
|
||||
{#each bots as bot}<option value={bot.id}>{bot.name} (@{bot.bot_username})</option>{/each}
|
||||
</select>
|
||||
{#if bots.length === 0}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('telegramBot.noBots')} <a href="/telegram-bots" 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">
|
||||
{t('targets.telegramSettings')}
|
||||
<span class="text-xs transition-transform duration-200" class:rotate-180={showTelegramSettings}>▼</span>
|
||||
</button>
|
||||
{#if showTelegramSettings}
|
||||
<div in:slide={{ duration: 150 }} class="grid grid-cols-2 gap-3 mt-3">
|
||||
<div>
|
||||
<label for="tgt-maxmedia" class="block text-xs mb-1">{t('targets.maxMedia')}<Hint text={t('hints.maxMedia')} /></label>
|
||||
<input id="tgt-maxmedia" type="number" bind:value={form.max_media_to_send} min="0" max="50" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="tgt-groupsize" class="block text-xs mb-1">{t('targets.maxGroupSize')}<Hint text={t('hints.groupSize')} /></label>
|
||||
<input id="tgt-groupsize" type="number" bind:value={form.max_media_per_group} min="2" max="10" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="tgt-delay" class="block text-xs mb-1">{t('targets.chunkDelay')}<Hint text={t('hints.chunkDelay')} /></label>
|
||||
<input id="tgt-delay" type="number" bind:value={form.media_delay} min="0" max="60000" step="100" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="tgt-maxsize" class="block text-xs mb-1">{t('targets.maxAssetSize')}<Hint text={t('hints.maxAssetSize')} /></label>
|
||||
<input id="tgt-maxsize" type="number" bind:value={form.max_asset_size} min="1" max="50" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm col-span-2"><input type="checkbox" bind:checked={form.disable_url_preview} /> {t('targets.disableUrlPreview')}</label>
|
||||
<label class="flex items-center gap-2 text-sm col-span-2"><input type="checkbox" bind:checked={form.send_large_photos_as_documents} /> {t('targets.sendLargeAsDocuments')}</label>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<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>
|
||||
{/if}
|
||||
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.ai_captions} /> {t('targets.aiCaptions')}<Hint text={t('hints.aiCaptions')} /></label>
|
||||
|
||||
<button type="submit" disabled={submitting} class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">{submitting ? t('common.loading') : (editing ? t('common.save') : t('targets.create'))}</button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if targets.length === 0 && !showForm}
|
||||
<Card>
|
||||
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
|
||||
<div style="opacity: 0.4;"><MdiIcon name="mdiTarget" size={40} /></div>
|
||||
@@ -28,20 +229,36 @@
|
||||
</div>
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="grid gap-4 sm:grid-cols-2 stagger-children">
|
||||
<div class="space-y-3 stagger-children">
|
||||
{#each targets as target}
|
||||
<Card hover>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-lg"
|
||||
style="background: var(--color-primary); color: var(--color-primary-foreground); opacity: 0.9;">
|
||||
<MdiIcon name={target.type === 'telegram' ? 'mdiTelegram' : 'mdiWebhook'} size={20} />
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="font-medium text-sm">{target.name}</h3>
|
||||
<p class="text-xs capitalize" style="color: var(--color-muted-foreground);">{target.type}</p>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if target.icon}<MdiIcon name={target.icon} />{/if}
|
||||
<p class="font-medium">{target.name}</p>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.type}</span>
|
||||
</div>
|
||||
<p class="text-sm text-[var(--color-muted-foreground)]">
|
||||
{target.type === 'telegram' ? `Chat: ${target.config?.chat_id || '***'}` : target.config?.url || ''}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(target)} />
|
||||
<IconButton icon="mdiSend" title={t('targets.test')} onclick={() => test(target.id)} />
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => confirmDelete = target} variant="danger" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{/if}
|
||||
|
||||
<ConfirmModal
|
||||
open={!!confirmDelete}
|
||||
message={t('targets.confirmDelete')}
|
||||
onconfirm={() => { if (confirmDelete) { remove(confirmDelete.id); confirmDelete = null; } }}
|
||||
oncancel={() => confirmDelete = null}
|
||||
/>
|
||||
|
||||
@@ -1,26 +1,153 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
import IconPicker from '$lib/components/IconPicker.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
import { snackSuccess, snackError, snackInfo } from '$lib/stores/snackbar.svelte';
|
||||
|
||||
let bots = $state<any[]>([]);
|
||||
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);
|
||||
|
||||
onMount(async () => {
|
||||
try { bots = await api('/telegram-bots'); } catch {}
|
||||
loaded = true;
|
||||
});
|
||||
// Per-bot expandable sections
|
||||
let chats = $state<Record<number, any[]>>({});
|
||||
let chatsLoading = $state<Record<number, boolean>>({});
|
||||
let expandedSection = $state<Record<number, string>>({});
|
||||
|
||||
onMount(load);
|
||||
async function load() {
|
||||
try { bots = await api('/telegram-bots'); }
|
||||
catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
||||
finally { loaded = true; }
|
||||
}
|
||||
|
||||
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 }) });
|
||||
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); }
|
||||
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); }
|
||||
}
|
||||
|
||||
function copyChatId(e: Event, chatId: string) {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(chatId);
|
||||
snackInfo(`${t('snack.copied')}: ${chatId}`);
|
||||
}
|
||||
|
||||
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')} />
|
||||
<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 !loaded}
|
||||
<Loading />
|
||||
{:else if bots.length === 0}
|
||||
{#if !loaded}<Loading />{:else}
|
||||
|
||||
{#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>
|
||||
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
|
||||
<div style="opacity: 0.4;"><MdiIcon name="mdiRobot" size={40} /></div>
|
||||
@@ -28,20 +155,68 @@
|
||||
</div>
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="grid gap-4 sm:grid-cols-2 stagger-children">
|
||||
<div class="space-y-3 stagger-children">
|
||||
{#each bots as bot}
|
||||
<Card hover>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-lg"
|
||||
style="background: #229ED9; color: white;">
|
||||
<MdiIcon name="mdiRobot" size={20} />
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="font-medium text-sm">{bot.name}</h3>
|
||||
<p class="text-xs" style="color: var(--color-muted-foreground);">@{bot.bot_username || '...'}</p>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if bot.icon}<MdiIcon name={bot.icon} />{/if}
|
||||
<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}
|
||||
</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>
|
||||
<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>
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} size={14}
|
||||
onclick={(e: MouseEvent) => { e.stopPropagation(); deleteChat(bot.id, chat.id); }} variant="danger" />
|
||||
</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}
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{/if}
|
||||
|
||||
<ConfirmModal open={confirmDelete !== null} message={t('telegramBot.confirmDelete')}
|
||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||
|
||||
@@ -1,26 +1,236 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
import IconPicker from '$lib/components/IconPicker.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import Hint from '$lib/components/Hint.svelte';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import JinjaEditor from '$lib/components/JinjaEditor.svelte';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
|
||||
let configs = $state<any[]>([]);
|
||||
let loaded = $state(false);
|
||||
let varsRef = $state<Record<string, any>>({});
|
||||
let showVarsFor = $state<string | null>(null);
|
||||
let showForm = $state(false);
|
||||
let editing = $state<number | null>(null);
|
||||
let error = $state('');
|
||||
let confirmDelete = $state<any>(null);
|
||||
let slotPreview = $state<Record<string, string>>({});
|
||||
let slotErrors = $state<Record<string, string>>({});
|
||||
let slotErrorLines = $state<Record<string, number | null>>({});
|
||||
let slotErrorTypes = $state<Record<string, string>>({});
|
||||
let validateTimers: Record<string, ReturnType<typeof setTimeout>> = {};
|
||||
|
||||
onMount(async () => {
|
||||
try { configs = await api('/template-configs'); } catch {}
|
||||
loaded = true;
|
||||
function validateSlot(slotKey: string, template: string, immediate = false) {
|
||||
if (validateTimers[slotKey]) clearTimeout(validateTimers[slotKey]);
|
||||
if (!template) {
|
||||
slotErrors = { ...slotErrors, [slotKey]: '' };
|
||||
slotErrorLines = { ...slotErrorLines, [slotKey]: null };
|
||||
slotErrorTypes = { ...slotErrorTypes, [slotKey]: '' };
|
||||
const { [slotKey]: _, ...rest } = slotPreview;
|
||||
slotPreview = rest;
|
||||
return;
|
||||
}
|
||||
|
||||
const doValidate = async () => {
|
||||
try {
|
||||
const res = await api('/template-configs/preview-raw', { method: 'POST', body: JSON.stringify({ template, target_type: previewTargetType }) });
|
||||
slotErrors = { ...slotErrors, [slotKey]: res.error || '' };
|
||||
slotErrorLines = { ...slotErrorLines, [slotKey]: res.error_line || null };
|
||||
slotErrorTypes = { ...slotErrorTypes, [slotKey]: res.error_type || '' };
|
||||
if (res.rendered) {
|
||||
slotPreview = { ...slotPreview, [slotKey]: res.rendered };
|
||||
} else {
|
||||
const { [slotKey]: _, ...rest } = slotPreview;
|
||||
slotPreview = rest;
|
||||
}
|
||||
} catch {
|
||||
slotErrors = { ...slotErrors, [slotKey]: '' };
|
||||
slotErrorLines = { ...slotErrorLines, [slotKey]: null };
|
||||
slotErrorTypes = { ...slotErrorTypes, [slotKey]: '' };
|
||||
}
|
||||
};
|
||||
if (immediate) { doValidate(); }
|
||||
else { validateTimers[slotKey] = setTimeout(doValidate, 800); }
|
||||
}
|
||||
|
||||
function refreshAllPreviews() {
|
||||
for (const group of templateSlots) {
|
||||
for (const slot of group.slots) {
|
||||
const template = (form as any)[slot.key];
|
||||
if (template && slot.key !== 'date_format') {
|
||||
validateSlot(slot.key, template, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const defaultForm = () => ({
|
||||
provider_type: 'immich', name: '', description: '', icon: '',
|
||||
message_assets_added: '',
|
||||
message_assets_removed: '',
|
||||
message_collection_renamed: '',
|
||||
message_collection_deleted: '',
|
||||
message_sharing_changed: '',
|
||||
periodic_summary_message: '',
|
||||
scheduled_assets_message: '',
|
||||
memory_mode_message: '',
|
||||
date_format: '%d.%m.%Y, %H:%M UTC',
|
||||
});
|
||||
let form = $state(defaultForm());
|
||||
let previewTargetType = $state('telegram');
|
||||
|
||||
const templateSlots = [
|
||||
{ group: 'eventMessages', slots: [
|
||||
{ key: 'message_assets_added', label: 'assetsAdded', rows: 10 },
|
||||
{ key: 'message_assets_removed', label: 'assetsRemoved', rows: 3 },
|
||||
{ key: 'message_collection_renamed', label: 'albumRenamed', rows: 2 },
|
||||
{ key: 'message_collection_deleted', label: 'albumDeleted', rows: 2 },
|
||||
{ key: 'message_sharing_changed', label: 'sharingChanged', rows: 2 },
|
||||
]},
|
||||
{ group: 'scheduledMessages', slots: [
|
||||
{ key: 'periodic_summary_message', label: 'periodicSummary', rows: 6 },
|
||||
{ key: 'scheduled_assets_message', label: 'scheduledAssets', rows: 6 },
|
||||
{ key: 'memory_mode_message', label: 'memoryMode', rows: 6 },
|
||||
]},
|
||||
{ group: 'settings', slots: [
|
||||
{ key: 'date_format', label: 'dateFormat', rows: 1 },
|
||||
]},
|
||||
];
|
||||
|
||||
onMount(load);
|
||||
async function load() {
|
||||
try {
|
||||
[configs, varsRef] = await Promise.all([
|
||||
api('/template-configs'),
|
||||
api('/template-configs/variables'),
|
||||
]);
|
||||
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
||||
finally { loaded = true; }
|
||||
}
|
||||
|
||||
function openNew() { form = defaultForm(); editing = null; showForm = true; slotPreview = {}; slotErrors = {}; }
|
||||
function edit(c: any) {
|
||||
form = { ...defaultForm(), ...c }; editing = c.id; showForm = true;
|
||||
slotPreview = {}; slotErrors = {};
|
||||
setTimeout(() => refreshAllPreviews(), 100);
|
||||
}
|
||||
|
||||
async function save(e: SubmitEvent) {
|
||||
e.preventDefault(); error = '';
|
||||
try {
|
||||
if (editing) await api(`/template-configs/${editing}`, { method: 'PUT', body: JSON.stringify(form) });
|
||||
else await api('/template-configs', { method: 'POST', body: JSON.stringify(form) });
|
||||
showForm = false; editing = null; await load();
|
||||
snackSuccess(t('snack.templateSaved'));
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
}
|
||||
|
||||
function remove(id: number) {
|
||||
confirmDelete = {
|
||||
id,
|
||||
onconfirm: async () => {
|
||||
try { await api(`/template-configs/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.templateDeleted')); }
|
||||
catch (err: any) { error = err.message; snackError(err.message); }
|
||||
finally { confirmDelete = null; }
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageHeader title={t('templateConfig.title')} description={t('templateConfig.description')} />
|
||||
<PageHeader title={t('templateConfig.title')} description={t('templateConfig.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('templateConfig.newConfig')}
|
||||
</button>
|
||||
</PageHeader>
|
||||
|
||||
{#if !loaded}
|
||||
<Loading />
|
||||
{:else if configs.length === 0}
|
||||
{#if !loaded}<Loading />{:else}
|
||||
|
||||
{#if showForm}
|
||||
<div in:slide={{ duration: 200 }}>
|
||||
<Card class="mb-6">
|
||||
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
||||
<form onsubmit={save} class="space-y-5">
|
||||
<div>
|
||||
<label for="tpc-name" class="block text-sm font-medium mb-1">{t('templateConfig.name')}</label>
|
||||
<div class="flex gap-2">
|
||||
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
|
||||
<input id="tpc-name" bind:value={form.name} required placeholder={t('templateConfig.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="tpc-desc" class="block text-sm font-medium mb-1">{t('common.description')}</label>
|
||||
<input id="tpc-desc" bind:value={form.description} placeholder={t('templateConfig.descriptionPlaceholder')}
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="preview-target" class="text-sm font-medium">{t('templateConfig.previewAs')}:</label>
|
||||
<select id="preview-target" bind:value={previewTargetType} onchange={refreshAllPreviews}
|
||||
class="px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value="telegram">Telegram</option>
|
||||
<option value="webhook">Webhook</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#each templateSlots as group}
|
||||
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
||||
<legend class="text-sm font-medium px-1">{t(`templateConfig.${group.group}`)}{#if group.group === 'eventMessages'}<Hint text={t('hints.eventMessages')} />{:else if group.group === 'scheduledMessages'}<Hint text={t('hints.scheduledMessages')} />{/if}</legend>
|
||||
<div class="space-y-3 mt-2">
|
||||
{#each group.slots as slot}
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<label class="text-xs text-[var(--color-muted-foreground)]">{t(`templateConfig.${slot.label}`)}</label>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if varsRef[slot.key]}
|
||||
<button type="button" onclick={() => showVarsFor = slot.key}
|
||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templateConfig.variables')}</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if slot.key === 'date_format'}
|
||||
<input bind:value={(form as any)[slot.key]}
|
||||
class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] font-mono" />
|
||||
{:else}
|
||||
<JinjaEditor value={(form as any)[slot.key] || ''} onchange={(v: string) => { (form as any)[slot.key] = v; validateSlot(slot.key, v); }} rows={slot.rows || 3} errorLine={slotErrorLines[slot.key] || null} />
|
||||
{#if slotErrors[slot.key]}
|
||||
{#if slotErrorTypes[slot.key] === 'undefined'}
|
||||
<p class="mt-1 text-xs" style="color: #d97706;">⚠ {t('common.undefinedVar')}: {slotErrors[slot.key]}</p>
|
||||
{:else}
|
||||
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">✕ {t('common.syntaxError')}: {slotErrors[slot.key]}{slotErrorLines[slot.key] ? ` (${t('common.line')} ${slotErrorLines[slot.key]})` : ''}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if slotPreview[slot.key] && !slotErrors[slot.key]}
|
||||
<div class="mt-1 p-2 bg-[var(--color-muted)] rounded text-sm">
|
||||
<pre class="whitespace-pre-wrap text-xs">{slotPreview[slot.key]}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
{/each}
|
||||
|
||||
<button type="submit" class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||
{editing ? t('common.save') : t('common.create')}
|
||||
</button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if configs.length === 0 && !showForm}
|
||||
<Card>
|
||||
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
|
||||
<div style="opacity: 0.4;"><MdiIcon name="mdiFileDocumentEdit" size={40} /></div>
|
||||
@@ -28,24 +238,71 @@
|
||||
</div>
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="grid gap-4 sm:grid-cols-2 stagger-children">
|
||||
<div class="space-y-3 stagger-children">
|
||||
{#each configs as config}
|
||||
<Card hover>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-lg"
|
||||
style="background: var(--color-accent); color: var(--color-accent-foreground);">
|
||||
<MdiIcon name={config.icon || 'mdiFileDocumentEdit'} size={20} />
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
{#if config.icon}<MdiIcon name={config.icon} />{/if}
|
||||
<p class="font-medium">{config.name}</p>
|
||||
{#if config.user_id === 0}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">System</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if config.description}
|
||||
<p class="text-sm text-[var(--color-muted-foreground)] mt-1">{config.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-medium text-sm">{config.name}</h3>
|
||||
<p class="text-xs" style="color: var(--color-muted-foreground);">{config.description || config.provider_type}</p>
|
||||
<div class="flex items-center gap-1 ml-4">
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(config)} />
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(config.id)} variant="danger" />
|
||||
</div>
|
||||
{#if config.user_id === 0}
|
||||
<span class="ml-auto text-xs px-2 py-0.5 rounded-full"
|
||||
style="background: var(--color-muted); color: var(--color-muted-foreground);">System</span>
|
||||
{/if}
|
||||
</div>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{/if}
|
||||
|
||||
<ConfirmModal open={confirmDelete !== null} message={t('templateConfig.confirmDelete')}
|
||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||
|
||||
<!-- Variables reference modal -->
|
||||
<Modal open={showVarsFor !== null} title="{t('templateConfig.variables')}: {showVarsFor ? t(`templateConfig.${templateSlots.flatMap(g => g.slots).find(s => s.key === showVarsFor)?.label || showVarsFor}`) : ''}" onclose={() => showVarsFor = null}>
|
||||
{#if showVarsFor && varsRef[showVarsFor]}
|
||||
<p class="text-sm text-[var(--color-muted-foreground)] mb-3">{t(`templateVars.${showVarsFor}.description`, varsRef[showVarsFor].description)}</p>
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs font-medium mb-1">{t('templateConfig.variables')}:</p>
|
||||
{#each Object.entries(varsRef[showVarsFor].variables || {}) as [name, desc]}
|
||||
<div class="flex items-start gap-2 text-sm">
|
||||
<code class="text-xs bg-[var(--color-muted)] px-1 py-0.5 rounded font-mono whitespace-nowrap">{'{{ ' + name + ' }}'}</code>
|
||||
<span class="text-xs text-[var(--color-muted-foreground)]">{t(`templateVars.${name}`, desc as string)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if varsRef[showVarsFor].asset_fields && typeof varsRef[showVarsFor].asset_fields === 'object'}
|
||||
<div class="mt-3 pt-3 border-t border-[var(--color-border)]">
|
||||
<p class="text-xs font-medium mb-1">{t('templateConfig.assetFields')}:</p>
|
||||
{#each Object.entries(varsRef[showVarsFor].asset_fields) as [name, desc]}
|
||||
<div class="flex items-start gap-2 text-sm">
|
||||
<code class="text-xs bg-[var(--color-muted)] px-1 py-0.5 rounded font-mono whitespace-nowrap">{'{{ asset.' + name + ' }}'}</code>
|
||||
<span class="text-xs text-[var(--color-muted-foreground)]">{t(`templateVars.asset_${name}`, desc as string)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if varsRef[showVarsFor].album_fields && typeof varsRef[showVarsFor].album_fields === 'object'}
|
||||
<div class="mt-3 pt-3 border-t border-[var(--color-border)]">
|
||||
<p class="text-xs font-medium mb-1">{t('templateConfig.albumFields')}:</p>
|
||||
{#each Object.entries(varsRef[showVarsFor].album_fields) as [name, desc]}
|
||||
<div class="flex items-start gap-2 text-sm">
|
||||
<code class="text-xs bg-[var(--color-muted)] px-1 py-0.5 rounded font-mono whitespace-nowrap">{'{{ album.' + name + ' }}'}</code>
|
||||
<span class="text-xs text-[var(--color-muted-foreground)]">{t(`templateVars.album_${name}`, desc as string)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
@@ -1,53 +1,382 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
import IconPicker from '$lib/components/IconPicker.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import Hint from '$lib/components/Hint.svelte';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
|
||||
let trackers = $state<any[]>([]);
|
||||
let loaded = $state(false);
|
||||
let loadError = $state('');
|
||||
let trackers = $state<any[]>([]);
|
||||
let providers = $state<any[]>([]);
|
||||
let targets = $state<any[]>([]);
|
||||
let trackingConfigs = $state<any[]>([]);
|
||||
let templateConfigs = $state<any[]>([]);
|
||||
let collections = $state<any[]>([]);
|
||||
let showForm = $state(false);
|
||||
let editing = $state<number | null>(null);
|
||||
let collectionFilter = $state('');
|
||||
let submitting = $state(false);
|
||||
let confirmDelete = $state<any>(null);
|
||||
let toggling = $state<Record<number, boolean>>({});
|
||||
// Per tracker-target test state (keyed by `${ttId}_${testType}`)
|
||||
let ttTesting = $state<Record<string, string>>({});
|
||||
let ttFeedback = $state<Record<string, string>>({});
|
||||
|
||||
onMount(async () => {
|
||||
try { trackers = await api('/trackers'); } catch {}
|
||||
loaded = true;
|
||||
// Tracker form
|
||||
const defaultForm = () => ({
|
||||
name: '', icon: '', provider_id: 0, collection_ids: [] as string[],
|
||||
scan_interval: 60, batch_duration: 0,
|
||||
});
|
||||
let form = $state(defaultForm());
|
||||
let error = $state('');
|
||||
|
||||
// Linked targets management (inline in tracker detail)
|
||||
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>>({});
|
||||
|
||||
onMount(load);
|
||||
async function load() {
|
||||
loadError = '';
|
||||
try {
|
||||
[trackers, providers, targets, trackingConfigs, templateConfigs] = await Promise.all([
|
||||
api('/trackers'), api('/providers'), api('/targets'),
|
||||
api('/tracking-configs'), api('/template-configs'),
|
||||
]);
|
||||
} catch (err: any) {
|
||||
loadError = err.message || 'Failed to load data';
|
||||
snackError(loadError);
|
||||
} finally { loaded = true; }
|
||||
}
|
||||
async function loadCollections() {
|
||||
if (!form.provider_id) return;
|
||||
try { collections = await api(`/providers/${form.provider_id}/collections`); } catch { collections = []; }
|
||||
}
|
||||
|
||||
function openNew() { form = defaultForm(); editing = null; showForm = true; collections = []; }
|
||||
async function edit(trk: any) {
|
||||
form = {
|
||||
name: trk.name, icon: trk.icon || '', provider_id: trk.provider_id,
|
||||
collection_ids: [...(trk.collection_ids || [])],
|
||||
scan_interval: trk.scan_interval, batch_duration: trk.batch_duration ?? 0,
|
||||
};
|
||||
editing = trk.id; showForm = true;
|
||||
if (form.provider_id) await loadCollections();
|
||||
}
|
||||
|
||||
async function save(e: SubmitEvent) {
|
||||
e.preventDefault(); error = '';
|
||||
if (submitting) return;
|
||||
submitting = true;
|
||||
try {
|
||||
if (editing) {
|
||||
await api(`/trackers/${editing}`, { method: 'PUT', body: JSON.stringify(form) });
|
||||
snackSuccess(t('snack.trackerUpdated'));
|
||||
} else {
|
||||
await api('/trackers', { method: 'POST', body: JSON.stringify(form) });
|
||||
snackSuccess(t('snack.trackerCreated'));
|
||||
}
|
||||
showForm = false; editing = null; await load();
|
||||
} catch (err: any) { error = err.message; snackError(err.message); } finally { submitting = false; }
|
||||
}
|
||||
async function toggle(tracker: any) {
|
||||
if (toggling[tracker.id]) return;
|
||||
toggling = { ...toggling, [tracker.id]: true };
|
||||
try {
|
||||
await api(`/trackers/${tracker.id}`, { method: 'PUT', body: JSON.stringify({ enabled: !tracker.enabled }) });
|
||||
await load();
|
||||
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 {
|
||||
await api(`/trackers/${confirmDelete.id}`, { method: 'DELETE' });
|
||||
await load();
|
||||
snackSuccess(t('snack.trackerDeleted'));
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
confirmDelete = null;
|
||||
}
|
||||
async function testTrackerTarget(trackerId: number, ttId: number, testType: string) {
|
||||
const key = `${ttId}_${testType}`;
|
||||
if (ttTesting[key]) return;
|
||||
ttTesting = { ...ttTesting, [key]: testType };
|
||||
ttFeedback = { ...ttFeedback, [key]: '' };
|
||||
try {
|
||||
const endpoint = testType === 'basic' ? 'test' : `test-${testType}`;
|
||||
await api(`/trackers/${trackerId}/targets/${ttId}/${endpoint}`, { method: 'POST' });
|
||||
ttFeedback = { ...ttFeedback, [key]: 'ok' };
|
||||
snackSuccess(t('snack.targetTestSent'));
|
||||
} catch (err: any) {
|
||||
ttFeedback = { ...ttFeedback, [key]: 'error' };
|
||||
snackError(err.message);
|
||||
} finally {
|
||||
ttTesting = { ...ttTesting, [key]: '' };
|
||||
setTimeout(() => { ttFeedback = { ...ttFeedback, [key]: '' }; }, 3000);
|
||||
}
|
||||
}
|
||||
function toggleCollection(collectionId: string) { form.collection_ids = form.collection_ids.includes(collectionId) ? form.collection_ids.filter(id => id !== collectionId) : [...form.collection_ids, collectionId]; }
|
||||
|
||||
// --- Linked Targets Management ---
|
||||
function toggleExpand(trackerId: number) {
|
||||
if (expandedTracker === trackerId) { expandedTracker = null; return; }
|
||||
expandedTracker = trackerId;
|
||||
// tracker_targets already loaded in tracker response
|
||||
}
|
||||
|
||||
function getUnlinkedTargets(tracker: any): any[] {
|
||||
const linkedIds = new Set((tracker.tracker_targets || []).map((tt: any) => tt.target_id));
|
||||
return targets.filter(t => !linkedIds.has(t.id));
|
||||
}
|
||||
|
||||
async function addTargetLink(trackerId: number) {
|
||||
const targetId = newLinkTargetId[trackerId];
|
||||
if (!targetId) return;
|
||||
addingTarget = { ...addingTarget, [trackerId]: true };
|
||||
try {
|
||||
await api(`/trackers/${trackerId}/targets`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
target_id: targetId,
|
||||
tracking_config_id: newLinkTrackingConfigId[trackerId] || null,
|
||||
template_config_id: newLinkTemplateConfigId[trackerId] || null,
|
||||
}),
|
||||
});
|
||||
newLinkTargetId[trackerId] = 0;
|
||||
newLinkTrackingConfigId[trackerId] = 0;
|
||||
newLinkTemplateConfigId[trackerId] = 0;
|
||||
await load();
|
||||
snackSuccess(t('snack.targetLinked'));
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
addingTarget = { ...addingTarget, [trackerId]: false };
|
||||
}
|
||||
|
||||
async function removeTargetLink(trackerId: number, ttId: number) {
|
||||
try {
|
||||
await api(`/trackers/${trackerId}/targets/${ttId}`, { method: 'DELETE' });
|
||||
await load();
|
||||
snackSuccess(t('snack.targetUnlinked'));
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
}
|
||||
|
||||
async function updateTargetLink(trackerId: number, tt: any, field: string, value: any) {
|
||||
try {
|
||||
await api(`/trackers/${trackerId}/targets/${tt.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ [field]: value }),
|
||||
});
|
||||
await load();
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageHeader title={t('trackers.title')} description={t('trackers.description')} />
|
||||
<PageHeader title={t('trackers.title')} description={t('trackers.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('trackers.cancel') : t('trackers.newTracker')}
|
||||
</button>
|
||||
</PageHeader>
|
||||
|
||||
{#if !loaded}
|
||||
<Loading />
|
||||
{:else if trackers.length === 0}
|
||||
<Loading />
|
||||
{:else if loadError}
|
||||
<Card>
|
||||
<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('trackers.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('trackers.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="trk-provider" class="block text-sm font-medium mb-1">{t('trackers.server')}</label>
|
||||
<select id="trk-provider" bind:value={form.provider_id} onchange={loadCollections} required class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value={0} disabled>{t('trackers.selectServer')}</option>
|
||||
{#each providers as p}<option value={p.id}>{p.name}</option>{/each}
|
||||
</select>
|
||||
</div>
|
||||
{#if collections.length > 0}
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{t('trackers.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>
|
||||
</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('trackers.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('trackers.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} 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">{editing ? t('common.save') : t('trackers.createTracker')}</button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loaded && !loadError}
|
||||
{#if trackers.length === 0 && !showForm}
|
||||
<Card>
|
||||
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
|
||||
<div style="opacity: 0.4;"><MdiIcon name="mdiRadar" size={40} /></div>
|
||||
<p class="text-sm">{t('trackers.noTrackers')}</p>
|
||||
</div>
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="grid gap-4 sm:grid-cols-2 stagger-children">
|
||||
{:else if !showForm}
|
||||
<div class="space-y-3 stagger-children">
|
||||
{#each trackers as tracker}
|
||||
<Card hover>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-lg"
|
||||
style="background: {tracker.enabled ? 'var(--color-success-bg)' : 'var(--color-muted)'}; color: {tracker.enabled ? 'var(--color-success-fg)' : 'var(--color-muted-foreground)'};">
|
||||
<MdiIcon name="mdiRadar" size={20} />
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="font-medium text-sm">{tracker.name}</h3>
|
||||
<p class="text-xs" style="color: var(--color-muted-foreground);">
|
||||
{tracker.collection_ids?.length || 0} {t('trackers.albums_count')} · {t('trackers.every')} {tracker.scan_interval}s
|
||||
<div class="flex items-center gap-2">
|
||||
{#if tracker.icon}<MdiIcon name={tracker.icon} />{/if}
|
||||
<p class="font-medium">{tracker.name}</p>
|
||||
<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('trackers.active') : t('trackers.paused')}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-[var(--color-muted-foreground)]">
|
||||
{(tracker.collection_ids || []).length} {t('trackers.albums_count')} · {t('trackers.every')} {tracker.scan_interval}s · {(tracker.tracker_targets || []).length} {t('trackers.linkedTargets')}
|
||||
</p>
|
||||
</div>
|
||||
<span class="ml-auto text-xs px-2 py-0.5 rounded-full"
|
||||
style="background: {tracker.enabled ? 'var(--color-success-bg)' : 'var(--color-muted)'}; color: {tracker.enabled ? 'var(--color-success-fg)' : 'var(--color-muted-foreground)'};">
|
||||
{tracker.enabled ? t('trackers.active') : t('trackers.paused')}
|
||||
</span>
|
||||
<div class="flex items-center gap-1 flex-wrap justify-end">
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(tracker)} />
|
||||
<IconButton icon="mdiPlay" title={t('common.test')} onclick={async () => { try { await api(`/trackers/${tracker.id}/trigger`, { method: 'POST' }); snackSuccess(t('snack.targetTestSent')); } catch (err) { snackError((err as any).message); } }} />
|
||||
<IconButton icon={tracker.enabled ? 'mdiPause' : 'mdiPlay'} title={tracker.enabled ? t('trackers.pause') : t('trackers.resume')} onclick={() => toggle(tracker)} disabled={toggling[tracker.id]} />
|
||||
<button onclick={() => toggleExpand(tracker.id)}
|
||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1">
|
||||
{t('trackers.linkedTargets')} {expandedTracker === tracker.id ? '▲' : '▼'}
|
||||
</button>
|
||||
<IconButton icon="mdiDelete" title={t('trackers.delete')} onclick={() => startDelete(tracker)} variant="danger" />
|
||||
</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('trackers.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">
|
||||
{#if tt.target_icon}<MdiIcon name={tt.target_icon} size={16} />{/if}
|
||||
<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('trackers.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 trackingConfigs as tc}<option value={tc.id}>{tc.name}</option>{/each}
|
||||
</select>
|
||||
<select value={tt.template_config_id || 0}
|
||||
onchange={(e: Event) => updateTargetLink(tracker.id, tt, 'template_config_id', Number((e.target as HTMLSelectElement).value) || null)}
|
||||
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
|
||||
<option value={0}>— {t('templateConfig.title')} —</option>
|
||||
{#each templateConfigs as tc}<option value={tc.id}>{tc.name}</option>{/each}
|
||||
</select>
|
||||
<IconButton icon="mdiSend" size={14} title={t('common.test')}
|
||||
onclick={() => testTrackerTarget(tracker.id, tt.id, 'basic')}
|
||||
disabled={!!ttTesting[`${tt.id}_basic`]} />
|
||||
<IconButton icon="mdiCalendarClock" size={14} title={t('trackingConfig.periodicSummary')}
|
||||
onclick={() => testTrackerTarget(tracker.id, tt.id, 'periodic')}
|
||||
disabled={!!ttTesting[`${tt.id}_periodic`]} />
|
||||
<IconButton icon="mdiHistory" size={14} title={t('trackingConfig.memoryMode')}
|
||||
onclick={() => testTrackerTarget(tracker.id, tt.id, 'memory')}
|
||||
disabled={!!ttTesting[`${tt.id}_memory`]} />
|
||||
{#each ['basic', 'periodic', 'memory'] as testType}
|
||||
{#if ttFeedback[`${tt.id}_${testType}`]}
|
||||
<span class="text-xs {ttFeedback[`${tt.id}_${testType}`] === 'ok' ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">
|
||||
{ttFeedback[`${tt.id}_${testType}`] === 'ok' ? '\u2713' : '\u2717'}
|
||||
</span>
|
||||
{/if}
|
||||
{/each}
|
||||
<IconButton icon={tt.enabled ? 'mdiPause' : 'mdiPlay'} size={14}
|
||||
title={tt.enabled ? t('trackers.pause') : t('trackers.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('trackers.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 trackingConfigs as tc}<option value={tc.id}>{tc.name}</option>{/each}
|
||||
</select>
|
||||
<select bind:value={newLinkTemplateConfigId[tracker.id]}
|
||||
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
|
||||
<option value={0}>— {t('templateConfig.title')} —</option>
|
||||
{#each templateConfigs as tc}<option value={tc.id}>{tc.name}</option>{/each}
|
||||
</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>
|
||||
{/if}
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<ConfirmModal
|
||||
open={!!confirmDelete}
|
||||
message={t('trackers.confirmDelete')}
|
||||
onconfirm={doDelete}
|
||||
oncancel={() => confirmDelete = null}
|
||||
/>
|
||||
|
||||
@@ -1,26 +1,200 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
import IconPicker from '$lib/components/IconPicker.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import Hint from '$lib/components/Hint.svelte';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
|
||||
let configs = $state<any[]>([]);
|
||||
let loaded = $state(false);
|
||||
let showForm = $state(false);
|
||||
let editing = $state<number | null>(null);
|
||||
let error = $state('');
|
||||
let confirmDelete = $state<any>(null);
|
||||
|
||||
onMount(async () => {
|
||||
try { configs = await api('/tracking-configs'); } catch {}
|
||||
loaded = true;
|
||||
const defaultForm = () => ({
|
||||
provider_type: 'immich', name: '', icon: '',
|
||||
track_assets_added: true, track_assets_removed: false,
|
||||
track_collection_renamed: true, track_collection_deleted: true, track_sharing_changed: false,
|
||||
track_images: true, track_videos: true, notify_favorites_only: false,
|
||||
include_tags: true, include_asset_details: false,
|
||||
max_assets_to_show: 5, assets_order_by: 'none', assets_order: 'descending',
|
||||
periodic_enabled: false, periodic_interval_days: 1, periodic_start_date: '2025-01-01', periodic_times: '12:00',
|
||||
scheduled_enabled: false, scheduled_times: '09:00', scheduled_collection_mode: 'per_collection',
|
||||
scheduled_limit: 10, scheduled_favorite_only: false, scheduled_asset_type: 'all',
|
||||
scheduled_min_rating: 0, scheduled_order_by: 'random', scheduled_order: 'descending',
|
||||
memory_enabled: false, memory_times: '09:00', memory_collection_mode: 'combined',
|
||||
memory_limit: 10, memory_favorite_only: false, memory_asset_type: 'all', memory_min_rating: 0,
|
||||
});
|
||||
let form = $state(defaultForm());
|
||||
|
||||
onMount(load);
|
||||
async function load() {
|
||||
try { configs = await api('/tracking-configs'); }
|
||||
catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
||||
finally { loaded = true; }
|
||||
}
|
||||
|
||||
function openNew() { form = defaultForm(); editing = null; showForm = true; }
|
||||
function edit(c: any) {
|
||||
form = { ...defaultForm(), ...c };
|
||||
editing = c.id; showForm = true;
|
||||
}
|
||||
|
||||
async function save(e: SubmitEvent) {
|
||||
e.preventDefault(); error = '';
|
||||
try {
|
||||
if (editing) await api(`/tracking-configs/${editing}`, { method: 'PUT', body: JSON.stringify(form) });
|
||||
else await api('/tracking-configs', { method: 'POST', body: JSON.stringify(form) });
|
||||
showForm = false; editing = null; await load();
|
||||
snackSuccess(t('snack.trackingConfigSaved'));
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
}
|
||||
|
||||
function remove(id: number) {
|
||||
confirmDelete = {
|
||||
id,
|
||||
onconfirm: async () => {
|
||||
try { await api(`/tracking-configs/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.trackingConfigDeleted')); }
|
||||
catch (err: any) { error = err.message; snackError(err.message); }
|
||||
finally { confirmDelete = null; }
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageHeader title={t('trackingConfig.title')} description={t('trackingConfig.description')} />
|
||||
<PageHeader title={t('trackingConfig.title')} description={t('trackingConfig.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('trackingConfig.newConfig')}
|
||||
</button>
|
||||
</PageHeader>
|
||||
|
||||
{#if !loaded}
|
||||
<Loading />
|
||||
{:else if configs.length === 0}
|
||||
{#if !loaded}<Loading />{:else}
|
||||
|
||||
{#if showForm}
|
||||
<div in:slide={{ duration: 200 }}>
|
||||
<Card class="mb-6">
|
||||
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
||||
<form onsubmit={save} class="space-y-5">
|
||||
<div>
|
||||
<label for="tc-name" class="block text-sm font-medium mb-1">{t('trackingConfig.name')}</label>
|
||||
<div class="flex gap-2">
|
||||
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
|
||||
<input id="tc-name" bind:value={form.name} required placeholder={t('trackingConfig.namePlaceholder')}
|
||||
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event tracking -->
|
||||
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
||||
<legend class="text-sm font-medium px-1">{t('trackingConfig.eventTracking')}</legend>
|
||||
<div class="grid grid-cols-2 gap-2 mt-2">
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_assets_added} /> {t('trackingConfig.assetsAdded')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_assets_removed} /> {t('trackingConfig.assetsRemoved')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_collection_renamed} /> {t('trackingConfig.albumRenamed')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_collection_deleted} /> {t('trackingConfig.albumDeleted')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_sharing_changed} /> {t('trackingConfig.sharingChanged')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_images} /> {t('trackingConfig.trackImages')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_videos} /> {t('trackingConfig.trackVideos')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.notify_favorites_only} /> {t('trackingConfig.favoritesOnly')}<Hint text={t('hints.favoritesOnly')} /></label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.include_tags} /> {t('trackingConfig.includePeople')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.include_asset_details} /> {t('trackingConfig.includeDetails')}</label>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-3 mt-3">
|
||||
<div>
|
||||
<label for="tc-max" class="block text-xs mb-1">{t('trackingConfig.maxAssets')}<Hint text={t('hints.maxAssets')} /></label>
|
||||
<input id="tc-max" type="number" bind:value={form.max_assets_to_show} min="0" max="50" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="tc-sort" class="block text-xs mb-1">{t('trackingConfig.sortBy')}</label>
|
||||
<select id="tc-sort" bind:value={form.assets_order_by} class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value="none">{t('trackingConfig.sortNone')}</option><option value="date">{t('trackingConfig.sortDate')}</option><option value="rating">{t('trackingConfig.sortRating')}</option><option value="name">{t('trackingConfig.sortName')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="tc-order" class="block text-xs mb-1">{t('trackingConfig.sortOrder')}</label>
|
||||
<select id="tc-order" bind:value={form.assets_order} class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value="descending">{t('trackingConfig.orderDesc')}</option><option value="ascending">{t('trackingConfig.orderAsc')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Periodic summary -->
|
||||
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
||||
<legend class="text-sm font-medium px-1">{t('trackingConfig.periodicSummary')}<Hint text={t('hints.periodicSummary')} /></legend>
|
||||
<label class="flex items-center gap-2 text-sm mt-1"><input type="checkbox" bind:checked={form.periodic_enabled} /> {t('trackingConfig.enabled')}</label>
|
||||
{#if form.periodic_enabled}
|
||||
<div class="grid grid-cols-3 gap-3 mt-3">
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.intervalDays')}</label><input type="number" bind:value={form.periodic_interval_days} min="1" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.startDate')}<Hint text={t('hints.periodicStartDate')} /></label><input type="date" bind:value={form.periodic_start_date} class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.times')}<Hint text={t('hints.times')} /></label><input bind:value={form.periodic_times} placeholder="12:00" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /></div>
|
||||
</div>
|
||||
{/if}
|
||||
</fieldset>
|
||||
|
||||
<!-- Scheduled assets -->
|
||||
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
||||
<legend class="text-sm font-medium px-1">{t('trackingConfig.scheduledAssets')}<Hint text={t('hints.scheduledAssets')} /></legend>
|
||||
<label class="flex items-center gap-2 text-sm mt-1"><input type="checkbox" bind:checked={form.scheduled_enabled} /> {t('trackingConfig.enabled')}</label>
|
||||
{#if form.scheduled_enabled}
|
||||
<div class="grid grid-cols-3 gap-3 mt-3">
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.times')}<Hint text={t('hints.times')} /></label><input bind:value={form.scheduled_times} placeholder="09:00" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.albumMode')}<Hint text={t('hints.albumMode')} /></label>
|
||||
<select bind:value={form.scheduled_collection_mode} class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value="per_collection">{t('trackingConfig.albumModePerAlbum')}</option><option value="combined">{t('trackingConfig.albumModeCombined')}</option><option value="random">{t('trackingConfig.albumModeRandom')}</option>
|
||||
</select></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.maxAssets')}<Hint text={t('hints.maxAssets')} /></label><input type="number" bind:value={form.scheduled_limit} min="1" max="100" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.assetType')}</label>
|
||||
<select bind:value={form.scheduled_asset_type} class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value="all">{t('trackingConfig.assetTypeAll')}</option><option value="photo">{t('trackingConfig.assetTypePhoto')}</option><option value="video">{t('trackingConfig.assetTypeVideo')}</option>
|
||||
</select></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.minRating')}<Hint text={t('hints.minRating')} /></label><input type="number" bind:value={form.scheduled_min_rating} min="0" max="5" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /></div>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.scheduled_favorite_only} /> {t('trackingConfig.favoritesOnly')}<Hint text={t('hints.favoritesOnly')} /></label>
|
||||
</div>
|
||||
{/if}
|
||||
</fieldset>
|
||||
|
||||
<!-- Memory mode -->
|
||||
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
||||
<legend class="text-sm font-medium px-1">{t('trackingConfig.memoryMode')}<Hint text={t('hints.memoryMode')} /></legend>
|
||||
<label class="flex items-center gap-2 text-sm mt-1"><input type="checkbox" bind:checked={form.memory_enabled} /> {t('trackingConfig.enabled')}</label>
|
||||
{#if form.memory_enabled}
|
||||
<div class="grid grid-cols-3 gap-3 mt-3">
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.times')}<Hint text={t('hints.times')} /></label><input bind:value={form.memory_times} placeholder="09:00" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.albumMode')}<Hint text={t('hints.albumMode')} /></label>
|
||||
<select bind:value={form.memory_collection_mode} class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value="per_collection">{t('trackingConfig.albumModePerAlbum')}</option><option value="combined">{t('trackingConfig.albumModeCombined')}</option><option value="random">{t('trackingConfig.albumModeRandom')}</option>
|
||||
</select></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.maxAssets')}<Hint text={t('hints.maxAssets')} /></label><input type="number" bind:value={form.memory_limit} min="1" max="100" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.assetType')}</label>
|
||||
<select bind:value={form.memory_asset_type} class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value="all">{t('trackingConfig.assetTypeAll')}</option><option value="photo">{t('trackingConfig.assetTypePhoto')}</option><option value="video">{t('trackingConfig.assetTypeVideo')}</option>
|
||||
</select></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.minRating')}<Hint text={t('hints.minRating')} /></label><input type="number" bind:value={form.memory_min_rating} min="0" max="5" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /></div>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.memory_favorite_only} /> {t('trackingConfig.favoritesOnly')}<Hint text={t('hints.favoritesOnly')} /></label>
|
||||
</div>
|
||||
{/if}
|
||||
</fieldset>
|
||||
|
||||
<button type="submit" class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||
{editing ? t('common.save') : t('common.create')}
|
||||
</button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if configs.length === 0 && !showForm}
|
||||
<Card>
|
||||
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
|
||||
<div style="opacity: 0.4;"><MdiIcon name="mdiCog" size={40} /></div>
|
||||
@@ -28,20 +202,33 @@
|
||||
</div>
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="grid gap-4 sm:grid-cols-2 stagger-children">
|
||||
<div class="space-y-3 stagger-children">
|
||||
{#each configs as config}
|
||||
<Card hover>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-lg"
|
||||
style="background: var(--color-accent); color: var(--color-accent-foreground);">
|
||||
<MdiIcon name={config.icon || 'mdiCog'} size={20} />
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="font-medium text-sm">{config.name}</h3>
|
||||
<p class="text-xs capitalize" style="color: var(--color-muted-foreground);">{config.provider_type}</p>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if config.icon}<MdiIcon name={config.icon} />{/if}
|
||||
<p class="font-medium">{config.name}</p>
|
||||
</div>
|
||||
<p class="text-sm text-[var(--color-muted-foreground)]">
|
||||
{[config.track_assets_added && 'added', config.track_assets_removed && 'removed', config.track_collection_renamed && 'renamed', config.track_collection_deleted && 'deleted'].filter(Boolean).join(', ')}
|
||||
{config.periodic_enabled ? ' · periodic' : ''}
|
||||
{config.scheduled_enabled ? ' · scheduled' : ''}
|
||||
{config.memory_enabled ? ' · memory' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(config)} />
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(config.id)} variant="danger" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{/if}
|
||||
|
||||
<ConfirmModal open={confirmDelete !== null} message={t('trackingConfig.confirmDelete')}
|
||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||
|
||||
@@ -2,46 +2,147 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import { getAuth } from '$lib/auth.svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
|
||||
const auth = getAuth();
|
||||
let users = $state<any[]>([]);
|
||||
let showForm = $state(false);
|
||||
let form = $state({ username: '', password: '', role: 'user' });
|
||||
let error = $state('');
|
||||
let loaded = $state(false);
|
||||
let confirmDelete = $state<any>(null);
|
||||
|
||||
onMount(async () => {
|
||||
try { users = await api('/users'); } catch {}
|
||||
loaded = true;
|
||||
});
|
||||
// Admin reset password
|
||||
let resetUserId = $state<number | null>(null);
|
||||
let resetUsername = $state('');
|
||||
let resetPassword = $state('');
|
||||
let resetMsg = $state('');
|
||||
let resetSuccess = $state(false);
|
||||
|
||||
onMount(load);
|
||||
async function load() {
|
||||
try { users = await api('/users'); }
|
||||
catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
||||
finally { loaded = true; }
|
||||
}
|
||||
|
||||
async function create(e: SubmitEvent) {
|
||||
e.preventDefault(); error = '';
|
||||
try { await api('/users', { method: 'POST', body: JSON.stringify(form) }); form = { username: '', password: '', role: 'user' }; showForm = false; await load(); snackSuccess(t('snack.userCreated')); }
|
||||
catch (err: any) { error = err.message; snackError(err.message); }
|
||||
}
|
||||
function remove(id: number) {
|
||||
confirmDelete = {
|
||||
id,
|
||||
onconfirm: async () => {
|
||||
try { await api(`/users/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.userDeleted')); }
|
||||
catch (err: any) { error = err.message; snackError(err.message); }
|
||||
finally { confirmDelete = null; }
|
||||
}
|
||||
};
|
||||
}
|
||||
function openResetPassword(user: any) {
|
||||
resetUserId = user.id; resetUsername = user.username; resetPassword = ''; resetMsg = ''; resetSuccess = false;
|
||||
}
|
||||
async function resetUserPassword(e: SubmitEvent) {
|
||||
e.preventDefault(); resetMsg = ''; resetSuccess = false;
|
||||
try {
|
||||
await api(`/users/${resetUserId}/password`, { method: 'PUT', body: JSON.stringify({ new_password: resetPassword }) });
|
||||
resetMsg = t('common.passwordChanged');
|
||||
resetSuccess = true;
|
||||
snackSuccess(t('snack.passwordChanged'));
|
||||
setTimeout(() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }, 2000);
|
||||
} catch (err: any) { resetMsg = err.message; resetSuccess = false; snackError(err.message); }
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageHeader title={t('users.title')} description={t('users.description')} />
|
||||
<PageHeader title={t('users.title')} description={t('users.description')}>
|
||||
<button onclick={() => showForm = !showForm}
|
||||
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('users.cancel') : t('users.addUser')}
|
||||
</button>
|
||||
</PageHeader>
|
||||
|
||||
{#if !loaded}
|
||||
<Loading />
|
||||
{:else if users.length === 0}
|
||||
<Card>
|
||||
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
|
||||
<div style="opacity: 0.4;"><MdiIcon name="mdiAccountGroup" size={40} /></div>
|
||||
<p class="text-sm">No users found.</p>
|
||||
</div>
|
||||
{#if !loaded}<Loading />{:else}
|
||||
|
||||
{#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={create} class="space-y-3">
|
||||
<div>
|
||||
<label for="usr-name" class="block text-sm font-medium mb-1">{t('users.username')}</label>
|
||||
<input id="usr-name" bind:value={form.username} required class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="usr-pass" class="block text-sm font-medium mb-1">{t('users.password')}</label>
|
||||
<input id="usr-pass" bind:value={form.password} required type="password" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="usr-role" class="block text-sm font-medium mb-1">{t('users.role')}</label>
|
||||
<select id="usr-role" bind:value={form.role} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value="user">{t('users.roleUser')}</option>
|
||||
<option value="admin">{t('users.roleAdmin')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">{t('users.create')}</button>
|
||||
</form>
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="grid gap-4 sm:grid-cols-2 stagger-children">
|
||||
{#each users as user}
|
||||
<Card hover>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold"
|
||||
style="background: var(--color-primary); color: var(--color-primary-foreground);">
|
||||
{user.username[0].toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-medium text-sm">{user.username}</h3>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--color-muted-foreground);">{user.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if users.length === 0}
|
||||
<Card>
|
||||
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
|
||||
<div style="opacity: 0.4;"><MdiIcon name="mdiAccountGroup" size={40} /></div>
|
||||
<p class="text-sm">{t('common.loadError')}</p>
|
||||
</div>
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-3 stagger-children">
|
||||
{#each users as user}
|
||||
<Card hover>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium">{user.username}</p>
|
||||
<p class="text-sm text-[var(--color-muted-foreground)]">{user.role} · {t('users.joined')} {new Date(user.created_at).toLocaleDateString()}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
{#if user.id !== auth.user?.id}
|
||||
<IconButton icon="mdiKeyVariant" title={t('common.changePassword')} onclick={() => openResetPassword(user)} />
|
||||
<IconButton icon="mdiDelete" title={t('users.delete')} onclick={() => remove(user.id)} variant="danger" />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{/if}
|
||||
|
||||
<!-- Admin reset password modal -->
|
||||
<Modal open={resetUserId !== null} title="{t('common.changePassword')}: {resetUsername}" onclose={() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }}>
|
||||
<form onsubmit={resetUserPassword} class="space-y-3">
|
||||
<div>
|
||||
<label for="reset-pwd" class="block text-sm font-medium mb-1">{t('common.newPassword')}</label>
|
||||
<input id="reset-pwd" type="password" bind:value={resetPassword} required
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
{#if resetMsg}
|
||||
<p class="text-sm {resetSuccess ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{resetMsg}</p>
|
||||
{/if}
|
||||
<button type="submit" class="w-full py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<ConfirmModal open={confirmDelete !== null} message={t('users.confirmDelete')}
|
||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||
|
||||
@@ -30,6 +30,8 @@ class TargetConfig:
|
||||
type: str # "telegram" or "webhook"
|
||||
config: dict[str, Any] # type-specific config
|
||||
template_slots: dict[str, str] | None = None # event_type -> template string
|
||||
provider_api_key: str | None = None # API key for downloading assets from provider
|
||||
provider_internal_url: str | None = None # Internal provider URL for API key scoping
|
||||
|
||||
|
||||
class NotificationDispatcher:
|
||||
@@ -88,12 +90,18 @@ class NotificationDispatcher:
|
||||
client = TelegramClient(session, bot_token)
|
||||
|
||||
# Build asset list for media sending
|
||||
# Only attach API key header for URLs pointing to the internal provider
|
||||
internal_url = target.provider_internal_url or ""
|
||||
assets = []
|
||||
for asset in event.added_assets:
|
||||
url = asset.full_url or asset.thumbnail_url
|
||||
if url:
|
||||
asset_type = "video" if asset.type.value == "video" else "photo"
|
||||
assets.append({"url": url, "type": asset_type})
|
||||
# Include API key only for internal provider URLs
|
||||
asset_headers = {}
|
||||
if target.provider_api_key and internal_url and url.startswith(internal_url):
|
||||
asset_headers["x-api-key"] = target.provider_api_key
|
||||
assets.append({"url": url, "type": asset_type, "headers": asset_headers})
|
||||
|
||||
return await client.send_notification(
|
||||
chat_id=str(chat_id),
|
||||
|
||||
@@ -60,6 +60,16 @@ _SAMPLE_CONTEXT = {
|
||||
"collection_id": "b2eeeaa4-bba0-477a-a06f-5cb9e21818e8",
|
||||
"collection_name": "Family Photos",
|
||||
"collection_url": "https://immich.example.com/share/abc123",
|
||||
"event_type": "assets_added",
|
||||
"timestamp": "2026-03-19T10:30:00+00:00",
|
||||
"service_name": "Immich",
|
||||
"service_type": "immich",
|
||||
# Immich aliases (always present alongside collection_*)
|
||||
"album_name": "Family Photos",
|
||||
"album_id": "b2eeeaa4-bba0-477a-a06f-5cb9e21818e8",
|
||||
"album_url": "https://immich.example.com/share/abc123",
|
||||
"old_album_name": "Old Album",
|
||||
"new_album_name": "New Album",
|
||||
"change_type": "assets_added",
|
||||
"added_count": 3,
|
||||
"removed_count": 1,
|
||||
@@ -118,31 +128,109 @@ async def list_configs(
|
||||
|
||||
|
||||
@router.get("/variables")
|
||||
async def get_template_variables(provider_type: str | None = None):
|
||||
"""Get the variable reference for all template slots."""
|
||||
from .template_vars import router as _ # noqa: ensure registered
|
||||
from notify_bridge_core.providers.base import ServiceProviderType
|
||||
from notify_bridge_core.templates.variables import registry
|
||||
async def get_template_variables():
|
||||
"""Get template variable reference grouped by slot.
|
||||
|
||||
if provider_type:
|
||||
try:
|
||||
pt = ServiceProviderType(provider_type)
|
||||
except ValueError:
|
||||
return {"error": f"Unknown provider type: {provider_type}"}
|
||||
variables = registry.get_variables(pt)
|
||||
else:
|
||||
variables = registry.get_base_variables()
|
||||
Returns a dict keyed by template slot name, each containing:
|
||||
- description: what the slot is for
|
||||
- variables: dict of variable_name -> description
|
||||
- asset_fields: dict of field_name -> description (for slots with assets)
|
||||
- album_fields: dict of field_name -> description (for slots with albums)
|
||||
"""
|
||||
# Core event variables available in all event templates
|
||||
event_vars = {
|
||||
"collection_id": "Collection ID (UUID)",
|
||||
"collection_name": "Collection name",
|
||||
"collection_url": "Public share URL (empty if not shared)",
|
||||
"added_count": "Number of assets added",
|
||||
"removed_count": "Number of assets removed",
|
||||
"people": "Detected people names (list, use {{ people | join(', ') }})",
|
||||
"shared": "Whether collection is shared (boolean)",
|
||||
"target_type": "Target type: 'telegram' or 'webhook'",
|
||||
"has_videos": "Whether added assets contain videos (boolean)",
|
||||
"has_photos": "Whether added assets contain photos (boolean)",
|
||||
# Immich aliases
|
||||
"album_name": "Alias for collection_name",
|
||||
"album_id": "Alias for collection_id",
|
||||
"album_url": "Alias for collection_url",
|
||||
}
|
||||
rename_vars = {
|
||||
**event_vars,
|
||||
"old_name": "Previous name (rename events)",
|
||||
"new_name": "New name (rename events)",
|
||||
}
|
||||
sharing_vars = {
|
||||
**event_vars,
|
||||
"old_shared": "Was shared before change (boolean)",
|
||||
"new_shared": "Is shared after change (boolean)",
|
||||
}
|
||||
asset_fields = {
|
||||
"id": "Asset ID (UUID)",
|
||||
"filename": "Original filename",
|
||||
"type": "IMAGE or VIDEO",
|
||||
"created_at": "Creation date/time (ISO 8601)",
|
||||
"owner": "Owner display name",
|
||||
"description": "User or EXIF description",
|
||||
"people": "People detected in this asset (list)",
|
||||
"is_favorite": "Whether asset is favorited (boolean)",
|
||||
"rating": "Star rating (1-5 or null)",
|
||||
"city": "City name",
|
||||
"state": "State/region name",
|
||||
"country": "Country name",
|
||||
"url": "Public viewer URL (if shared)",
|
||||
"download_url": "Direct download URL (if shared)",
|
||||
"photo_url": "Preview image URL (images only, if shared)",
|
||||
"playback_url": "Video playback URL (videos only, if shared)",
|
||||
}
|
||||
album_fields = {
|
||||
"name": "Collection/album name",
|
||||
"url": "Share URL",
|
||||
"asset_count": "Total assets in collection",
|
||||
"shared": "Whether collection is shared",
|
||||
}
|
||||
scheduled_vars = {
|
||||
"date": "Current date string",
|
||||
"target_type": "Target type: 'telegram' or 'webhook'",
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
"name": v.name,
|
||||
"type": v.type,
|
||||
"description": v.description,
|
||||
"example": v.example,
|
||||
"provider_type": v.provider_type.value if v.provider_type else None,
|
||||
}
|
||||
for v in variables
|
||||
]
|
||||
return {
|
||||
"message_assets_added": {
|
||||
"description": "Notification when new assets are added to a collection",
|
||||
"variables": {**event_vars, "added_assets": "List of asset dicts (use {% for asset in added_assets %})"},
|
||||
"asset_fields": asset_fields,
|
||||
},
|
||||
"message_assets_removed": {
|
||||
"description": "Notification when assets are removed from a collection",
|
||||
"variables": {**event_vars, "removed_assets": "List of removed asset IDs (strings)"},
|
||||
},
|
||||
"message_collection_renamed": {
|
||||
"description": "Notification when a collection is renamed",
|
||||
"variables": rename_vars,
|
||||
},
|
||||
"message_collection_deleted": {
|
||||
"description": "Notification when a collection is deleted",
|
||||
"variables": event_vars,
|
||||
},
|
||||
"message_sharing_changed": {
|
||||
"description": "Notification when sharing status changes",
|
||||
"variables": sharing_vars,
|
||||
},
|
||||
"periodic_summary_message": {
|
||||
"description": "Periodic summary of all tracked collections",
|
||||
"variables": {**scheduled_vars, "collections": "List of collection dicts (use {% for album in collections %})"},
|
||||
"album_fields": album_fields,
|
||||
},
|
||||
"scheduled_assets_message": {
|
||||
"description": "Scheduled asset delivery (daily photo picks)",
|
||||
"variables": {**scheduled_vars, "assets": "List of asset dicts (use {% for asset in assets %})"},
|
||||
"asset_fields": asset_fields,
|
||||
},
|
||||
"memory_mode_message": {
|
||||
"description": "\"On This Day\" memories from previous years",
|
||||
"variables": {**scheduled_vars, "assets": "List of asset dicts (use {% for asset in assets %})"},
|
||||
"asset_fields": asset_fields,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
@@ -259,8 +347,9 @@ async def preview_raw(
|
||||
|
||||
|
||||
def _response(c: TemplateConfig) -> dict:
|
||||
return {k: getattr(c, k) for k in TemplateConfig.model_fields if k != "user_id"} | {
|
||||
"created_at": c.created_at.isoformat()
|
||||
return {k: getattr(c, k) for k in TemplateConfig.model_fields if k not in ("user_id", "created_at")} | {
|
||||
"user_id": c.user_id,
|
||||
"created_at": c.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
"""Tracker-Target link management API routes."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from ..auth.dependencies import get_current_user
|
||||
from ..database.engine import get_session
|
||||
from ..database.models import (
|
||||
NotificationTarget,
|
||||
TemplateConfig,
|
||||
Tracker,
|
||||
TrackerTarget,
|
||||
TrackingConfig,
|
||||
User,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/trackers/{tracker_id}/targets", tags=["tracker-targets"])
|
||||
|
||||
|
||||
class TrackerTargetCreate(BaseModel):
|
||||
target_id: int
|
||||
tracking_config_id: int | None = None
|
||||
template_config_id: int | None = None
|
||||
enabled: bool = True
|
||||
quiet_hours_start: str | None = None
|
||||
quiet_hours_end: str | None = None
|
||||
commands_config: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class TrackerTargetUpdate(BaseModel):
|
||||
tracking_config_id: int | None = None
|
||||
template_config_id: int | None = None
|
||||
enabled: bool | None = None
|
||||
quiet_hours_start: str | None = None
|
||||
quiet_hours_end: str | None = None
|
||||
commands_config: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_tracker_targets(
|
||||
tracker_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""List all target links for a tracker."""
|
||||
await _get_user_tracker(session, tracker_id, user.id)
|
||||
result = await session.exec(
|
||||
select(TrackerTarget).where(TrackerTarget.tracker_id == tracker_id)
|
||||
)
|
||||
return [await _tt_response(session, tt) for tt in result.all()]
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_tracker_target(
|
||||
tracker_id: int,
|
||||
body: TrackerTargetCreate,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Link a target to a tracker with per-link configuration."""
|
||||
await _get_user_tracker(session, tracker_id, user.id)
|
||||
|
||||
# Validate target exists and belongs to user
|
||||
target = await session.get(NotificationTarget, body.target_id)
|
||||
if not target or target.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Target not found")
|
||||
|
||||
# Check for duplicate link
|
||||
result = await session.exec(
|
||||
select(TrackerTarget).where(
|
||||
TrackerTarget.tracker_id == tracker_id,
|
||||
TrackerTarget.target_id == body.target_id,
|
||||
)
|
||||
)
|
||||
if result.first():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Target is already linked to this tracker",
|
||||
)
|
||||
|
||||
# Validate config ownership
|
||||
if body.tracking_config_id:
|
||||
tc = await session.get(TrackingConfig, body.tracking_config_id)
|
||||
if not tc or tc.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Tracking config not found")
|
||||
if body.template_config_id:
|
||||
tpc = await session.get(TemplateConfig, body.template_config_id)
|
||||
if not tpc or (tpc.user_id != user.id and tpc.user_id != 0):
|
||||
raise HTTPException(status_code=404, detail="Template config not found")
|
||||
|
||||
tt = TrackerTarget(tracker_id=tracker_id, **body.model_dump())
|
||||
session.add(tt)
|
||||
await session.commit()
|
||||
await session.refresh(tt)
|
||||
return await _tt_response(session, tt)
|
||||
|
||||
|
||||
@router.put("/{tracker_target_id}")
|
||||
async def update_tracker_target(
|
||||
tracker_id: int,
|
||||
tracker_target_id: int,
|
||||
body: TrackerTargetUpdate,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Update a tracker-target link's configuration."""
|
||||
await _get_user_tracker(session, tracker_id, user.id)
|
||||
tt = await session.get(TrackerTarget, tracker_target_id)
|
||||
if not tt or tt.tracker_id != tracker_id:
|
||||
raise HTTPException(status_code=404, detail="Tracker-target link not found")
|
||||
|
||||
updates = body.model_dump(exclude_unset=True)
|
||||
# Validate config ownership if being changed
|
||||
if "tracking_config_id" in updates and updates["tracking_config_id"]:
|
||||
tc = await session.get(TrackingConfig, updates["tracking_config_id"])
|
||||
if not tc or tc.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Tracking config not found")
|
||||
if "template_config_id" in updates and updates["template_config_id"]:
|
||||
tpc = await session.get(TemplateConfig, updates["template_config_id"])
|
||||
if not tpc or (tpc.user_id != user.id and tpc.user_id != 0):
|
||||
raise HTTPException(status_code=404, detail="Template config not found")
|
||||
|
||||
for field, value in updates.items():
|
||||
setattr(tt, field, value)
|
||||
session.add(tt)
|
||||
await session.commit()
|
||||
await session.refresh(tt)
|
||||
return await _tt_response(session, tt)
|
||||
|
||||
|
||||
@router.delete("/{tracker_target_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_tracker_target(
|
||||
tracker_id: int,
|
||||
tracker_target_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Remove a target link from a tracker."""
|
||||
await _get_user_tracker(session, tracker_id, user.id)
|
||||
tt = await session.get(TrackerTarget, tracker_target_id)
|
||||
if not tt or tt.tracker_id != tracker_id:
|
||||
raise HTTPException(status_code=404, detail="Tracker-target link not found")
|
||||
await session.delete(tt)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@router.post("/{tracker_target_id}/test")
|
||||
async def test_tracker_target(
|
||||
tracker_id: int,
|
||||
tracker_target_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Send a test notification to a specific linked target."""
|
||||
await _get_user_tracker(session, tracker_id, user.id)
|
||||
tt = await session.get(TrackerTarget, tracker_target_id)
|
||||
if not tt or tt.tracker_id != tracker_id:
|
||||
raise HTTPException(status_code=404, detail="Tracker-target link not found")
|
||||
|
||||
target = await session.get(NotificationTarget, tt.target_id)
|
||||
if not target:
|
||||
raise HTTPException(status_code=404, detail="Target not found")
|
||||
|
||||
from ..services.notifier import send_test_notification
|
||||
r = await send_test_notification(target)
|
||||
return {"target": target.name, **r}
|
||||
|
||||
|
||||
@router.post("/{tracker_target_id}/test-periodic")
|
||||
async def test_periodic_tracker_target(
|
||||
tracker_id: int,
|
||||
tracker_target_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Send a test periodic summary to a specific linked target."""
|
||||
await _get_user_tracker(session, tracker_id, user.id)
|
||||
tt = await session.get(TrackerTarget, tracker_target_id)
|
||||
if not tt or tt.tracker_id != tracker_id:
|
||||
raise HTTPException(status_code=404, detail="Tracker-target link not found")
|
||||
|
||||
target = await session.get(NotificationTarget, tt.target_id)
|
||||
if not target:
|
||||
raise HTTPException(status_code=404, detail="Target not found")
|
||||
|
||||
template_config = None
|
||||
if tt.template_config_id:
|
||||
template_config = await session.get(TemplateConfig, tt.template_config_id)
|
||||
template_str = (template_config.periodic_summary_message if template_config else "") or ""
|
||||
|
||||
from ..services.notifier import send_test_template_notification
|
||||
r = await send_test_template_notification(target, "periodic_summary", template_str)
|
||||
return {"target": target.name, **r}
|
||||
|
||||
|
||||
@router.post("/{tracker_target_id}/test-memory")
|
||||
async def test_memory_tracker_target(
|
||||
tracker_id: int,
|
||||
tracker_target_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Send a test memory/on-this-day notification to a specific linked target."""
|
||||
await _get_user_tracker(session, tracker_id, user.id)
|
||||
tt = await session.get(TrackerTarget, tracker_target_id)
|
||||
if not tt or tt.tracker_id != tracker_id:
|
||||
raise HTTPException(status_code=404, detail="Tracker-target link not found")
|
||||
|
||||
target = await session.get(NotificationTarget, tt.target_id)
|
||||
if not target:
|
||||
raise HTTPException(status_code=404, detail="Target not found")
|
||||
|
||||
template_config = None
|
||||
if tt.template_config_id:
|
||||
template_config = await session.get(TemplateConfig, tt.template_config_id)
|
||||
template_str = (template_config.memory_mode_message if template_config else "") or ""
|
||||
|
||||
from ..services.notifier import send_test_template_notification
|
||||
r = await send_test_template_notification(target, "memory_mode", template_str)
|
||||
return {"target": target.name, **r}
|
||||
|
||||
|
||||
async def _tt_response(session: AsyncSession, tt: TrackerTarget) -> dict:
|
||||
"""Build tracker-target response with target details."""
|
||||
target = await session.get(NotificationTarget, tt.target_id)
|
||||
return {
|
||||
"id": tt.id,
|
||||
"tracker_id": tt.tracker_id,
|
||||
"target_id": tt.target_id,
|
||||
"target_name": target.name if target else None,
|
||||
"target_type": target.type if target else None,
|
||||
"target_icon": target.icon if target else None,
|
||||
"tracking_config_id": tt.tracking_config_id,
|
||||
"template_config_id": tt.template_config_id,
|
||||
"enabled": tt.enabled,
|
||||
"quiet_hours_start": tt.quiet_hours_start,
|
||||
"quiet_hours_end": tt.quiet_hours_end,
|
||||
"commands_config": tt.commands_config,
|
||||
"created_at": tt.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
async def _get_user_tracker(
|
||||
session: AsyncSession, tracker_id: int, user_id: int
|
||||
) -> Tracker:
|
||||
tracker = await session.get(Tracker, tracker_id)
|
||||
if not tracker or tracker.user_id != user_id:
|
||||
raise HTTPException(status_code=404, detail="Tracker not found")
|
||||
return tracker
|
||||
@@ -7,7 +7,15 @@ from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from ..auth.dependencies import get_current_user
|
||||
from ..database.engine import get_session
|
||||
from ..database.models import EventLog, NotificationTarget, ServiceProvider, Tracker, User
|
||||
from ..database.models import (
|
||||
EventLog,
|
||||
NotificationTarget,
|
||||
ServiceProvider,
|
||||
Tracker,
|
||||
TrackerState,
|
||||
TrackerTarget,
|
||||
User,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/trackers", tags=["trackers"])
|
||||
|
||||
@@ -17,24 +25,18 @@ class TrackerCreate(BaseModel):
|
||||
name: str
|
||||
icon: str = ""
|
||||
collection_ids: list[str] = []
|
||||
target_ids: list[int] = []
|
||||
tracking_config_id: int | None = None
|
||||
scan_interval: int = 60
|
||||
batch_duration: int = 0
|
||||
enabled: bool = True
|
||||
quiet_hours_start: str | None = None
|
||||
quiet_hours_end: str | None = None
|
||||
|
||||
|
||||
class TrackerUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
icon: str | None = None
|
||||
collection_ids: list[str] | None = None
|
||||
target_ids: list[int] | None = None
|
||||
tracking_config_id: int | None = None
|
||||
scan_interval: int | None = None
|
||||
batch_duration: int | None = None
|
||||
enabled: bool | None = None
|
||||
quiet_hours_start: str | None = None
|
||||
quiet_hours_end: str | None = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
@@ -45,7 +47,8 @@ async def list_trackers(
|
||||
result = await session.exec(
|
||||
select(Tracker).where(Tracker.user_id == user.id)
|
||||
)
|
||||
return [_tracker_response(t) for t in result.all()]
|
||||
trackers = result.all()
|
||||
return [await _tracker_response(session, t) for t in trackers]
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
@@ -62,7 +65,10 @@ async def create_tracker(
|
||||
session.add(tracker)
|
||||
await session.commit()
|
||||
await session.refresh(tracker)
|
||||
return _tracker_response(tracker)
|
||||
if tracker.enabled:
|
||||
from ..services.scheduler import schedule_tracker
|
||||
await schedule_tracker(tracker.id, tracker.scan_interval)
|
||||
return await _tracker_response(session, tracker)
|
||||
|
||||
|
||||
@router.get("/{tracker_id}")
|
||||
@@ -71,7 +77,8 @@ async def get_tracker(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
return _tracker_response(await _get_user_tracker(session, tracker_id, user.id))
|
||||
tracker = await _get_user_tracker(session, tracker_id, user.id)
|
||||
return await _tracker_response(session, tracker)
|
||||
|
||||
|
||||
@router.put("/{tracker_id}")
|
||||
@@ -87,7 +94,12 @@ async def update_tracker(
|
||||
session.add(tracker)
|
||||
await session.commit()
|
||||
await session.refresh(tracker)
|
||||
return _tracker_response(tracker)
|
||||
from ..services.scheduler import schedule_tracker, unschedule_tracker
|
||||
if tracker.enabled:
|
||||
await schedule_tracker(tracker.id, tracker.scan_interval)
|
||||
else:
|
||||
await unschedule_tracker(tracker.id)
|
||||
return await _tracker_response(session, tracker)
|
||||
|
||||
|
||||
@router.delete("/{tracker_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -97,8 +109,29 @@ async def delete_tracker(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
tracker = await _get_user_tracker(session, tracker_id, user.id)
|
||||
# Delete associated tracker-target links
|
||||
result = await session.exec(
|
||||
select(TrackerTarget).where(TrackerTarget.tracker_id == tracker_id)
|
||||
)
|
||||
for tt in result.all():
|
||||
await session.delete(tt)
|
||||
# Delete associated tracker state
|
||||
state_result = await session.exec(
|
||||
select(TrackerState).where(TrackerState.tracker_id == tracker_id)
|
||||
)
|
||||
for ts in state_result.all():
|
||||
await session.delete(ts)
|
||||
# Nullify event log references
|
||||
event_result = await session.exec(
|
||||
select(EventLog).where(EventLog.tracker_id == tracker_id)
|
||||
)
|
||||
for el in event_result.all():
|
||||
el.tracker_id = None
|
||||
session.add(el)
|
||||
await session.delete(tracker)
|
||||
await session.commit()
|
||||
from ..services.scheduler import unschedule_tracker
|
||||
await unschedule_tracker(tracker_id)
|
||||
|
||||
|
||||
@router.post("/{tracker_id}/trigger")
|
||||
@@ -119,15 +152,27 @@ async def test_periodic(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Send a test periodic summary notification to all targets."""
|
||||
"""Send a test periodic summary notification using actual templates."""
|
||||
from ..services.notifier import send_test_template_notification
|
||||
from ..database.models import TemplateConfig
|
||||
tracker = await _get_user_tracker(session, tracker_id, user.id)
|
||||
from ..services.notifier import send_test_notification
|
||||
result = await session.exec(
|
||||
select(TrackerTarget).where(
|
||||
TrackerTarget.tracker_id == tracker.id,
|
||||
TrackerTarget.enabled == True,
|
||||
)
|
||||
)
|
||||
results = []
|
||||
for tid in list(tracker.target_ids):
|
||||
target = await session.get(NotificationTarget, tid)
|
||||
if target:
|
||||
r = await send_test_notification(target)
|
||||
results.append({"target": target.name, **r})
|
||||
for tt in result.all():
|
||||
target = await session.get(NotificationTarget, tt.target_id)
|
||||
if not target:
|
||||
continue
|
||||
template_config = None
|
||||
if tt.template_config_id:
|
||||
template_config = await session.get(TemplateConfig, tt.template_config_id)
|
||||
template_str = (template_config.periodic_summary_message if template_config else "") or ""
|
||||
r = await send_test_template_notification(target, "periodic_summary", template_str)
|
||||
results.append({"target": target.name, **r})
|
||||
return {"test": "periodic_summary", "results": results}
|
||||
|
||||
|
||||
@@ -137,15 +182,27 @@ async def test_memory(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Send a test memory/on-this-day notification to all targets."""
|
||||
"""Send a test memory/on-this-day notification using actual templates."""
|
||||
from ..services.notifier import send_test_template_notification
|
||||
from ..database.models import TemplateConfig
|
||||
tracker = await _get_user_tracker(session, tracker_id, user.id)
|
||||
from ..services.notifier import send_test_notification
|
||||
result = await session.exec(
|
||||
select(TrackerTarget).where(
|
||||
TrackerTarget.tracker_id == tracker.id,
|
||||
TrackerTarget.enabled == True,
|
||||
)
|
||||
)
|
||||
results = []
|
||||
for tid in list(tracker.target_ids):
|
||||
target = await session.get(NotificationTarget, tid)
|
||||
if target:
|
||||
r = await send_test_notification(target)
|
||||
results.append({"target": target.name, **r})
|
||||
for tt in result.all():
|
||||
target = await session.get(NotificationTarget, tt.target_id)
|
||||
if not target:
|
||||
continue
|
||||
template_config = None
|
||||
if tt.template_config_id:
|
||||
template_config = await session.get(TemplateConfig, tt.template_config_id)
|
||||
template_str = (template_config.memory_mode_message if template_config else "") or ""
|
||||
r = await send_test_template_notification(target, "memory_mode", template_str)
|
||||
results.append({"target": target.name, **r})
|
||||
return {"test": "memory_mode", "results": results}
|
||||
|
||||
|
||||
@@ -176,19 +233,39 @@ async def tracker_history(
|
||||
]
|
||||
|
||||
|
||||
def _tracker_response(t: Tracker) -> dict:
|
||||
async def _tracker_response(session: AsyncSession, t: Tracker) -> dict:
|
||||
"""Build tracker response with nested tracker_targets."""
|
||||
result = await session.exec(
|
||||
select(TrackerTarget).where(TrackerTarget.tracker_id == t.id)
|
||||
)
|
||||
tracker_targets = []
|
||||
for tt in result.all():
|
||||
target = await session.get(NotificationTarget, tt.target_id)
|
||||
tracker_targets.append({
|
||||
"id": tt.id,
|
||||
"target_id": tt.target_id,
|
||||
"target_name": target.name if target else None,
|
||||
"target_type": target.type if target else None,
|
||||
"target_icon": target.icon if target else None,
|
||||
"tracking_config_id": tt.tracking_config_id,
|
||||
"template_config_id": tt.template_config_id,
|
||||
"enabled": tt.enabled,
|
||||
"quiet_hours_start": tt.quiet_hours_start,
|
||||
"quiet_hours_end": tt.quiet_hours_end,
|
||||
"commands_config": tt.commands_config,
|
||||
"created_at": tt.created_at.isoformat(),
|
||||
})
|
||||
|
||||
return {
|
||||
"id": t.id,
|
||||
"name": t.name,
|
||||
"icon": t.icon,
|
||||
"provider_id": t.provider_id,
|
||||
"collection_ids": t.collection_ids,
|
||||
"target_ids": t.target_ids,
|
||||
"tracking_config_id": t.tracking_config_id,
|
||||
"scan_interval": t.scan_interval,
|
||||
"batch_duration": t.batch_duration,
|
||||
"enabled": t.enabled,
|
||||
"quiet_hours_start": t.quiet_hours_start,
|
||||
"quiet_hours_end": t.quiet_hours_end,
|
||||
"tracker_targets": tracker_targets,
|
||||
"created_at": t.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@@ -152,8 +152,8 @@ async def delete_config(
|
||||
|
||||
|
||||
def _response(c: TrackingConfig) -> dict:
|
||||
return {k: getattr(c, k) for k in TrackingConfig.model_fields if k != "user_id"} | {
|
||||
"created_at": c.created_at.isoformat()
|
||||
return {k: getattr(c, k) for k in TrackingConfig.model_fields if k not in ("user_id", "created_at")} | {
|
||||
"created_at": c.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -44,18 +44,6 @@ class TelegramBot(SQLModel, table=True):
|
||||
icon: str = Field(default="")
|
||||
bot_username: str = Field(default="")
|
||||
bot_id: int = Field(default=0)
|
||||
commands_config: dict[str, Any] = Field(
|
||||
default_factory=lambda: {
|
||||
"enabled": ["status", "albums", "events", "summary", "latest",
|
||||
"memory", "random", "search", "find", "person",
|
||||
"place", "favorites", "people", "help"],
|
||||
"default_count": 5,
|
||||
"response_mode": "media",
|
||||
"rate_limits": {"search": 30, "find": 30, "default": 10},
|
||||
"locale": "en",
|
||||
},
|
||||
sa_column=Column(JSON),
|
||||
)
|
||||
created_at: datetime = Field(default_factory=_utcnow)
|
||||
|
||||
|
||||
@@ -134,7 +122,7 @@ class TemplateConfig(SQLModel, table=True):
|
||||
__tablename__ = "template_config"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
user_id: int = Field(foreign_key="user.id")
|
||||
user_id: int = Field(default=0) # 0 = system-owned (no FK to allow sentinel)
|
||||
provider_type: str # Must match provider's type
|
||||
name: str
|
||||
description: str = Field(default="")
|
||||
@@ -158,7 +146,7 @@ class TemplateConfig(SQLModel, table=True):
|
||||
|
||||
|
||||
class NotificationTarget(SQLModel, table=True):
|
||||
"""Where to send notifications. Owns the template config."""
|
||||
"""Where to send notifications. Pure delivery endpoint."""
|
||||
|
||||
__tablename__ = "notification_target"
|
||||
|
||||
@@ -168,12 +156,11 @@ class NotificationTarget(SQLModel, table=True):
|
||||
name: str
|
||||
icon: str = Field(default="")
|
||||
config: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
|
||||
template_config_id: int | None = Field(default=None, foreign_key="template_config.id")
|
||||
created_at: datetime = Field(default_factory=_utcnow)
|
||||
|
||||
|
||||
class Tracker(SQLModel, table=True):
|
||||
"""Watches a provider's collections for changes. Owns the tracking config."""
|
||||
"""Watches a provider's collections for changes."""
|
||||
|
||||
__tablename__ = "tracker"
|
||||
|
||||
@@ -183,12 +170,32 @@ class Tracker(SQLModel, table=True):
|
||||
name: str
|
||||
icon: str = Field(default="")
|
||||
collection_ids: list[str] = Field(default_factory=list, sa_column=Column(JSON))
|
||||
target_ids: list[int] = Field(default_factory=list, sa_column=Column(JSON))
|
||||
tracking_config_id: int | None = Field(default=None, foreign_key="tracking_config.id")
|
||||
scan_interval: int = Field(default=60)
|
||||
batch_duration: int = Field(default=0) # seconds to accumulate events before dispatch (0=immediate)
|
||||
enabled: bool = Field(default=True)
|
||||
created_at: datetime = Field(default_factory=_utcnow)
|
||||
|
||||
|
||||
class TrackerTarget(SQLModel, table=True):
|
||||
"""Junction between Tracker and NotificationTarget with per-link config."""
|
||||
|
||||
__tablename__ = "tracker_target"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
tracker_id: int = Field(foreign_key="tracker.id", index=True)
|
||||
target_id: int = Field(foreign_key="notification_target.id", index=True)
|
||||
tracking_config_id: int | None = Field(
|
||||
default=None, foreign_key="tracking_config.id"
|
||||
)
|
||||
template_config_id: int | None = Field(
|
||||
default=None, foreign_key="template_config.id"
|
||||
)
|
||||
enabled: bool = Field(default=True)
|
||||
quiet_hours_start: str | None = None
|
||||
quiet_hours_end: str | None = None
|
||||
commands_config: dict[str, Any] | None = Field(
|
||||
default=None, sa_column=Column(JSON)
|
||||
)
|
||||
created_at: datetime = Field(default_factory=_utcnow)
|
||||
|
||||
|
||||
@@ -200,6 +207,8 @@ class TrackerState(SQLModel, table=True):
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
tracker_id: int = Field(foreign_key="tracker.id")
|
||||
collection_id: str
|
||||
collection_name: str = Field(default="")
|
||||
shared: bool = Field(default=False)
|
||||
asset_ids: list[str] = Field(default_factory=list, sa_column=Column(JSON))
|
||||
pending_asset_ids: list[str] = Field(default_factory=list, sa_column=Column(JSON))
|
||||
last_updated: datetime = Field(default_factory=_utcnow)
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Test notification sender."""
|
||||
|
||||
import logging
|
||||
|
||||
import aiohttp
|
||||
|
||||
from ..database.models import NotificationTarget
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def send_test_notification(target: NotificationTarget) -> dict:
|
||||
"""Send a simple test message to a notification target."""
|
||||
try:
|
||||
if target.type == "telegram":
|
||||
return await _test_telegram(target)
|
||||
elif target.type == "webhook":
|
||||
return await _test_webhook(target)
|
||||
return {"success": False, "error": f"Unknown target type: {target.type}"}
|
||||
except Exception as e:
|
||||
_LOGGER.error("Test notification failed: %s", e)
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
async def _test_telegram(target: NotificationTarget) -> dict:
|
||||
from notify_bridge_core.notifications.telegram.client import TelegramClient
|
||||
|
||||
bot_token = target.config.get("bot_token")
|
||||
chat_id = target.config.get("chat_id")
|
||||
if not bot_token or not chat_id:
|
||||
return {"success": False, "error": "Missing bot_token or chat_id"}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
client = TelegramClient(session, bot_token)
|
||||
return await client.send_notification(
|
||||
chat_id=str(chat_id),
|
||||
caption="Test notification from Notify Bridge",
|
||||
)
|
||||
|
||||
|
||||
async def send_test_template_notification(
|
||||
target: NotificationTarget, slot: str, template_str: str
|
||||
) -> dict:
|
||||
"""Render a template slot with sample data and send it to a target."""
|
||||
from jinja2.sandbox import SandboxedEnvironment
|
||||
from ..api.template_configs import _SAMPLE_CONTEXT
|
||||
|
||||
if not template_str:
|
||||
return await send_test_notification(target)
|
||||
|
||||
try:
|
||||
env = SandboxedEnvironment(autoescape=False)
|
||||
tmpl = env.from_string(template_str)
|
||||
message = tmpl.render(**_SAMPLE_CONTEXT)
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Template render error: {e}"}
|
||||
|
||||
try:
|
||||
if target.type == "telegram":
|
||||
return await _test_telegram_with_message(target, message)
|
||||
elif target.type == "webhook":
|
||||
return await _test_webhook_with_message(target, message)
|
||||
return {"success": False, "error": f"Unknown target type: {target.type}"}
|
||||
except Exception as e:
|
||||
_LOGGER.error("Test template notification failed: %s", e)
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
async def _test_telegram_with_message(target: NotificationTarget, message: str) -> dict:
|
||||
from notify_bridge_core.notifications.telegram.client import TelegramClient
|
||||
bot_token = target.config.get("bot_token")
|
||||
chat_id = target.config.get("chat_id")
|
||||
if not bot_token or not chat_id:
|
||||
return {"success": False, "error": "Missing bot_token or chat_id"}
|
||||
async with aiohttp.ClientSession() as session:
|
||||
client = TelegramClient(session, bot_token)
|
||||
return await client.send_notification(chat_id=str(chat_id), caption=message)
|
||||
|
||||
|
||||
async def _test_webhook_with_message(target: NotificationTarget, message: str) -> dict:
|
||||
from notify_bridge_core.notifications.webhook.client import WebhookClient
|
||||
url = target.config.get("url")
|
||||
headers = target.config.get("headers", {})
|
||||
if not url:
|
||||
return {"success": False, "error": "Missing url in target config"}
|
||||
async with aiohttp.ClientSession() as session:
|
||||
client = WebhookClient(session, url, headers)
|
||||
return await client.send({"message": message, "event_type": "test_template"})
|
||||
|
||||
|
||||
async def _test_webhook(target: NotificationTarget) -> dict:
|
||||
from notify_bridge_core.notifications.webhook.client import WebhookClient
|
||||
|
||||
url = target.config.get("url")
|
||||
headers = target.config.get("headers", {})
|
||||
if not url:
|
||||
return {"success": False, "error": "Missing url in target config"}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
client = WebhookClient(session, url, headers)
|
||||
return await client.send({
|
||||
"message": "Test notification from Notify Bridge",
|
||||
"event_type": "test",
|
||||
})
|
||||
@@ -57,6 +57,34 @@ async def _load_tracker_jobs() -> None:
|
||||
_LOGGER.info("Scheduled tracker %d (%s) every %ds", tracker.id, tracker.name, tracker.scan_interval)
|
||||
|
||||
|
||||
async def schedule_tracker(tracker_id: int, interval: int) -> None:
|
||||
"""Add or update a scheduler job for a tracker."""
|
||||
scheduler = get_scheduler()
|
||||
job_id = f"tracker_{tracker_id}"
|
||||
if scheduler.get_job(job_id):
|
||||
scheduler.reschedule_job(job_id, trigger="interval", seconds=interval)
|
||||
_LOGGER.info("Rescheduled tracker %d every %ds", tracker_id, interval)
|
||||
else:
|
||||
scheduler.add_job(
|
||||
_poll_tracker,
|
||||
"interval",
|
||||
seconds=interval,
|
||||
id=job_id,
|
||||
args=[tracker_id],
|
||||
replace_existing=True,
|
||||
)
|
||||
_LOGGER.info("Scheduled tracker %d every %ds", tracker_id, interval)
|
||||
|
||||
|
||||
async def unschedule_tracker(tracker_id: int) -> None:
|
||||
"""Remove a scheduler job for a tracker."""
|
||||
scheduler = get_scheduler()
|
||||
job_id = f"tracker_{tracker_id}"
|
||||
if scheduler.get_job(job_id):
|
||||
scheduler.remove_job(job_id)
|
||||
_LOGGER.info("Unscheduled tracker %d", tracker_id)
|
||||
|
||||
|
||||
async def _poll_tracker(tracker_id: int) -> None:
|
||||
"""Poll a tracker for changes."""
|
||||
from .watcher import check_tracker
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, time, timezone
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
@@ -22,11 +22,43 @@ from ..database.models import (
|
||||
TemplateConfig,
|
||||
Tracker,
|
||||
TrackerState,
|
||||
TrackerTarget,
|
||||
TrackingConfig,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _in_quiet_hours(start: str | None, end: str | None) -> bool:
|
||||
"""Check if the current UTC time is within the quiet hours window."""
|
||||
if not start or not end:
|
||||
return False
|
||||
try:
|
||||
now = datetime.now(timezone.utc).time()
|
||||
t_start = time.fromisoformat(start)
|
||||
t_end = time.fromisoformat(end)
|
||||
if t_start <= t_end:
|
||||
return t_start <= now <= t_end
|
||||
else:
|
||||
# Overnight window (e.g., 22:00 - 06:00)
|
||||
return now >= t_start or now <= t_end
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def _event_allowed_by_config(event: ServiceEvent, tc: TrackingConfig) -> bool:
|
||||
"""Check if an event type is allowed by the tracking config's flags."""
|
||||
event_type = event.event_type.value
|
||||
flag_map = {
|
||||
"assets_added": tc.track_assets_added,
|
||||
"assets_removed": tc.track_assets_removed,
|
||||
"collection_renamed": tc.track_collection_renamed,
|
||||
"collection_deleted": tc.track_collection_deleted,
|
||||
"sharing_changed": tc.track_sharing_changed,
|
||||
}
|
||||
return flag_map.get(event_type, True)
|
||||
|
||||
|
||||
async def check_tracker(tracker_id: int) -> dict[str, Any]:
|
||||
"""Poll a tracker's provider for changes and dispatch notifications."""
|
||||
engine = get_engine()
|
||||
@@ -49,27 +81,44 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
|
||||
state_dict: dict[str, Any] = {}
|
||||
for s in states:
|
||||
state_dict[s.collection_id] = {
|
||||
"name": "",
|
||||
"name": s.collection_name or "",
|
||||
"asset_ids": s.asset_ids,
|
||||
"pending_asset_ids": s.pending_asset_ids,
|
||||
"shared": False,
|
||||
"shared": bool(s.shared),
|
||||
}
|
||||
|
||||
# Load targets
|
||||
targets_db: list[NotificationTarget] = []
|
||||
for tid in (tracker.target_ids or []):
|
||||
t = await session.get(NotificationTarget, tid)
|
||||
if t:
|
||||
targets_db.append(t)
|
||||
# Load tracker-target links (replaces old target_ids JSON array)
|
||||
tt_result = await session.exec(
|
||||
select(TrackerTarget).where(TrackerTarget.tracker_id == tracker_id)
|
||||
)
|
||||
tracker_targets = tt_result.all()
|
||||
|
||||
# Load template configs for targets
|
||||
template_configs: dict[int, TemplateConfig | None] = {}
|
||||
for t in targets_db:
|
||||
if t.template_config_id:
|
||||
tc = await session.get(TemplateConfig, t.template_config_id)
|
||||
template_configs[t.id] = tc
|
||||
else:
|
||||
template_configs[t.id] = None
|
||||
# For each link, load target + tracking config + template config
|
||||
link_data: list[dict[str, Any]] = []
|
||||
for tt in tracker_targets:
|
||||
if not tt.enabled:
|
||||
continue
|
||||
if _in_quiet_hours(tt.quiet_hours_start, tt.quiet_hours_end):
|
||||
continue
|
||||
|
||||
target = await session.get(NotificationTarget, tt.target_id)
|
||||
if not target:
|
||||
continue
|
||||
|
||||
tracking_config = None
|
||||
if tt.tracking_config_id:
|
||||
tracking_config = await session.get(TrackingConfig, tt.tracking_config_id)
|
||||
|
||||
template_config = None
|
||||
if tt.template_config_id:
|
||||
template_config = await session.get(TemplateConfig, tt.template_config_id)
|
||||
|
||||
link_data.append({
|
||||
"target_type": target.type,
|
||||
"target_config": dict(target.config),
|
||||
"tracking_config": tracking_config,
|
||||
"template_config": template_config,
|
||||
})
|
||||
|
||||
# Snapshot the data we need
|
||||
provider_type = provider.type
|
||||
@@ -110,11 +159,15 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
|
||||
if existing:
|
||||
existing.asset_ids = cstate.get("asset_ids", [])
|
||||
existing.pending_asset_ids = cstate.get("pending_asset_ids", [])
|
||||
existing.collection_name = cstate.get("name", "")
|
||||
existing.shared = cstate.get("shared", False)
|
||||
session.add(existing)
|
||||
else:
|
||||
new_ts = TrackerState(
|
||||
tracker_id=tracker_id,
|
||||
collection_id=cid,
|
||||
collection_name=cstate.get("name", ""),
|
||||
shared=cstate.get("shared", False),
|
||||
asset_ids=cstate.get("asset_ids", []),
|
||||
pending_asset_ids=cstate.get("pending_asset_ids", []),
|
||||
)
|
||||
@@ -136,28 +189,61 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
|
||||
|
||||
await session.commit()
|
||||
|
||||
# Dispatch notifications
|
||||
if events and targets_db:
|
||||
# Dispatch notifications — per-link config resolution
|
||||
# Filter out empty events (e.g. assets_added with 0 added)
|
||||
events = [
|
||||
e for e in events
|
||||
if not (e.event_type.value == "assets_added" and e.added_count == 0)
|
||||
and not (e.event_type.value == "assets_removed" and e.removed_count == 0)
|
||||
]
|
||||
|
||||
_LOGGER.info(
|
||||
"Tracker %d: %d events after filter, %d links",
|
||||
tracker_id, len(events), len(link_data),
|
||||
)
|
||||
|
||||
if events and link_data:
|
||||
dispatcher = NotificationDispatcher()
|
||||
for event in events:
|
||||
_LOGGER.info(
|
||||
"Dispatching event %s for %s (added=%d removed=%d)",
|
||||
event.event_type.value, event.collection_name,
|
||||
event.added_count, event.removed_count,
|
||||
)
|
||||
target_configs = []
|
||||
for t in targets_db:
|
||||
tc = template_configs.get(t.id)
|
||||
for ld in link_data:
|
||||
# Apply per-link event filtering from tracking config
|
||||
tc = ld["tracking_config"]
|
||||
if tc and not _event_allowed_by_config(event, tc):
|
||||
_LOGGER.info(" Skipped by tracking config filter")
|
||||
continue
|
||||
|
||||
# Build template slots from template config
|
||||
tmpl = ld["template_config"]
|
||||
slots = None
|
||||
if tc:
|
||||
if tmpl:
|
||||
slots = {
|
||||
"assets_added": tc.message_assets_added,
|
||||
"assets_removed": tc.message_assets_removed,
|
||||
"collection_renamed": tc.message_collection_renamed,
|
||||
"collection_deleted": tc.message_collection_deleted,
|
||||
"sharing_changed": tc.message_sharing_changed,
|
||||
"assets_added": tmpl.message_assets_added,
|
||||
"assets_removed": tmpl.message_assets_removed,
|
||||
"collection_renamed": tmpl.message_collection_renamed,
|
||||
"collection_deleted": tmpl.message_collection_deleted,
|
||||
"sharing_changed": tmpl.message_sharing_changed,
|
||||
}
|
||||
target_configs.append(TargetConfig(
|
||||
type=t.type,
|
||||
config=t.config,
|
||||
type=ld["target_type"],
|
||||
config=ld["target_config"],
|
||||
template_slots=slots,
|
||||
provider_api_key=provider_config.get("api_key"),
|
||||
provider_internal_url=provider_config.get("url", ""),
|
||||
))
|
||||
await dispatcher.dispatch(event, target_configs)
|
||||
|
||||
if target_configs:
|
||||
results = await dispatcher.dispatch(event, target_configs)
|
||||
for r in results:
|
||||
if r.get("success"):
|
||||
_LOGGER.info(" Notification sent successfully")
|
||||
else:
|
||||
_LOGGER.error(" Notification failed: %s", r.get("error", "unknown"))
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
|
||||
Reference in New Issue
Block a user