Merge pull request #16 from Salastil/development
More category, Weather, Stocks and Bookmark panels.
This commit is contained in:
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}` +
|
||||
`¤t=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
|
||||
}));
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
};
|
||||
Reference in New Issue
Block a user