Initial Commit

This commit is contained in:
2026-07-17 10:33:20 -04:00
parent a5d35515f2
commit a5b108d9fa
88 changed files with 9802 additions and 1 deletions
+23
View File
@@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
+1
View File
@@ -0,0 +1 @@
engine-strict=true
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
}
+42
View File
@@ -0,0 +1,42 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project
npx sv create my-app
```
To recreate this project with the same configuration:
```sh
# recreate this project
npx sv@0.16.2 create --template minimal --types ts --install npm frontend
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
+1390
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "frontend",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^7.0.1",
"@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"svelte": "^5.56.1",
"svelte-check": "^4.6.0",
"typescript": "^6.0.3",
"vite": "^8.0.16"
}
}
+13
View File
@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+24
View File
@@ -0,0 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="text-scale" content="scale" />
<script>
(function () {
var stored = localStorage.getItem('homefeed:theme');
var theme =
stored === 'light' || stored === 'dark'
? stored
: window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
document.documentElement.setAttribute('data-theme', theme);
})();
</script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+92
View File
@@ -0,0 +1,92 @@
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> {
const res = await fetchFn(`${getBackendUrl()}${path}`, {
...options,
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...(options.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);
// 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);
// 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);
};
+74
View File
@@ -0,0 +1,74 @@
export interface RetentionSettings {
publishedArticleMaxAgeDays: number | null;
rawItemMaxAgeDays: number | null;
storageCapEnabled: boolean;
storageCapValue: number;
storageCapUnit: 'MB' | 'GB';
storageUsedMB: number;
}
export interface CategoryPriority {
id: string;
name: string;
priorityRank: number;
}
export interface AdminSettings {
mergeStrictness: 1 | 2 | 3 | 4 | 5;
defaultPollIntervalMinutes: number;
holdBeforePublishMinutes: number;
tagDedupThreshold: number;
tagExpiryDays: number;
followUpMinHoursSinceLast: number;
followUpMinNewSources: number;
aiServiceHost: string;
aiServicePort: number;
selectedModels: { embedding: string; image: string; synthesis: string };
retention: RetentionSettings;
categoryPriority: CategoryPriority[];
}
export interface AdminSource {
id: string;
name: string;
type: 'rss' | 'api' | 'telegram' | 'custom';
category: string[];
url: string;
pollIntervalMinutes: number;
enabled: boolean;
lastPolledAt: string | null;
lastError: string | null;
}
export interface AdminTrackedEvent {
id: string;
name: string;
description: string;
sourceIds: string[];
cadence: 'continuous' | 'daily' | 'hourly' | 'custom';
cadenceTime: string | null;
active: boolean;
retentionOverrideDays: number | null;
}
export interface ModelCatalog {
embedding: string[];
image: string[];
synthesis: string[];
}
export interface AiStatus {
connected: boolean;
host: string;
port: number;
ramGB: number;
gpu: string;
}
export interface LogEntry {
id: number;
timestamp: string;
level: 'info' | 'warn' | 'error';
source: string;
message: string;
}
+28
View File
@@ -0,0 +1,28 @@
import { getBackendUrl } from './config';
import type { MergedArticle, Tag, TrackedEventPublic } from './types';
async function get<T>(path: string, fetchFn: typeof fetch = fetch): Promise<T> {
const res = await fetchFn(`${getBackendUrl()}${path}`);
if (!res.ok) throw new Error(`Request failed: ${path} (${res.status})`);
return res.json();
}
export function getFeed(
params: { category?: string; geo?: string; eventId?: string; tag?: string } = {},
fetchFn?: typeof fetch
): Promise<MergedArticle[]> {
const qs = new URLSearchParams(params as Record<string, string>).toString();
return get<MergedArticle[]>(`/api/feed${qs ? `?${qs}` : ''}`, fetchFn);
}
export function getArticle(id: string, fetchFn?: typeof fetch): Promise<MergedArticle> {
return get<MergedArticle>(`/api/article/${id}`, fetchFn);
}
export function getTags(fetchFn?: typeof fetch): Promise<Tag[]> {
return get<Tag[]>('/api/tags', fetchFn);
}
export function getEvents(fetchFn?: typeof fetch): Promise<TrackedEventPublic[]> {
return get<TrackedEventPublic[]>('/api/events', fetchFn);
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,76 @@
<script lang="ts">
import type { MergedArticle } from '$lib/types';
import { timeAgo } from '$lib/format';
let { article }: { article: MergedArticle } = $props();
const sourceLabel = $derived(
article.sourceCount > 1
? `${article.sourceCount} sources`
: (article.sources[0]?.sourceName ?? 'Single source')
);
</script>
<a class="card" href={`/article/${article.id}`}>
{#if article.heroImage}
<img class="thumb" src={article.heroImage.url} alt="" loading="lazy" />
{:else}
<div class="thumb placeholder"></div>
{/if}
<div class="meta">
{#if article.video}
<i class="tag-icon"></i> Video
{:else if article.sourceCount > 1}
<i class="tag-icon"></i> {sourceLabel}
{:else}
{sourceLabel}
{/if}
</div>
<div class="title">{article.title}</div>
<div class="sub">{timeAgo(article.publishedAt)}</div>
</a>
<style>
.card {
display: block;
color: inherit;
}
.card:hover {
text-decoration: none;
}
.card:hover .title {
text-decoration: underline;
}
.thumb {
width: 100%;
aspect-ratio: 16 / 10;
object-fit: cover;
border-radius: var(--radius);
margin-bottom: 8px;
background: var(--surface-1);
}
.thumb.placeholder {
display: block;
}
.meta {
font-size: 11px;
color: var(--text-accent);
margin-bottom: 3px;
display: flex;
align-items: center;
gap: 4px;
}
.tag-icon {
font-style: normal;
}
.title {
font-size: 14px;
font-weight: 500;
line-height: 1.35;
margin-bottom: 4px;
}
.sub {
font-size: 11px;
color: var(--text-muted);
}
</style>
@@ -0,0 +1,62 @@
<script lang="ts">
import { onMount } from 'svelte';
import { getTheme, initTheme, toggleTheme } from '$lib/theme.svelte';
onMount(() => {
initTheme();
});
</script>
<button
class="toggle"
class:dark={getTheme() === 'dark'}
onclick={toggleTheme}
aria-label={`Switch to ${getTheme() === 'dark' ? 'light' : 'dark'} theme`}
title={`Switch to ${getTheme() === 'dark' ? 'light' : 'dark'} theme`}
>
<span class="icon sun"></span>
<span class="icon moon"></span>
<span class="thumb"></span>
</button>
<style>
.toggle {
position: relative;
width: 46px;
height: 24px;
border-radius: 12px;
border: 0.5px solid var(--border);
background: var(--surface-1);
padding: 0;
display: flex;
align-items: center;
cursor: pointer;
}
.icon {
position: absolute;
font-size: 11px;
top: 50%;
transform: translateY(-50%);
line-height: 1;
color: var(--text-muted);
}
.sun {
left: 6px;
}
.moon {
right: 6px;
}
.thumb {
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--pill-bg);
transition: transform 0.15s ease;
}
.toggle.dark .thumb {
transform: translateX(22px);
}
</style>
@@ -0,0 +1,133 @@
<script lang="ts">
import type { AdminSettings, AiStatus } from '$lib/adminTypes';
import { getBackendUrl, setBackendUrl } from '$lib/config';
import { updateSettings, getAiStatus } from '$lib/adminApi';
import SaveStatus from './SaveStatus.svelte';
let { settings, aiStatus: initialStatus }: { settings: AdminSettings; aiStatus: AiStatus } = $props();
let backendUrl = $state(getBackendUrl());
let backendSaved = $state(false);
let aiHost = $state(settings.aiServiceHost);
let aiPort = $state(settings.aiServicePort);
let aiStatus = $state(initialStatus);
let testing = $state(false);
let status = $state<'idle' | 'saving' | 'saved' | 'error'>('idle');
function saveBackendUrl() {
setBackendUrl(backendUrl);
backendSaved = true;
setTimeout(() => (backendSaved = false), 1500);
}
async function saveAiService() {
status = 'saving';
try {
await updateSettings({ aiServiceHost: aiHost, aiServicePort: aiPort });
aiStatus = await getAiStatus();
status = 'saved';
setTimeout(() => (status = 'idle'), 1500);
} catch {
status = 'error';
}
}
async function testConnection() {
testing = true;
try {
aiStatus = await getAiStatus();
} finally {
testing = false;
}
}
</script>
<div class="panel">
<div class="head">
<span class="panel-title">Backend address</span>
{#if backendSaved}<span class="saved-note">✓ Saved to this browser</span>{/if}
</div>
<p class="hint">
Stored in this browser only — each device connecting to the admin panel sets its own
backend address. Not synced across devices.
</p>
<div class="row">
<input type="text" bind:value={backendUrl} placeholder="https://backend.homefeed.local:8443" />
<button onclick={saveBackendUrl}>Save</button>
</div>
</div>
<div class="panel">
<div class="head">
<span class="panel-title">AI service connection</span>
<SaveStatus {status} />
</div>
<p class="hint">
Address of your self-hosted inference server (e.g. Ollama). This is a backend setting,
shared across everyone using this admin panel.
</p>
<div class="row">
<input type="text" bind:value={aiHost} placeholder="http://10.0.0.14" style="flex: 1" />
<input type="text" bind:value={aiPort} placeholder="11434" style="width: 90px" />
<button onclick={testConnection} disabled={testing}>{testing ? 'Testing…' : 'Test'}</button>
<button class="primary" onclick={saveAiService}>Save</button>
</div>
<div class="status-row">
{#if aiStatus.connected}
<span class="connected">✓ Connected · {aiStatus.ramGB}GB RAM · GPU: {aiStatus.gpu}</span>
{:else}
<span class="disconnected">✕ Unreachable</span>
{/if}
</div>
</div>
<style>
.panel {
background: var(--surface-1);
border-radius: 12px;
padding: 16px;
margin-bottom: 14px;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.panel-title {
font-size: 13px;
font-weight: 500;
}
.hint {
font-size: 12px;
color: var(--text-secondary);
margin: 4px 0 12px;
}
.row {
display: flex;
gap: 8px;
margin-bottom: 10px;
}
.row input {
flex: 1;
}
.primary {
background: var(--pill-bg);
color: var(--pill-text);
border-color: var(--pill-bg);
}
.saved-note {
font-size: 12px;
color: var(--text-success);
}
.status-row {
font-size: 12px;
}
.connected {
color: var(--text-success);
}
.disconnected {
color: var(--text-danger);
}
</style>
@@ -0,0 +1,172 @@
<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: '', cadence: 'daily' as AdminTrackedEvent['cadence'], cadenceTime: '18:00' });
function sourceNames(ids: string[]) {
return ids.map((id) => sources.find((s) => s.id === id)?.name).filter(Boolean).join(', ') || 'No sources assigned';
}
async function handleAdd() {
if (!newEvent.name) return;
const created = await addEvent({
name: newEvent.name,
description: '',
sourceIds: [],
cadence: newEvent.cadence,
cadenceTime: newEvent.cadence === 'daily' ? newEvent.cadenceTime : null,
retentionOverrideDays: null
});
events = [...events, created];
newEvent = { name: '', cadence: 'daily', cadenceTime: '18:00' };
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));
}
async function handleDelete(id: string) {
await deleteEvent(id);
events = events.filter((e) => e.id !== id);
}
</script>
<div class="toolbar">
<span class="count">{events.length} tracked events</span>
<button class="add-btn" onclick={() => (showAdd = !showAdd)}>+ New event</button>
</div>
{#if showAdd}
<div class="add-panel">
<div class="add-grid">
<input placeholder="Event name (e.g. Iran war)" bind:value={newEvent.name} />
<select bind:value={newEvent.cadence}>
<option value="continuous">Continuous</option>
<option value="daily">Daily</option>
<option value="hourly">Hourly</option>
</select>
{#if newEvent.cadence === 'daily'}
<input type="text" placeholder="18:00" bind:value={newEvent.cadenceTime} />
{/if}
</div>
<div class="add-actions">
<button onclick={() => (showAdd = false)}>Cancel</button>
<button class="primary" onclick={handleAdd}>Create</button>
</div>
<p class="hint">Assign sources to this event from the Sources tab once created.</p>
</div>
{/if}
<div class="list">
{#each events as event (event.id)}
<div class="row">
<div>
<div class="name">{event.name}</div>
<div class="sub">
{sourceNames(event.sourceIds)} ·
{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 danger" onclick={() => handleDelete(event.id)} title="Delete"></button>
</div>
{/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.danger:hover {
color: var(--text-danger);
}
</style>
@@ -0,0 +1,169 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import type { LogEntry } from '$lib/adminTypes';
import { getLogs } from '$lib/adminApi';
let { logs: initial }: { logs: LogEntry[] } = $props();
let logs = $state([...initial]);
let filter = $state<'all' | 'info' | 'warn' | 'error'>('all');
let autoRefresh = $state(true);
let loading = $state(false);
let timer: ReturnType<typeof setInterval>;
async function refresh() {
loading = true;
try {
logs = await getLogs(filter === 'all' ? {} : { level: filter });
} finally {
loading = false;
}
}
function setFilter(f: typeof filter) {
filter = f;
refresh();
}
onMount(() => {
timer = setInterval(() => {
if (autoRefresh) refresh();
}, 5000);
});
onDestroy(() => clearInterval(timer));
function formatTime(iso: string): string {
const d = new Date(iso);
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
</script>
<div class="toolbar">
<div class="filters">
<button class="pill" class:active={filter === 'all'} onclick={() => setFilter('all')}>All</button>
<button class="pill" class:active={filter === 'info'} onclick={() => setFilter('info')}>Standard</button>
<button class="pill warn" class:active={filter === 'warn'} onclick={() => setFilter('warn')}>Warn</button>
<button class="pill err" class:active={filter === 'error'} onclick={() => setFilter('error')}>Error</button>
</div>
<div class="right">
<label class="checkbox">
<input type="checkbox" bind:checked={autoRefresh} />
Auto-refresh
</label>
<button onclick={refresh} disabled={loading}>{loading ? 'Refreshing…' : 'Refresh'}</button>
</div>
</div>
<div class="log-list">
{#if logs.length === 0}
<div class="empty">Nothing logged yet.</div>
{/if}
{#each logs as log (log.id)}
<div class="log-row">
<span class="time">{formatTime(log.timestamp)}</span>
<span class="level" class:warn={log.level === 'warn'} class:err={log.level === 'error'}>
{log.level === 'info' ? 'standard' : log.level}
</span>
<span class="source">{log.source}</span>
<span class="message">{log.message}</span>
</div>
{/each}
</div>
<style>
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
flex-wrap: wrap;
gap: 10px;
}
.filters {
display: flex;
gap: 6px;
}
.pill {
font-size: 12px;
padding: 5px 12px;
border-radius: var(--radius);
border: 0.5px solid var(--border);
background: var(--surface-2);
color: var(--text-secondary);
}
.pill.active {
background: var(--pill-bg);
color: var(--pill-text);
border-color: var(--pill-bg);
}
.pill.warn.active {
background: #a8710f;
border-color: #a8710f;
color: #fff;
}
.pill.err.active {
background: var(--text-danger);
border-color: var(--text-danger);
color: #fff;
}
.right {
display: flex;
align-items: center;
gap: 10px;
}
.checkbox {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-secondary);
}
.checkbox input {
width: auto;
}
.log-list {
background: var(--surface-1);
border-radius: 12px;
max-height: 480px;
overflow-y: auto;
font-family: var(--font-mono);
}
.empty {
padding: 20px;
font-size: 12px;
color: var(--text-muted);
font-family: var(--font-sans);
}
.log-row {
display: grid;
grid-template-columns: 76px 66px 90px 1fr;
gap: 10px;
padding: 6px 12px;
font-size: 12px;
border-bottom: 0.5px solid var(--border);
align-items: baseline;
}
.log-row:last-child {
border-bottom: none;
}
.time {
color: var(--text-muted);
}
.level {
color: var(--text-secondary);
text-transform: uppercase;
font-size: 10px;
}
.level.warn {
color: #a8710f;
}
.level.err {
color: var(--text-danger);
}
.source {
color: var(--text-accent);
}
.message {
color: var(--text-primary);
word-break: break-word;
}
</style>
@@ -0,0 +1,259 @@
<script lang="ts">
import type { AdminSettings } from '$lib/adminTypes';
import { updateSettings } from '$lib/adminApi';
import SaveStatus from './SaveStatus.svelte';
let { settings }: { settings: AdminSettings } = $props();
let local = $state({ ...settings, categoryPriority: [...settings.categoryPriority] });
let status = $state<'idle' | 'saving' | 'saved' | 'error'>('idle');
let saveTimer: ReturnType<typeof setTimeout>;
function scheduleSave() {
status = 'saving';
clearTimeout(saveTimer);
saveTimer = setTimeout(async () => {
try {
await updateSettings({
mergeStrictness: local.mergeStrictness,
defaultPollIntervalMinutes: local.defaultPollIntervalMinutes,
holdBeforePublishMinutes: local.holdBeforePublishMinutes,
followUpMinHoursSinceLast: local.followUpMinHoursSinceLast,
followUpMinNewSources: local.followUpMinNewSources,
tagDedupThreshold: local.tagDedupThreshold,
tagExpiryDays: local.tagExpiryDays,
categoryPriority: local.categoryPriority
});
status = 'saved';
setTimeout(() => (status = 'idle'), 1500);
} catch {
status = 'error';
}
}, 500);
}
function move(index: number, dir: -1 | 1) {
const target = index + dir;
if (target < 0 || target >= local.categoryPriority.length) return;
const arr = [...local.categoryPriority];
[arr[index], arr[target]] = [arr[target], arr[index]];
arr.forEach((c, i) => (c.priorityRank = i + 1));
local.categoryPriority = arr;
scheduleSave();
}
</script>
<div class="panel">
<div class="head">
<span class="panel-title">Merge strictness</span>
<SaveStatus {status} />
</div>
<p class="hint">How similar articles must be before they're combined into one story.</p>
<div class="slider-row">
<span class="end">Loose</span>
<input
type="range"
min="1"
max="5"
step="1"
bind:value={local.mergeStrictness}
oninput={scheduleSave}
/>
<span class="end">Strict</span>
<span class="value">{local.mergeStrictness}</span>
</div>
</div>
<div class="grid-2">
<div class="panel">
<span class="panel-title">Poll interval</span>
<p class="hint">How often each source is checked for new items.</p>
<select bind:value={local.defaultPollIntervalMinutes} onchange={scheduleSave}>
<option value={5}>Every 5 minutes</option>
<option value={15}>Every 15 minutes</option>
<option value={60}>Every hour</option>
</select>
</div>
<div class="panel">
<span class="panel-title">Hold before publish</span>
<p class="hint">Wait window to gather more sources before finalizing a story.</p>
<select bind:value={local.holdBeforePublishMinutes} onchange={scheduleSave}>
<option value={0}>Publish immediately</option>
<option value={30}>Wait 30 minutes</option>
<option value={120}>Wait 2 hours</option>
</select>
</div>
</div>
<div class="panel">
<span class="panel-title">Follow-up articles</span>
<p class="hint">
Instead of editing a published article, a distinct follow-up is created once enough new
corroborating sources arrive after enough time has passed.
</p>
<div class="grid-2">
<div>
<label class="field-label" for="followup-hours">Minimum time since last article</label>
<select id="followup-hours" bind:value={local.followUpMinHoursSinceLast} onchange={scheduleSave}>
<option value={1}>1 hour</option>
<option value={6}>6 hours</option>
<option value={12}>12 hours</option>
<option value={24}>24 hours</option>
</select>
</div>
<div>
<label class="field-label" for="followup-sources">Minimum new sources</label>
<select id="followup-sources" bind:value={local.followUpMinNewSources} onchange={scheduleSave}>
<option value={1}>1</option>
<option value={2}>2</option>
<option value={3}>3</option>
<option value={4}>4</option>
</select>
</div>
</div>
</div>
<div class="panel">
<span class="panel-title">Category priority</span>
<p class="hint">
Synthesis queue processes higher-ranked categories first. Nothing is dropped — lower
categories just wait longer when the queue is busy.
</p>
<div class="priority-list">
{#each local.categoryPriority as cat, i (cat.id)}
<div class="priority-row">
<span class="rank">{i + 1}</span>
<span class="name">{cat.name}</span>
<button class="icon-btn" onclick={() => move(i, -1)} disabled={i === 0} aria-label="Move up"></button>
<button
class="icon-btn"
onclick={() => move(i, 1)}
disabled={i === local.categoryPriority.length - 1}
aria-label="Move down"></button
>
</div>
{/each}
</div>
</div>
<div class="panel">
<span class="panel-title">Tags</span>
<div class="grid-2">
<div>
<label class="field-label" for="dedup">Tag dedup threshold</label>
<div class="slider-row">
<input
id="dedup"
type="range"
min="0.5"
max="0.99"
step="0.01"
bind:value={local.tagDedupThreshold}
oninput={scheduleSave}
/>
<span class="value">{local.tagDedupThreshold.toFixed(2)}</span>
</div>
</div>
<div>
<label class="field-label" for="expiry">Tag expiry</label>
<select id="expiry" bind:value={local.tagExpiryDays} onchange={scheduleSave}>
<option value={7}>7 days</option>
<option value={21}>21 days</option>
<option value={60}>60 days</option>
</select>
</div>
</div>
</div>
<style>
.panel {
background: var(--surface-1);
border-radius: 12px;
padding: 16px;
margin-bottom: 14px;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.panel-title {
font-size: 13px;
font-weight: 500;
}
.hint {
font-size: 12px;
color: var(--text-secondary);
margin: 4px 0 12px;
}
.slider-row {
display: flex;
align-items: center;
gap: 12px;
}
.slider-row input[type='range'] {
flex: 1;
border: none;
padding: 0;
background: transparent;
}
.end {
font-size: 11px;
color: var(--text-muted);
}
.value {
font-size: 13px;
font-weight: 500;
min-width: 32px;
text-align: right;
}
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.field-label {
display: block;
font-size: 11px;
color: var(--text-muted);
margin-bottom: 6px;
}
select {
width: 100%;
}
.priority-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.priority-row {
display: flex;
align-items: center;
gap: 10px;
background: var(--surface-2);
border-radius: var(--radius);
padding: 8px 12px;
}
.rank {
font-size: 11px;
color: var(--text-muted);
width: 16px;
}
.name {
font-size: 13px;
flex: 1;
}
.icon-btn {
font-size: 11px;
padding: 2px 6px;
background: transparent;
border: none;
color: var(--text-secondary);
}
.icon-btn:disabled {
color: var(--text-muted);
opacity: 0.4;
cursor: default;
}
</style>
@@ -0,0 +1,127 @@
<script lang="ts">
import type { AdminSettings, ModelCatalog, AiStatus } from '$lib/adminTypes';
import { updateSettings, getAiStatus } from '$lib/adminApi';
import SaveStatus from './SaveStatus.svelte';
let { settings, models, aiStatus: initialStatus }: { settings: AdminSettings; models: ModelCatalog; aiStatus: AiStatus } =
$props();
let selected = $state({ ...settings.selectedModels });
let aiStatus = $state(initialStatus);
let testing = $state(false);
let status = $state<'idle' | 'saving' | 'saved' | 'error'>('idle');
async function save() {
status = 'saving';
try {
await updateSettings({ selectedModels: selected });
status = 'saved';
setTimeout(() => (status = 'idle'), 1500);
} catch {
status = 'error';
}
}
async function testConnection() {
testing = true;
try {
aiStatus = await getAiStatus();
} finally {
testing = false;
}
}
</script>
<div class="panel">
<div class="head">
<span class="panel-title">AI service connection</span>
</div>
<p class="hint">
Address of your self-hosted inference server (e.g. Ollama). Model lists below are fetched
live from it.
</p>
<div class="status-row">
{#if aiStatus.connected}
<span class="connected">✓ Connected · {aiStatus.host}:{aiStatus.port} · {aiStatus.ramGB}GB RAM · GPU: {aiStatus.gpu}</span>
{:else}
<span class="disconnected">✕ Unreachable</span>
{/if}
<button onclick={testConnection} disabled={testing}>{testing ? 'Testing…' : 'Test'}</button>
</div>
</div>
<div class="panel">
<div class="head">
<span class="panel-title">Embedding &amp; clustering</span>
</div>
<select bind:value={selected.embedding} onchange={save}>
{#each models.embedding as m}
<option value={m}>{m}</option>
{/each}
</select>
</div>
<div class="panel">
<div class="head">
<span class="panel-title">Image selection</span>
</div>
<select bind:value={selected.image} onchange={save}>
{#each models.image as m}
<option value={m}>{m}</option>
{/each}
</select>
</div>
<div class="panel accent">
<div class="head">
<span class="panel-title">Article synthesis</span>
<SaveStatus {status} />
</div>
<select bind:value={selected.synthesis} onchange={save}>
{#each models.synthesis as m}
<option value={m}>{m}</option>
{/each}
</select>
</div>
<style>
.panel {
background: var(--surface-1);
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
}
.panel.accent {
border: 0.5px solid var(--border-accent);
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.panel-title {
font-size: 13px;
font-weight: 500;
}
.hint {
font-size: 12px;
color: var(--text-secondary);
margin: 4px 0 12px;
}
.status-row {
display: flex;
align-items: center;
gap: 10px;
font-size: 12px;
}
.connected {
color: var(--text-success);
}
.disconnected {
color: var(--text-danger);
}
select {
width: 100%;
}
</style>
@@ -0,0 +1,189 @@
<script lang="ts">
import type { AdminSettings } from '$lib/adminTypes';
import { updateSettings } from '$lib/adminApi';
import SaveStatus from './SaveStatus.svelte';
let { settings }: { settings: AdminSettings } = $props();
let retention = $state({ ...settings.retention });
let status = $state<'idle' | 'saving' | 'saved' | 'error'>('idle');
let saveTimer: ReturnType<typeof setTimeout>;
function scheduleSave() {
status = 'saving';
clearTimeout(saveTimer);
saveTimer = setTimeout(async () => {
try {
await updateSettings({ retention });
status = 'saved';
setTimeout(() => (status = 'idle'), 1500);
} catch {
status = 'error';
}
}, 500);
}
const usagePercent = $derived(
retention.storageCapEnabled
? Math.min(
100,
(retention.storageUsedMB / (retention.storageCapValue * (retention.storageCapUnit === 'GB' ? 1024 : 1))) * 100
)
: 0
);
const presets = [
{ label: '7 days', value: 7 },
{ label: '30 days', value: 30 },
{ label: '1 year', value: 365 },
{ label: 'Forever', value: null }
];
</script>
<div class="panel">
<div class="head">
<span class="panel-title">Published articles</span>
<SaveStatus {status} />
</div>
<p class="hint">How long merged, published stories stay available.</p>
<div class="pill-row">
{#each presets as preset}
<button
class="pill"
class:active={retention.publishedArticleMaxAgeDays === preset.value}
onclick={() => {
retention.publishedArticleMaxAgeDays = preset.value;
scheduleSave();
}}
>
{preset.label}
</button>
{/each}
</div>
</div>
<div class="panel">
<span class="panel-title">Raw source items</span>
<p class="hint">Individual RSS/API/Telegram entries used to build merged stories.</p>
<div class="pill-row">
{#each [{ label: '3 days', value: 3 }, { label: '7 days', value: 7 }, { label: '30 days', value: 30 }] as preset}
<button
class="pill"
class:active={retention.rawItemMaxAgeDays === preset.value}
onclick={() => {
retention.rawItemMaxAgeDays = preset.value;
scheduleSave();
}}
>
{preset.label}
</button>
{/each}
</div>
</div>
<div class="panel">
<div class="head">
<span class="panel-title">Storage cap</span>
<label class="checkbox">
<input
type="checkbox"
bind:checked={retention.storageCapEnabled}
onchange={scheduleSave}
/>
Enabled
</label>
</div>
<p class="hint">
When total storage exceeds this size, oldest items are deleted first (FIFO) regardless of
the age settings above.
</p>
<div class="cap-row">
<input
type="number"
bind:value={retention.storageCapValue}
oninput={scheduleSave}
style="width: 100px"
/>
<select bind:value={retention.storageCapUnit} onchange={scheduleSave} style="width: 90px">
<option value="MB">MB</option>
<option value="GB">GB</option>
</select>
<span class="usage-label">currently using {retention.storageUsedMB} MB</span>
</div>
<div class="bar">
<div class="bar-fill" style="width: {usagePercent}%"></div>
</div>
</div>
<style>
.panel {
background: var(--surface-1);
border-radius: 12px;
padding: 16px;
margin-bottom: 14px;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.panel-title {
font-size: 13px;
font-weight: 500;
}
.hint {
font-size: 12px;
color: var(--text-secondary);
margin: 4px 0 12px;
}
.pill-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.pill {
font-size: 12px;
padding: 6px 12px;
border-radius: var(--radius);
border: 0.5px solid var(--border);
background: var(--surface-2);
color: var(--text-secondary);
}
.pill.active {
background: var(--pill-bg);
color: var(--pill-text);
border-color: var(--pill-bg);
}
.checkbox {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-secondary);
}
.checkbox input {
width: auto;
}
.cap-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.usage-label {
font-size: 12px;
color: var(--text-muted);
}
.bar {
width: 100%;
height: 6px;
background: var(--border);
border-radius: 4px;
overflow: hidden;
}
.bar-fill {
height: 100%;
background: var(--border-accent);
}
</style>
@@ -0,0 +1,26 @@
<script lang="ts">
let { status }: { status: 'idle' | 'saving' | 'saved' | 'error' } = $props();
</script>
{#if status === 'saving'}
<span class="status saving">Saving…</span>
{:else if status === 'saved'}
<span class="status saved">✓ Saved</span>
{:else if status === 'error'}
<span class="status error">Save failed</span>
{/if}
<style>
.status {
font-size: 12px;
}
.saving {
color: var(--text-muted);
}
.saved {
color: var(--text-success);
}
.error {
color: var(--text-danger);
}
</style>
@@ -0,0 +1,246 @@
<script lang="ts">
import type { AdminSource } from '$lib/adminTypes';
import { addSource, deleteSource, updateSource, pollSourceNow } from '$lib/adminApi';
let { sources: initial }: { sources: AdminSource[] } = $props();
let sources = $state([...initial]);
let showAdd = $state(false);
let newSource = $state({ name: '', type: 'rss' as AdminSource['type'], url: '', category: '', pollIntervalMinutes: 15 });
let pollingId = $state<string | null>(null);
let justPolled = $state<{ id: string; count: number } | null>(null);
async function handleAdd() {
if (!newSource.name || !newSource.url) return;
const created = await addSource({
name: newSource.name,
type: newSource.type,
url: newSource.url,
category: newSource.category ? [newSource.category] : [],
pollIntervalMinutes: newSource.pollIntervalMinutes
});
sources = [...sources, created];
newSource = { name: '', type: 'rss', url: '', category: '', pollIntervalMinutes: 15 };
showAdd = false;
}
async function handleDelete(id: string) {
await deleteSource(id);
sources = sources.filter((s) => s.id !== id);
}
async function toggleEnabled(source: AdminSource) {
const updated = await updateSource(source.id, { enabled: !source.enabled });
sources = sources.map((s) => (s.id === source.id ? updated : s));
}
async function pollNow(source: AdminSource) {
pollingId = source.id;
justPolled = null;
try {
const { ingested, source: updated } = await pollSourceNow(source.id);
sources = sources.map((s) => (s.id === source.id ? updated : s));
justPolled = { id: source.id, count: ingested };
setTimeout(() => {
if (justPolled?.id === source.id) justPolled = null;
}, 3000);
} finally {
pollingId = null;
}
}
const typeIcon = (type: string) => (type === 'rss' ? '⟳' : type === 'telegram' ? '✈' : type === 'api' ? '⇄' : '•');
</script>
<div class="toolbar">
<span class="count">{sources.length} sources</span>
<button class="add-btn" onclick={() => (showAdd = !showAdd)}>+ Add source</button>
</div>
{#if showAdd}
<div class="add-panel">
<div class="add-grid">
<input placeholder="Name" bind:value={newSource.name} />
<select bind:value={newSource.type}>
<option value="rss">RSS</option>
<option value="api">API</option>
<option value="telegram">Telegram</option>
<option value="custom">Custom</option>
</select>
<input placeholder="URL or channel" bind:value={newSource.url} />
<input placeholder="Category" bind:value={newSource.category} />
</div>
<div class="add-actions">
<button onclick={() => (showAdd = false)}>Cancel</button>
<button class="primary" onclick={handleAdd}>Add</button>
</div>
</div>
{/if}
<div class="list">
<div class="row header">
<span></span>
<span>Name</span>
<span>Type</span>
<span>Category</span>
<span>Poll</span>
<span></span>
</div>
{#each sources as source (source.id)}
<div class="row" class:disabled={!source.enabled}>
<button
class="type-icon"
class:spinning={pollingId === source.id}
onclick={() => pollNow(source)}
disabled={pollingId === source.id}
title="Poll now"
aria-label={`Poll ${source.name} now`}
>
{typeIcon(source.type)}
</button>
<div>
<div class="name">{source.name}</div>
<div class="sub" class:error={source.lastError && !justPolled} class:success={justPolled?.id === source.id}>
{#if justPolled?.id === source.id}
{justPolled.count > 0 ? `✓ ${justPolled.count} new item(s)` : '✓ up to date, nothing new'}
{:else if source.lastError}
last poll failed · {source.lastError}
{:else}
{source.url}
{/if}
</div>
</div>
<span class="badge">{source.type.toUpperCase()}</span>
<span class="cat">{source.category.join(', ')}</span>
<span class="cat">{source.pollIntervalMinutes} min</span>
<div class="actions">
<button class="icon-btn" onclick={() => toggleEnabled(source)} title={source.enabled ? 'Disable' : 'Enable'}>
{source.enabled ? '⏸' : '▶'}
</button>
<button class="icon-btn danger" onclick={() => handleDelete(source.id)} title="Delete"></button>
</div>
</div>
{/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;
}
.primary {
background: var(--pill-bg);
color: var(--pill-text);
border-color: var(--pill-bg);
}
.list {
display: flex;
flex-direction: column;
}
.row {
display: grid;
grid-template-columns: 20px 1.4fr 0.7fr 0.9fr 0.7fr 60px;
gap: 10px;
padding: 10px;
align-items: center;
border-bottom: 0.5px solid var(--border);
}
.row.header {
font-size: 11px;
color: var(--text-muted);
padding: 6px 10px;
}
.row.disabled {
opacity: 0.5;
}
.type-icon {
background: transparent;
border: none;
color: var(--text-secondary);
font-size: 15px;
cursor: pointer;
padding: 2px;
line-height: 1;
border-radius: 4px;
}
.type-icon:hover:not(:disabled) {
color: var(--text-accent);
background: var(--bg-accent);
}
.type-icon.spinning {
animation: spin 0.8s linear infinite;
color: var(--text-accent);
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.name {
font-size: 13px;
}
.sub {
font-size: 11px;
color: var(--text-muted);
}
.sub.error {
color: var(--text-danger);
}
.sub.success {
color: var(--text-success);
}
.badge {
font-size: 11px;
padding: 2px 8px;
background: var(--surface-1);
border-radius: var(--radius);
width: fit-content;
}
.cat {
font-size: 12px;
color: var(--text-secondary);
}
.actions {
display: flex;
gap: 6px;
}
.icon-btn {
font-size: 12px;
padding: 3px 6px;
background: transparent;
border: none;
color: var(--text-secondary);
}
.icon-btn.danger:hover {
color: var(--text-danger);
}
</style>
+22
View File
@@ -0,0 +1,22 @@
// The frontend->backend address is deliberately NOT a backend-stored setting
// (see project-structure.md "Frontend → backend address"). It's read here from:
// 1. a deploy-time env var (VITE_BACKEND_URL), or
// 2. a value saved in the browser via the connection setup screen, or
// 3. a localhost fallback for local dev against the mock backend.
const STORAGE_KEY = 'homefeed:backendUrl';
const DEFAULT_URL = 'http://localhost:4000';
export function getBackendUrl(): string {
if (typeof localStorage !== 'undefined') {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) return stored;
}
return import.meta.env.VITE_BACKEND_URL || DEFAULT_URL;
}
export function setBackendUrl(url: string) {
if (typeof localStorage !== 'undefined') {
localStorage.setItem(STORAGE_KEY, url);
}
}
+9
View File
@@ -0,0 +1,9 @@
export function timeAgo(iso: string): string {
const diffMs = Date.now() - new Date(iso).getTime();
const mins = Math.round(diffMs / 60000);
if (mins < 60) return `${mins}m ago`;
const hours = Math.round(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.round(hours / 24);
return `${days}d ago`;
}
+1
View File
@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.
+83
View File
@@ -0,0 +1,83 @@
:root {
--surface-0: #faf9f6;
--surface-1: #f0eee7;
--surface-2: #ffffff;
--text-primary: #1c1b18;
--text-secondary: #55534c;
--text-muted: #8a887f;
--text-accent: #993c1d;
--bg-accent: #faece7;
--border: #ddd9cd;
--border-accent: #d85a30;
--pill-bg: #1c1b18;
--pill-text: #ffffff;
--text-success: #3a7d4f;
--text-danger: #b3402c;
--radius: 8px;
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
--font-voice: Georgia, 'Times New Roman', serif;
--font-mono: 'SF Mono', Menlo, Consolas, monospace;
}
:root[data-theme='dark'] {
--surface-0: #16181d;
--surface-1: #1f232a;
--surface-2: #262b33;
--text-primary: #e7e5df;
--text-secondary: #a7a59c;
--text-muted: #75736a;
--text-accent: #e2896a;
--bg-accent: #3a2a22;
--border: #333740;
--border-accent: #b3532c;
--pill-bg: #e7e5df;
--pill-text: #16181d;
--text-success: #5fb37b;
--text-danger: #e07356;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
background: var(--surface-0);
color: var(--text-primary);
font-family: var(--font-sans);
transition: background-color 0.15s ease, color 0.15s ease;
}
a {
color: var(--text-accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
input,
select,
textarea,
button {
font-family: var(--font-sans);
font-size: 13px;
border: 0.5px solid var(--border);
border-radius: var(--radius);
padding: 8px 10px;
background: var(--surface-2);
color: var(--text-primary);
}
button {
cursor: pointer;
}
.page {
max-width: 1080px;
margin: 0 auto;
padding: 0 24px 60px;
}
+38
View File
@@ -0,0 +1,38 @@
const STORAGE_KEY = 'homefeed:theme';
type Theme = 'light' | 'dark';
let theme = $state<Theme>('light');
export function getTheme(): Theme {
return theme;
}
export function initTheme() {
if (typeof localStorage === 'undefined') return;
const stored = localStorage.getItem(STORAGE_KEY) as Theme | null;
if (stored === 'light' || stored === 'dark') {
theme = stored;
} else {
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
applyTheme();
}
export function toggleTheme() {
theme = theme === 'light' ? 'dark' : 'light';
if (typeof localStorage !== 'undefined') {
localStorage.setItem(STORAGE_KEY, theme);
}
applyTheme();
}
function applyTheme() {
if (typeof document !== 'undefined') {
document.documentElement.setAttribute('data-theme', theme);
}
}
export function isDark(): boolean {
return theme === 'dark';
}
+44
View File
@@ -0,0 +1,44 @@
// Mirrors homefeed-data-schema.md — the frontend's view of MergedArticle etc.
// Kept minimal to only what the frontend actually renders.
export interface ArticleSource {
itemId: string;
sourceName: string;
link: string;
publishedAt: string;
}
export interface MergedArticle {
id: string;
title: string;
body: string;
heroImage: { url: string; sourceItemId: string; selectionReason: string } | null;
video: { url: string; provider?: string; sourceItemId: string } | null;
category: string[];
geo: string | null;
eventId: string | null;
sourceCount: number;
sources: ArticleSource[];
publishedAt: string;
updatedAt: string;
mergeConfidence: number;
tags: string[];
threadId: string;
previousArticleId: string | null;
nextArticleId: string | null;
}
export interface Tag {
id: string;
label: string;
slug: string;
articleCount: number;
status: 'active' | 'expired';
}
export interface TrackedEventPublic {
id: string;
name: string;
active: boolean;
cadence: string;
}
+119
View File
@@ -0,0 +1,119 @@
<script lang="ts">
import '../lib/styles/app.css';
import { page } from '$app/stores';
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
let { children } = $props();
const categories = [
{ label: 'Top stories', href: '/' },
{ label: 'Local', href: '/category/local' },
{ label: 'World', href: '/category/world' },
{ label: 'Business', href: '/category/business' },
{ label: 'Tech', href: '/category/tech' },
{ label: 'Culture', href: '/category/culture' }
];
function isActive(href: string): boolean {
if (href === '/') return $page.url.pathname === '/';
return $page.url.pathname.startsWith(href);
}
</script>
<header class="masthead">
<div class="page masthead-inner">
<div class="brand">
<span class="brand-name">Homefeed</span>
<span class="brand-tag">self-hosted</span>
</div>
<nav class="tabs">
{#each categories as cat}
<a class="tab" class:active={isActive(cat.href)} href={cat.href}>{cat.label}</a>
{/each}
</nav>
<div class="controls">
<ThemeToggle />
<a class="cog" href="/admin/settings" aria-label="Admin settings" title="Admin settings">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="12" r="3" />
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"
/>
</svg>
</a>
</div>
</div>
</header>
<main class="page">
{@render children()}
</main>
<style>
.masthead {
border-bottom: 2px solid var(--text-primary);
}
.masthead-inner {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding-top: 20px;
padding-bottom: 12px;
}
.brand {
display: flex;
align-items: baseline;
gap: 10px;
}
.brand-name {
font-family: var(--font-voice);
font-size: 28px;
font-weight: 500;
letter-spacing: -0.5px;
}
.brand-tag {
font-size: 11px;
color: var(--text-muted);
border: 0.5px solid var(--border);
padding: 2px 6px;
border-radius: var(--radius);
}
.tabs {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.tab {
font-size: 13px;
color: var(--text-secondary);
padding: 6px 12px;
border-radius: var(--radius);
}
.tab:hover {
text-decoration: none;
background: var(--surface-1);
}
.tab.active {
background: var(--pill-bg);
color: var(--pill-text);
}
.controls {
display: flex;
align-items: center;
gap: 12px;
}
.cog {
display: flex;
align-items: center;
color: var(--text-secondary);
padding: 4px;
border-radius: var(--radius);
}
.cog:hover {
text-decoration: none;
background: var(--surface-1);
color: var(--text-primary);
}
</style>
+124
View File
@@ -0,0 +1,124 @@
<script lang="ts">
import type { PageData } from './$types';
import ArticleCard from '$lib/components/ArticleCard.svelte';
import { timeAgo } from '$lib/format';
let { data }: { data: PageData } = $props();
</script>
{#if data.hero}
<a class="hero" href={`/article/${data.hero.id}`}>
<div class="hero-meta">
{#if data.hero.sourceCount > 1}
<span>⇄ Merged from {data.hero.sourceCount} sources</span>
{/if}
<span>{data.hero.category.join(', ')}</span>
</div>
{#if data.hero.heroImage}
<img class="hero-img" src={data.hero.heroImage.url} alt="" />
{/if}
<div class="hero-title">{data.hero.title}</div>
<div class="hero-sub">{timeAgo(data.hero.publishedAt)}</div>
</a>
{/if}
<section>
<div class="section-head">
<span class="section-title">Local &middot; Philadelphia</span>
<a href="/category/local">See all</a>
</div>
<div class="grid">
{#each data.local as article}
<ArticleCard {article} />
{/each}
</div>
</section>
<section>
<div class="section-head">
<span class="section-title">Business</span>
<a href="/category/business">See all</a>
</div>
<div class="grid">
{#each data.business as article}
<ArticleCard {article} />
{/each}
</div>
</section>
<section>
<div class="section-head">
<span class="section-title">Tech</span>
<a href="/category/tech">See all</a>
</div>
<div class="grid">
{#each data.tech as article}
<ArticleCard {article} />
{/each}
</div>
</section>
<style>
.hero {
display: block;
color: inherit;
margin: 24px 0 36px;
max-width: 720px;
}
.hero:hover {
text-decoration: none;
}
.hero:hover .hero-title {
text-decoration: underline;
}
.hero-meta {
font-size: 12px;
color: var(--text-accent);
display: flex;
gap: 10px;
margin-bottom: 10px;
}
.hero-img {
width: 100%;
aspect-ratio: 16 / 8;
object-fit: cover;
border-radius: 12px;
margin-bottom: 12px;
background: var(--surface-1);
}
.hero-title {
font-family: var(--font-voice);
font-size: 30px;
font-weight: 500;
line-height: 1.25;
margin-bottom: 8px;
}
.hero-sub {
font-size: 12px;
color: var(--text-muted);
}
section {
margin-bottom: 32px;
}
.section-head {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 12px;
}
.section-title {
font-family: var(--font-voice);
font-size: 19px;
font-weight: 500;
}
.section-head a {
font-size: 12px;
color: var(--text-muted);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 20px;
}
</style>
+19
View File
@@ -0,0 +1,19 @@
import type { PageLoad } from './$types';
import { getFeed } from '$lib/api';
export const load: PageLoad = async ({ fetch }) => {
const [all, local] = await Promise.all([
getFeed({}, fetch),
getFeed({ geo: 'philadelphia' }, fetch)
]);
const byCategory = (name: string) =>
all.filter((a) => a.category.some((c) => c.toLowerCase() === name.toLowerCase()));
return {
hero: all[0] ?? null,
local,
business: byCategory('business'),
tech: byCategory('tech')
};
};
+50
View File
@@ -0,0 +1,50 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { logout } from '$lib/adminApi';
let { children } = $props();
async function handleLogout() {
await logout();
await goto('/admin/login');
}
</script>
<div class="admin-shell">
<div class="page admin-inner">
<div class="top-row">
<a class="back" href="/">← Back to site</a>
<button class="logout" onclick={handleLogout}>Log out</button>
</div>
{@render children()}
</div>
</div>
<style>
.admin-shell {
min-height: 100vh;
}
.admin-inner {
padding-top: 20px;
}
.top-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.back {
font-size: 12px;
color: var(--text-muted);
}
.logout {
font-size: 12px;
padding: 5px 10px;
background: transparent;
color: var(--text-muted);
}
.logout:hover {
color: var(--text-primary);
background: var(--surface-1);
}
</style>
+7
View File
@@ -0,0 +1,7 @@
// The admin section talks to a different origin (the backend) than the frontend
// itself. During SSR, the `load` function's fetch runs on the Node server, which has
// no access to the browser's cookie jar — it can't attach the session cookie to a
// cross-origin request. Disabling SSR here means all admin data fetching happens in
// the actual browser instead, where credentials: 'include' works correctly against
// whatever cookie the browser already holds from login.
export const ssr = false;
@@ -0,0 +1,79 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { login } from '$lib/adminApi';
let username = $state('admin');
let password = $state('');
let error = $state('');
let loading = $state(false);
async function handleSubmit(e: Event) {
e.preventDefault();
error = '';
loading = true;
try {
await login(username, password);
const redirectTo = $page.url.searchParams.get('redirectTo') || '/admin/settings';
await goto(redirectTo);
} catch (err) {
error = err instanceof Error ? err.message : 'Login failed';
} finally {
loading = false;
}
}
</script>
<div class="wrap">
<form onsubmit={handleSubmit}>
<span class="title">Admin login</span>
<label class="field-label" for="username">Username</label>
<input id="username" type="text" bind:value={username} autocomplete="username" />
<label class="field-label" for="password">Password</label>
<input id="password" type="password" bind:value={password} autocomplete="current-password" />
{#if error}<div class="error">{error}</div>{/if}
<button type="submit" class="primary" disabled={loading}>{loading ? 'Signing in…' : 'Sign in'}</button>
</form>
</div>
<style>
.wrap {
display: flex;
justify-content: center;
padding-top: 60px;
}
form {
width: 280px;
display: flex;
flex-direction: column;
gap: 10px;
}
.title {
font-family: var(--font-voice);
font-size: 22px;
font-weight: 500;
margin-bottom: 8px;
}
.field-label {
font-size: 11px;
color: var(--text-muted);
}
input {
width: 100%;
margin-bottom: 4px;
}
.primary {
margin-top: 8px;
background: var(--pill-bg);
color: var(--pill-text);
border-color: var(--pill-bg);
}
.error {
font-size: 12px;
color: var(--text-danger);
}
</style>
@@ -0,0 +1,89 @@
<script lang="ts">
import type { PageData } from './$types';
import MergeTab from '$lib/components/admin/MergeTab.svelte';
import SourcesTab from '$lib/components/admin/SourcesTab.svelte';
import ModelsTab from '$lib/components/admin/ModelsTab.svelte';
import RetentionTab from '$lib/components/admin/RetentionTab.svelte';
import EventsTab from '$lib/components/admin/EventsTab.svelte';
import ConnectionsTab from '$lib/components/admin/ConnectionsTab.svelte';
import LogsTab from '$lib/components/admin/LogsTab.svelte';
let { data }: { data: PageData } = $props();
const tabs = [
{ id: 'merge', label: 'Merge' },
{ id: 'sources', label: 'Sources' },
{ id: 'models', label: 'Models' },
{ id: 'retention', label: 'Retention' },
{ id: 'events', label: 'Tracked events' },
{ id: 'connections', label: 'Connections' },
{ id: 'logs', label: 'Logs' }
];
let active = $state('merge');
</script>
<div class="head">
<span class="title">Admin</span>
<nav class="tabs">
{#each tabs as tab}
<button class="tab" class:active={active === tab.id} onclick={() => (active = tab.id)}>
{tab.label}
</button>
{/each}
</nav>
</div>
<div class="content">
{#if active === 'merge'}
<MergeTab settings={data.settings} />
{:else if active === 'sources'}
<SourcesTab sources={data.sources} />
{:else if active === 'models'}
<ModelsTab settings={data.settings} models={data.models} aiStatus={data.aiStatus} />
{:else if active === 'retention'}
<RetentionTab settings={data.settings} />
{:else if active === 'events'}
<EventsTab events={data.events} sources={data.sources} />
{:else if active === 'connections'}
<ConnectionsTab settings={data.settings} aiStatus={data.aiStatus} />
{:else if active === 'logs'}
<LogsTab logs={data.logs} />
{/if}
</div>
<style>
.head {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 20px;
}
.title {
font-family: var(--font-voice);
font-size: 24px;
font-weight: 500;
}
.tabs {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.tab {
font-size: 12px;
padding: 5px 12px;
border: none;
background: transparent;
color: var(--text-secondary);
border-radius: var(--radius);
}
.tab.active {
background: var(--pill-bg);
color: var(--pill-text);
}
.content {
max-width: 720px;
}
</style>
@@ -0,0 +1,34 @@
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
import { getSettings, getSources, getEvents, getModels, getAiStatus, getLogs } from '$lib/adminApi';
import type { ModelCatalog, AiStatus } from '$lib/adminTypes';
const EMPTY_MODELS: ModelCatalog = { embedding: [], image: [], synthesis: [] };
export const load: PageLoad = async ({ fetch }) => {
try {
const [settings, sources, events, logs] = await Promise.all([
getSettings(fetch),
getSources(fetch),
getEvents(fetch),
getLogs({}, fetch)
]);
// The AI service (Ollama) may not be running yet — that shouldn't take down the
// whole settings page, just leave the Models/Connections tabs showing "unreachable".
let models: ModelCatalog = EMPTY_MODELS;
let aiStatus: AiStatus = { connected: false, host: settings.aiServiceHost, port: settings.aiServicePort, ramGB: 0, gpu: 'unknown' };
try {
[models, aiStatus] = await Promise.all([getModels(fetch), getAiStatus(fetch)]);
} catch {
// swallow — surfaced instead via aiStatus.connected in the UI
}
return { settings, sources, events, models, aiStatus, logs };
} catch (err) {
if ((err as { status?: number }).status === 401) {
throw redirect(302, '/admin/login?redirectTo=/admin/settings');
}
throw err;
}
};
@@ -0,0 +1,187 @@
<script lang="ts">
import type { PageData } from './$types';
import { timeAgo } from '$lib/format';
let { data }: { data: PageData } = $props();
const a = $derived(data.article);
</script>
<div class="breadcrumb">
<a href="/">{a.category[0] ?? 'Top stories'}</a>
<span></span>
<span>{a.title.slice(0, 40)}{a.title.length > 40 ? '…' : ''}</span>
</div>
<article>
<div class="meta">
{#if a.sourceCount > 1}
<span>⇄ Merged from {a.sourceCount} sources</span>
<span>&middot;</span>
{/if}
<span>{a.category.join(', ')}</span>
</div>
<h1>{a.title}</h1>
<div class="dates">
<span>Published {timeAgo(a.publishedAt)}</span>
{#if a.updatedAt !== a.publishedAt}
<span>&middot; Updated {timeAgo(a.updatedAt)}</span>
{/if}
</div>
{#if a.heroImage}
<img class="hero-img" src={a.heroImage.url} alt="" />
<div class="img-caption">
Image via <span class="accent">{a.sources[0]?.sourceName ?? 'source'}</span>
</div>
{/if}
{#each a.body.split('\n\n') as paragraph}
<p>{paragraph}</p>
{/each}
{#if a.video}
<div class="video-embed">▶ video embed &middot; via {a.video.provider}</div>
{/if}
{#if data.tagLabels.length}
<div class="tags">
{#each data.tagLabels as tag}
<a class="tag-chip" href={`/tag/${tag.slug}`}>{tag.label}</a>
{/each}
</div>
{/if}
{#if data.nextArticle}
<a class="thread-banner" href={`/article/${data.nextArticle.id}`}>
→ Newer coverage available: <strong>{data.nextArticle.title}</strong>
</a>
{/if}
{#if data.previousArticle}
<a class="thread-banner" href={`/article/${data.previousArticle.id}`}>
← Earlier coverage: <strong>{data.previousArticle.title}</strong>
</a>
{/if}
{#if a.sources.length}
<div class="sources">
<span class="sources-label">Sources:</span>
{#each a.sources as source, i}
<a href={source.link} target="_blank" rel="noreferrer">{source.sourceName}</a>
{#if i < a.sources.length - 1}<span>&middot;</span>{/if}
{/each}
</div>
{/if}
</article>
<style>
.breadcrumb {
font-size: 12px;
color: var(--text-muted);
margin: 20px 0 18px;
display: flex;
gap: 8px;
}
article {
max-width: 640px;
}
.meta {
font-size: 11px;
color: var(--text-accent);
margin-bottom: 10px;
display: flex;
gap: 6px;
}
h1 {
font-family: var(--font-voice);
font-size: 32px;
line-height: 1.2;
font-weight: 500;
margin: 0 0 14px;
}
.dates {
font-size: 12px;
color: var(--text-muted);
margin-bottom: 20px;
display: flex;
gap: 6px;
}
.hero-img {
width: 100%;
border-radius: 12px;
margin-bottom: 8px;
background: var(--surface-1);
}
.img-caption {
font-size: 11px;
color: var(--text-muted);
margin-bottom: 24px;
}
.accent {
color: var(--text-accent);
}
p {
font-size: 16px;
line-height: 1.75;
color: var(--text-primary);
margin: 0 0 16px;
}
.video-embed {
width: 100%;
border-radius: 12px;
background: var(--surface-1);
height: 200px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--text-muted);
font-size: 12px;
margin-bottom: 24px;
}
.tags {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 20px;
}
.tag-chip {
font-size: 12px;
padding: 5px 12px;
background: var(--surface-1);
border-radius: 16px;
color: var(--text-secondary);
}
.tag-chip:hover {
text-decoration: none;
background: var(--border);
}
.thread-banner {
display: block;
background: var(--bg-accent);
border-radius: var(--radius);
padding: 12px 14px;
margin-bottom: 16px;
font-size: 13px;
color: var(--text-accent);
}
.thread-banner:hover {
text-decoration: none;
}
.thread-banner strong {
font-weight: 500;
}
.sources {
border-top: 0.5px solid var(--border);
padding-top: 14px;
margin-top: 8px;
display: flex;
gap: 8px;
flex-wrap: wrap;
font-size: 12px;
}
.sources-label {
color: var(--text-muted);
}
</style>
+21
View File
@@ -0,0 +1,21 @@
import type { PageLoad } from './$types';
import { getArticle, getTags } from '$lib/api';
export const load: PageLoad = async ({ params, fetch }) => {
const [article, tags] = await Promise.all([getArticle(params.id, fetch), getTags(fetch)]);
let nextArticle = null;
let previousArticle = null;
if (article.nextArticleId) {
nextArticle = await getArticle(article.nextArticleId, fetch);
}
if (article.previousArticleId) {
previousArticle = await getArticle(article.previousArticleId, fetch);
}
const tagLabels = article.tags
.map((id) => tags.find((t) => t.id === id))
.filter((t): t is NonNullable<typeof t> => Boolean(t));
return { article, tagLabels, nextArticle, previousArticle };
};
@@ -0,0 +1,49 @@
<script lang="ts">
import type { PageData } from './$types';
import ArticleCard from '$lib/components/ArticleCard.svelte';
let { data }: { data: PageData } = $props();
</script>
<div class="head">
<span class="title">{data.name}</span>
<span class="count">{data.articles.length} stories</span>
</div>
{#if data.articles.length === 0}
<p class="empty">No stories in this category yet.</p>
{:else}
<div class="grid">
{#each data.articles as article}
<ArticleCard {article} />
{/each}
</div>
{/if}
<style>
.head {
display: flex;
align-items: baseline;
justify-content: space-between;
margin: 24px 0 20px;
}
.title {
font-family: var(--font-voice);
font-size: 26px;
font-weight: 500;
text-transform: capitalize;
}
.count {
font-size: 12px;
color: var(--text-muted);
}
.empty {
color: var(--text-muted);
font-size: 14px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 20px;
}
</style>
@@ -0,0 +1,12 @@
import type { PageLoad } from './$types';
import { getFeed } from '$lib/api';
export const load: PageLoad = async ({ params, fetch }) => {
const name = params.name;
const isLocal = name.toLowerCase() === 'local';
const articles = await getFeed(
isLocal ? { geo: 'philadelphia' } : { category: name },
fetch
);
return { articles, name };
};
+3
View File
@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:
+20
View File
@@ -0,0 +1,20 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}
+20
View File
@@ -0,0 +1,20 @@
import adapter from '@sveltejs/adapter-auto';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
sveltekit({
compilerOptions: {
// Force runes mode for the project, except for libraries. Can be removed in svelte 6.
runes: ({ filename }) =>
filename.split(/[/\\]/).includes('node_modules') ? undefined : true
},
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter()
})
]
});