Fix missing storage-used display; add keyword filter for tracked events
Retention panel's "currently using" line and usage bar were always blank — totalStorageBytes() existed but was never wired into the settings response, so retention.storageUsedMB was undefined on every load. Now computed fresh on every GET/PATCH /api/admin/settings. Tracked events gain a keywords field: only items whose title/summary/ body contain at least one of them (word, phrase, or emoji — e.g. 🇮🇷 for an "Iran war" event) qualify for that event's recap, instead of every item from its assigned sources. An item from an event-linked source that doesn't match now falls through to normal synthesis rather than being silently dropped. Also added the source-assignment + keyword-filter edit UI to EventsTab.svelte, which had no way to populate sourceIds at all before this (the "assign from the Sources tab" comment referenced a feature that was never built). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
This commit is contained in:
@@ -4,15 +4,23 @@ import * as sourcesDb from '../storage/db/sources.js';
|
|||||||
import * as eventsDb from '../storage/db/events.js';
|
import * as eventsDb from '../storage/db/events.js';
|
||||||
import * as categoriesDb from '../storage/db/categories.js';
|
import * as categoriesDb from '../storage/db/categories.js';
|
||||||
import { clearSourceContent, reissueSourceContent, clearAllArticles, clearAllMedia } from '../storage/contentCascade.js';
|
import { clearSourceContent, reissueSourceContent, clearAllArticles, clearAllMedia } from '../storage/contentCascade.js';
|
||||||
|
import { totalStorageBytes } from '../storage/media/index.js';
|
||||||
import { OllamaProvider } from '../inference/ollama-provider.js';
|
import { OllamaProvider } from '../inference/ollama-provider.js';
|
||||||
import { pollSourceNow } from '../ingestion/poller.js';
|
import { pollSourceNow } from '../ingestion/poller.js';
|
||||||
import { logger, listLogs } from '../storage/db/logs.js';
|
import { logger, listLogs } from '../storage/db/logs.js';
|
||||||
import * as telegramClient from '../telegram/client.js';
|
import * as telegramClient from '../telegram/client.js';
|
||||||
|
|
||||||
|
// Not part of GlobalSettings itself (nothing to persist) — computed fresh on every
|
||||||
|
// settings read/write so the Retention tab's "currently using" line and usage bar
|
||||||
|
// always reflect the real total, not whatever was true when the row was last saved.
|
||||||
|
function withStorageUsed(settings: ReturnType<typeof settingsDb.getSettings>) {
|
||||||
|
return { ...settings, retention: { ...settings.retention, storageUsedMB: Math.round(totalStorageBytes() / (1024 * 1024)) } };
|
||||||
|
}
|
||||||
|
|
||||||
export async function registerAdminRoutes(app: FastifyInstance) {
|
export async function registerAdminRoutes(app: FastifyInstance) {
|
||||||
// --- Settings ---
|
// --- Settings ---
|
||||||
app.get('/api/admin/settings', async () => {
|
app.get('/api/admin/settings', async () => {
|
||||||
const settings = settingsDb.getSettings();
|
const settings = withStorageUsed(settingsDb.getSettings());
|
||||||
return { ...settings, categoryPriority: categoriesDb.listCategories() };
|
return { ...settings, categoryPriority: categoriesDb.listCategories() };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -22,7 +30,7 @@ export async function registerAdminRoutes(app: FastifyInstance) {
|
|||||||
categoriesDb.setCategoryOrder(body.categoryPriority);
|
categoriesDb.setCategoryOrder(body.categoryPriority);
|
||||||
delete body.categoryPriority;
|
delete body.categoryPriority;
|
||||||
}
|
}
|
||||||
const settings = settingsDb.updateSettings(body);
|
const settings = withStorageUsed(settingsDb.updateSettings(body));
|
||||||
return { ...settings, categoryPriority: categoriesDb.listCategories() };
|
return { ...settings, categoryPriority: categoriesDb.listCategories() };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ export async function runEventRecaps(provider: InferenceProvider, settings: Glob
|
|||||||
if (event.sourceIds.length === 0 || !isDue(event)) continue;
|
if (event.sourceIds.length === 0 || !isDue(event)) continue;
|
||||||
|
|
||||||
const since = event.lastRecapAt ?? new Date(Date.now() - 24 * 3600_000).toISOString();
|
const since = event.lastRecapAt ?? new Date(Date.now() - 24 * 3600_000).toISOString();
|
||||||
const items = contentItemsDb.unclusteredItemsForSources(event.sourceIds, since);
|
const items = contentItemsDb
|
||||||
|
.unclusteredItemsForSources(event.sourceIds, since)
|
||||||
|
.filter((item) => eventsDb.itemMatchesEventKeywords(item, event.keywords));
|
||||||
if (items.length === 0) continue;
|
if (items.length === 0) continue;
|
||||||
|
|
||||||
const embedded = await embedPendingItems(provider, settings.selectedModels.embedding, items);
|
const embedded = await embedPendingItems(provider, settings.selectedModels.embedding, items);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { embedPendingItems } from '../pipeline/embedding.js';
|
|||||||
import { clusterItems } from '../pipeline/clustering.js';
|
import { clusterItems } from '../pipeline/clustering.js';
|
||||||
import { publishCluster, publishDirect } from '../pipeline/publish.js';
|
import { publishCluster, publishDirect } from '../pipeline/publish.js';
|
||||||
import { logger } from '../storage/db/logs.js';
|
import { logger } from '../storage/db/logs.js';
|
||||||
import type { GlobalSettings, ContentItem } from '../storage/db/types.js';
|
import type { GlobalSettings, ContentItem, TrackedEvent } from '../storage/db/types.js';
|
||||||
|
|
||||||
function partition<T>(items: T[], predicate: (item: T) => boolean): [T[], T[]] {
|
function partition<T>(items: T[], predicate: (item: T) => boolean): [T[], T[]] {
|
||||||
const matches: T[] = [];
|
const matches: T[] = [];
|
||||||
@@ -16,6 +16,18 @@ function partition<T>(items: T[], predicate: (item: T) => boolean): [T[], T[]] {
|
|||||||
return [matches, rest];
|
return [matches, rest];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An item is "claimed" by a tracked event — and so left for eventsRecap.ts to handle
|
||||||
|
* instead of normal synthesis — only if it belongs to one of the event's sources AND
|
||||||
|
* matches its keyword filter. An item from an event-linked source that doesn't match
|
||||||
|
* (e.g. a general Middle-East feed assigned to an "Iran war" event, but this particular
|
||||||
|
* item doesn't mention Iran) falls through to normal synthesis rather than being
|
||||||
|
* silently dropped — it just isn't part of that event's recap.
|
||||||
|
*/
|
||||||
|
function isClaimedByEvent(item: ContentItem, events: TrackedEvent[]): boolean {
|
||||||
|
return events.some((e) => e.sourceIds.includes(item.sourceId) && eventsDb.itemMatchesEventKeywords(item, e.keywords));
|
||||||
|
}
|
||||||
|
|
||||||
function primaryCategoryRank(item: ContentItem, rankByName: Map<string, number>): number {
|
function primaryCategoryRank(item: ContentItem, rankByName: Map<string, number>): number {
|
||||||
const source = sourcesDb.getSource(item.sourceId);
|
const source = sourcesDb.getSource(item.sourceId);
|
||||||
const cats = source?.category ?? [];
|
const cats = source?.category ?? [];
|
||||||
@@ -39,8 +51,8 @@ function primaryCategoryRank(item: ContentItem, rankByName: Map<string, number>)
|
|||||||
* Still respects category priority.
|
* Still respects category priority.
|
||||||
*/
|
*/
|
||||||
export async function runPassthroughCycle(settings: GlobalSettings): Promise<number> {
|
export async function runPassthroughCycle(settings: GlobalSettings): Promise<number> {
|
||||||
const eventSourceIds = eventsDb.listActiveEvents().flatMap((e) => e.sourceIds);
|
const activeEvents = eventsDb.listActiveEvents();
|
||||||
const items = contentItemsDb.unclusteredItemsExcludingSources(eventSourceIds);
|
const items = contentItemsDb.unclusteredItemsExcludingSources([]).filter((item) => !isClaimedByEvent(item, activeEvents));
|
||||||
if (items.length === 0) return 0;
|
if (items.length === 0) return 0;
|
||||||
|
|
||||||
const categories = categoriesDb.listCategories();
|
const categories = categoriesDb.listCategories();
|
||||||
@@ -68,13 +80,14 @@ export async function runPassthroughCycle(settings: GlobalSettings): Promise<num
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* One pass of the synthesis queue: cluster whatever's unclustered (excluding items
|
* One pass of the synthesis queue: cluster whatever's unclustered (excluding items
|
||||||
* belonging to tracked-event sources, which are handled by eventsRecap.ts instead),
|
* claimed by a tracked event — belonging to one of its sources AND matching its
|
||||||
* ordered by admin-defined category priority, and publish clusters that have cleared
|
* keyword filter, if any — which are handled by eventsRecap.ts instead), ordered by
|
||||||
* the hold-before-publish window.
|
* admin-defined category priority, and publish clusters that have cleared the
|
||||||
|
* hold-before-publish window.
|
||||||
*/
|
*/
|
||||||
export async function runSynthesisCycle(provider: InferenceProvider, settings: GlobalSettings): Promise<number> {
|
export async function runSynthesisCycle(provider: InferenceProvider, settings: GlobalSettings): Promise<number> {
|
||||||
const eventSourceIds = eventsDb.listActiveEvents().flatMap((e) => e.sourceIds);
|
const activeEvents = eventsDb.listActiveEvents();
|
||||||
const items = contentItemsDb.unclusteredItemsExcludingSources(eventSourceIds);
|
const items = contentItemsDb.unclusteredItemsExcludingSources([]).filter((item) => !isClaimedByEvent(item, activeEvents));
|
||||||
if (items.length === 0) return 0;
|
if (items.length === 0) return 0;
|
||||||
|
|
||||||
// YouTube videos, Nitter tweets, and Telegram messages never get LLM-merged with
|
// YouTube videos, Nitter tweets, and Telegram messages never get LLM-merged with
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ function rowToEvent(row: any): TrackedEvent {
|
|||||||
name: row.name,
|
name: row.name,
|
||||||
description: row.description,
|
description: row.description,
|
||||||
sourceIds: JSON.parse(row.source_ids),
|
sourceIds: JSON.parse(row.source_ids),
|
||||||
|
keywords: JSON.parse(row.keywords),
|
||||||
cadence: row.cadence,
|
cadence: row.cadence,
|
||||||
cadenceTime: row.cadence_time,
|
cadenceTime: row.cadence_time,
|
||||||
active: !!row.active,
|
active: !!row.active,
|
||||||
@@ -17,6 +18,21 @@ function rowToEvent(row: any): TrackedEvent {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an item qualifies for a tracked event's keyword filter — empty keywords
|
||||||
|
* means "match everything" (the original, filter-less behavior). Matching is a plain
|
||||||
|
* case-insensitive substring check against title+summary+body; works the same for a
|
||||||
|
* word/phrase ("Tehran") or an emoji ("🇮🇷", which has no case to fold).
|
||||||
|
*/
|
||||||
|
export function itemMatchesEventKeywords(
|
||||||
|
item: { title: string; summary: string; body: string | null },
|
||||||
|
keywords: string[]
|
||||||
|
): boolean {
|
||||||
|
if (keywords.length === 0) return true;
|
||||||
|
const haystack = `${item.title} ${item.summary} ${item.body ?? ''}`.toLowerCase();
|
||||||
|
return keywords.some((k) => haystack.includes(k.toLowerCase()));
|
||||||
|
}
|
||||||
|
|
||||||
export function listEvents(): TrackedEvent[] {
|
export function listEvents(): TrackedEvent[] {
|
||||||
const rows = db.prepare('SELECT * FROM tracked_events ORDER BY created_at').all();
|
const rows = db.prepare('SELECT * FROM tracked_events ORDER BY created_at').all();
|
||||||
return rows.map(rowToEvent);
|
return rows.map(rowToEvent);
|
||||||
@@ -36,13 +52,14 @@ export function createEvent(input: Partial<TrackedEvent>): TrackedEvent {
|
|||||||
const id = `evt-${randomUUID()}`;
|
const id = `evt-${randomUUID()}`;
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO tracked_events (id, name, description, source_ids, cadence, cadence_time, active, retention_override_days, last_recap_at, created_at)
|
`INSERT INTO tracked_events (id, name, description, source_ids, keywords, cadence, cadence_time, active, retention_override_days, last_recap_at, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, ?)`
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?)`
|
||||||
).run(
|
).run(
|
||||||
id,
|
id,
|
||||||
input.name ?? 'Untitled event',
|
input.name ?? 'Untitled event',
|
||||||
input.description ?? '',
|
input.description ?? '',
|
||||||
JSON.stringify(input.sourceIds ?? []),
|
JSON.stringify(input.sourceIds ?? []),
|
||||||
|
JSON.stringify(input.keywords ?? []),
|
||||||
input.cadence ?? 'continuous',
|
input.cadence ?? 'continuous',
|
||||||
input.cadenceTime ?? null,
|
input.cadenceTime ?? null,
|
||||||
input.active === false ? 0 : 1,
|
input.active === false ? 0 : 1,
|
||||||
@@ -57,11 +74,12 @@ export function updateEvent(id: string, patch: Partial<TrackedEvent>): TrackedEv
|
|||||||
if (!existing) return null;
|
if (!existing) return null;
|
||||||
const merged = { ...existing, ...patch };
|
const merged = { ...existing, ...patch };
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`UPDATE tracked_events SET name=?, description=?, source_ids=?, cadence=?, cadence_time=?, active=?, retention_override_days=?, last_recap_at=? WHERE id=?`
|
`UPDATE tracked_events SET name=?, description=?, source_ids=?, keywords=?, cadence=?, cadence_time=?, active=?, retention_override_days=?, last_recap_at=? WHERE id=?`
|
||||||
).run(
|
).run(
|
||||||
merged.name,
|
merged.name,
|
||||||
merged.description,
|
merged.description,
|
||||||
JSON.stringify(merged.sourceIds),
|
JSON.stringify(merged.sourceIds),
|
||||||
|
JSON.stringify(merged.keywords),
|
||||||
merged.cadence,
|
merged.cadence,
|
||||||
merged.cadenceTime,
|
merged.cadenceTime,
|
||||||
merged.active ? 1 : 0,
|
merged.active ? 1 : 0,
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ export function migrate() {
|
|||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
description TEXT NOT NULL DEFAULT '',
|
description TEXT NOT NULL DEFAULT '',
|
||||||
source_ids TEXT NOT NULL DEFAULT '[]', -- JSON
|
source_ids TEXT NOT NULL DEFAULT '[]', -- JSON
|
||||||
|
keywords TEXT NOT NULL DEFAULT '[]', -- JSON string array — empty means "match everything from source_ids"
|
||||||
cadence TEXT NOT NULL DEFAULT 'continuous',
|
cadence TEXT NOT NULL DEFAULT 'continuous',
|
||||||
cadence_time TEXT,
|
cadence_time TEXT,
|
||||||
active INTEGER NOT NULL DEFAULT 1,
|
active INTEGER NOT NULL DEFAULT 1,
|
||||||
@@ -236,6 +237,9 @@ export function migrate() {
|
|||||||
if (!hasColumn('global_settings', 'telegram_media_mode')) {
|
if (!hasColumn('global_settings', 'telegram_media_mode')) {
|
||||||
db.exec("ALTER TABLE global_settings ADD COLUMN telegram_media_mode TEXT NOT NULL DEFAULT 'self-host'");
|
db.exec("ALTER TABLE global_settings ADD COLUMN telegram_media_mode TEXT NOT NULL DEFAULT 'self-host'");
|
||||||
}
|
}
|
||||||
|
if (!hasColumn('tracked_events', 'keywords')) {
|
||||||
|
db.exec("ALTER TABLE tracked_events ADD COLUMN keywords TEXT NOT NULL DEFAULT '[]'");
|
||||||
|
}
|
||||||
|
|
||||||
// Seed default categories if none exist yet. "News" sits right under "Top stories" —
|
// Seed default categories if none exist yet. "News" sits right under "Top stories" —
|
||||||
// general news sources belong here, not on "Top stories" itself, which isn't a real
|
// general news sources belong here, not on "Top stories" itself, which isn't a real
|
||||||
|
|||||||
@@ -174,6 +174,8 @@ export interface TrackedEvent {
|
|||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
sourceIds: string[];
|
sourceIds: string[];
|
||||||
|
/** Only items whose title/summary/body contain at least one of these (case-insensitive) qualify for this event — empty means "match everything from sourceIds", the original behavior. */
|
||||||
|
keywords: string[];
|
||||||
cadence: 'continuous' | 'daily' | 'hourly' | 'custom';
|
cadence: 'continuous' | 'daily' | 'hourly' | 'custom';
|
||||||
cadenceTime: string | null;
|
cadenceTime: string | null;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ export interface AdminTrackedEvent {
|
|||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
sourceIds: string[];
|
sourceIds: string[];
|
||||||
|
/** Only items whose title/summary/body contain at least one of these (case-insensitive) qualify for this event — empty means "match everything from sourceIds". */
|
||||||
|
keywords: string[];
|
||||||
cadence: 'continuous' | 'daily' | 'hourly' | 'custom';
|
cadence: 'continuous' | 'daily' | 'hourly' | 'custom';
|
||||||
cadenceTime: string | null;
|
cadenceTime: string | null;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
|
|||||||
@@ -7,6 +7,20 @@
|
|||||||
let showAdd = $state(false);
|
let showAdd = $state(false);
|
||||||
let newEvent = $state({ name: '', cadence: 'daily' as AdminTrackedEvent['cadence'], cadenceTime: '18:00' });
|
let newEvent = $state({ name: '', cadence: 'daily' as AdminTrackedEvent['cadence'], cadenceTime: '18:00' });
|
||||||
|
|
||||||
|
let editingId = $state<string | null>(null);
|
||||||
|
function emptyEditForm() {
|
||||||
|
return {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
sourceIdSet: new Set<string>(),
|
||||||
|
keywordsText: '',
|
||||||
|
cadence: 'daily' as AdminTrackedEvent['cadence'],
|
||||||
|
cadenceTime: '18:00',
|
||||||
|
retentionOverrideDays: null as number | null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let editForm = $state(emptyEditForm());
|
||||||
|
|
||||||
function sourceNames(ids: string[]) {
|
function sourceNames(ids: string[]) {
|
||||||
return ids.map((id) => sources.find((s) => s.id === id)?.name).filter(Boolean).join(', ') || 'No sources assigned';
|
return ids.map((id) => sources.find((s) => s.id === id)?.name).filter(Boolean).join(', ') || 'No sources assigned';
|
||||||
}
|
}
|
||||||
@@ -17,6 +31,7 @@
|
|||||||
name: newEvent.name,
|
name: newEvent.name,
|
||||||
description: '',
|
description: '',
|
||||||
sourceIds: [],
|
sourceIds: [],
|
||||||
|
keywords: [],
|
||||||
cadence: newEvent.cadence,
|
cadence: newEvent.cadence,
|
||||||
cadenceTime: newEvent.cadence === 'daily' ? newEvent.cadenceTime : null,
|
cadenceTime: newEvent.cadence === 'daily' ? newEvent.cadenceTime : null,
|
||||||
retentionOverrideDays: null
|
retentionOverrideDays: null
|
||||||
@@ -35,6 +50,49 @@
|
|||||||
await deleteEvent(id);
|
await deleteEvent(id);
|
||||||
events = events.filter((e) => e.id !== 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 ?? '18:00',
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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.cadence === 'daily' ? editForm.cadenceTime : null,
|
||||||
|
retentionOverrideDays: editForm.retentionOverrideDays
|
||||||
|
});
|
||||||
|
events = events.map((e) => (e.id === editingId ? updated : e));
|
||||||
|
editingId = null;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
@@ -59,25 +117,74 @@
|
|||||||
<button onclick={() => (showAdd = false)}>Cancel</button>
|
<button onclick={() => (showAdd = false)}>Cancel</button>
|
||||||
<button class="primary" onclick={handleAdd}>Create</button>
|
<button class="primary" onclick={handleAdd}>Create</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">Assign sources to this event from the Sources tab once created.</p>
|
<p class="hint">Assign sources and a keyword filter via Edit once created.</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="list">
|
<div class="list">
|
||||||
{#each events as event (event.id)}
|
{#each events as event (event.id)}
|
||||||
<div class="row">
|
{#if editingId === event.id}
|
||||||
<div>
|
<div class="edit-panel">
|
||||||
<div class="name">{event.name}</div>
|
<div class="add-grid">
|
||||||
<div class="sub">
|
<input placeholder="Event name" bind:value={editForm.name} />
|
||||||
{sourceNames(event.sourceIds)} ·
|
<select bind:value={editForm.cadence}>
|
||||||
{event.cadence === 'daily' ? `daily recap at ${event.cadenceTime}` : event.cadence}
|
<option value="continuous">Continuous</option>
|
||||||
|
<option value="daily">Daily</option>
|
||||||
|
<option value="hourly">Hourly</option>
|
||||||
|
</select>
|
||||||
|
{#if editForm.cadence === 'daily'}
|
||||||
|
<input type="text" placeholder="18:00" bind:value={editForm.cadenceTime} />
|
||||||
|
{/if}
|
||||||
|
</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 event's recap. Leave blank to
|
||||||
|
include everything from the assigned sources.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="add-actions">
|
||||||
|
<button onclick={cancelEdit}>Cancel</button>
|
||||||
|
<button class="primary" onclick={saveEdit}>Save</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="badge" class:active={event.active} onclick={() => toggleActive(event)} role="button" tabindex="0">
|
{:else}
|
||||||
{event.active ? 'Active' : 'Paused'}
|
<div class="row">
|
||||||
</span>
|
<div>
|
||||||
<button class="icon-btn danger" onclick={() => handleDelete(event.id)} title="Delete">✕</button>
|
<div class="name">{event.name}</div>
|
||||||
</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>
|
||||||
|
<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}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -166,7 +273,45 @@
|
|||||||
border: none;
|
border: none;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
.icon-btn:hover {
|
||||||
|
color: var(--text-accent);
|
||||||
|
}
|
||||||
.icon-btn.danger:hover {
|
.icon-btn.danger:hover {
|
||||||
color: var(--text-danger);
|
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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user