5bd63a2191
Mirror the providers form pattern (defaultName tied to type) across bots, targets, trackers, actions, and configs. Each form now derives form.name from the selected type or provider while the user hasn't manually edited it; switching to edit-mode flips the manualEdited flag so existing names are preserved. Defaults: bots → "<Type> Bot"; targets → type label; notification trackers → "<provider> Tracker"; command trackers → "<provider> Commands"; actions → "<provider> <Action Type>"; tracking/template/ command/command-template configs → "<descriptor.defaultName> <Suffix>". TargetForm and TrackerForm grew an optional onnameinput prop so parents can flag manual edits in subform inputs.
621 lines
25 KiB
Svelte
621 lines
25 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy } from 'svelte';
|
|
import { api, parseDate } from '$lib/api';
|
|
import { topbarAction } from '$lib/stores/topbar-action.svelte';
|
|
import { t, getLocale } from '$lib/i18n';
|
|
import { providersCache, targetsCache, trackingConfigsCache, templateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte';
|
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
|
import Card from '$lib/components/Card.svelte';
|
|
import Loading from '$lib/components/Loading.svelte';
|
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
|
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
|
import IconButton from '$lib/components/IconButton.svelte';
|
|
import CrossLink from '$lib/components/CrossLink.svelte';
|
|
import EntitySelect from '$lib/components/EntitySelect.svelte';
|
|
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
|
import { highlightFromUrl } from '$lib/highlight';
|
|
import { providerDefaultIcon } from '$lib/grid-items';
|
|
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
|
import { getDescriptor } from '$lib/providers';
|
|
import Button from '$lib/components/Button.svelte';
|
|
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
|
import type { Tracker, TrackerTarget, TrackingConfig, TemplateConfig, NotificationTarget } from '$lib/types';
|
|
|
|
import TrackerForm from './TrackerForm.svelte';
|
|
import LinkedTargetsSection from './LinkedTargetsSection.svelte';
|
|
import SharedLinkModal from './SharedLinkModal.svelte';
|
|
import TestMenu from './TestMenu.svelte';
|
|
|
|
let loaded = $state(false);
|
|
let loadError = $state('');
|
|
let allNotificationTrackers = $state<Tracker[]>([]);
|
|
let filterText = $state('');
|
|
let filterProviderId = $state(0);
|
|
let effectiveProviderId = $derived(globalProviderFilter.id || filterProviderId);
|
|
let notificationTrackers = $derived(allNotificationTrackers.filter(t =>
|
|
(!filterText || t.name.toLowerCase().includes(filterText.toLowerCase())) &&
|
|
(!effectiveProviderId || t.provider_id === effectiveProviderId)
|
|
));
|
|
let providers = $derived(providersCache.items);
|
|
let allCapabilities: Record<string, any> = $derived(capabilitiesCache.items || {});
|
|
const providerItems = $derived(providers
|
|
.filter(p => !globalProviderFilter.providerType || p.type === globalProviderFilter.providerType)
|
|
.map(p => ({ value: p.id, label: p.name, icon: providerDefaultIcon(p), desc: p.type })));
|
|
let targets = $derived(targetsCache.items);
|
|
let trackingConfigs = $derived(trackingConfigsCache.items);
|
|
let templateConfigs = $derived(templateConfigsCache.items);
|
|
let collections = $state<Record<string, any>[]>([]);
|
|
let users = $state<{ id: string; name: string }[]>([]);
|
|
let showForm = $state(false);
|
|
let editing = $state<number | null>(null);
|
|
let collectionFilter = $state('');
|
|
let submitting = $state(false);
|
|
let confirmDelete = $state<Tracker | null>(null);
|
|
let toggling = $state<Record<number, boolean>>({});
|
|
let ttTesting = $state<Record<string, string>>({});
|
|
|
|
// Shared link validation
|
|
let linkWarning = $state<{ albums: { id: string; name: string; issue: string }[], providerId: number } | null>(null);
|
|
let linkCheckLoading = $state(false);
|
|
let linkCreating = $state(false);
|
|
let previousCollectionIds = $state<string[]>([]);
|
|
|
|
// Tracker form
|
|
const defaultForm = () => ({
|
|
name: '', icon: '', provider_id: 0, collection_ids: [] as string[],
|
|
scan_interval: 60,
|
|
adaptive_max_skip: null as number | null,
|
|
default_tracking_config_id: 0, default_template_config_id: 0,
|
|
filters: {} as Record<string, any>,
|
|
});
|
|
let form = $state(defaultForm());
|
|
let nameManuallyEdited = $state(false);
|
|
let selectedProviderType = $derived(
|
|
providers.find(p => p.id === form.provider_id)?.type || ''
|
|
);
|
|
let error = $state('');
|
|
|
|
$effect(() => {
|
|
if (showForm && !nameManuallyEdited && !editing) {
|
|
const provider = providers.find(p => p.id === form.provider_id);
|
|
form.name = provider ? `${provider.name} Tracker` : 'Tracker';
|
|
}
|
|
});
|
|
|
|
// Linked targets management
|
|
let expandedTracker = $state<number | null>(null);
|
|
let addingTarget = $state<Record<number, boolean>>({});
|
|
let newLinkTargetId = $state<Record<number, number>>({});
|
|
let newLinkTrackingConfigId = $state<Record<number, number>>({});
|
|
let newLinkTemplateConfigId = $state<Record<number, number>>({});
|
|
|
|
// Test menu
|
|
let testMenuOpen = $state<string | null>(null);
|
|
let testMenuStyle = $state('');
|
|
|
|
// Test types: basic is always available; periodic/scheduled/memory only for providers
|
|
// that have those notification slots in their capabilities AND have the feature
|
|
// enabled on the tracker's default TrackingConfig. A disabled feature on the
|
|
// default config means cron dispatch won't fire it in production either — so
|
|
// the test button would just surface a silent skip.
|
|
const allTestTypes: Record<string, {
|
|
key: string; icon: string; labelKey: string;
|
|
requiredSlot?: string; enabledField?: string;
|
|
}> = {
|
|
basic: { key: 'basic', icon: 'mdiSend', labelKey: 'notificationTracker.testBasic' },
|
|
periodic: { key: 'periodic', icon: 'mdiCalendarClock', labelKey: 'notificationTracker.testPeriodic', requiredSlot: 'periodic_summary_message', enabledField: 'periodic_enabled' },
|
|
scheduled: { key: 'scheduled', icon: 'mdiCalendarCheck', labelKey: 'notificationTracker.testScheduled', requiredSlot: 'scheduled_assets_message', enabledField: 'scheduled_enabled' },
|
|
memory: { key: 'memory', icon: 'mdiHistory', labelKey: 'notificationTracker.testMemory', requiredSlot: 'memory_mode_message', enabledField: 'memory_enabled' },
|
|
};
|
|
|
|
let testMenuTrackerId = $state<number | null>(null);
|
|
let testTypes = $derived.by(() => {
|
|
const base: { key: string; icon: string; labelKey: string; disabledReason?: string }[] = [allTestTypes.basic];
|
|
if (!testMenuTrackerId) return base;
|
|
const tracker = notificationTrackers.find(t => t.id === testMenuTrackerId);
|
|
if (!tracker) return base;
|
|
const provider = providers.find(p => p.id === tracker.provider_id);
|
|
if (!provider) return base;
|
|
const caps = allCapabilities[provider.type];
|
|
if (!caps) return base;
|
|
const slotNames = new Set((caps.notification_slots || []).map((s: any) => s.name));
|
|
const defaultTc = trackingConfigs.find(c => c.id === tracker.default_tracking_config_id);
|
|
for (const tt of [allTestTypes.periodic, allTestTypes.scheduled, allTestTypes.memory]) {
|
|
if (!tt.requiredSlot || !slotNames.has(tt.requiredSlot)) continue;
|
|
const enabled = !!defaultTc && !!tt.enabledField && !!(defaultTc as any)[tt.enabledField];
|
|
base.push({
|
|
key: tt.key, icon: tt.icon, labelKey: tt.labelKey,
|
|
// When surfaced, the button still renders but is disabled and
|
|
// shows *why* — users who land here via the test menu without
|
|
// having toggled the feature on Tracking Config see a clear
|
|
// pointer to the missing setting instead of a silent failure.
|
|
disabledReason: enabled ? undefined : 'notificationTracker.testDisabledHint',
|
|
});
|
|
}
|
|
return base;
|
|
});
|
|
|
|
onMount(() => {
|
|
topbarAction.set({
|
|
label: t('notificationTracker.newTracker'),
|
|
onclick: () => { showForm ? (showForm = false, editing = null) : openNew(); },
|
|
});
|
|
load();
|
|
});
|
|
onDestroy(() => topbarAction.clear());
|
|
|
|
const headerPills = $derived.by(() => {
|
|
const pills: Array<{ label: string; tone: 'mint' | 'sky' | 'coral' | 'citrus' }> = [];
|
|
const armed = notificationTrackers.filter(t => t.enabled).length;
|
|
const paused = notificationTrackers.length - armed;
|
|
if (armed > 0) pills.push({ label: `${armed} ${t('notificationTracker.armed')}`, tone: 'mint' });
|
|
if (paused > 0) pills.push({ label: `${paused} ${t('notificationTracker.paused')}`, tone: 'citrus' });
|
|
const providerCount = new Set(notificationTrackers.map(t => t.provider_id)).size;
|
|
if (providerCount > 0) pills.push({ label: `${providerCount} ${providerCount === 1 ? t('providers.typeSingular') : t('providers.typePlural')}`, tone: 'sky' });
|
|
return pills;
|
|
});
|
|
|
|
async function load() {
|
|
loadError = '';
|
|
try {
|
|
[allNotificationTrackers] = await Promise.all([
|
|
api<Tracker[]>('/notification-trackers'),
|
|
providersCache.fetch(), targetsCache.fetch(),
|
|
trackingConfigsCache.fetch(), templateConfigsCache.fetch(),
|
|
capabilitiesCache.fetch(),
|
|
]);
|
|
} catch (err: any) {
|
|
loadError = err.message || t('common.loadFailed');
|
|
snackError(loadError);
|
|
} finally { loaded = true; highlightFromUrl(); }
|
|
}
|
|
|
|
async function loadCollections() {
|
|
if (!form.provider_id) return;
|
|
try { collections = await api(`/providers/${form.provider_id}/collections`); } catch (e) { console.warn('Failed to load collections:', e); collections = []; }
|
|
}
|
|
|
|
async function loadUsers() {
|
|
if (!form.provider_id) { users = []; return; }
|
|
// Skip the fetch when the descriptor has no user filters — saves a
|
|
// pointless round-trip for providers like Immich/Scheduler.
|
|
const desc = getDescriptor(selectedProviderType);
|
|
if (!desc?.userFilters || desc.userFilters.length === 0) { users = []; return; }
|
|
try { users = await api(`/providers/${form.provider_id}/users`); }
|
|
catch (e) { console.warn('Failed to load users:', e); users = []; }
|
|
}
|
|
|
|
let _prevProviderId = $state(0);
|
|
$effect(() => {
|
|
if (showForm && form.provider_id && form.provider_id !== _prevProviderId) {
|
|
_prevProviderId = form.provider_id;
|
|
loadCollections();
|
|
loadUsers();
|
|
// Re-pick tracking/template configs for the new provider type. The
|
|
// previously-selected ids may belong to a different provider type
|
|
// and therefore no longer appear in the filtered EntitySelect list,
|
|
// which would render the selector as empty.
|
|
if (editing === null) {
|
|
const ptype = providers.find(p => p.id === form.provider_id)?.type || '';
|
|
if (ptype) {
|
|
const currentTc = trackingConfigs.find(c => c.id === form.default_tracking_config_id);
|
|
if (!currentTc || currentTc.provider_type !== ptype) {
|
|
const first = trackingConfigs.find(c => c.provider_type === ptype);
|
|
form.default_tracking_config_id = first?.id ?? 0;
|
|
}
|
|
const currentTpl = templateConfigs.find(c => c.id === form.default_template_config_id);
|
|
if (!currentTpl || currentTpl.provider_type !== ptype) {
|
|
const first = templateConfigs.find(c => c.provider_type === ptype);
|
|
form.default_template_config_id = first?.id ?? 0;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
function openNew() {
|
|
form = defaultForm();
|
|
// Auto-select first provider if any
|
|
if (providers.length > 0) form.provider_id = providers[0].id;
|
|
nameManuallyEdited = false;
|
|
editing = null; showForm = true; collections = []; users = []; previousCollectionIds = [];
|
|
}
|
|
|
|
async function edit(trk: Tracker) {
|
|
form = {
|
|
name: trk.name, icon: trk.icon || '', provider_id: trk.provider_id,
|
|
collection_ids: [...(trk.collection_ids || [])],
|
|
scan_interval: trk.scan_interval,
|
|
adaptive_max_skip: trk.adaptive_max_skip ?? null,
|
|
default_tracking_config_id: trk.default_tracking_config_id ?? 0,
|
|
default_template_config_id: trk.default_template_config_id ?? 0,
|
|
filters: trk.filters || {},
|
|
};
|
|
previousCollectionIds = [...(trk.collection_ids || [])];
|
|
nameManuallyEdited = true;
|
|
editing = trk.id; showForm = true;
|
|
if (form.provider_id) {
|
|
await Promise.all([loadCollections(), loadUsers()]);
|
|
}
|
|
}
|
|
|
|
async function save(e: SubmitEvent) {
|
|
e.preventDefault(); error = '';
|
|
if (submitting) return;
|
|
|
|
// Delegate provider-specific pre-save checks to the descriptor
|
|
const desc = getDescriptor(selectedProviderType);
|
|
if (desc?.onBeforeSave && form.provider_id) {
|
|
linkCheckLoading = true;
|
|
try {
|
|
const result = await desc.onBeforeSave({
|
|
form, previousCollectionIds, collections, api,
|
|
});
|
|
if (!result.proceed) {
|
|
if (result.warnings?.length) {
|
|
linkWarning = { albums: result.warnings, providerId: form.provider_id };
|
|
}
|
|
linkCheckLoading = false;
|
|
return;
|
|
}
|
|
} catch (err) { console.warn('Pre-save check failed, proceeding:', err); }
|
|
linkCheckLoading = false;
|
|
}
|
|
|
|
await doSave();
|
|
}
|
|
|
|
async function doSave() {
|
|
submitting = true;
|
|
try {
|
|
const payload = {
|
|
...form,
|
|
default_tracking_config_id: form.default_tracking_config_id || null,
|
|
default_template_config_id: form.default_template_config_id || null,
|
|
// Empty string, 0, or null all mean "disable adaptive polling".
|
|
// Coerce to null so the DB column stays NULL rather than 0.
|
|
adaptive_max_skip:
|
|
form.adaptive_max_skip && form.adaptive_max_skip > 1
|
|
? form.adaptive_max_skip
|
|
: null,
|
|
};
|
|
if (editing) {
|
|
await api(`/notification-trackers/${editing}`, { method: 'PUT', body: JSON.stringify(payload) });
|
|
snackSuccess(t('snack.trackerUpdated'));
|
|
} else {
|
|
await api('/notification-trackers', { method: 'POST', body: JSON.stringify(payload) });
|
|
snackSuccess(t('snack.trackerCreated'));
|
|
}
|
|
showForm = false; editing = null; linkWarning = null; await load();
|
|
} catch (err: any) { error = err.message; snackError(err.message); } finally { submitting = false; }
|
|
}
|
|
|
|
async function autoCreateLinks() {
|
|
if (!linkWarning) return;
|
|
linkCreating = true;
|
|
let created = 0;
|
|
for (const album of linkWarning.albums) {
|
|
if (album.issue === 'missing') {
|
|
try {
|
|
await api(`/providers/${linkWarning.providerId}/albums/${album.id}/shared-links`, { method: 'POST' });
|
|
created++;
|
|
} catch (err: any) {
|
|
snackError(`Failed to create link for "${album.name}": ${err.message}`);
|
|
}
|
|
}
|
|
}
|
|
if (created > 0) snackSuccess(t('notificationTracker.createdLinks').replace('{count}', String(created)));
|
|
linkWarning = null;
|
|
linkCreating = false;
|
|
await doSave();
|
|
}
|
|
|
|
async function dismissLinkWarning() {
|
|
linkWarning = null;
|
|
await doSave();
|
|
}
|
|
|
|
async function toggle(tracker: Tracker) {
|
|
if (toggling[tracker.id]) return;
|
|
toggling = { ...toggling, [tracker.id]: true };
|
|
try {
|
|
await api(`/notification-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: Tracker) { confirmDelete = tracker; }
|
|
|
|
async function doDelete() {
|
|
if (!confirmDelete) return;
|
|
try {
|
|
await api(`/notification-trackers/${confirmDelete.id}`, { method: 'DELETE' });
|
|
await load();
|
|
snackSuccess(t('snack.trackerDeleted'));
|
|
} catch (err: any) { error = err.message; snackError(err.message); }
|
|
confirmDelete = null;
|
|
}
|
|
|
|
function toggleCollection(collectionId: string) {
|
|
form.collection_ids = form.collection_ids.includes(collectionId)
|
|
? form.collection_ids.filter(id => id !== collectionId)
|
|
: [...form.collection_ids, collectionId];
|
|
}
|
|
|
|
function formatDate(dateStr: string): string {
|
|
if (!dateStr) return '';
|
|
try {
|
|
const d = parseDate(dateStr);
|
|
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
|
} catch (e) { console.warn('Date format error:', e); return ''; }
|
|
}
|
|
|
|
// --- Linked Targets helpers ---
|
|
function toggleExpand(trackerId: number) {
|
|
if (expandedTracker === trackerId) { expandedTracker = null; return; }
|
|
expandedTracker = trackerId;
|
|
}
|
|
|
|
function getProviderType(tracker: Tracker): string {
|
|
const p = providers.find(p => p.id === tracker.provider_id);
|
|
return p?.type || '';
|
|
}
|
|
|
|
function getProviderName(id: number): string {
|
|
const p = providers.find(p => p.id === id);
|
|
return p?.name || `#${id}`;
|
|
}
|
|
|
|
function getCollectionLabel(tracker: Tracker): string {
|
|
const pt = getProviderType(tracker);
|
|
const desc = getDescriptor(pt);
|
|
return desc?.collectionMeta ? t(desc.collectionMeta.countLabel) : t('notificationTracker.collections_count');
|
|
}
|
|
|
|
function configsForTracker(tracker: Tracker, configs: (TrackingConfig | TemplateConfig)[]): (TrackingConfig | TemplateConfig)[] {
|
|
const pt = getProviderType(tracker);
|
|
return pt ? configs.filter((c) => c.provider_type === pt) : configs;
|
|
}
|
|
|
|
function getUnlinkedTargets(tracker: Tracker): NotificationTarget[] {
|
|
const linkedIds = new Set((tracker.tracker_targets || []).map((tt: TrackerTarget) => tt.target_id));
|
|
return targets.filter(t => !linkedIds.has(t.id));
|
|
}
|
|
|
|
async function addTargetLink(trackerId: number) {
|
|
const targetId = newLinkTargetId[trackerId];
|
|
if (!targetId) return;
|
|
addingTarget = { ...addingTarget, [trackerId]: true };
|
|
try {
|
|
await api(`/notification-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(`/notification-trackers/${trackerId}/targets/${ttId}`, { method: 'DELETE' });
|
|
await load();
|
|
snackSuccess(t('snack.targetUnlinked'));
|
|
} catch (err: any) { snackError(err.message); }
|
|
}
|
|
|
|
async function updateTargetLink(trackerId: number, tt: TrackerTarget, field: string, value: string | number | boolean | null) {
|
|
try {
|
|
await api(`/notification-trackers/${trackerId}/targets/${tt.id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ [field]: value }),
|
|
});
|
|
await load();
|
|
} catch (err: any) { snackError(err.message); }
|
|
}
|
|
|
|
async function testTrackerTarget(trackerId: number, ttId: number, testType: string) {
|
|
testMenuOpen = null;
|
|
const key = `${ttId}_${testType}`;
|
|
if (ttTesting[key]) return;
|
|
ttTesting = { ...ttTesting, [key]: testType };
|
|
try {
|
|
// The endpoint returns 200 OK with ``{success: false, error: "..."}``
|
|
// on soft failures (missing template slot, no matching assets,
|
|
// provider unreachable, etc.), so checking for a thrown exception
|
|
// is not enough. Surface ``error`` as a snackError when present.
|
|
const res = await api<{ success?: boolean; error?: string; target?: string }>(
|
|
`/notification-trackers/${trackerId}/targets/${ttId}/test/${testType}?locale=${getLocale()}`,
|
|
{ method: 'POST' },
|
|
);
|
|
if (res && res.success === false) {
|
|
snackError(res.error || t('common.error'));
|
|
} else {
|
|
snackSuccess(t('snack.targetTestSent'));
|
|
}
|
|
} catch (err: any) {
|
|
snackError(err.message);
|
|
} finally {
|
|
ttTesting = { ...ttTesting, [key]: '' };
|
|
}
|
|
}
|
|
|
|
function openTestMenu(ttId: number, event: MouseEvent) {
|
|
const btn = event.currentTarget as HTMLElement;
|
|
const rect = btn.getBoundingClientRect();
|
|
testMenuStyle = `position:fixed; z-index:9999; top:${rect.bottom + 4}px; right:${window.innerWidth - rect.right}px;`;
|
|
testMenuTrackerId = notificationTrackers.find(t => t.tracker_targets?.some((x: TrackerTarget) => String(x.id) === String(ttId)))?.id ?? null;
|
|
testMenuOpen = testMenuOpen === String(ttId) ? null : String(ttId);
|
|
}
|
|
|
|
function handleTestFromMenu(ttId: number, testType: string) {
|
|
const trackerId = notificationTrackers.find(t => t.tracker_targets?.some((x: TrackerTarget) => String(x.id) === String(ttId)))?.id;
|
|
if (trackerId) testTrackerTarget(trackerId, ttId, testType);
|
|
}
|
|
</script>
|
|
|
|
<PageHeader
|
|
title={t('notificationTracker.title')}
|
|
emphasis={t('notificationTracker.titleEmphasis')}
|
|
description={t('notificationTracker.description')}
|
|
crumb="Routing · Notification"
|
|
count={notificationTrackers.length}
|
|
countLabel={t('dashboard.trackersShort')}
|
|
pills={headerPills}
|
|
>
|
|
<Button size="sm" onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}>
|
|
{showForm ? t('notificationTracker.cancel') : t('notificationTracker.newTracker')}
|
|
</Button>
|
|
</PageHeader>
|
|
|
|
{#if !loaded}
|
|
<Loading />
|
|
{:else if loadError}
|
|
<Card>
|
|
<ErrorBanner message={loadError} class="mb-0" />
|
|
</Card>
|
|
{:else if showForm}
|
|
<TrackerForm
|
|
bind:form
|
|
{providerItems}
|
|
{collections}
|
|
{users}
|
|
bind:collectionFilter
|
|
trackingConfigItems={trackingConfigs.filter(c => !selectedProviderType || c.provider_type === selectedProviderType).map(c => ({ value: c.id, label: c.name, icon: (c as any).icon || 'mdiCog' }))}
|
|
templateConfigItems={templateConfigs.filter(c => !selectedProviderType || c.provider_type === selectedProviderType).map(c => ({ value: c.id, label: c.name, icon: (c as any).icon || 'mdiFileDocumentEdit' }))}
|
|
{editing}
|
|
{submitting}
|
|
{linkCheckLoading}
|
|
{error}
|
|
providerType={selectedProviderType}
|
|
onsave={save}
|
|
ontoggleCollection={toggleCollection}
|
|
{formatDate}
|
|
onnameinput={() => nameManuallyEdited = true}
|
|
/>
|
|
{/if}
|
|
|
|
{#if loaded && !loadError}
|
|
|
|
{#if !showForm && allNotificationTrackers.length > 0}
|
|
<div class="flex items-center gap-2 mb-3">
|
|
<input type="text" bind:value={filterText} placeholder={t('common.filterByName')}
|
|
class="flex-1 px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
|
{#if !globalProviderFilter.id}
|
|
<div class="w-48">
|
|
<EntitySelect items={[{value: 0, label: t('common.allProviders'), icon: 'mdiFilterOff'}, ...providerItems]} bind:value={filterProviderId} placeholder={t('common.allProviders')} />
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if allNotificationTrackers.length === 0 && !showForm}
|
|
<Card>
|
|
<EmptyState icon="mdiRadar" message={t('notificationTracker.noTrackers')} />
|
|
</Card>
|
|
{:else if notificationTrackers.length === 0 && !showForm}
|
|
<Card>
|
|
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
|
|
</Card>
|
|
{:else if !showForm}
|
|
<div class="space-y-3 stagger-children">
|
|
{#each notificationTrackers as tracker (tracker.id)}
|
|
{@const trkDesc = getDescriptor(getProviderType(tracker))}
|
|
<Card hover entityId={tracker.id}>
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<div class="flex items-center gap-2">
|
|
<span style="color: var(--color-primary);"><MdiIcon name={tracker.icon || 'mdiRadar'} size={20} /></span>
|
|
<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('notificationTracker.active') : t('notificationTracker.paused')}
|
|
</span>
|
|
<CrossLink href="/providers" icon="mdiServer" label={getProviderName(tracker.provider_id)} entityId={tracker.provider_id} />
|
|
</div>
|
|
<p class="text-sm text-[var(--color-muted-foreground)]">
|
|
{(tracker.collection_ids || []).length} {getCollectionLabel(tracker)} ·
|
|
{#if !trkDesc?.webhookBased}{t('notificationTracker.every')} {tracker.scan_interval}s ·{/if}
|
|
{(tracker.tracker_targets || []).length} {t('notificationTracker.linkedTargets')}
|
|
</p>
|
|
</div>
|
|
<div class="flex items-center gap-1 flex-wrap justify-end">
|
|
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(tracker)} />
|
|
<IconButton icon={tracker.enabled ? 'mdiPause' : 'mdiPlay'} title={tracker.enabled ? t('notificationTracker.pause') : t('notificationTracker.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('notificationTracker.linkedTargets')} {expandedTracker === tracker.id ? '▲' : '▼'}
|
|
</button>
|
|
<IconButton icon="mdiDelete" title={t('notificationTracker.delete')} onclick={() => startDelete(tracker)} variant="danger" />
|
|
</div>
|
|
</div>
|
|
|
|
{#if expandedTracker === tracker.id}
|
|
<LinkedTargetsSection
|
|
{tracker}
|
|
{trackingConfigs}
|
|
{templateConfigs}
|
|
unlinkedTargets={targets}
|
|
newLinkTargetId={newLinkTargetId[tracker.id] || 0}
|
|
newLinkTrackingConfigId={newLinkTrackingConfigId[tracker.id] || 0}
|
|
newLinkTemplateConfigId={newLinkTemplateConfigId[tracker.id] || 0}
|
|
addingTarget={addingTarget[tracker.id] || false}
|
|
{ttTesting}
|
|
configsForTracker={(configs) => configsForTracker(tracker, configs)}
|
|
onupdateLink={(tt, field, value) => updateTargetLink(tracker.id, tt, field, value)}
|
|
onremoveLink={(ttId) => removeTargetLink(tracker.id, ttId)}
|
|
onaddLink={() => addTargetLink(tracker.id)}
|
|
onopenTestMenu={openTestMenu}
|
|
onchangeNewTarget={(v) => newLinkTargetId = { ...newLinkTargetId, [tracker.id]: v }}
|
|
onchangeNewTrackingConfig={(v) => newLinkTrackingConfigId = { ...newLinkTrackingConfigId, [tracker.id]: v }}
|
|
onchangeNewTemplateConfig={(v) => newLinkTemplateConfigId = { ...newLinkTemplateConfigId, [tracker.id]: v }}
|
|
/>
|
|
{/if}
|
|
</Card>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
|
|
<TestMenu
|
|
{testMenuOpen}
|
|
{testMenuStyle}
|
|
{ttTesting}
|
|
testTypes={testTypes}
|
|
ontest={handleTestFromMenu}
|
|
onclose={() => testMenuOpen = null}
|
|
/>
|
|
|
|
<SharedLinkModal
|
|
{linkWarning}
|
|
{linkCreating}
|
|
onclose={() => { linkWarning = null; }}
|
|
onautoCreate={autoCreateLinks}
|
|
ondismiss={dismissLinkWarning}
|
|
onupdate={(remaining) => {
|
|
if (!linkWarning) return;
|
|
if (remaining.length === 0) {
|
|
linkWarning = null;
|
|
doSave();
|
|
} else {
|
|
linkWarning = { ...linkWarning, albums: remaining };
|
|
}
|
|
}}
|
|
/>
|
|
|
|
<ConfirmModal
|
|
open={!!confirmDelete}
|
|
message={t('notificationTracker.confirmDelete')}
|
|
onconfirm={doDelete}
|
|
oncancel={() => confirmDelete = null}
|
|
/>
|