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:
@@ -1,53 +1,382 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
import IconPicker from '$lib/components/IconPicker.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import Hint from '$lib/components/Hint.svelte';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
|
||||
let trackers = $state<any[]>([]);
|
||||
let loaded = $state(false);
|
||||
let loadError = $state('');
|
||||
let trackers = $state<any[]>([]);
|
||||
let providers = $state<any[]>([]);
|
||||
let targets = $state<any[]>([]);
|
||||
let trackingConfigs = $state<any[]>([]);
|
||||
let templateConfigs = $state<any[]>([]);
|
||||
let collections = $state<any[]>([]);
|
||||
let showForm = $state(false);
|
||||
let editing = $state<number | null>(null);
|
||||
let collectionFilter = $state('');
|
||||
let submitting = $state(false);
|
||||
let confirmDelete = $state<any>(null);
|
||||
let toggling = $state<Record<number, boolean>>({});
|
||||
// Per tracker-target test state (keyed by `${ttId}_${testType}`)
|
||||
let ttTesting = $state<Record<string, string>>({});
|
||||
let ttFeedback = $state<Record<string, string>>({});
|
||||
|
||||
onMount(async () => {
|
||||
try { trackers = await api('/trackers'); } catch {}
|
||||
loaded = true;
|
||||
// Tracker form
|
||||
const defaultForm = () => ({
|
||||
name: '', icon: '', provider_id: 0, collection_ids: [] as string[],
|
||||
scan_interval: 60, batch_duration: 0,
|
||||
});
|
||||
let form = $state(defaultForm());
|
||||
let error = $state('');
|
||||
|
||||
// Linked targets management (inline in tracker detail)
|
||||
let expandedTracker = $state<number | null>(null);
|
||||
let linkedTargets = $state<Record<number, any[]>>({});
|
||||
let addingTarget = $state<Record<number, boolean>>({});
|
||||
let newLinkTargetId = $state<Record<number, number>>({});
|
||||
let newLinkTrackingConfigId = $state<Record<number, number>>({});
|
||||
let newLinkTemplateConfigId = $state<Record<number, number>>({});
|
||||
|
||||
onMount(load);
|
||||
async function load() {
|
||||
loadError = '';
|
||||
try {
|
||||
[trackers, providers, targets, trackingConfigs, templateConfigs] = await Promise.all([
|
||||
api('/trackers'), api('/providers'), api('/targets'),
|
||||
api('/tracking-configs'), api('/template-configs'),
|
||||
]);
|
||||
} catch (err: any) {
|
||||
loadError = err.message || 'Failed to load data';
|
||||
snackError(loadError);
|
||||
} finally { loaded = true; }
|
||||
}
|
||||
async function loadCollections() {
|
||||
if (!form.provider_id) return;
|
||||
try { collections = await api(`/providers/${form.provider_id}/collections`); } catch { collections = []; }
|
||||
}
|
||||
|
||||
function openNew() { form = defaultForm(); editing = null; showForm = true; collections = []; }
|
||||
async function edit(trk: any) {
|
||||
form = {
|
||||
name: trk.name, icon: trk.icon || '', provider_id: trk.provider_id,
|
||||
collection_ids: [...(trk.collection_ids || [])],
|
||||
scan_interval: trk.scan_interval, batch_duration: trk.batch_duration ?? 0,
|
||||
};
|
||||
editing = trk.id; showForm = true;
|
||||
if (form.provider_id) await loadCollections();
|
||||
}
|
||||
|
||||
async function save(e: SubmitEvent) {
|
||||
e.preventDefault(); error = '';
|
||||
if (submitting) return;
|
||||
submitting = true;
|
||||
try {
|
||||
if (editing) {
|
||||
await api(`/trackers/${editing}`, { method: 'PUT', body: JSON.stringify(form) });
|
||||
snackSuccess(t('snack.trackerUpdated'));
|
||||
} else {
|
||||
await api('/trackers', { method: 'POST', body: JSON.stringify(form) });
|
||||
snackSuccess(t('snack.trackerCreated'));
|
||||
}
|
||||
showForm = false; editing = null; await load();
|
||||
} catch (err: any) { error = err.message; snackError(err.message); } finally { submitting = false; }
|
||||
}
|
||||
async function toggle(tracker: any) {
|
||||
if (toggling[tracker.id]) return;
|
||||
toggling = { ...toggling, [tracker.id]: true };
|
||||
try {
|
||||
await api(`/trackers/${tracker.id}`, { method: 'PUT', body: JSON.stringify({ enabled: !tracker.enabled }) });
|
||||
await load();
|
||||
snackSuccess(tracker.enabled ? t('snack.trackerPaused') : t('snack.trackerResumed'));
|
||||
} catch (err: any) { snackError(err.message); } finally { toggling = { ...toggling, [tracker.id]: false }; }
|
||||
}
|
||||
function startDelete(tracker: any) { confirmDelete = tracker; }
|
||||
async function doDelete() {
|
||||
if (!confirmDelete) return;
|
||||
try {
|
||||
await api(`/trackers/${confirmDelete.id}`, { method: 'DELETE' });
|
||||
await load();
|
||||
snackSuccess(t('snack.trackerDeleted'));
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
confirmDelete = null;
|
||||
}
|
||||
async function testTrackerTarget(trackerId: number, ttId: number, testType: string) {
|
||||
const key = `${ttId}_${testType}`;
|
||||
if (ttTesting[key]) return;
|
||||
ttTesting = { ...ttTesting, [key]: testType };
|
||||
ttFeedback = { ...ttFeedback, [key]: '' };
|
||||
try {
|
||||
const endpoint = testType === 'basic' ? 'test' : `test-${testType}`;
|
||||
await api(`/trackers/${trackerId}/targets/${ttId}/${endpoint}`, { method: 'POST' });
|
||||
ttFeedback = { ...ttFeedback, [key]: 'ok' };
|
||||
snackSuccess(t('snack.targetTestSent'));
|
||||
} catch (err: any) {
|
||||
ttFeedback = { ...ttFeedback, [key]: 'error' };
|
||||
snackError(err.message);
|
||||
} finally {
|
||||
ttTesting = { ...ttTesting, [key]: '' };
|
||||
setTimeout(() => { ttFeedback = { ...ttFeedback, [key]: '' }; }, 3000);
|
||||
}
|
||||
}
|
||||
function toggleCollection(collectionId: string) { form.collection_ids = form.collection_ids.includes(collectionId) ? form.collection_ids.filter(id => id !== collectionId) : [...form.collection_ids, collectionId]; }
|
||||
|
||||
// --- Linked Targets Management ---
|
||||
function toggleExpand(trackerId: number) {
|
||||
if (expandedTracker === trackerId) { expandedTracker = null; return; }
|
||||
expandedTracker = trackerId;
|
||||
// tracker_targets already loaded in tracker response
|
||||
}
|
||||
|
||||
function getUnlinkedTargets(tracker: any): any[] {
|
||||
const linkedIds = new Set((tracker.tracker_targets || []).map((tt: any) => tt.target_id));
|
||||
return targets.filter(t => !linkedIds.has(t.id));
|
||||
}
|
||||
|
||||
async function addTargetLink(trackerId: number) {
|
||||
const targetId = newLinkTargetId[trackerId];
|
||||
if (!targetId) return;
|
||||
addingTarget = { ...addingTarget, [trackerId]: true };
|
||||
try {
|
||||
await api(`/trackers/${trackerId}/targets`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
target_id: targetId,
|
||||
tracking_config_id: newLinkTrackingConfigId[trackerId] || null,
|
||||
template_config_id: newLinkTemplateConfigId[trackerId] || null,
|
||||
}),
|
||||
});
|
||||
newLinkTargetId[trackerId] = 0;
|
||||
newLinkTrackingConfigId[trackerId] = 0;
|
||||
newLinkTemplateConfigId[trackerId] = 0;
|
||||
await load();
|
||||
snackSuccess(t('snack.targetLinked'));
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
addingTarget = { ...addingTarget, [trackerId]: false };
|
||||
}
|
||||
|
||||
async function removeTargetLink(trackerId: number, ttId: number) {
|
||||
try {
|
||||
await api(`/trackers/${trackerId}/targets/${ttId}`, { method: 'DELETE' });
|
||||
await load();
|
||||
snackSuccess(t('snack.targetUnlinked'));
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
}
|
||||
|
||||
async function updateTargetLink(trackerId: number, tt: any, field: string, value: any) {
|
||||
try {
|
||||
await api(`/trackers/${trackerId}/targets/${tt.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ [field]: value }),
|
||||
});
|
||||
await load();
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageHeader title={t('trackers.title')} description={t('trackers.description')} />
|
||||
<PageHeader title={t('trackers.title')} description={t('trackers.description')}>
|
||||
<button onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}
|
||||
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||
{showForm ? t('trackers.cancel') : t('trackers.newTracker')}
|
||||
</button>
|
||||
</PageHeader>
|
||||
|
||||
{#if !loaded}
|
||||
<Loading />
|
||||
{:else if trackers.length === 0}
|
||||
<Loading />
|
||||
{:else if loadError}
|
||||
<Card>
|
||||
<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3">{loadError}</div>
|
||||
</Card>
|
||||
{:else if showForm}
|
||||
<div in:slide={{ duration: 200 }}>
|
||||
<Card class="mb-6">
|
||||
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
||||
<form onsubmit={save} class="space-y-4">
|
||||
<div>
|
||||
<label for="trk-name" class="block text-sm font-medium mb-1">{t('trackers.name')}</label>
|
||||
<div class="flex gap-2">
|
||||
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
|
||||
<input id="trk-name" bind:value={form.name} required placeholder={t('trackers.namePlaceholder')} class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="trk-provider" class="block text-sm font-medium mb-1">{t('trackers.server')}</label>
|
||||
<select id="trk-provider" bind:value={form.provider_id} onchange={loadCollections} required class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value={0} disabled>{t('trackers.selectServer')}</option>
|
||||
{#each providers as p}<option value={p.id}>{p.name}</option>{/each}
|
||||
</select>
|
||||
</div>
|
||||
{#if collections.length > 0}
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{t('trackers.albums')} ({collections.length})</label>
|
||||
<input type="text" bind:value={collectionFilter} placeholder="Filter..."
|
||||
class="w-full px-3 py-1.5 mb-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
<div class="max-h-56 overflow-y-auto border border-[var(--color-border)] rounded-md p-2 space-y-1">
|
||||
{#each collections.filter(a => !collectionFilter || (a.albumName || a.name || '').toLowerCase().includes(collectionFilter.toLowerCase())) as col}
|
||||
<label class="flex items-center justify-between text-sm cursor-pointer hover:bg-[var(--color-muted)] px-2 py-1 rounded">
|
||||
<span class="flex items-center gap-2">
|
||||
<input type="checkbox" checked={form.collection_ids.includes(col.id)} onchange={() => toggleCollection(col.id)} />
|
||||
{col.albumName || col.name} <span class="text-[var(--color-muted-foreground)]">({col.assetCount ?? col.asset_count ?? 0})</span>
|
||||
</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label for="trk-interval" class="block text-sm font-medium mb-1">{t('trackers.scanInterval')}<Hint text={t('hints.scanInterval')} /></label>
|
||||
<input id="trk-interval" type="number" bind:value={form.scan_interval} min="10" max="3600" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="trk-batch" class="block text-sm font-medium mb-1">{t('trackers.batchDuration')}<Hint text={t('hints.batchDuration')} /></label>
|
||||
<input id="trk-batch" type="number" bind:value={form.batch_duration} min="0" max="3600" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={submitting} class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">{editing ? t('common.save') : t('trackers.createTracker')}</button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loaded && !loadError}
|
||||
{#if trackers.length === 0 && !showForm}
|
||||
<Card>
|
||||
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
|
||||
<div style="opacity: 0.4;"><MdiIcon name="mdiRadar" size={40} /></div>
|
||||
<p class="text-sm">{t('trackers.noTrackers')}</p>
|
||||
</div>
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="grid gap-4 sm:grid-cols-2 stagger-children">
|
||||
{:else if !showForm}
|
||||
<div class="space-y-3 stagger-children">
|
||||
{#each trackers as tracker}
|
||||
<Card hover>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-lg"
|
||||
style="background: {tracker.enabled ? 'var(--color-success-bg)' : 'var(--color-muted)'}; color: {tracker.enabled ? 'var(--color-success-fg)' : 'var(--color-muted-foreground)'};">
|
||||
<MdiIcon name="mdiRadar" size={20} />
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="font-medium text-sm">{tracker.name}</h3>
|
||||
<p class="text-xs" style="color: var(--color-muted-foreground);">
|
||||
{tracker.collection_ids?.length || 0} {t('trackers.albums_count')} · {t('trackers.every')} {tracker.scan_interval}s
|
||||
<div class="flex items-center gap-2">
|
||||
{#if tracker.icon}<MdiIcon name={tracker.icon} />{/if}
|
||||
<p class="font-medium">{tracker.name}</p>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded {tracker.enabled ? 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]' : 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
|
||||
{tracker.enabled ? t('trackers.active') : t('trackers.paused')}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-[var(--color-muted-foreground)]">
|
||||
{(tracker.collection_ids || []).length} {t('trackers.albums_count')} · {t('trackers.every')} {tracker.scan_interval}s · {(tracker.tracker_targets || []).length} {t('trackers.linkedTargets')}
|
||||
</p>
|
||||
</div>
|
||||
<span class="ml-auto text-xs px-2 py-0.5 rounded-full"
|
||||
style="background: {tracker.enabled ? 'var(--color-success-bg)' : 'var(--color-muted)'}; color: {tracker.enabled ? 'var(--color-success-fg)' : 'var(--color-muted-foreground)'};">
|
||||
{tracker.enabled ? t('trackers.active') : t('trackers.paused')}
|
||||
</span>
|
||||
<div class="flex items-center gap-1 flex-wrap justify-end">
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(tracker)} />
|
||||
<IconButton icon="mdiPlay" title={t('common.test')} onclick={async () => { try { await api(`/trackers/${tracker.id}/trigger`, { method: 'POST' }); snackSuccess(t('snack.targetTestSent')); } catch (err) { snackError((err as any).message); } }} />
|
||||
<IconButton icon={tracker.enabled ? 'mdiPause' : 'mdiPlay'} title={tracker.enabled ? t('trackers.pause') : t('trackers.resume')} onclick={() => toggle(tracker)} disabled={toggling[tracker.id]} />
|
||||
<button onclick={() => toggleExpand(tracker.id)}
|
||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1">
|
||||
{t('trackers.linkedTargets')} {expandedTracker === tracker.id ? '▲' : '▼'}
|
||||
</button>
|
||||
<IconButton icon="mdiDelete" title={t('trackers.delete')} onclick={() => startDelete(tracker)} variant="danger" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Linked Targets Section -->
|
||||
{#if expandedTracker === tracker.id}
|
||||
<div class="mt-3 border-t border-[var(--color-border)] pt-3 space-y-2" in:slide>
|
||||
{#if (tracker.tracker_targets || []).length === 0}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)]">{t('trackers.noLinkedTargets')}</p>
|
||||
{:else}
|
||||
{#each tracker.tracker_targets as tt}
|
||||
<div class="flex items-center justify-between text-sm px-2 py-1.5 rounded bg-[var(--color-muted)]/30">
|
||||
<div class="flex items-center gap-2">
|
||||
{#if tt.target_icon}<MdiIcon name={tt.target_icon} size={16} />{/if}
|
||||
<span class="font-medium">{tt.target_name || `Target #${tt.target_id}`}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{tt.target_type}</span>
|
||||
{#if !tt.enabled}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-warning-bg)] text-[var(--color-warning-fg)]">{t('trackers.paused')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-wrap justify-end">
|
||||
<select value={tt.tracking_config_id || 0}
|
||||
onchange={(e: Event) => updateTargetLink(tracker.id, tt, 'tracking_config_id', Number((e.target as HTMLSelectElement).value) || null)}
|
||||
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
|
||||
<option value={0}>— {t('trackingConfig.title')} —</option>
|
||||
{#each trackingConfigs as tc}<option value={tc.id}>{tc.name}</option>{/each}
|
||||
</select>
|
||||
<select value={tt.template_config_id || 0}
|
||||
onchange={(e: Event) => updateTargetLink(tracker.id, tt, 'template_config_id', Number((e.target as HTMLSelectElement).value) || null)}
|
||||
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
|
||||
<option value={0}>— {t('templateConfig.title')} —</option>
|
||||
{#each templateConfigs as tc}<option value={tc.id}>{tc.name}</option>{/each}
|
||||
</select>
|
||||
<IconButton icon="mdiSend" size={14} title={t('common.test')}
|
||||
onclick={() => testTrackerTarget(tracker.id, tt.id, 'basic')}
|
||||
disabled={!!ttTesting[`${tt.id}_basic`]} />
|
||||
<IconButton icon="mdiCalendarClock" size={14} title={t('trackingConfig.periodicSummary')}
|
||||
onclick={() => testTrackerTarget(tracker.id, tt.id, 'periodic')}
|
||||
disabled={!!ttTesting[`${tt.id}_periodic`]} />
|
||||
<IconButton icon="mdiHistory" size={14} title={t('trackingConfig.memoryMode')}
|
||||
onclick={() => testTrackerTarget(tracker.id, tt.id, 'memory')}
|
||||
disabled={!!ttTesting[`${tt.id}_memory`]} />
|
||||
{#each ['basic', 'periodic', 'memory'] as testType}
|
||||
{#if ttFeedback[`${tt.id}_${testType}`]}
|
||||
<span class="text-xs {ttFeedback[`${tt.id}_${testType}`] === 'ok' ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">
|
||||
{ttFeedback[`${tt.id}_${testType}`] === 'ok' ? '\u2713' : '\u2717'}
|
||||
</span>
|
||||
{/if}
|
||||
{/each}
|
||||
<IconButton icon={tt.enabled ? 'mdiPause' : 'mdiPlay'} size={14}
|
||||
title={tt.enabled ? t('trackers.pause') : t('trackers.resume')}
|
||||
onclick={() => updateTargetLink(tracker.id, tt, 'enabled', !tt.enabled)} />
|
||||
<IconButton icon="mdiClose" size={14} title={t('common.delete')}
|
||||
onclick={() => removeTargetLink(tracker.id, tt.id)} variant="danger" />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<!-- Add target link -->
|
||||
{#if getUnlinkedTargets(tracker).length > 0}
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<select bind:value={newLinkTargetId[tracker.id]}
|
||||
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)] flex-1">
|
||||
<option value={0}>— {t('trackers.addTarget')} —</option>
|
||||
{#each getUnlinkedTargets(tracker) as tgt}<option value={tgt.id}>{tgt.name} ({tgt.type})</option>{/each}
|
||||
</select>
|
||||
<select bind:value={newLinkTrackingConfigId[tracker.id]}
|
||||
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
|
||||
<option value={0}>— {t('trackingConfig.title')} —</option>
|
||||
{#each trackingConfigs as tc}<option value={tc.id}>{tc.name}</option>{/each}
|
||||
</select>
|
||||
<select bind:value={newLinkTemplateConfigId[tracker.id]}
|
||||
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
|
||||
<option value={0}>— {t('templateConfig.title')} —</option>
|
||||
{#each templateConfigs as tc}<option value={tc.id}>{tc.name}</option>{/each}
|
||||
</select>
|
||||
<button onclick={() => addTargetLink(tracker.id)}
|
||||
disabled={!newLinkTargetId[tracker.id] || addingTarget[tracker.id]}
|
||||
class="text-xs px-3 py-1 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded hover:opacity-90 disabled:opacity-50">
|
||||
{t('common.add')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<ConfirmModal
|
||||
open={!!confirmDelete}
|
||||
message={t('trackers.confirmDelete')}
|
||||
onconfirm={doDelete}
|
||||
oncancel={() => confirmDelete = null}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user