e204c70e00
- Fix "Body cannot be empty" error on DELETE by making the JSON content-type parser tolerate empty bodies, and by only sending Content-Type from the frontend when a request actually has one. - Deleting a source now cascades: raw content items and any article composed entirely from that source are removed too, plus their media. - Add admin endpoints/UI to clear all articles, all media, or a single source's content without deleting the source, so things can be repopulated fresh. - Sources can now be assigned multiple categories via checkboxes (instead of free text) and edited in place, not just added/deleted. - Add a "News" default category (seeded fresh, backfilled on existing DBs) so general news sources have a real home instead of the pseudo-category "Top stories", which is just the homepage's all-categories chronological view. - Widen the site's content column 15% (1080px -> 1242px). - Add YouTube as its own source type/ingestion module: pulls a channel's public Atom feed, and each video always publishes directly as its own article (title, embedded video, publish date, description) rather than going through the cross-source clustering/synthesis pipeline.
119 lines
5.1 KiB
TypeScript
119 lines
5.1 KiB
TypeScript
import { getBackendUrl } from './config';
|
|
import type {
|
|
AdminSettings,
|
|
AdminSource,
|
|
AdminTrackedEvent,
|
|
ModelCatalog,
|
|
AiStatus,
|
|
LogEntry
|
|
} from './adminTypes';
|
|
|
|
async function request<T>(path: string, options: RequestInit = {}, fetchFn: typeof fetch = fetch): Promise<T> {
|
|
// Fastify's default JSON body parser rejects an empty body when Content-Type is
|
|
// application/json ("Body cannot be empty when content-type is set to
|
|
// 'application/json'") — so this header is only attached when there's actually a
|
|
// body to send (PATCH/POST with a JSON payload), never for bodyless DELETE/POST calls.
|
|
const headers = options.body ? { 'Content-Type': 'application/json', ...(options.headers || {}) } : options.headers;
|
|
const res = await fetchFn(`${getBackendUrl()}${path}`, {
|
|
...options,
|
|
credentials: 'include',
|
|
headers
|
|
});
|
|
if (res.status === 401) {
|
|
const err = new Error('unauthorized') as Error & { status?: number };
|
|
err.status = 401;
|
|
throw err;
|
|
}
|
|
if (!res.ok) throw new Error(`Admin request failed: ${path} (${res.status})`);
|
|
if (res.status === 204) return undefined as T;
|
|
return res.json();
|
|
}
|
|
|
|
// Auth
|
|
export async function login(username: string, password: string, fetchFn: typeof fetch = fetch): Promise<void> {
|
|
const res = await fetchFn(`${getBackendUrl()}/api/admin/login`, {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ username, password })
|
|
});
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => ({}));
|
|
throw new Error(body.error || `Login failed (${res.status})`);
|
|
}
|
|
}
|
|
|
|
export async function logout(fetchFn: typeof fetch = fetch): Promise<void> {
|
|
await fetchFn(`${getBackendUrl()}/api/admin/logout`, { method: 'POST', credentials: 'include' });
|
|
}
|
|
|
|
// Settings
|
|
export const getSettings = (fetchFn?: typeof fetch) =>
|
|
request<AdminSettings>('/api/admin/settings', {}, fetchFn);
|
|
|
|
export const updateSettings = (patch: Partial<AdminSettings>, fetchFn?: typeof fetch) =>
|
|
request<AdminSettings>('/api/admin/settings', { method: 'PATCH', body: JSON.stringify(patch) }, fetchFn);
|
|
|
|
// Categories
|
|
export const createCategory = (name: string, fetchFn?: typeof fetch) =>
|
|
request<{ id: string; name: string; priorityRank: number; isDefault: boolean }>(
|
|
'/api/admin/categories',
|
|
{ method: 'POST', body: JSON.stringify({ name }) },
|
|
fetchFn
|
|
);
|
|
|
|
export const deleteCategory = (id: string, fetchFn?: typeof fetch) =>
|
|
request<void>(`/api/admin/categories/${id}`, { method: 'DELETE' }, fetchFn);
|
|
|
|
// Sources
|
|
export const getSources = (fetchFn?: typeof fetch) =>
|
|
request<AdminSource[]>('/api/admin/sources', {}, fetchFn);
|
|
|
|
export const addSource = (source: Partial<AdminSource>, fetchFn?: typeof fetch) =>
|
|
request<AdminSource>('/api/admin/sources', { method: 'POST', body: JSON.stringify(source) }, fetchFn);
|
|
|
|
export const updateSource = (id: string, patch: Partial<AdminSource>, fetchFn?: typeof fetch) =>
|
|
request<AdminSource>(`/api/admin/sources/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }, fetchFn);
|
|
|
|
export const deleteSource = (id: string, fetchFn?: typeof fetch) =>
|
|
request<void>(`/api/admin/sources/${id}`, { method: 'DELETE' }, fetchFn);
|
|
|
|
export const pollSourceNow = (id: string, fetchFn?: typeof fetch) =>
|
|
request<{ ingested: number; source: AdminSource }>(`/api/admin/sources/${id}/poll`, { method: 'POST' }, fetchFn);
|
|
|
|
// Content clearing — wipe articles/media/a source's raw items so they can be repopulated fresh.
|
|
export const clearSourceContent = (id: string, fetchFn?: typeof fetch) =>
|
|
request<{ itemsDeleted: number; articlesDeleted: number }>(`/api/admin/content/sources/${id}`, { method: 'DELETE' }, fetchFn);
|
|
|
|
export const clearAllArticles = (fetchFn?: typeof fetch) =>
|
|
request<{ deleted: number }>('/api/admin/content/articles', { method: 'DELETE' }, fetchFn);
|
|
|
|
export const clearAllMedia = (fetchFn?: typeof fetch) =>
|
|
request<{ deleted: number }>('/api/admin/content/media', { method: 'DELETE' }, fetchFn);
|
|
|
|
// Tracked events
|
|
export const getEvents = (fetchFn?: typeof fetch) =>
|
|
request<AdminTrackedEvent[]>('/api/admin/events', {}, fetchFn);
|
|
|
|
export const addEvent = (event: Partial<AdminTrackedEvent>, fetchFn?: typeof fetch) =>
|
|
request<AdminTrackedEvent>('/api/admin/events', { method: 'POST', body: JSON.stringify(event) }, fetchFn);
|
|
|
|
export const updateEvent = (id: string, patch: Partial<AdminTrackedEvent>, fetchFn?: typeof fetch) =>
|
|
request<AdminTrackedEvent>(`/api/admin/events/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }, fetchFn);
|
|
|
|
export const deleteEvent = (id: string, fetchFn?: typeof fetch) =>
|
|
request<void>(`/api/admin/events/${id}`, { method: 'DELETE' }, fetchFn);
|
|
|
|
// Models / AI service
|
|
export const getModels = (fetchFn?: typeof fetch) =>
|
|
request<ModelCatalog>('/api/admin/models', {}, fetchFn);
|
|
|
|
export const getAiStatus = (fetchFn?: typeof fetch) =>
|
|
request<AiStatus>('/api/admin/ai-status', {}, fetchFn);
|
|
|
|
// Logs
|
|
export const getLogs = (filters: { level?: 'info' | 'warn' | 'error'; limit?: number } = {}, fetchFn?: typeof fetch) => {
|
|
const qs = new URLSearchParams(filters as Record<string, string>).toString();
|
|
return request<LogEntry[]>(`/api/admin/logs${qs ? `?${qs}` : ''}`, {}, fetchFn);
|
|
};
|