Files
homefeed/frontend/src/lib/components/admin/EventsTab.svelte
T
Claude 1068a4a5f7 Rename Tracked Events to Tracked Items, add More>> spillover, lock recap cadence
- Tracked items get the same isSpillover flag as categories: a per-item "More"
  checkbox collapses it into the "More »" nav tab instead of its own top-level
  tab, mirroring Category.isSpillover end to end (schema, CRUD, public API,
  nav partitioning, /more page).
- Renamed user-facing "Tracked events" text to "Tracked items" (admin tab,
  toolbar, buttons, placeholders, /event/:id subtitle) — the underlying
  TrackedEvent/events data model and routes are unchanged.
- Locked the per-item recap cadence dropdown (previously a live
  Continuous/Daily/Hourly picker) and moved it to its own labeled section with
  an explanatory hint: reading eventsRecap.ts confirmed Continuous and Hourly
  currently behave identically, so the live choice was more confusing than
  useful. New items are fixed to Continuous; existing items show their real
  cadence read-only.
2026-07-26 17:59:56 +00:00

377 lines
10 KiB
Svelte

<script lang="ts">
import type { AdminTrackedEvent, AdminSource } from '$lib/adminTypes';
import { addEvent, updateEvent, deleteEvent } from '$lib/adminApi';
let { events: initial, sources }: { events: AdminTrackedEvent[]; sources: AdminSource[] } = $props();
let events = $state([...initial]);
let showAdd = $state(false);
let newEvent = $state({ name: '' });
let editingId = $state<string | null>(null);
function emptyEditForm() {
return {
name: '',
description: '',
sourceIdSet: new Set<string>(),
keywordsText: '',
cadence: 'continuous' as AdminTrackedEvent['cadence'],
cadenceTime: null as string | null,
retentionOverrideDays: null as number | null
};
}
let editForm = $state(emptyEditForm());
function sourceNames(ids: string[]) {
return ids.map((id) => sources.find((s) => s.id === id)?.name).filter(Boolean).join(', ') || 'No sources assigned';
}
// Cadence is fixed at Continuous for every new item — see the "Recap cadence" hint
// below for why the dropdown is locked rather than a live choice right now.
async function handleAdd() {
if (!newEvent.name) return;
const created = await addEvent({
name: newEvent.name,
description: '',
sourceIds: [],
keywords: [],
cadence: 'continuous',
cadenceTime: null,
isSpillover: false,
retentionOverrideDays: null
});
events = [...events, created];
newEvent = { name: '' };
showAdd = false;
}
async function toggleActive(event: AdminTrackedEvent) {
const updated = await updateEvent(event.id, { active: !event.active });
events = events.map((e) => (e.id === event.id ? updated : e));
}
// Same "More »" collapse as Category.isSpillover (see MergeTab.svelte) — an item
// marked here loses its own top-nav tab and shows up on /more instead.
async function toggleSpillover(event: AdminTrackedEvent) {
const updated = await updateEvent(event.id, { isSpillover: !event.isSpillover });
events = events.map((e) => (e.id === event.id ? updated : e));
}
async function handleDelete(id: string) {
await deleteEvent(id);
events = events.filter((e) => e.id !== id);
}
function startEdit(event: AdminTrackedEvent) {
editingId = event.id;
editForm = {
name: event.name,
description: event.description,
sourceIdSet: new Set(event.sourceIds),
keywordsText: event.keywords.join(', '),
cadence: event.cadence,
cadenceTime: event.cadenceTime,
retentionOverrideDays: event.retentionOverrideDays
};
}
function cancelEdit() {
editingId = null;
}
function toggleEditSource(id: string) {
const next = new Set(editForm.sourceIdSet);
if (next.has(id)) next.delete(id);
else next.add(id);
editForm.sourceIdSet = next;
}
// Cadence/cadenceTime are read-only in this form (disabled select below) and are
// passed straight through unchanged, so editing name/sources/keywords never
// accidentally shifts an item's recap timing.
async function saveEdit() {
if (!editingId || !editForm.name) return;
const keywords = editForm.keywordsText
.split(',')
.map((k) => k.trim())
.filter(Boolean);
const updated = await updateEvent(editingId, {
name: editForm.name,
description: editForm.description,
sourceIds: [...editForm.sourceIdSet],
keywords,
cadence: editForm.cadence,
cadenceTime: editForm.cadenceTime,
retentionOverrideDays: editForm.retentionOverrideDays
});
events = events.map((e) => (e.id === editingId ? updated : e));
editingId = null;
}
</script>
<div class="toolbar">
<span class="count">{events.length} tracked items</span>
<button class="add-btn" onclick={() => (showAdd = !showAdd)}>+ New item</button>
</div>
{#if showAdd}
<div class="add-panel">
<div class="add-grid">
<input placeholder="Item name (e.g. Iran war)" bind:value={newEvent.name} />
</div>
<div class="cadence-block">
<div class="field-label">Recap cadence</div>
<select disabled value="continuous">
<option value="continuous">Continuous</option>
</select>
<p class="hint">
Controls how often this item's AI recap is written, on top of its individual articles
(which publish immediately either way). Locked for now — Continuous and Hourly
currently behave identically (roughly once an hour, as long as new coverage keeps
arriving), so every new item uses Continuous.
</p>
</div>
<div class="add-actions">
<button onclick={() => (showAdd = false)}>Cancel</button>
<button class="primary" onclick={handleAdd}>Create</button>
</div>
<p class="hint">Assign sources and a keyword filter via Edit once created.</p>
</div>
{/if}
<div class="list">
{#each events as event (event.id)}
{#if editingId === event.id}
<div class="edit-panel">
<div class="add-grid">
<input placeholder="Item name" bind:value={editForm.name} />
</div>
<textarea placeholder="Description (optional)" bind:value={editForm.description} rows="2"></textarea>
<div class="field-label">Sources</div>
<div class="source-checks">
{#each sources as source (source.id)}
<label class="source-check">
<input
type="checkbox"
checked={editForm.sourceIdSet.has(source.id)}
onchange={() => toggleEditSource(source.id)}
/>
{source.name}
</label>
{/each}
</div>
<div class="field-label">Keyword filter</div>
<input placeholder="e.g. 🇮🇷, Tehran, IRGC" bind:value={editForm.keywordsText} />
<p class="hint">
Comma-separated words, phrases, or emoji — only items from the sources above whose
title/summary/body contain at least one qualify for this item's recap. Leave blank to
include everything from the assigned sources.
</p>
<div class="cadence-block">
<div class="field-label">Recap cadence</div>
<select disabled value={editForm.cadence}>
<option value="continuous">Continuous</option>
<option value="daily">Daily</option>
<option value="hourly">Hourly</option>
</select>
{#if editForm.cadence === 'daily' && editForm.cadenceTime}
<span class="cadence-time">at {editForm.cadenceTime}</span>
{/if}
<p class="hint">
Controls how often this item's AI recap is written, on top of its individual
articles (which publish immediately either way). Locked for now — Continuous and
Hourly currently behave identically (roughly once an hour, as long as new coverage
keeps arriving); Daily instead waits for the one fixed time shown above.
</p>
</div>
<div class="add-actions">
<button onclick={cancelEdit}>Cancel</button>
<button class="primary" onclick={saveEdit}>Save</button>
</div>
</div>
{:else}
<div class="row">
<div>
<div class="name">{event.name}</div>
<div class="sub">
{sourceNames(event.sourceIds)}
{#if event.keywords.length > 0}
· matching {event.keywords.map((k) => `"${k}"`).join(', ')}
{/if}
·
{event.cadence === 'daily' ? `daily recap at ${event.cadenceTime}` : event.cadence}
</div>
</div>
<label class="spillover-toggle">
<input type="checkbox" checked={event.isSpillover} onchange={() => toggleSpillover(event)} />
More
</label>
<span class="badge" class:active={event.active} onclick={() => toggleActive(event)} role="button" tabindex="0">
{event.active ? 'Active' : 'Paused'}
</span>
<button class="icon-btn" onclick={() => startEdit(event)} title="Edit">Edit</button>
<button class="icon-btn danger" onclick={() => handleDelete(event.id)} title="Delete"></button>
</div>
{/if}
{/each}
</div>
<style>
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.count {
font-size: 12px;
color: var(--text-muted);
}
.add-btn {
font-size: 12px;
padding: 6px 12px;
}
.add-panel {
background: var(--surface-1);
border-radius: 12px;
padding: 14px;
margin-bottom: 14px;
}
.add-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 8px;
margin-bottom: 10px;
}
.add-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-bottom: 6px;
}
.primary {
background: var(--pill-bg);
color: var(--pill-text);
border-color: var(--pill-bg);
}
.hint {
font-size: 11px;
color: var(--text-muted);
margin: 0;
}
.list {
display: flex;
flex-direction: column;
gap: 8px;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
background: var(--surface-1);
border-radius: var(--radius);
padding: 10px 14px;
}
.name {
font-size: 13px;
font-weight: 500;
}
.sub {
font-size: 11px;
color: var(--text-muted);
}
.badge {
font-size: 11px;
padding: 2px 10px;
border-radius: var(--radius);
background: var(--surface-2);
color: var(--text-muted);
cursor: pointer;
white-space: nowrap;
}
.badge.active {
background: var(--bg-accent);
color: var(--text-accent);
}
.icon-btn {
font-size: 12px;
padding: 3px 6px;
background: transparent;
border: none;
color: var(--text-secondary);
}
.icon-btn:hover {
color: var(--text-accent);
}
.icon-btn.danger:hover {
color: var(--text-danger);
}
.edit-panel {
background: var(--surface-1);
border-radius: 12px;
padding: 14px;
}
.edit-panel textarea {
width: 100%;
resize: vertical;
font: inherit;
margin-bottom: 10px;
}
.field-label {
font-size: 11px;
color: var(--text-muted);
margin-bottom: 6px;
}
.source-checks {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 12px;
}
.source-check {
display: flex;
align-items: center;
gap: 5px;
font-size: 12px;
color: var(--text-secondary);
}
.source-check input {
width: auto;
}
.edit-panel .hint {
margin: 6px 0 10px;
}
.cadence-block {
margin-top: 12px;
padding-top: 12px;
border-top: 0.5px solid var(--border);
}
.cadence-block select:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.cadence-time {
font-size: 12px;
color: var(--text-secondary);
margin-left: 8px;
}
.cadence-block .hint {
margin-top: 6px;
}
.spillover-toggle {
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
color: var(--text-secondary);
white-space: nowrap;
}
.spillover-toggle input {
width: auto;
}
</style>