Initial Commit
This commit is contained in:
@@ -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-*
|
||||
@@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["svelte.svelte-vscode"]
|
||||
}
|
||||
@@ -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.
|
||||
Generated
+1390
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
Vendored
+13
@@ -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 {};
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 & 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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 · 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>
|
||||
@@ -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')
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>·</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>· 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 · 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>·</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>
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
]
|
||||
});
|
||||
Reference in New Issue
Block a user