"use client"; import { useEffect, useState } from "react"; import { useTheme } from "@/hooks/useTheme"; import { THEMES, MODES } from "@/lib/themes"; import { downloadExport } from "@/lib/export"; import { uploadImport } from "@/lib/import"; import type { SourceState } from "@/lib/types"; import type { Settings } from "@/lib/schemas/setting"; const cardStyle = { background: "var(--bg-card)", border: "1px solid var(--border)", borderRadius: 8, }; const inputStyle = { background: "var(--bg-elevated)", color: "var(--text)", border: "1px solid var(--border-strong)", borderRadius: 6, }; function Section({ title, children }: { title: string; children: React.ReactNode }) { return (

{title}

{children}
); } export function SettingsView() { const { theme, mode, setTheme, setMode } = useTheme(); const [settings, setSettings] = useState(null); const [sources, setSources] = useState([]); async function load() { const [s, src] = await Promise.all([ fetch("/api/settings").then((r) => r.json()), fetch("/api/sources").then((r) => r.json()), ]); setSettings(s.settings); setSources(src.sources); } useEffect(() => { void load(); }, []); async function saveSettings(patch: Partial) { const res = await fetch("/api/settings", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch), }); if (res.ok) setSettings((await res.json()).settings); } async function patchSource(id: string, patch: Record) { await fetch(`/api/sources/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch), }); await load(); } async function move(index: number, dir: -1 | 1) { const a = sources[index]; const b = sources[index + dir]; if (!a || !b) return; await Promise.all([ patchSource(a.source.id, { position: b.source.position }), patchSource(b.source.id, { position: a.source.position }), ]); } return (

Settings

{THEMES.map((t) => ( setTheme(t)}>{t} ))}
{MODES.map((m) => ( setMode(m)}>{m} ))}
{settings && (
saveSettings({ location: loc })} />
saveSettings({ location: { label: e.target.value, lat: settings.location?.lat ?? 0, lon: settings.location?.lon ?? 0, }, }) } /> saveSettings({ location: { label: settings.location?.label ?? "Home", lat: Number(e.target.value), lon: settings.location?.lon ?? 0, }, }) } /> saveSettings({ location: { label: settings.location?.label ?? "Home", lat: settings.location?.lat ?? 0, lon: Number(e.target.value), }, }) } />
{(["imperial", "metric"] as const).map((u) => ( saveSettings({ units: u })}> {u} ))}
)}
{sources.map((it, i) => ( patchSource(it.source.id, patch)} onUp={i > 0 ? () => move(i, -1) : undefined} onDown={i < sources.length - 1 ? () => move(i, 1) : undefined} /> ))}

Uncheck a card to hide it from the dashboard. Every source stays available — nothing is ever removed. Drag to reorder and resize cards from the dashboard’s “Edit layout” mode.

); } function SourceRow({ state, onPatch, onUp, onDown, }: { state: SourceState; onPatch: (patch: Record) => void; onUp?: () => void; onDown?: () => void; }) { const { source } = state; const [open, setOpen] = useState(false); const configurable = source.kind === "news" || source.kind === "feeds" || source.kind === "obsidian" || source.kind === "calendar" || source.kind === "sports" || source.kind === "links" || source.kind === "markets" || source.kind === "hackernews" || source.kind === "uptime"; return (
onPatch({ enabled: e.target.checked })} aria-label={`Enable ${source.label}`} /> {source.label} {state.hasModule ? (state.configured ? "ready" : "needs config") : "local"} onPatch({ refreshSeconds: s })} /> {configurable && ( )}
{open && configurable && }
); } // Refresh interval as a friendly value + unit (minutes / hours). The store still // holds raw refreshSeconds; we convert on load and on save. Floors at 15 min / 1 hr. type RefreshUnit = "min" | "hr"; function RefreshControl({ seconds, onChange, }: { seconds: number; onChange: (seconds: number) => void; }) { // A whole number of hours shows as hours; everything else as minutes. const derivedUnit: RefreshUnit = seconds % 3600 === 0 && seconds >= 3600 ? "hr" : "min"; const [unit, setUnit] = useState(derivedUnit); const floor = (u: RefreshUnit) => (u === "hr" ? 1 : 15); // Display the true stored value (never lie about it); only the floor below // applies when the user actually edits/commits a new interval. const toValue = (s: number, u: RefreshUnit) => Math.max(1, Math.round(u === "hr" ? s / 3600 : s / 60)); const toSeconds = (v: number, u: RefreshUnit) => Math.max(floor(u), Math.round(v || 0)) * (u === "hr" ? 3600 : 60); const [draft, setDraft] = useState(String(toValue(seconds, unit))); // Re-sync the draft whenever the stored value or unit changes externally. useEffect(() => setDraft(String(toValue(seconds, unit))), [seconds, unit]); function changeUnit(u: RefreshUnit) { setUnit(u); // keep the same real duration, re-expressed in the new unit onChange(toSeconds(toValue(seconds, u), u)); } return ( ); } function SourceConfig({ state, onPatch, }: { state: SourceState; onPatch: (patch: Record) => void; }) { const cfg = state.source.config as Record; if (state.source.kind === "news" || state.source.kind === "feeds") { const feeds = Array.isArray(cfg.feeds) ? (cfg.feeds as string[]).join("\n") : ""; return (