Merge pull request #16 from Salastil/development

More category, Weather, Stocks and Bookmark panels.
This commit is contained in:
Salastil
2026-07-24 21:16:09 -04:00
committed by GitHub
33 changed files with 2362 additions and 40 deletions
+76 -2
View File
@@ -3,12 +3,17 @@ import * as settingsDb from '../storage/db/settings.js';
import * as sourcesDb from '../storage/db/sources.js';
import * as eventsDb from '../storage/db/events.js';
import * as categoriesDb from '../storage/db/categories.js';
import * as stocksDb from '../storage/db/stocks.js';
import * as bookmarksDb from '../storage/db/bookmarks.js';
import { clearSourceContent, reissueSourceContent, clearAllArticles, clearAllMedia } from '../storage/contentCascade.js';
import { totalStorageBytes } from '../storage/media/index.js';
import { OllamaProvider } from '../inference/ollama-provider.js';
import { pollSourceNow } from '../ingestion/poller.js';
import { logger, listLogs } from '../storage/db/logs.js';
import * as telegramClient from '../telegram/client.js';
import { geocodeLocation } from '../weather/client.js';
import { pollWeatherNow } from '../weather/poller.js';
import { pollStocksNow } from '../stocks/poller.js';
// Not part of GlobalSettings itself (nothing to persist) — computed fresh on every
// settings read/write so the Retention tab's "currently using" line and usage bar
@@ -31,14 +36,19 @@ export async function registerAdminRoutes(app: FastifyInstance) {
delete body.categoryPriority;
}
const settings = withStorageUsed(settingsDb.updateSettings(body));
if (body.weather) {
// Poll immediately rather than waiting for the next scheduler tick (up to 45
// minutes) — the admin just changed the location/unit and expects to see it reflected.
pollWeatherNow().catch((err) => logger.error('weather', `Immediate poll failed: ${err.message}`));
}
return { ...settings, categoryPriority: categoriesDb.listCategories() };
});
// --- Categories (add/remove — reordering/privacy is via PATCH /settings above) ---
app.post('/api/admin/categories', async (req, reply) => {
const { name, isPrivate } = req.body as { name?: string; isPrivate?: boolean };
const { name, isPrivate, isSpillover } = req.body as { name?: string; isPrivate?: boolean; isSpillover?: boolean };
if (!name || !name.trim()) return reply.code(400).send({ error: 'name required' });
const created = categoriesDb.createCategory(name.trim(), !!isPrivate);
const created = categoriesDb.createCategory(name.trim(), !!isPrivate, !!isSpillover);
return reply.code(201).send(created);
});
@@ -202,6 +212,70 @@ export async function registerAdminRoutes(app: FastifyInstance) {
return reply.code(200).send(telegramClient.getStatus());
});
// --- Weather (config lives in global_settings — see PATCH /api/admin/settings above) ---
app.get('/api/admin/weather/geocode', async (req, reply) => {
const { query } = req.query as { query?: string };
if (!query || !query.trim()) return reply.code(400).send({ error: 'query required' });
try {
return await geocodeLocation(query.trim());
} catch (err) {
return reply.code(502).send({ error: `Geocoding service unreachable: ${(err as Error).message}` });
}
});
// --- Stocks ---
app.get('/api/admin/stocks', async () => stocksDb.listStockTickers());
app.post('/api/admin/stocks', async (req, reply) => {
const { label, symbol } = req.body as { label?: string; symbol?: string };
if (!label || !label.trim() || !symbol || !symbol.trim()) {
return reply.code(400).send({ error: 'label and symbol are required' });
}
const created = stocksDb.createStockTicker(label.trim(), symbol.trim());
// Poll immediately rather than waiting for the next tick (up to 15 minutes) — cheap,
// and refreshes every existing ticker's price too.
pollStocksNow().catch((err) => logger.error('stocks', `Immediate poll failed: ${err.message}`));
return reply.code(201).send(created);
});
app.patch('/api/admin/stocks/:id', async (req, reply) => {
const { id } = req.params as { id: string };
const updated = stocksDb.updateStockTicker(id, req.body as any);
if (!updated) return reply.code(404).send({ error: 'not found' });
return updated;
});
app.delete('/api/admin/stocks/:id', async (req, reply) => {
const { id } = req.params as { id: string };
stocksDb.deleteStockTicker(id);
return reply.code(204).send();
});
// --- Bookmarks ---
app.get('/api/admin/bookmarks', async () => bookmarksDb.listBookmarks());
app.post('/api/admin/bookmarks', async (req, reply) => {
const { name, url, isPrivate } = req.body as { name?: string; url?: string; isPrivate?: boolean };
if (!name || !name.trim() || !url || !url.trim()) {
return reply.code(400).send({ error: 'name and url are required' });
}
const created = bookmarksDb.createBookmark(name.trim(), url.trim(), !!isPrivate);
return reply.code(201).send(created);
});
app.patch('/api/admin/bookmarks/:id', async (req, reply) => {
const { id } = req.params as { id: string };
const updated = bookmarksDb.updateBookmark(id, req.body as any);
if (!updated) return reply.code(404).send({ error: 'not found' });
return updated;
});
app.delete('/api/admin/bookmarks/:id', async (req, reply) => {
const { id } = req.params as { id: string };
bookmarksDb.deleteBookmark(id);
return reply.code(204).send();
});
// --- Logs ---
app.get('/api/admin/logs', async (req) => {
const { level, limit } = req.query as { level?: string; limit?: string };
+14
View File
@@ -3,6 +3,9 @@ import * as articlesDb from '../storage/db/articles.js';
import * as tagsDb from '../storage/db/tags.js';
import * as eventsDb from '../storage/db/events.js';
import * as categoriesDb from '../storage/db/categories.js';
import * as settingsDb from '../storage/db/settings.js';
import * as stocksDb from '../storage/db/stocks.js';
import * as bookmarksDb from '../storage/db/bookmarks.js';
import { hasPrivateAccess } from './privateAccess.js';
export async function registerPublicRoutes(app: FastifyInstance) {
@@ -55,4 +58,15 @@ export async function registerPublicRoutes(app: FastifyInstance) {
if (hasPrivateAccess(req)) return categories;
return categories.filter((c) => !c.isPrivate);
});
// Sidebar widgets — see WeatherTab/StocksTab/BookmarksTab in the admin panel.
app.get('/api/weather', async () => settingsDb.getSettings().weather);
app.get('/api/stocks', async () => stocksDb.listStockTickers());
app.get('/api/bookmarks', async (req) => {
const bookmarks = bookmarksDb.listBookmarks();
if (hasPrivateAccess(req)) return bookmarks;
return bookmarks.filter((b) => !b.isPrivate);
});
}
+19 -1
View File
@@ -5,10 +5,14 @@ import { runRetentionSweep } from './retention.js';
import { OllamaProvider } from '../inference/ollama-provider.js';
import * as settingsDb from '../storage/db/settings.js';
import { logger } from '../storage/db/logs.js';
import { pollWeatherNow } from '../weather/poller.js';
import { pollStocksNow } from '../stocks/poller.js';
const POLL_TICK_MS = 60_000; // checks which sources are due every minute; each source's own interval governs actual fetch frequency
const SYNTHESIS_TICK_MS = 60_000;
const RETENTION_TICK_MS = 60 * 60_000; // hourly
const WEATHER_TICK_MS = 45 * 60_000;
const STOCKS_TICK_MS = 15 * 60_000; // per admin spec — stock prices move faster than weather
export function startScheduler() {
const provider = () => {
@@ -61,5 +65,19 @@ export function startScheduler() {
}
}, RETENTION_TICK_MS);
logger.info('scheduler', 'Started: poll every 1m, synthesis every 1m, retention every 1h');
// Immediate first call for both — unlike RSS sources (whose "due" check makes a
// brand-new source eligible on the very next 1-minute tick), weather/stocks have no
// such shortcut; without this the sidebar is empty for up to 45/15 minutes after
// every restart.
pollWeatherNow().catch((err) => logger.error('weather', `Initial poll failed: ${err.message}`));
setInterval(() => {
pollWeatherNow().catch((err) => logger.error('weather', `Poll tick failed: ${err.message}`));
}, WEATHER_TICK_MS);
pollStocksNow().catch((err) => logger.error('stocks', `Initial poll failed: ${err.message}`));
setInterval(() => {
pollStocksNow().catch((err) => logger.error('stocks', `Poll tick failed: ${err.message}`));
}, STOCKS_TICK_MS);
logger.info('scheduler', 'Started: poll every 1m, synthesis every 1m, retention every 1h, weather every 45m, stocks every 15m');
}
+61
View File
@@ -0,0 +1,61 @@
// Yahoo Finance's unofficial chart endpoint — free, no account or API key required.
// This is the only file that talks to it; poller.ts orchestrates when/how results get
// saved, same separation as backend/src/telegram/ keeps between the raw client and its
// callers.
//
// Previously used Stooq's CSV quote endpoint, which started gating every request behind
// a client-side proof-of-work challenge (compute a SHA-256 hashcash puzzle in JS, POST it
// to /__verify) — not something a plain server-side fetch can pass, and not worth running
// a headless browser to poll ticker prices. Confirmed via manual curl testing that Yahoo's
// /v8/finance/chart/<symbol> endpoint still works with a plain fetch, but ONLY with a
// browser-like User-Agent header — bare `curl`/`fetch` UAs get a 429 on the very first
// request, before any real volume. This is an undocumented, unofficial API Yahoo could
// change or wall off without notice, same caveat as Stooq — if it goes the same way,
// there's no realistic simple-fetch alternative left; the fallback would be a provider
// requiring a free API key.
const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36';
export interface StockQuote {
price: number;
changePercent: number;
}
export async function fetchQuotes(symbols: string[]): Promise<Map<string, StockQuote | Error>> {
const results = new Map<string, StockQuote | Error>();
if (symbols.length === 0) return results;
// No batch endpoint used here — Yahoo's multi-symbol /v7/finance/quote requires a
// cookie+crumb handshake first, while /v8/finance/chart/<symbol> (single symbol, no
// crumb needed) is the one confirmed to work with just a User-Agent. One request per
// ticker per poll is trivial at the scale of a sidebar widget (a handful of tickers,
// polled every 15 minutes).
await Promise.all(
symbols.map(async (symbol) => {
try {
const res = await fetch(`https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(symbol)}`, {
headers: { 'User-Agent': USER_AGENT }
});
if (!res.ok) throw new Error(`Yahoo returned ${res.status}`);
const data = (await res.json()) as {
chart: {
result: { meta: { regularMarketPrice: number; previousClose?: number; chartPreviousClose?: number } }[] | null;
error: { description: string } | null;
};
};
if (data.chart.error) throw new Error(data.chart.error.description);
const meta = data.chart.result?.[0]?.meta;
if (!meta) throw new Error('No data returned for this symbol');
const previousClose = meta.previousClose ?? meta.chartPreviousClose;
if (previousClose === undefined) throw new Error('No previous close available for this symbol');
results.set(symbol, {
price: meta.regularMarketPrice,
changePercent: ((meta.regularMarketPrice - previousClose) / previousClose) * 100
});
} catch (err) {
results.set(symbol, err instanceof Error ? err : new Error(String(err)));
}
})
);
return results;
}
+31
View File
@@ -0,0 +1,31 @@
import * as stocksDb from '../storage/db/stocks.js';
import { logger } from '../storage/db/logs.js';
import { fetchQuotes } from './client.js';
// Called on a schedule (see queue/scheduler.ts) and immediately after the admin adds a
// ticker (see api/admin.ts) — one request per configured ticker (see client.ts for why
// there's no batch endpoint here). A symbol Yahoo can't resolve gets its own lastError,
// it never aborts the rest of the batch.
export async function pollStocksNow(): Promise<void> {
const tickers = stocksDb.listStockTickers();
if (tickers.length === 0) return;
let quotes: Map<string, { price: number; changePercent: number } | Error>;
try {
quotes = await fetchQuotes(tickers.map((t) => t.symbol));
} catch (err) {
logger.error('stocks', `Poll failed: ${(err as Error).message}`);
return;
}
for (const ticker of tickers) {
const quote = quotes.get(ticker.symbol);
if (!quote) {
stocksDb.markStockPolled(ticker.id, null, null, 'No quote returned');
} else if (quote instanceof Error) {
stocksDb.markStockPolled(ticker.id, null, null, quote.message);
} else {
stocksDb.markStockPolled(ticker.id, quote.price, quote.changePercent, null);
}
}
}
+46
View File
@@ -0,0 +1,46 @@
import { randomUUID } from 'node:crypto';
import { db } from './index.js';
import type { Bookmark } from './types.js';
function rowToBookmark(row: any): Bookmark {
return {
id: row.id,
name: row.name,
url: row.url,
priorityRank: row.priority_rank,
isPrivate: !!row.is_private,
createdAt: row.created_at
};
}
// Always returns every bookmark, private or not — filtering for unauthenticated visitors
// happens at the route layer (GET /api/bookmarks), same as categoriesDb.listCategories().
export function listBookmarks(): Bookmark[] {
const rows = db.prepare('SELECT * FROM bookmarks ORDER BY priority_rank').all();
return rows.map(rowToBookmark);
}
export function createBookmark(name: string, url: string, isPrivate = false): Bookmark {
const id = `bm-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')}-${randomUUID().slice(0, 6)}`;
const maxRank = db.prepare('SELECT COALESCE(MAX(priority_rank), 0) as m FROM bookmarks').get() as { m: number };
const createdAt = new Date().toISOString();
db.prepare(
'INSERT INTO bookmarks (id, name, url, priority_rank, is_private, created_at) VALUES (?, ?, ?, ?, ?, ?)'
).run(id, name, url, maxRank.m + 1, isPrivate ? 1 : 0, createdAt);
return { id, name, url, priorityRank: maxRank.m + 1, isPrivate, createdAt };
}
export function updateBookmark(id: string, patch: { name?: string; url?: string; isPrivate?: boolean }): Bookmark | null {
const existing = db.prepare('SELECT * FROM bookmarks WHERE id = ?').get(id);
if (!existing) return null;
const current = rowToBookmark(existing);
const merged = { ...current, ...patch };
db.prepare('UPDATE bookmarks SET name = ?, url = ?, is_private = ? WHERE id = ?').run(
merged.name, merged.url, merged.isPrivate ? 1 : 0, id
);
return merged;
}
export function deleteBookmark(id: string) {
db.prepare('DELETE FROM bookmarks WHERE id = ?').run(id);
}
+10 -12
View File
@@ -8,7 +8,8 @@ function rowToCategory(row: any): Category {
name: row.name,
priorityRank: row.priority_rank,
isDefault: !!row.is_default,
isPrivate: !!row.is_private
isPrivate: !!row.is_private,
isSpillover: !!row.is_spillover
};
}
@@ -23,21 +24,18 @@ export function listPrivateCategoryNames(): string[] {
return rows.map((r) => r.name);
}
export function setCategoryOrder(order: { id: string; priorityRank: number; isPrivate: boolean }[]) {
const stmt = db.prepare('UPDATE categories SET priority_rank = ?, is_private = ? WHERE id = ?');
for (const c of order) stmt.run(c.priorityRank, c.isPrivate ? 1 : 0, c.id);
export function setCategoryOrder(order: { id: string; priorityRank: number; isPrivate: boolean; isSpillover: boolean }[]) {
const stmt = db.prepare('UPDATE categories SET priority_rank = ?, is_private = ?, is_spillover = ? WHERE id = ?');
for (const c of order) stmt.run(c.priorityRank, c.isPrivate ? 1 : 0, c.isSpillover ? 1 : 0, c.id);
}
export function createCategory(name: string, isPrivate = false): Category {
export function createCategory(name: string, isPrivate = false, isSpillover = false): Category {
const id = `cat-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')}-${randomUUID().slice(0, 6)}`;
const maxRank = db.prepare('SELECT COALESCE(MAX(priority_rank), 0) as m FROM categories').get() as { m: number };
db.prepare('INSERT INTO categories (id, name, priority_rank, is_default, is_private) VALUES (?, ?, ?, 0, ?)').run(
id,
name,
maxRank.m + 1,
isPrivate ? 1 : 0
);
return { id, name, priorityRank: maxRank.m + 1, isDefault: false, isPrivate };
db.prepare(
'INSERT INTO categories (id, name, priority_rank, is_default, is_private, is_spillover) VALUES (?, ?, ?, 0, ?, ?)'
).run(id, name, maxRank.m + 1, isPrivate ? 1 : 0, isSpillover ? 1 : 0);
return { id, name, priorityRank: maxRank.m + 1, isDefault: false, isPrivate, isSpillover };
}
export function deleteCategory(id: string) {
+90 -2
View File
@@ -144,7 +144,8 @@ export function migrate() {
name TEXT NOT NULL,
priority_rank INTEGER NOT NULL,
is_default INTEGER NOT NULL DEFAULT 0,
is_private INTEGER NOT NULL DEFAULT 0
is_private INTEGER NOT NULL DEFAULT 0,
is_spillover INTEGER NOT NULL DEFAULT 0 -- collapsed into the nav's "More »" overflow page instead of its own tab
);
CREATE TABLE IF NOT EXISTS logs (
@@ -175,7 +176,46 @@ export function migrate() {
storage_cap_unit TEXT NOT NULL DEFAULT 'GB',
nitter_media_mode TEXT NOT NULL DEFAULT 'proxy', -- self-host | proxy | direct
fxtwitter_base_url TEXT NOT NULL DEFAULT 'https://api.fxtwitter.com',
telegram_media_mode TEXT NOT NULL DEFAULT 'self-host' -- self-host | proxy (no "direct" — Telegram has no public hotlinkable media URL)
telegram_media_mode TEXT NOT NULL DEFAULT 'self-host', -- self-host | proxy (no "direct" — Telegram has no public hotlinkable media URL)
weather_location_name TEXT,
weather_latitude REAL,
weather_longitude REAL,
weather_unit TEXT NOT NULL DEFAULT 'fahrenheit', -- celsius | fahrenheit
weather_wind_unit TEXT NOT NULL DEFAULT 'mph', -- mph | kph
weather_pressure_unit TEXT NOT NULL DEFAULT 'inHg', -- inHg | hPa
-- JSON {temp, feelsLike, conditionText, icon, humidity, precipitationChance,
-- windSpeed, windDirection, pressure, sunrise, sunset}, NULL pre-first-poll
weather_current TEXT,
weather_hourly TEXT NOT NULL DEFAULT '[]', -- JSON array
weather_daily TEXT NOT NULL DEFAULT '[]', -- JSON array
weather_alerts TEXT NOT NULL DEFAULT '[]', -- JSON array — active NWS alerts for the configured location, US-only (see weather/client.ts)
weather_updated_at TEXT -- ISO timestamp, NULL pre-first-poll
);
-- Sidebar "Stocks" widget — polled every 15 minutes from Stooq (see stocks/poller.ts).
-- Price/change/poll-state live directly on the row, same as sources.last_polled_at,
-- rather than a separate quote-cache table.
CREATE TABLE IF NOT EXISTS stock_tickers (
id TEXT PRIMARY KEY,
label TEXT NOT NULL,
symbol TEXT NOT NULL, -- Stooq symbol syntax, e.g. "^dji", "aapl.us", "btcusd"
priority_rank INTEGER NOT NULL,
last_price REAL,
last_change_percent REAL,
last_polled_at TEXT,
last_error TEXT,
created_at TEXT NOT NULL
);
-- Sidebar "Bookmarks" widget — admin-curated links, each independently hidden/public
-- via is_private (same private-access lock feature as categories.is_private).
CREATE TABLE IF NOT EXISTS bookmarks (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
url TEXT NOT NULL,
priority_rank INTEGER NOT NULL,
is_private INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
-- Singleton row (see storage/crypto.ts) — encrypted Telegram API credentials and
@@ -229,6 +269,9 @@ export function migrate() {
if (!hasColumn('categories', 'is_private')) {
db.exec('ALTER TABLE categories ADD COLUMN is_private INTEGER NOT NULL DEFAULT 0');
}
if (!hasColumn('categories', 'is_spillover')) {
db.exec('ALTER TABLE categories ADD COLUMN is_spillover INTEGER NOT NULL DEFAULT 0');
}
if (!hasColumn('content_items', 'telegram_message')) {
db.exec('ALTER TABLE content_items ADD COLUMN telegram_message TEXT');
}
@@ -244,6 +287,51 @@ export function migrate() {
if (!hasColumn('merged_articles', 'is_recap')) {
db.exec('ALTER TABLE merged_articles ADD COLUMN is_recap INTEGER NOT NULL DEFAULT 0');
}
if (!hasColumn('global_settings', 'weather_unit')) {
db.exec('ALTER TABLE global_settings ADD COLUMN weather_location_name TEXT');
db.exec('ALTER TABLE global_settings ADD COLUMN weather_latitude REAL');
db.exec('ALTER TABLE global_settings ADD COLUMN weather_longitude REAL');
db.exec("ALTER TABLE global_settings ADD COLUMN weather_unit TEXT NOT NULL DEFAULT 'fahrenheit'");
db.exec('ALTER TABLE global_settings ADD COLUMN weather_current TEXT');
db.exec("ALTER TABLE global_settings ADD COLUMN weather_hourly TEXT NOT NULL DEFAULT '[]'");
db.exec("ALTER TABLE global_settings ADD COLUMN weather_daily TEXT NOT NULL DEFAULT '[]'");
db.exec('ALTER TABLE global_settings ADD COLUMN weather_updated_at TEXT');
}
if (!hasColumn('global_settings', 'weather_wind_unit')) {
db.exec("ALTER TABLE global_settings ADD COLUMN weather_wind_unit TEXT NOT NULL DEFAULT 'mph'");
db.exec("ALTER TABLE global_settings ADD COLUMN weather_pressure_unit TEXT NOT NULL DEFAULT 'inHg'");
db.exec("ALTER TABLE global_settings ADD COLUMN weather_alerts TEXT NOT NULL DEFAULT '[]'");
}
// Seed a handful of sensible default tickers so the Stocks widget isn't empty on a
// fresh install — the admin can remove/replace any of them via the Stocks tab.
const tickerCount = db.prepare('SELECT COUNT(*) as c FROM stock_tickers').get() as { c: number };
if (tickerCount.c === 0) {
const defaults: [string, string][] = [
['Dow Jones', '^DJI'],
['S&P 500', '^GSPC'],
['Bitcoin', 'BTC-USD']
];
const stmt = db.prepare(
'INSERT INTO stock_tickers (id, label, symbol, priority_rank, created_at) VALUES (?, ?, ?, ?, ?)'
);
defaults.forEach(([label, symbol], i) => {
stmt.run(`stk-${symbol.replace(/[^a-z0-9]+/gi, '-')}`, label, symbol, i + 1, new Date().toISOString());
});
}
// Stocks switched data providers from Stooq (walled off behind a proof-of-work
// challenge) to Yahoo Finance, which uses different symbol syntax — rewrites only
// rows still holding exactly one of the three old Stooq-format default symbols we
// ourselves seeded, never touching a symbol the admin typed in themselves.
const stooqToYahooSymbols: [string, string][] = [
['^dji', '^DJI'],
['^spx', '^GSPC'],
['btcusd', 'BTC-USD']
];
for (const [oldSymbol, newSymbol] of stooqToYahooSymbols) {
db.prepare('UPDATE stock_tickers SET symbol = ? WHERE symbol = ?').run(newSymbol, oldSymbol);
}
// Seed default categories if none exist yet. "News" sits right under "Top stories" —
// general news sources belong here, not on "Top stories" itself, which isn't a real
+32 -3
View File
@@ -22,6 +22,20 @@ function rowToSettings(row: any): GlobalSettings {
storageCapEnabled: !!row.storage_cap_enabled,
storageCapValue: row.storage_cap_value,
storageCapUnit: row.storage_cap_unit
},
weather: {
locationName: row.weather_location_name,
latitude: row.weather_latitude,
longitude: row.weather_longitude,
unit: row.weather_unit,
windUnit: row.weather_wind_unit,
pressureUnit: row.weather_pressure_unit,
// Unlike retention, this is genuinely absent pre-first-poll (and pre-location-config) — null-safe parse.
current: row.weather_current ? JSON.parse(row.weather_current) : null,
hourly: JSON.parse(row.weather_hourly),
daily: JSON.parse(row.weather_daily),
alerts: JSON.parse(row.weather_alerts),
updatedAt: row.weather_updated_at
}
};
}
@@ -37,7 +51,8 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
...current,
...patch,
retention: { ...current.retention, ...(patch.retention ?? {}) },
selectedModels: { ...current.selectedModels, ...(patch.selectedModels ?? {}) }
selectedModels: { ...current.selectedModels, ...(patch.selectedModels ?? {}) },
weather: { ...current.weather, ...(patch.weather ?? {}) }
};
db.prepare(
`UPDATE global_settings SET
@@ -46,7 +61,10 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
ai_service_host=?, ai_service_port=?, selected_models=?,
nitter_media_mode=?, fxtwitter_base_url=?, telegram_media_mode=?,
published_article_max_age_days=?, raw_item_max_age_days=?,
storage_cap_enabled=?, storage_cap_value=?, storage_cap_unit=?
storage_cap_enabled=?, storage_cap_value=?, storage_cap_unit=?,
weather_location_name=?, weather_latitude=?, weather_longitude=?, weather_unit=?,
weather_wind_unit=?, weather_pressure_unit=?,
weather_current=?, weather_hourly=?, weather_daily=?, weather_alerts=?, weather_updated_at=?
WHERE id = 1`
).run(
merged.mergeStrictness,
@@ -66,7 +84,18 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
merged.retention.rawItemMaxAgeDays,
merged.retention.storageCapEnabled ? 1 : 0,
merged.retention.storageCapValue,
merged.retention.storageCapUnit
merged.retention.storageCapUnit,
merged.weather.locationName,
merged.weather.latitude,
merged.weather.longitude,
merged.weather.unit,
merged.weather.windUnit,
merged.weather.pressureUnit,
merged.weather.current ? JSON.stringify(merged.weather.current) : null,
JSON.stringify(merged.weather.hourly),
JSON.stringify(merged.weather.daily),
JSON.stringify(merged.weather.alerts),
merged.weather.updatedAt
);
return getSettings();
}
+54
View File
@@ -0,0 +1,54 @@
import { randomUUID } from 'node:crypto';
import { db } from './index.js';
import type { StockTicker } from './types.js';
function rowToTicker(row: any): StockTicker {
return {
id: row.id,
label: row.label,
symbol: row.symbol,
priorityRank: row.priority_rank,
lastPrice: row.last_price,
lastChangePercent: row.last_change_percent,
lastPolledAt: row.last_polled_at,
lastError: row.last_error,
createdAt: row.created_at
};
}
export function listStockTickers(): StockTicker[] {
const rows = db.prepare('SELECT * FROM stock_tickers ORDER BY priority_rank').all();
return rows.map(rowToTicker);
}
export function createStockTicker(label: string, symbol: string): StockTicker {
const id = `stk-${symbol.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')}-${randomUUID().slice(0, 6)}`;
const maxRank = db.prepare('SELECT COALESCE(MAX(priority_rank), 0) as m FROM stock_tickers').get() as { m: number };
const createdAt = new Date().toISOString();
db.prepare(
'INSERT INTO stock_tickers (id, label, symbol, priority_rank, created_at) VALUES (?, ?, ?, ?, ?)'
).run(id, label, symbol, maxRank.m + 1, createdAt);
return {
id, label, symbol, priorityRank: maxRank.m + 1,
lastPrice: null, lastChangePercent: null, lastPolledAt: null, lastError: null, createdAt
};
}
export function updateStockTicker(id: string, patch: { label?: string; symbol?: string }): StockTicker | null {
const existing = db.prepare('SELECT * FROM stock_tickers WHERE id = ?').get(id);
if (!existing) return null;
const current = rowToTicker(existing);
const merged = { ...current, ...patch };
db.prepare('UPDATE stock_tickers SET label = ?, symbol = ? WHERE id = ?').run(merged.label, merged.symbol, id);
return { ...merged };
}
export function deleteStockTicker(id: string) {
db.prepare('DELETE FROM stock_tickers WHERE id = ?').run(id);
}
export function markStockPolled(id: string, price: number | null, changePercent: number | null, error: string | null) {
db.prepare(
'UPDATE stock_tickers SET last_price = ?, last_change_percent = ?, last_polled_at = ?, last_error = ? WHERE id = ?'
).run(price, changePercent, new Date().toISOString(), error, id);
}
+79
View File
@@ -202,6 +202,44 @@ export interface Category {
isDefault: boolean;
/** Hidden from /api/categories, /api/feed, and article detail for anyone without a valid private-access cookie. */
isPrivate: boolean;
/** Grouped into the nav's "More »" overflow page instead of getting its own top-level tab — see +layout.svelte and /more. */
isSpillover: boolean;
}
export interface WeatherHourEntry {
time: string;
temp: number;
conditionText: string;
icon: string;
}
export interface WeatherDayEntry {
date: string;
tempMax: number;
tempMin: number;
conditionText: string;
icon: string;
}
export interface StockTicker {
id: string;
label: string;
symbol: string;
priorityRank: number;
lastPrice: number | null;
lastChangePercent: number | null;
lastPolledAt: string | null;
lastError: string | null;
createdAt: string;
}
export interface Bookmark {
id: string;
name: string;
url: string;
priorityRank: number;
isPrivate: boolean;
createdAt: string;
}
export interface GlobalSettings {
@@ -228,4 +266,45 @@ export interface GlobalSettings {
storageCapValue: number;
storageCapUnit: 'MB' | 'GB';
};
/** Sidebar "Weather" widget config + cache — see weather/poller.ts. Singleton, since there's only ever one configured location. */
weather: {
locationName: string | null;
latitude: number | null;
longitude: number | null;
unit: 'celsius' | 'fahrenheit';
windUnit: 'mph' | 'kph';
pressureUnit: 'inHg' | 'hPa';
current: {
temp: number;
/** Apparent temperature (Open-Meteo's own heat-index/wind-chill blend) — "Feels like". */
feelsLike: number;
conditionText: string;
icon: string;
/** Percent, 0-100. */
humidity: number;
/** Percent, 0-100 — the current hour's forecast precipitation probability (there's no true instantaneous "chance of rain" measurement). */
precipitationChance: number;
/** Already in the admin's configured windUnit. */
windSpeed: number;
/** 8-point compass abbreviation, e.g. "NW". */
windDirection: string;
/** Already in the admin's configured pressureUnit. */
pressure: number;
sunrise: string;
sunset: string;
} | null;
hourly: WeatherHourEntry[];
daily: WeatherDayEntry[];
/** Active NWS alerts (flash flood, hurricane, blizzard, etc.) for the configured location — US-only, empty elsewhere. See weather/client.ts's fetchActiveAlerts. */
alerts: WeatherAlert[];
updatedAt: string | null;
};
}
export interface WeatherAlert {
id: string;
event: string;
headline: string;
severity: string;
expires: string;
}
+215
View File
@@ -0,0 +1,215 @@
// Open-Meteo (api.open-meteo.com / geocoding-api.open-meteo.com) — free, no account or API
// key required, which is why it was picked over any commercial weather provider. This is
// the only file that talks to it; poller.ts orchestrates when/how the result gets saved,
// same separation as backend/src/telegram/ keeps between the raw client and its callers.
export interface GeocodeResult {
name: string;
admin1: string | null;
country: string | null;
latitude: number;
longitude: number;
}
export interface WeatherCondition {
text: string;
icon: string;
}
// WMO weather interpretation codes, as returned by Open-Meteo's weather_code field —
// https://open-meteo.com/en/docs lists the full table this summarizes.
const WMO_CONDITIONS: Record<number, WeatherCondition> = {
0: { text: 'Clear sky', icon: '☀️' },
1: { text: 'Mainly clear', icon: '🌤️' },
2: { text: 'Partly cloudy', icon: '⛅' },
3: { text: 'Overcast', icon: '☁️' },
45: { text: 'Fog', icon: '🌫️' },
48: { text: 'Depositing rime fog', icon: '🌫️' },
51: { text: 'Light drizzle', icon: '🌦️' },
53: { text: 'Moderate drizzle', icon: '🌦️' },
55: { text: 'Dense drizzle', icon: '🌦️' },
56: { text: 'Light freezing drizzle', icon: '🌧️' },
57: { text: 'Dense freezing drizzle', icon: '🌧️' },
61: { text: 'Slight rain', icon: '🌧️' },
63: { text: 'Moderate rain', icon: '🌧️' },
65: { text: 'Heavy rain', icon: '🌧️' },
66: { text: 'Light freezing rain', icon: '🌧️' },
67: { text: 'Heavy freezing rain', icon: '🌧️' },
71: { text: 'Slight snow', icon: '🌨️' },
73: { text: 'Moderate snow', icon: '🌨️' },
75: { text: 'Heavy snow', icon: '❄️' },
77: { text: 'Snow grains', icon: '❄️' },
80: { text: 'Slight rain showers', icon: '🌦️' },
81: { text: 'Moderate rain showers', icon: '🌦️' },
82: { text: 'Violent rain showers', icon: '⛈️' },
85: { text: 'Slight snow showers', icon: '🌨️' },
86: { text: 'Heavy snow showers', icon: '🌨️' },
95: { text: 'Thunderstorm', icon: '⛈️' },
96: { text: 'Thunderstorm, slight hail', icon: '⛈️' },
99: { text: 'Thunderstorm, heavy hail', icon: '⛈️' }
};
export function wmoToCondition(code: number): WeatherCondition {
return WMO_CONDITIONS[code] ?? { text: 'Unknown', icon: '❔' };
}
const COMPASS_POINTS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
function degreesToCompass(degrees: number): string {
return COMPASS_POINTS[Math.round(degrees / 45) % 8];
}
function hPaToInHg(hpa: number): number {
return hpa * 0.0295299830714;
}
export async function geocodeLocation(query: string): Promise<GeocodeResult[]> {
const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(query)}&count=8`;
const res = await fetch(url);
if (!res.ok) throw new Error(`Geocoding API returned ${res.status}`);
const data = (await res.json()) as {
results?: { name: string; admin1?: string; country?: string; latitude: number; longitude: number }[];
};
return (data.results ?? []).map((r) => ({
name: r.name,
admin1: r.admin1 ?? null,
country: r.country ?? null,
latitude: r.latitude,
longitude: r.longitude
}));
}
export interface CurrentConditions {
temp: number;
feelsLike: number;
conditionText: string;
icon: string;
humidity: number;
precipitationChance: number;
windSpeed: number;
windDirection: string;
pressure: number;
sunrise: string;
sunset: string;
}
export interface ForecastResult {
current: CurrentConditions;
hourly: { time: string; temp: number; conditionText: string; icon: string }[];
daily: { date: string; tempMax: number; tempMin: number; conditionText: string; icon: string }[];
}
export async function fetchForecast(
latitude: number,
longitude: number,
unit: 'celsius' | 'fahrenheit',
windUnit: 'mph' | 'kph',
pressureUnit: 'inHg' | 'hPa'
): Promise<ForecastResult> {
const url =
`https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}` +
`&current=temperature_2m,apparent_temperature,weather_code,relative_humidity_2m,wind_speed_10m,wind_direction_10m,pressure_msl` +
`&hourly=temperature_2m,weather_code,precipitation_probability` +
`&daily=temperature_2m_max,temperature_2m_min,weather_code,sunrise,sunset` +
`&temperature_unit=${unit}&wind_speed_unit=${windUnit === 'kph' ? 'kmh' : 'mph'}&timezone=auto&forecast_days=7`;
const res = await fetch(url);
if (!res.ok) throw new Error(`Forecast API returned ${res.status}`);
const data = (await res.json()) as {
current: {
temperature_2m: number;
apparent_temperature: number;
weather_code: number;
relative_humidity_2m: number;
wind_speed_10m: number;
wind_direction_10m: number;
pressure_msl: number;
};
hourly: { time: string[]; temperature_2m: number[]; weather_code: number[]; precipitation_probability: number[] };
daily: {
time: string[];
temperature_2m_max: number[];
temperature_2m_min: number[];
weather_code: number[];
sunrise: string[];
sunset: string[];
};
};
// hourly.time starts at today's midnight, not the current hour — find the first entry
// at or after now so the strip shown to the user starts from "now", not from midnight,
// and so the current hour's precipitation_probability can stand in for "right now"
// (there's no true instantaneous "chance of rain" measurement, current forecasts don't have one).
const now = Date.now();
const startIdx = Math.max(
0,
data.hourly.time.findIndex((t) => new Date(t).getTime() >= now)
);
const currentCondition = wmoToCondition(data.current.weather_code);
const pressure = pressureUnit === 'inHg' ? hPaToInHg(data.current.pressure_msl) : data.current.pressure_msl;
const current: CurrentConditions = {
temp: data.current.temperature_2m,
feelsLike: data.current.apparent_temperature,
conditionText: currentCondition.text,
icon: currentCondition.icon,
humidity: data.current.relative_humidity_2m,
precipitationChance: data.hourly.precipitation_probability[startIdx] ?? 0,
windSpeed: data.current.wind_speed_10m,
windDirection: degreesToCompass(data.current.wind_direction_10m),
pressure: pressureUnit === 'inHg' ? Math.round(pressure * 100) / 100 : Math.round(pressure),
sunrise: data.daily.sunrise[0],
sunset: data.daily.sunset[0]
};
const hourly = data.hourly.time.slice(startIdx, startIdx + 24).map((time, i) => {
const idx = startIdx + i;
const condition = wmoToCondition(data.hourly.weather_code[idx]);
return { time, temp: data.hourly.temperature_2m[idx], conditionText: condition.text, icon: condition.icon };
});
const daily = data.daily.time.map((date, i) => {
const condition = wmoToCondition(data.daily.weather_code[i]);
return {
date,
tempMax: data.daily.temperature_2m_max[i],
tempMin: data.daily.temperature_2m_min[i],
conditionText: condition.text,
icon: condition.icon
};
});
return { current, hourly, daily };
}
export interface WeatherAlertResult {
id: string;
event: string;
headline: string;
severity: string;
expires: string;
}
// US National Weather Service — free, no key, no account, covers the US and territories
// only. A non-US location will reliably fail this call; that's expected, not an error
// (see poller.ts, which treats a failure here as "no alerts" rather than propagating it).
export async function fetchActiveAlerts(latitude: number, longitude: number): Promise<WeatherAlertResult[]> {
const url = `https://api.weather.gov/alerts/active?point=${latitude},${longitude}`;
const res = await fetch(url, {
headers: {
// NWS's API usage policy requires an identifying User-Agent on every request.
'User-Agent': 'Homefeed/1.0 (self-hosted news aggregator)',
Accept: 'application/geo+json'
}
});
if (!res.ok) throw new Error(`NWS alerts API returned ${res.status}`);
const data = (await res.json()) as {
features: { id: string; properties: { event: string; headline: string; severity: string; expires: string } }[];
};
return data.features.map((f) => ({
id: f.id,
event: f.properties.event,
headline: f.properties.headline,
severity: f.properties.severity,
expires: f.properties.expires
}));
}
+52
View File
@@ -0,0 +1,52 @@
import * as settingsDb from '../storage/db/settings.js';
import { logger } from '../storage/db/logs.js';
import { fetchForecast, fetchActiveAlerts } from './client.js';
// Called on a schedule (see queue/scheduler.ts) and immediately after the admin changes
// the weather location/units (see api/admin.ts) — writes straight into global_settings'
// weather_* columns via settingsDb, same singleton-row approach as retention.
export async function pollWeatherNow(): Promise<void> {
const { weather } = settingsDb.getSettings();
if (weather.latitude === null || weather.longitude === null) {
// No location configured yet — not an error, just nothing to do.
return;
}
let forecastUpdate: Partial<typeof weather> = {};
let forecastSucceeded = false;
try {
const { current, hourly, daily } = await fetchForecast(
weather.latitude,
weather.longitude,
weather.unit,
weather.windUnit,
weather.pressureUnit
);
forecastUpdate = { current, hourly, daily };
forecastSucceeded = true;
} catch (err) {
// Leave the existing cache untouched — a stale forecast beats a blank widget.
logger.error('weather', `Forecast poll failed: ${(err as Error).message}`);
}
// Fetched independently of the forecast — the NWS only covers the US, so this fails
// reliably (and expectedly) for every non-US location. A failure here shouldn't
// touch the forecast update above, and unlike a stale forecast, a stale alert that's
// since expired is worse to keep showing than none at all — clear to empty on failure.
let alerts = weather.alerts;
try {
alerts = await fetchActiveAlerts(weather.latitude, weather.longitude);
} catch (err) {
alerts = [];
logger.warn('weather', `Alerts poll failed (expected outside the US): ${(err as Error).message}`);
}
settingsDb.updateSettings({
weather: {
...weather,
...forecastUpdate,
alerts,
updatedAt: forecastSucceeded ? new Date().toISOString() : weather.updatedAt
}
});
}
+43 -4
View File
@@ -4,10 +4,14 @@ import type {
AdminSettings,
AdminSource,
AdminTrackedEvent,
CategoryPriority,
ModelCatalog,
AiStatus,
TelegramStatus,
LogEntry
LogEntry,
GeocodeResult,
AdminStockTicker,
AdminBookmark
} from './adminTypes';
async function request<T>(path: string, options: RequestInit = {}, fetchFn: typeof fetch = fetch): Promise<T> {
@@ -60,10 +64,10 @@ export const updateSettings = (patch: Partial<AdminSettings>, fetchFn?: typeof f
request<AdminSettings>('/api/admin/settings', { method: 'PATCH', body: JSON.stringify(patch) }, fetchFn);
// Categories
export const createCategory = (name: string, isPrivate = false, fetchFn?: typeof fetch) =>
request<{ id: string; name: string; priorityRank: number; isDefault: boolean; isPrivate: boolean }>(
export const createCategory = (name: string, isPrivate = false, isSpillover = false, fetchFn?: typeof fetch) =>
request<CategoryPriority>(
'/api/admin/categories',
{ method: 'POST', body: JSON.stringify({ name, isPrivate }) },
{ method: 'POST', body: JSON.stringify({ name, isPrivate, isSpillover }) },
fetchFn
);
@@ -163,3 +167,38 @@ export const getLogs = (filters: { level?: 'info' | 'warn' | 'error'; limit?: nu
const qs = new URLSearchParams(filters as Record<string, string>).toString();
return request<LogEntry[]>(`/api/admin/logs${qs ? `?${qs}` : ''}`, {}, fetchFn);
};
// Weather — config/cache lives on AdminSettings.weather (see updateSettings above); this
// is just the geocoding lookup used to resolve a typed city name to lat/lon.
export const geocodeLocation = (query: string, fetchFn?: typeof fetch) =>
request<GeocodeResult[]>(`/api/admin/weather/geocode?query=${encodeURIComponent(query)}`, {}, fetchFn);
// Stocks
export const getStockTickers = (fetchFn?: typeof fetch) =>
request<AdminStockTicker[]>('/api/admin/stocks', {}, fetchFn);
export const addStockTicker = (label: string, symbol: string, fetchFn?: typeof fetch) =>
request<AdminStockTicker>('/api/admin/stocks', { method: 'POST', body: JSON.stringify({ label, symbol }) }, fetchFn);
export const updateStockTicker = (id: string, patch: { label?: string; symbol?: string }, fetchFn?: typeof fetch) =>
request<AdminStockTicker>(`/api/admin/stocks/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }, fetchFn);
export const deleteStockTicker = (id: string, fetchFn?: typeof fetch) =>
request<void>(`/api/admin/stocks/${id}`, { method: 'DELETE' }, fetchFn);
// Bookmarks
export const getAdminBookmarks = (fetchFn?: typeof fetch) =>
request<AdminBookmark[]>('/api/admin/bookmarks', {}, fetchFn);
export const addBookmark = (name: string, url: string, isPrivate = false, fetchFn?: typeof fetch) =>
request<AdminBookmark>(
'/api/admin/bookmarks',
{ method: 'POST', body: JSON.stringify({ name, url, isPrivate }) },
fetchFn
);
export const updateBookmark = (id: string, patch: { name?: string; url?: string; isPrivate?: boolean }, fetchFn?: typeof fetch) =>
request<AdminBookmark>(`/api/admin/bookmarks/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }, fetchFn);
export const deleteBookmark = (id: string, fetchFn?: typeof fetch) =>
request<void>(`/api/admin/bookmarks/${id}`, { method: 'DELETE' }, fetchFn);
+80
View File
@@ -13,6 +13,85 @@ export interface CategoryPriority {
priorityRank: number;
isDefault: boolean;
isPrivate: boolean;
isSpillover: boolean;
}
export interface WeatherHourEntry {
time: string;
temp: number;
conditionText: string;
icon: string;
}
export interface WeatherDayEntry {
date: string;
tempMax: number;
tempMin: number;
conditionText: string;
icon: string;
}
export interface WeatherCurrentConditions {
temp: number;
feelsLike: number;
conditionText: string;
icon: string;
humidity: number;
precipitationChance: number;
windSpeed: number;
windDirection: string;
pressure: number;
sunrise: string;
sunset: string;
}
export interface WeatherAlert {
id: string;
event: string;
headline: string;
severity: string;
expires: string;
}
export interface AdminWeatherSettings {
locationName: string | null;
latitude: number | null;
longitude: number | null;
unit: 'celsius' | 'fahrenheit';
windUnit: 'mph' | 'kph';
pressureUnit: 'inHg' | 'hPa';
current: WeatherCurrentConditions | null;
hourly: WeatherHourEntry[];
daily: WeatherDayEntry[];
alerts: WeatherAlert[];
updatedAt: string | null;
}
export interface GeocodeResult {
name: string;
admin1: string | null;
country: string | null;
latitude: number;
longitude: number;
}
export interface AdminStockTicker {
id: string;
label: string;
symbol: string;
priorityRank: number;
lastPrice: number | null;
lastChangePercent: number | null;
lastPolledAt: string | null;
lastError: string | null;
}
export interface AdminBookmark {
id: string;
name: string;
url: string;
priorityRank: number;
isPrivate: boolean;
}
export interface AdminSettings {
@@ -31,6 +110,7 @@ export interface AdminSettings {
telegramMediaMode: 'self-host' | 'proxy';
retention: RetentionSettings;
categoryPriority: CategoryPriority[];
weather: AdminWeatherSettings;
}
export interface AdminSource {
+13 -1
View File
@@ -1,5 +1,5 @@
import { getBackendUrl } from './config';
import type { MergedArticle, Tag, TrackedEventPublic, Category } from './types';
import type { MergedArticle, Tag, TrackedEventPublic, Category, Weather, StockTicker, Bookmark } from './types';
async function get<T>(path: string, fetchFn: typeof fetch = fetch): Promise<T> {
// credentials: 'include' so the private-access cookie (see lib/privateAccess.ts)
@@ -41,3 +41,15 @@ export function getEvents(fetchFn?: typeof fetch): Promise<TrackedEventPublic[]>
export function getCategories(fetchFn?: typeof fetch): Promise<Category[]> {
return get<Category[]>('/api/categories', fetchFn);
}
export function getWeather(fetchFn?: typeof fetch): Promise<Weather> {
return get<Weather>('/api/weather', fetchFn);
}
export function getStocks(fetchFn?: typeof fetch): Promise<StockTicker[]> {
return get<StockTicker[]>('/api/stocks', fetchFn);
}
export function getBookmarks(fetchFn?: typeof fetch): Promise<Bookmark[]> {
return get<Bookmark[]>('/api/bookmarks', fetchFn);
}
@@ -0,0 +1,194 @@
<script lang="ts">
import type { AdminBookmark } from '$lib/adminTypes';
import { addBookmark, updateBookmark, deleteBookmark } from '$lib/adminApi';
let { bookmarks: initial }: { bookmarks: AdminBookmark[] } = $props();
let bookmarks = $state([...initial]);
let showAdd = $state(false);
let newBookmark = $state({ name: '', url: '', isPrivate: false });
let editingId = $state<string | null>(null);
let editForm = $state({ name: '', url: '' });
async function handleAdd() {
if (!newBookmark.name.trim() || !newBookmark.url.trim()) return;
const created = await addBookmark(newBookmark.name.trim(), newBookmark.url.trim(), newBookmark.isPrivate);
bookmarks = [...bookmarks, created];
newBookmark = { name: '', url: '', isPrivate: false };
showAdd = false;
}
async function togglePrivate(bookmark: AdminBookmark) {
const updated = await updateBookmark(bookmark.id, { isPrivate: !bookmark.isPrivate });
bookmarks = bookmarks.map((b) => (b.id === bookmark.id ? updated : b));
}
async function handleDelete(id: string) {
await deleteBookmark(id);
bookmarks = bookmarks.filter((b) => b.id !== id);
}
function startEdit(bookmark: AdminBookmark) {
editingId = bookmark.id;
editForm = { name: bookmark.name, url: bookmark.url };
}
function cancelEdit() {
editingId = null;
}
async function saveEdit() {
if (!editingId || !editForm.name.trim() || !editForm.url.trim()) return;
const updated = await updateBookmark(editingId, { name: editForm.name.trim(), url: editForm.url.trim() });
bookmarks = bookmarks.map((b) => (b.id === editingId ? updated : b));
editingId = null;
}
</script>
<div class="toolbar">
<span class="count">{bookmarks.length} bookmarks</span>
<button class="add-btn" onclick={() => (showAdd = !showAdd)}>+ New bookmark</button>
</div>
{#if showAdd}
<div class="add-panel">
<div class="add-grid">
<input placeholder="Name, e.g. Weather.gov" bind:value={newBookmark.name} />
<input placeholder="https://example.com" bind:value={newBookmark.url} />
</div>
<label class="private-toggle">
<input type="checkbox" bind:checked={newBookmark.isPrivate} />
Private
</label>
<div class="add-actions">
<button onclick={() => (showAdd = false)}>Cancel</button>
<button class="primary" onclick={handleAdd}>Create</button>
</div>
</div>
{/if}
<div class="list">
{#each bookmarks as bookmark (bookmark.id)}
{#if editingId === bookmark.id}
<div class="edit-panel">
<div class="add-grid">
<input placeholder="Name" bind:value={editForm.name} />
<input placeholder="URL" bind:value={editForm.url} />
</div>
<div class="add-actions">
<button onclick={cancelEdit}>Cancel</button>
<button class="primary" onclick={saveEdit}>Save</button>
</div>
</div>
{:else}
<div class="row">
<div>
<div class="name">{bookmark.name}</div>
<div class="sub">{bookmark.url}</div>
</div>
<label class="private-toggle">
<input type="checkbox" checked={bookmark.isPrivate} onchange={() => togglePrivate(bookmark)} />
Private
</label>
<button class="icon-btn" onclick={() => startEdit(bookmark)} title="Edit">Edit</button>
<button class="icon-btn danger" onclick={() => handleDelete(bookmark.id)} title="Delete"></button>
</div>
{/if}
{/each}
</div>
<style>
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.count {
font-size: 12px;
color: var(--text-muted);
}
.add-btn {
font-size: 12px;
padding: 6px 12px;
}
.add-panel {
background: var(--surface-1);
border-radius: 12px;
padding: 14px;
margin-bottom: 14px;
}
.add-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 8px;
margin-bottom: 10px;
}
.add-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-bottom: 6px;
}
.primary {
background: var(--pill-bg);
color: var(--pill-text);
border-color: var(--pill-bg);
}
.private-toggle {
display: flex;
align-items: center;
gap: 5px;
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
margin-bottom: 10px;
}
.private-toggle input {
width: auto;
}
.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);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 320px;
}
.icon-btn {
font-size: 12px;
padding: 3px 6px;
background: transparent;
border: none;
color: var(--text-secondary);
}
.icon-btn:hover {
color: var(--text-accent);
}
.icon-btn.danger:hover {
color: var(--text-danger);
}
.edit-panel {
background: var(--surface-1);
border-radius: 12px;
padding: 14px;
}
</style>
@@ -10,8 +10,15 @@
let saveTimer: ReturnType<typeof setTimeout>;
let newCategoryName = $state('');
let newCategoryPrivate = $state(false);
let newCategorySpillover = $state(false);
let addingCategory = $state(false);
// Advisory only — the nav starts getting too wide / wrapping past ~10 tabs, so this
// nudges the admin toward marking some categories as spillover once they cross that
// rough guideline. Never enforced — actual wrapping depends on name lengths and
// viewport width, which this simple count can't know.
const primaryCategoryCount = $derived(local.categoryPriority.filter((c) => !c.isSpillover).length);
function scheduleSave() {
status = 'saving';
clearTimeout(saveTimer);
@@ -40,10 +47,11 @@
if (!name) return;
addingCategory = true;
try {
const created = await createCategory(name, newCategoryPrivate);
const created = await createCategory(name, newCategoryPrivate, newCategorySpillover);
local.categoryPriority = [...local.categoryPriority, created];
newCategoryName = '';
newCategoryPrivate = false;
newCategorySpillover = false;
} finally {
addingCategory = false;
}
@@ -54,6 +62,11 @@
scheduleSave();
}
function toggleSpillover(id: string) {
local.categoryPriority = local.categoryPriority.map((c) => (c.id === id ? { ...c, isSpillover: !c.isSpillover } : c));
scheduleSave();
}
async function removeCategory(id: string, isDefault: boolean, name: string) {
if (isDefault) {
// Sensible-default categories can still be removed — e.g. a fresh install's
@@ -153,8 +166,16 @@
categories just wait longer when the queue is busy. This list also drives the site's nav —
remove anything you're not interested in (Business, Culture, etc.) or add your own. A
private category (and everything in it) is hidden from the public site until a visitor
logs in with the lock icon in the masthead.
logs in with the lock icon in the masthead. A "More" category is collapsed into a single
"More »" nav tab instead of getting its own, and shows up on that overflow page with its
latest few articles.
</p>
{#if primaryCategoryCount > 10}
<p class="hint warn">
{primaryCategoryCount} categories showing directly in the nav — consider marking some as
"More" below before it gets too wide (a rough guideline, not a hard limit).
</p>
{/if}
<div class="priority-list">
{#each local.categoryPriority as cat, i (cat.id)}
<div class="priority-row">
@@ -165,6 +186,10 @@
<input type="checkbox" checked={cat.isPrivate} onchange={() => togglePrivate(cat.id)} />
Private
</label>
<label class="private-toggle">
<input type="checkbox" checked={cat.isSpillover} onchange={() => toggleSpillover(cat.id)} />
More
</label>
{/if}
<button class="icon-btn" onclick={() => move(i, -1)} disabled={i === 0} aria-label="Move up"></button>
<button
@@ -192,6 +217,10 @@
<input type="checkbox" bind:checked={newCategoryPrivate} />
Private
</label>
<label class="private-toggle">
<input type="checkbox" bind:checked={newCategorySpillover} />
More
</label>
<button onclick={addCategory} disabled={addingCategory || !newCategoryName.trim()}>
{addingCategory ? 'Adding…' : '+ Add'}
</button>
@@ -249,6 +278,9 @@
color: var(--text-secondary);
margin: 4px 0 12px;
}
.hint.warn {
color: var(--text-accent);
}
.slider-row {
display: flex;
align-items: center;
@@ -0,0 +1,203 @@
<script lang="ts">
import type { AdminStockTicker } from '$lib/adminTypes';
import { addStockTicker, updateStockTicker, deleteStockTicker } from '$lib/adminApi';
let { tickers: initial }: { tickers: AdminStockTicker[] } = $props();
let tickers = $state([...initial]);
let showAdd = $state(false);
let newTicker = $state({ label: '', symbol: '' });
let editingId = $state<string | null>(null);
let editForm = $state({ label: '', symbol: '' });
async function handleAdd() {
if (!newTicker.label.trim() || !newTicker.symbol.trim()) return;
const created = await addStockTicker(newTicker.label.trim(), newTicker.symbol.trim());
tickers = [...tickers, created];
newTicker = { label: '', symbol: '' };
showAdd = false;
}
async function handleDelete(id: string) {
await deleteStockTicker(id);
tickers = tickers.filter((t) => t.id !== id);
}
function startEdit(ticker: AdminStockTicker) {
editingId = ticker.id;
editForm = { label: ticker.label, symbol: ticker.symbol };
}
function cancelEdit() {
editingId = null;
}
async function saveEdit() {
if (!editingId || !editForm.label.trim() || !editForm.symbol.trim()) return;
const updated = await updateStockTicker(editingId, { label: editForm.label.trim(), symbol: editForm.symbol.trim() });
tickers = tickers.map((t) => (t.id === editingId ? updated : t));
editingId = null;
}
</script>
<div class="toolbar">
<span class="count">{tickers.length} tickers</span>
<button class="add-btn" onclick={() => (showAdd = !showAdd)}>+ New ticker</button>
</div>
<p class="hint" style="margin: -6px 0 12px;">Price and % change are today's — since the previous trading day's close.</p>
{#if showAdd}
<div class="add-panel">
<div class="add-grid">
<input placeholder="Label, e.g. Apple" bind:value={newTicker.label} />
<input placeholder="Yahoo symbol, e.g. AAPL" bind:value={newTicker.symbol} />
</div>
<div class="add-actions">
<button onclick={() => (showAdd = false)}>Cancel</button>
<button class="primary" onclick={handleAdd}>Create</button>
</div>
<p class="hint">
Yahoo Finance has no symbol search, so type the exact syntax: stocks are plain tickers
(AAPL), indices use a caret (^DJI, ^GSPC), crypto pairs use a dash (BTC-USD). Polled
every 15 minutes; a new ticker is polled immediately.
</p>
</div>
{/if}
<div class="list">
{#each tickers as ticker (ticker.id)}
{#if editingId === ticker.id}
<div class="edit-panel">
<div class="add-grid">
<input placeholder="Label" bind:value={editForm.label} />
<input placeholder="Symbol" bind:value={editForm.symbol} />
</div>
<div class="add-actions">
<button onclick={cancelEdit}>Cancel</button>
<button class="primary" onclick={saveEdit}>Save</button>
</div>
</div>
{:else}
<div class="row">
<div>
<div class="name">{ticker.label}</div>
<div class="sub">
{ticker.symbol}
{#if ticker.lastError}
· <span class="error">{ticker.lastError}</span>
{/if}
</div>
</div>
{#if ticker.lastPrice !== null}
<span class="price" class:up={(ticker.lastChangePercent ?? 0) >= 0} class:down={(ticker.lastChangePercent ?? 0) < 0}>
{ticker.lastPrice.toFixed(2)}
{#if ticker.lastChangePercent !== null}
({ticker.lastChangePercent >= 0 ? '+' : ''}{ticker.lastChangePercent.toFixed(2)}%)
{/if}
</span>
{/if}
<button class="icon-btn" onclick={() => startEdit(ticker)} title="Edit">Edit</button>
<button class="icon-btn danger" onclick={() => handleDelete(ticker.id)} title="Delete"></button>
</div>
{/if}
{/each}
</div>
<style>
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.count {
font-size: 12px;
color: var(--text-muted);
}
.add-btn {
font-size: 12px;
padding: 6px 12px;
}
.add-panel {
background: var(--surface-1);
border-radius: 12px;
padding: 14px;
margin-bottom: 14px;
}
.add-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 8px;
margin-bottom: 10px;
}
.add-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-bottom: 6px;
}
.primary {
background: var(--pill-bg);
color: var(--pill-text);
border-color: var(--pill-bg);
}
.hint {
font-size: 11px;
color: var(--text-muted);
margin: 0;
}
.list {
display: flex;
flex-direction: column;
gap: 8px;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
background: var(--surface-1);
border-radius: var(--radius);
padding: 10px 14px;
}
.name {
font-size: 13px;
font-weight: 500;
}
.sub {
font-size: 11px;
color: var(--text-muted);
}
.error {
color: var(--text-danger);
}
.price {
font-size: 12px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.price.up {
color: var(--text-success);
}
.price.down {
color: var(--text-danger);
}
.icon-btn {
font-size: 12px;
padding: 3px 6px;
background: transparent;
border: none;
color: var(--text-secondary);
}
.icon-btn:hover {
color: var(--text-accent);
}
.icon-btn.danger:hover {
color: var(--text-danger);
}
.edit-panel {
background: var(--surface-1);
border-radius: 12px;
padding: 14px;
}
</style>
@@ -0,0 +1,245 @@
<script lang="ts">
import type { AdminSettings } from '$lib/adminTypes';
import { updateSettings, geocodeLocation } from '$lib/adminApi';
import { timeAgo } from '$lib/format';
import SaveStatus from './SaveStatus.svelte';
let { settings }: { settings: AdminSettings } = $props();
let weather = $state({ ...settings.weather });
let status = $state<'idle' | 'saving' | 'saved' | 'error'>('idle');
let saveTimer: ReturnType<typeof setTimeout>;
let query = $state('');
let searching = $state(false);
let searchError = $state<string | null>(null);
let results = $state<Awaited<ReturnType<typeof geocodeLocation>>>([]);
function scheduleSave() {
status = 'saving';
clearTimeout(saveTimer);
saveTimer = setTimeout(async () => {
try {
await updateSettings({ weather });
status = 'saved';
setTimeout(() => (status = 'idle'), 1500);
} catch {
status = 'error';
}
}, 500);
}
async function handleSearch() {
if (!query.trim()) return;
searching = true;
searchError = null;
try {
results = await geocodeLocation(query.trim());
if (results.length === 0) searchError = 'No matches found';
} catch {
searchError = 'Geocoding service unreachable';
} finally {
searching = false;
}
}
function selectResult(r: (typeof results)[number]) {
weather.locationName = r.admin1 ? `${r.name}, ${r.admin1}` : r.name;
weather.latitude = r.latitude;
weather.longitude = r.longitude;
results = [];
query = '';
scheduleSave();
}
const units: { label: string; value: 'celsius' | 'fahrenheit' }[] = [
{ label: '°F', value: 'fahrenheit' },
{ label: '°C', value: 'celsius' }
];
const windUnits: { label: string; value: 'mph' | 'kph' }[] = [
{ label: 'mph', value: 'mph' },
{ label: 'kph', value: 'kph' }
];
const pressureUnits: { label: string; value: 'inHg' | 'hPa' }[] = [
{ label: 'inHg', value: 'inHg' },
{ label: 'hPa', value: 'hPa' }
];
</script>
<div class="panel">
<div class="head">
<span class="panel-title">Location</span>
<SaveStatus {status} />
</div>
<p class="hint">Powers the sidebar weather widget and the /weather page — searched via Open-Meteo's free geocoding lookup.</p>
<div class="search-row">
<input
type="text"
bind:value={query}
onkeydown={(e) => e.key === 'Enter' && handleSearch()}
placeholder="City name, e.g. Chicago"
/>
<button onclick={handleSearch} disabled={searching}>{searching ? 'Searching…' : 'Search'}</button>
</div>
{#if searchError}
<p class="hint" style="color: var(--text-danger);">{searchError}</p>
{/if}
{#if results.length > 0}
<div class="results">
{#each results as r}
<button class="result-row" onclick={() => selectResult(r)}>
{r.name}{r.admin1 ? `, ${r.admin1}` : ''}{r.country ? ` ${r.country}` : ''}
</button>
{/each}
</div>
{/if}
<div class="current-row">
<span class="usage-label">
{#if weather.locationName}
Configured: {weather.locationName}
{:else}
No location configured yet
{/if}
</span>
</div>
<div class="field-label">Temperature</div>
<div class="pill-row">
{#each units as unit}
<button
class="pill"
class:active={weather.unit === unit.value}
onclick={() => {
weather.unit = unit.value;
scheduleSave();
}}
>
{unit.label}
</button>
{/each}
</div>
<div class="field-label">Wind speed</div>
<div class="pill-row">
{#each windUnits as unit}
<button
class="pill"
class:active={weather.windUnit === unit.value}
onclick={() => {
weather.windUnit = unit.value;
scheduleSave();
}}
>
{unit.label}
</button>
{/each}
</div>
<div class="field-label">Pressure</div>
<div class="pill-row">
{#each pressureUnits as unit}
<button
class="pill"
class:active={weather.pressureUnit === unit.value}
onclick={() => {
weather.pressureUnit = unit.value;
scheduleSave();
}}
>
{unit.label}
</button>
{/each}
</div>
<p class="hint" style="margin-top: 14px; margin-bottom: 0;">
{#if weather.current}
Currently showing: {Math.round(weather.current.temp)}° (feels like {Math.round(weather.current.feelsLike)}°) ·
{weather.current.conditionText} (updated {timeAgo(weather.updatedAt ?? '')})
{:else}
Not showing any data yet — configure a location above, it polls immediately.
{/if}
</p>
</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;
}
.search-row {
display: flex;
gap: 8px;
}
.search-row input {
flex: 1;
}
.results {
display: flex;
flex-direction: column;
margin-top: 8px;
border-radius: var(--radius);
overflow: hidden;
border: 0.5px solid var(--border);
}
.result-row {
text-align: left;
font-size: 12px;
padding: 8px 10px;
background: var(--surface-2);
border: none;
border-radius: 0;
}
.result-row:hover {
background: var(--bg-accent);
}
.current-row {
margin-top: 12px;
}
.usage-label {
font-size: 12px;
color: var(--text-muted);
}
.field-label {
font-size: 11px;
color: var(--text-muted);
margin: 12px 0 6px;
}
.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);
}
</style>
@@ -0,0 +1,53 @@
<script lang="ts">
import type { Bookmark } from '$lib/types';
let { bookmarks }: { bookmarks: Bookmark[] } = $props();
</script>
<div class="widget">
<span class="title">Bookmarks</span>
{#if bookmarks.length > 0}
<div class="list">
{#each bookmarks as bookmark (bookmark.id)}
<a class="row" href={bookmark.url} target="_blank" rel="noopener noreferrer">{bookmark.name}</a>
{/each}
</div>
{:else}
<p class="empty">No bookmarks yet</p>
{/if}
</div>
<style>
.widget {
background: var(--surface-1);
border-radius: 12px;
padding: 14px;
}
.title {
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
}
.list {
display: flex;
flex-direction: column;
margin-top: 8px;
}
.row {
font-size: 13px;
padding: 6px 0;
border-top: 0.5px solid var(--border);
color: inherit;
}
.row:first-child {
border-top: none;
}
.row:hover {
color: var(--text-accent);
}
.empty {
font-size: 12px;
color: var(--text-muted);
margin: 8px 0 0;
}
</style>
@@ -0,0 +1,24 @@
<script lang="ts">
import type { Weather, StockTicker, Bookmark } from '$lib/types';
import WeatherWidget from './WeatherWidget.svelte';
import StocksWidget from './StocksWidget.svelte';
import BookmarksWidget from './BookmarksWidget.svelte';
let { weather, stocks, bookmarks }: { weather: Weather; stocks: StockTicker[]; bookmarks: Bookmark[] } = $props();
</script>
<aside class="sidebar">
<WeatherWidget {weather} />
<StocksWidget {stocks} />
<BookmarksWidget {bookmarks} />
</aside>
<style>
.sidebar {
display: flex;
flex-direction: column;
gap: 16px;
position: sticky;
top: 20px;
}
</style>
@@ -0,0 +1,94 @@
<script lang="ts">
import type { StockTicker } from '$lib/types';
let { stocks }: { stocks: StockTicker[] } = $props();
</script>
<div class="widget">
<div class="head">
<span class="title">Stocks</span>
{#if stocks.length > 0}<span class="interval">today</span>{/if}
</div>
{#if stocks.length > 0}
<div class="list">
{#each stocks as stock (stock.id)}
<div class="row">
<span class="label">{stock.label}</span>
{#if stock.lastPrice !== null}
<span class="price" class:up={(stock.lastChangePercent ?? 0) >= 0} class:down={(stock.lastChangePercent ?? 0) < 0}>
{stock.lastPrice.toFixed(2)}
{#if stock.lastChangePercent !== null}
<span class="change">{stock.lastChangePercent >= 0 ? '+' : ''}{stock.lastChangePercent.toFixed(2)}%</span>
{/if}
</span>
{:else}
<span class="price"></span>
{/if}
</div>
{/each}
</div>
{:else}
<p class="empty">No tickers configured</p>
{/if}
</div>
<style>
.widget {
background: var(--surface-1);
border-radius: 12px;
padding: 14px;
}
.head {
display: flex;
align-items: baseline;
justify-content: space-between;
}
.title {
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
}
.interval {
font-size: 10px;
color: var(--text-muted);
}
.list {
display: flex;
flex-direction: column;
margin-top: 8px;
}
.row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
padding: 6px 0;
border-top: 0.5px solid var(--border);
}
.row:first-child {
border-top: none;
}
.label {
font-size: 13px;
}
.price {
font-size: 12px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
color: var(--text-secondary);
}
.price.up .change {
color: var(--text-success);
}
.price.down .change {
color: var(--text-danger);
}
.change {
margin-left: 4px;
}
.empty {
font-size: 12px;
color: var(--text-muted);
margin: 8px 0 0;
}
</style>
@@ -0,0 +1,75 @@
<script lang="ts">
import type { Weather } from '$lib/types';
let { weather }: { weather: Weather } = $props();
</script>
<a class="widget" href="/weather">
<span class="title">Weather{weather.locationName ? ` - ${weather.locationName}` : ''}</span>
{#if weather.current}
<div class="body">
<span class="icon">{weather.current.icon}</span>
<div class="readout">
<span class="temp">{Math.round(weather.current.temp)}°{weather.unit === 'celsius' ? 'C' : 'F'}</span>
<span class="condition">{weather.current.conditionText}</span>
<span class="feels-like">Feels like {Math.round(weather.current.feelsLike)}°</span>
</div>
</div>
{:else}
<p class="empty">Not configured yet</p>
{/if}
</a>
<style>
.widget {
display: block;
background: var(--surface-1);
border-radius: 12px;
padding: 14px;
color: inherit;
}
.widget:hover {
text-decoration: none;
background: var(--surface-2);
}
.title {
display: block;
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.body {
display: flex;
align-items: center;
gap: 10px;
margin-top: 8px;
}
.icon {
font-size: 34px;
line-height: 1;
}
.readout {
display: flex;
flex-direction: column;
}
.temp {
font-size: 22px;
font-weight: 500;
}
.condition {
font-size: 12px;
color: var(--text-secondary);
}
.feels-like {
font-size: 11px;
color: var(--text-muted);
}
.empty {
font-size: 12px;
color: var(--text-muted);
margin: 8px 0 0;
}
</style>
+65
View File
@@ -89,4 +89,69 @@ export interface Category {
priorityRank: number;
isDefault: boolean;
isPrivate: boolean;
isSpillover: boolean;
}
export interface WeatherHourEntry {
time: string;
temp: number;
conditionText: string;
icon: string;
}
export interface WeatherDayEntry {
date: string;
tempMax: number;
tempMin: number;
conditionText: string;
icon: string;
}
export interface WeatherCurrentConditions {
temp: number;
feelsLike: number;
conditionText: string;
icon: string;
humidity: number;
precipitationChance: number;
windSpeed: number;
windDirection: string;
pressure: number;
sunrise: string;
sunset: string;
}
export interface WeatherAlert {
id: string;
event: string;
headline: string;
severity: string;
expires: string;
}
export interface Weather {
locationName: string | null;
unit: 'celsius' | 'fahrenheit';
windUnit: 'mph' | 'kph';
pressureUnit: 'inHg' | 'hPa';
current: WeatherCurrentConditions | null;
hourly: WeatherHourEntry[];
daily: WeatherDayEntry[];
alerts: WeatherAlert[];
updatedAt: string | null;
}
export interface StockTicker {
id: string;
label: string;
symbol: string;
lastPrice: number | null;
lastChangePercent: number | null;
}
export interface Bookmark {
id: string;
name: string;
url: string;
isPrivate: boolean;
}
+36 -5
View File
@@ -4,6 +4,7 @@
import { invalidateAll } from '$app/navigation';
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
import PrivateAccessModal from '$lib/components/PrivateAccessModal.svelte';
import Sidebar from '$lib/components/sidebar/Sidebar.svelte';
import { logoutPrivateAccess } from '$lib/privateAccess';
import { slugify } from '$lib/format';
import type { LayoutData } from './$types';
@@ -12,6 +13,11 @@
let showLoginModal = $state(false);
// Admin pages already use full page width for their own tab UI — the sidebar's utility
// widgets don't belong there, unlike every reader-facing route (home, category,
// article, event, more, weather).
const showSidebar = $derived(!$page.url.pathname.startsWith('/admin'));
async function handleLockClick() {
if (data.privateAccess.authenticated) {
await logoutPrivateAccess();
@@ -29,18 +35,24 @@
// "Top stories" is a real Category row (it drives synthesis queue priority) but
// isn't itself a filterable category — it always means "everything, chronological",
// i.e. the homepage. Every other admin-defined category gets its own /category/:slug
// page. See MergeTab's category priority list for where these are managed.
// page, unless it's flagged "spillover" (see MergeTab.svelte's category priority
// list) — those collapse into a single trailing "More »" tab instead, so the nav
// doesn't get too wide or wrap once there are more than a handful of categories.
//
// A tracked event is a displayed category too, just backed by a source+keyword
// filter instead of manual per-source category checkboxes, and periodically
// AI-recapped — see EventsTab.svelte. Active ones get their own /event/:id tab,
// appended after the regular categories.
const primaryCategories = $derived(data.categories.filter((c) => !c.isSpillover));
const spilloverCategories = $derived(data.categories.filter((c) => c.isSpillover));
const navItems = $derived([
...data.categories.map((cat) => ({
...primaryCategories.map((cat) => ({
label: cat.name,
href: cat.name.toLowerCase() === 'top stories' ? '/' : `/category/${slugify(cat.name)}`
})),
...data.events.map((event) => ({ label: event.name, href: `/event/${event.id}` }))
...data.events.map((event) => ({ label: event.name, href: `/event/${event.id}` })),
...(spilloverCategories.length > 0 ? [{ label: 'More »', href: '/more' }] : [])
]);
function isActive(href: string): boolean {
@@ -100,8 +112,13 @@
<PrivateAccessModal onClose={() => (showLoginModal = false)} onSuccess={handleLoginSuccess} />
{/if}
<main class="page">
{@render children()}
<main class="page" class:with-sidebar={showSidebar}>
<div class="main-col">
{@render children()}
</div>
{#if showSidebar}
<Sidebar weather={data.weather} stocks={data.stocks} bookmarks={data.bookmarks} />
{/if}
</main>
<style>
@@ -184,4 +201,18 @@
background: var(--surface-1);
color: var(--text-primary);
}
.page.with-sidebar {
display: grid;
grid-template-columns: minmax(0, 1fr) 300px;
gap: 32px;
align-items: start;
}
.main-col {
min-width: 0; /* keeps wide content, e.g. tweet media grids, from forcing the track wider */
}
@media (max-width: 900px) {
.page.with-sidebar {
grid-template-columns: 1fr;
}
}
</style>
+15 -4
View File
@@ -1,14 +1,25 @@
import type { LayoutLoad } from './$types';
import { getCategories, getEvents } from '$lib/api';
import { getCategories, getEvents, getWeather, getStocks, getBookmarks } from '$lib/api';
import { getPrivateAccessStatus } from '$lib/privateAccess';
export const load: LayoutLoad = async ({ fetch, data }) => {
const [categories, events, privateAccess] = await Promise.all([
const [categories, events, privateAccess, weather, stocks, bookmarks] = await Promise.all([
getCategories(fetch),
getEvents(fetch),
getPrivateAccessStatus(fetch)
getPrivateAccessStatus(fetch),
getWeather(fetch),
getStocks(fetch),
getBookmarks(fetch)
]);
// Tracked events are a displayed category like any other (see MergeTab/EventsTab) —
// only active ones show up as browsable, same as a paused/disabled category wouldn't.
return { ...data, categories, events: events.filter((e) => e.active), privateAccess };
return {
...data,
categories,
events: events.filter((e) => e.active),
privateAccess,
weather,
stocks,
bookmarks
};
};
@@ -5,6 +5,9 @@
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 WeatherTab from '$lib/components/admin/WeatherTab.svelte';
import StocksTab from '$lib/components/admin/StocksTab.svelte';
import BookmarksTab from '$lib/components/admin/BookmarksTab.svelte';
import ConnectionsTab from '$lib/components/admin/ConnectionsTab.svelte';
import LogsTab from '$lib/components/admin/LogsTab.svelte';
@@ -16,6 +19,9 @@
{ id: 'models', label: 'Models' },
{ id: 'retention', label: 'Retention' },
{ id: 'events', label: 'Tracked events' },
{ id: 'weather', label: 'Weather' },
{ id: 'stocks', label: 'Stocks' },
{ id: 'bookmarks', label: 'Bookmarks' },
{ id: 'connections', label: 'Connections' },
{ id: 'logs', label: 'Logs' }
];
@@ -45,6 +51,12 @@
<RetentionTab settings={data.settings} />
{:else if active === 'events'}
<EventsTab events={data.events} sources={data.sources} />
{:else if active === 'weather'}
<WeatherTab settings={data.settings} />
{:else if active === 'stocks'}
<StocksTab tickers={data.stockTickers} />
{:else if active === 'bookmarks'}
<BookmarksTab bookmarks={data.bookmarks} />
{:else if active === 'connections'}
<ConnectionsTab settings={data.settings} aiStatus={data.aiStatus} telegramStatus={data.telegramStatus} />
{:else if active === 'logs'}
+16 -4
View File
@@ -1,17 +1,29 @@
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
import { getSettings, getSources, getEvents, getModels, getAiStatus, getTelegramStatus, getLogs } from '$lib/adminApi';
import {
getSettings,
getSources,
getEvents,
getModels,
getAiStatus,
getTelegramStatus,
getLogs,
getStockTickers,
getAdminBookmarks
} from '$lib/adminApi';
import type { ModelCatalog, AiStatus, TelegramStatus } 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([
const [settings, sources, events, logs, stockTickers, bookmarks] = await Promise.all([
getSettings(fetch),
getSources(fetch),
getEvents(fetch),
getLogs({}, fetch)
getLogs({}, fetch),
getStockTickers(fetch),
getAdminBookmarks(fetch)
]);
// The AI service (Ollama) may not be running yet — that shouldn't take down the
@@ -28,7 +40,7 @@ export const load: PageLoad = async ({ fetch }) => {
() => ({ credentialsConfigured: false, connected: false, phone: null })
);
return { settings, sources, events, models, aiStatus, telegramStatus, logs };
return { settings, sources, events, models, aiStatus, telegramStatus, logs, stockTickers, bookmarks };
} catch (err) {
if ((err as { status?: number }).status === 401) {
throw redirect(302, '/admin/login?redirectTo=/admin/settings');
+60
View File
@@ -0,0 +1,60 @@
<script lang="ts">
import type { PageData } from './$types';
import { slugify } from '$lib/format';
import ArticleListRow from '$lib/components/ArticleListRow.svelte';
let { data }: { data: PageData } = $props();
</script>
<div class="head">
<span class="title">More</span>
</div>
<div class="sections">
{#each data.sections as section (section.category.id)}
{#if section.articles.length > 0}
<section class="cat-section">
<a class="cat-name" href={`/category/${slugify(section.category.name)}`}>{section.category.name}</a>
<div class="list">
{#each section.articles as article (article.id)}
<ArticleListRow {article} />
{/each}
</div>
</section>
{/if}
{/each}
</div>
<style>
.head {
margin: 24px 0 8px;
}
.title {
font-family: var(--font-voice);
font-size: 26px;
font-weight: 500;
}
.sections {
display: flex;
flex-direction: column;
gap: 22px;
margin-top: 12px;
}
.cat-section {
border-bottom: 0.5px solid var(--border);
padding-bottom: 18px;
}
.cat-name {
display: inline-block;
font-size: 16px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 10px;
}
.cat-name:hover {
color: var(--text-accent);
}
.list {
max-width: 720px;
}
</style>
+22
View File
@@ -0,0 +1,22 @@
import type { PageLoad } from './$types';
import { getFeed } from '$lib/api';
const PREVIEW_COUNT = 5;
// The "More »" nav tab (see +layout.svelte) leads here — one section per spillover
// category (see MergeTab.svelte's "More" toggle) with its few newest articles, the
// category name itself linking through to the full /category/:slug page. Mirrors
// category/[name]/+page.ts's parent()-based category access rather than a second fetch.
export const load: PageLoad = async ({ fetch, parent }) => {
const { categories } = await parent();
const spillover = categories.filter((c) => c.isSpillover);
const sections = await Promise.all(
spillover.map(async (category) => ({
category,
articles: await getFeed({ category: category.name, limit: PREVIEW_COUNT }, fetch)
}))
);
return { sections };
};
+291
View File
@@ -0,0 +1,291 @@
<script lang="ts">
import type { PageData } from './$types';
import { timeAgo } from '$lib/format';
let { data }: { data: PageData } = $props();
const weather = $derived(data.weather);
const unitLabel = $derived(weather.unit === 'celsius' ? 'C' : 'F');
</script>
<div class="head">
<span class="title">Weather</span>
{#if weather.locationName}
<span class="location">{weather.locationName}</span>
{/if}
</div>
{#if !weather.current}
<p class="empty">Not configured yet — set a location in the admin panel's Weather tab.</p>
{:else}
<div class="current">
<span class="icon">{weather.current.icon}</span>
<div class="readout">
<div class="temp-row">
<span class="temp">{Math.round(weather.current.temp)}°{unitLabel}</span>
<span class="feels-like">Feels like {Math.round(weather.current.feelsLike)}°</span>
</div>
<span class="condition">{weather.current.conditionText}</span>
<span class="updated">Updated {timeAgo(weather.updatedAt ?? '')}</span>
</div>
</div>
<div class="conditions-grid">
<div class="stat">
<span class="stat-label">Humidity</span>
<span class="stat-value">{weather.current.humidity}%</span>
</div>
<div class="stat">
<span class="stat-label">Precip. chance</span>
<span class="stat-value">{weather.current.precipitationChance}%</span>
</div>
<div class="stat">
<span class="stat-label">Wind</span>
<span class="stat-value">{weather.current.windDirection} {Math.round(weather.current.windSpeed)} {weather.windUnit}</span>
</div>
<div class="stat">
<span class="stat-label">Pressure</span>
<span class="stat-value">{weather.current.pressure} {weather.pressureUnit}</span>
</div>
<div class="stat">
<span class="stat-label">Sunrise</span>
<span class="stat-value">{new Date(weather.current.sunrise).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}</span>
</div>
<div class="stat">
<span class="stat-label">Sunset</span>
<span class="stat-value">{new Date(weather.current.sunset).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}</span>
</div>
</div>
{#if weather.alerts.length > 0}
<div class="section">
<span class="section-title">Weather alerts</span>
<div class="alerts-list">
{#each weather.alerts as alert (alert.id)}
<div class="alert-row severity-{alert.severity.toLowerCase()}">
<div class="alert-head">
<span class="alert-event">{alert.event}</span>
<span class="alert-expires">Until {new Date(alert.expires).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}</span>
</div>
<p class="alert-headline">{alert.headline}</p>
</div>
{/each}
</div>
</div>
{/if}
<div class="section">
<span class="section-title">Hourly</span>
<div class="hourly-strip">
{#each weather.hourly as hour (hour.time)}
<div class="hour-col">
<span class="hour-time">{new Date(hour.time).toLocaleTimeString([], { hour: 'numeric' })}</span>
<span class="hour-icon">{hour.icon}</span>
<span class="hour-temp">{Math.round(hour.temp)}°</span>
</div>
{/each}
</div>
</div>
<div class="section">
<span class="section-title">7-day forecast</span>
<div class="daily-list">
{#each weather.daily as day (day.date)}
<div class="day-row">
<span class="day-name">{new Date(day.date).toLocaleDateString([], { weekday: 'short' })}</span>
<span class="day-icon">{day.icon}</span>
<span class="day-condition">{day.conditionText}</span>
<span class="day-range">{Math.round(day.tempMax)}° / {Math.round(day.tempMin)}°</span>
</div>
{/each}
</div>
</div>
{/if}
<style>
.head {
display: flex;
align-items: baseline;
gap: 12px;
margin: 24px 0 8px;
}
.title {
font-family: var(--font-voice);
font-size: 26px;
font-weight: 500;
}
.location {
font-size: 14px;
color: var(--text-muted);
}
.empty {
font-size: 13px;
color: var(--text-muted);
}
.current {
display: flex;
align-items: center;
gap: 16px;
margin: 20px 0 28px;
}
.icon {
font-size: 64px;
line-height: 1;
}
.readout {
display: flex;
flex-direction: column;
}
.temp-row {
display: flex;
align-items: baseline;
gap: 10px;
}
.temp {
font-size: 40px;
font-weight: 500;
}
.feels-like {
font-size: 13px;
color: var(--text-muted);
}
.condition {
font-size: 15px;
color: var(--text-secondary);
}
.updated {
font-size: 11px;
color: var(--text-muted);
margin-top: 4px;
}
.conditions-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
gap: 16px;
max-width: 640px;
margin-bottom: 28px;
padding: 16px;
background: var(--surface-1);
border-radius: 12px;
}
.stat {
display: flex;
flex-direction: column;
gap: 2px;
}
.stat-label {
font-size: 11px;
color: var(--text-muted);
}
.stat-value {
font-size: 15px;
font-weight: 500;
}
.section {
margin-bottom: 28px;
}
.section-title {
display: block;
font-size: 14px;
font-weight: 500;
margin-bottom: 12px;
}
.alerts-list {
display: flex;
flex-direction: column;
gap: 10px;
max-width: 640px;
}
.alert-row {
border-left: 3px solid var(--text-muted);
background: var(--surface-1);
border-radius: 0 var(--radius) var(--radius) 0;
padding: 10px 14px;
}
.alert-row.severity-extreme,
.alert-row.severity-severe {
border-left-color: var(--text-danger);
}
.alert-row.severity-moderate {
border-left-color: var(--border-accent);
}
.alert-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
}
.alert-event {
font-size: 13px;
font-weight: 500;
}
.alert-expires {
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
}
.alert-headline {
font-size: 12px;
color: var(--text-secondary);
margin: 4px 0 0;
}
.hourly-strip {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 14px 8px;
}
.hour-col {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.hour-time {
font-size: 11px;
color: var(--text-muted);
}
.hour-icon {
font-size: 22px;
}
.hour-temp {
font-size: 13px;
}
@media (max-width: 640px) {
.hourly-strip {
grid-template-columns: repeat(6, 1fr);
}
}
.daily-list {
display: flex;
flex-direction: column;
max-width: 480px;
}
.day-row {
display: flex;
align-items: center;
gap: 14px;
padding: 10px 0;
border-top: 0.5px solid var(--border);
}
.day-row:first-child {
border-top: none;
}
.day-name {
font-size: 13px;
font-weight: 500;
width: 40px;
}
.day-icon {
font-size: 20px;
width: 28px;
}
.day-condition {
font-size: 13px;
color: var(--text-secondary);
flex: 1;
}
.day-range {
font-size: 13px;
color: var(--text-muted);
white-space: nowrap;
}
</style>
+8
View File
@@ -0,0 +1,8 @@
import type { PageLoad } from './$types';
// The sidebar's WeatherWidget (see +layout.svelte/Sidebar.svelte) already fetches this
// same data via the root layout load — no need for a second fetch here.
export const load: PageLoad = async ({ parent }) => {
const { weather } = await parent();
return { weather };
};