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:
2026-03-20 14:26:20 +03:00
parent 9eec21a5b2
commit 91e5cd58e9
24 changed files with 3514 additions and 375 deletions
+2 -2
View File
@@ -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>
+448
View File
@@ -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"
}
}
+448
View File
@@ -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": "Добавить"
}
}
+2 -2
View File
@@ -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>
+14 -16
View File
@@ -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>
+159 -47
View File
@@ -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>
+61 -83
View File
@@ -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">&larr; 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>
+233 -16
View File
@@ -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}
/>
+191 -16
View File
@@ -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} />
+276 -19
View File
@@ -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>
+350 -21
View File
@@ -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')} &middot; {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}
/>
+202 -15
View File
@@ -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} />
+131 -30
View File
@@ -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",