diff --git a/src/app/api/stats/route.ts b/src/app/api/stats/route.ts index fbbdc2d..b311664 100644 --- a/src/app/api/stats/route.ts +++ b/src/app/api/stats/route.ts @@ -1,6 +1,10 @@ import { getStats } from "@/lib/statsBuilder"; +import { isConfigured } from "@/lib/settings"; export async function GET(req: Request) { + if (!isConfigured()) { + return Response.json({ error: "not_configured" }, { status: 428 }); + } const force = new URL(req.url).searchParams.has("force"); try { return Response.json(await getStats(force)); diff --git a/src/app/page.tsx b/src/app/page.tsx index 0b3009f..99d33b0 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -8,6 +8,7 @@ import LeaderboardTable from "@/components/LeaderboardTable"; import AlertsPanel from "@/components/AlertsPanel"; import RefreshButton from "@/components/RefreshButton"; import SettingsModal from "@/components/SettingsModal"; +import SetupScreen from "@/components/SetupScreen"; type Tab = "leaderboard" | "alerts"; const LS_KEY = "oversnitch_stats"; @@ -19,6 +20,7 @@ export default function Page() { const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(null); const [settingsOpen, setSettingsOpen] = useState(false); + const [needsSetup, setNeedsSetup] = useState(false); const didInit = useRef(false); const load = useCallback(async (force = false) => { @@ -32,8 +34,15 @@ export default function Page() { try { const res = await fetch(force ? "/api/stats?force=1" : "/api/stats"); const json = await res.json(); + if (res.status === 428 || json?.error === "not_configured") { + setNeedsSetup(true); + setData(null); + try { localStorage.removeItem(LS_KEY); } catch {} + return; + } if (!res.ok) throw new Error(json.error ?? `HTTP ${res.status}`); const stats = json as DashboardStats; + setNeedsSetup(false); setData(stats); try { localStorage.setItem(LS_KEY, JSON.stringify(stats)); } catch {} } catch (e) { @@ -67,6 +76,17 @@ export default function Page() { const openAlertCount = data?.summary.openAlertCount ?? 0; const generatedAt = data?.generatedAt ?? null; + if (needsSetup) { + return ( + { + setNeedsSetup(false); + load(true); + }} + /> + ); + } + return (
diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index c87892c..ca0aac3 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -2,21 +2,8 @@ import { useState, useEffect, useRef } from "react"; import { AppSettings, ServiceConfig, DiscordConfig } from "@/lib/settings"; - -// ── Icons ───────────────────────────────────────────────────────────────────── - -function EyeIcon({ open }: { open: boolean }) { - return open ? ( - - - - - ) : ( - - - - ); -} +import ServiceSection from "@/components/settings/ServiceSection"; +import DiscordSection from "@/components/settings/DiscordSection"; function XIcon() { return ( @@ -26,219 +13,6 @@ function XIcon() { ); } -// ── Per-service section ─────────────────────────────────────────────────────── - -type ServiceKey = "radarr" | "sonarr" | "seerr" | "tautulli"; - -interface SectionProps { - id: ServiceKey; - label: string; - placeholder: string; - optional?: boolean; - config: ServiceConfig; - onChange: (patch: Partial) => void; -} - -function ServiceSection({ id, label, placeholder, optional, config, onChange }: SectionProps) { - const [showKey, setShowKey] = useState(false); - const [testing, setTesting] = useState(false); - const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null); - - async function handleTest() { - setTesting(true); - setTestResult(null); - try { - const res = await fetch("/api/settings/test", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ service: id, url: config.url.trim(), apiKey: config.apiKey.trim() }), - }); - const data = await res.json() as { ok: boolean; message: string }; - setTestResult(data); - } catch { - setTestResult({ ok: false, message: "Network error" }); - } finally { - setTesting(false); - } - } - - // Clear test result when inputs change - function handleUrlChange(v: string) { - setTestResult(null); - onChange({ url: v }); - } - function handleKeyChange(v: string) { - setTestResult(null); - onChange({ apiKey: v }); - } - - const canTest = config.url.trim().length > 0 && config.apiKey.trim().length > 0; - - return ( -
-
-

{label}

- {optional && ( - optional - )} -
- -
- {/* URL */} -
- - handleUrlChange(e.target.value)} - placeholder={placeholder} - spellCheck={false} - autoComplete="off" - className="flex-1 rounded-lg bg-slate-800 border border-slate-700/60 focus:border-slate-500 text-sm text-white placeholder-slate-600 px-3 py-2 focus:outline-none transition-colors font-mono" - /> -
- - {/* API Key */} -
- -
-
- handleKeyChange(e.target.value)} - placeholder="••••••••••••••••••••••••••••••••" - spellCheck={false} - autoComplete="off" - className="w-full rounded-lg bg-slate-800 border border-slate-700/60 focus:border-slate-500 text-sm text-white placeholder-slate-700 px-3 py-2 pr-9 focus:outline-none transition-colors font-mono" - /> - -
- -
-
-
- - {/* Test result */} - {testResult && ( -
- {testResult.ok ? ( - - - - ) : ( - - - - )} - {testResult.message} -
- )} -
- ); -} - -// ── Discord section (webhook URL only, no API key) ──────────────────────────── - -interface DiscordSectionProps { - config: DiscordConfig; - onChange: (patch: Partial) => void; -} - -function DiscordSection({ config, onChange }: DiscordSectionProps) { - const [testing, setTesting] = useState(false); - const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null); - - async function handleTest() { - setTesting(true); - setTestResult(null); - try { - const res = await fetch("/api/settings/test", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ service: "discord", url: config.webhookUrl.trim(), apiKey: "" }), - }); - const data = await res.json() as { ok: boolean; message: string }; - setTestResult(data); - } catch { - setTestResult({ ok: false, message: "Network error" }); - } finally { - setTesting(false); - } - } - - function handleChange(v: string) { - setTestResult(null); - onChange({ webhookUrl: v }); - } - - const canTest = config.webhookUrl.trim().length > 0; - - return ( -
-
-

Discord

- optional -
- -
- -
- handleChange(e.target.value)} - placeholder="https://discord.com/api/webhooks/…" - spellCheck={false} - autoComplete="off" - className="flex-1 rounded-lg bg-slate-800 border border-slate-700/60 focus:border-slate-500 text-sm text-white placeholder-slate-600 px-3 py-2 focus:outline-none transition-colors font-mono" - /> - -
-
- - {testResult && ( -
- {testResult.ok ? ( - - - - ) : ( - - - - )} - {testResult.message} -
- )} -
- ); -} - -// ── Modal ───────────────────────────────────────────────────────────────────── - interface Props { open: boolean; onClose: () => void; @@ -261,7 +35,6 @@ export default function SettingsModal({ open, onClose, onSaved }: Props) { const [saveResult, setSaveResult] = useState<"saved" | "error" | null>(null); const panelRef = useRef(null); - // Load current settings when modal opens useEffect(() => { if (!open) return; setSaveResult(null); @@ -273,7 +46,6 @@ export default function SettingsModal({ open, onClose, onSaved }: Props) { .finally(() => setLoading(false)); }, [open]); - // Close on Escape useEffect(() => { if (!open) return; function onKey(e: KeyboardEvent) { @@ -283,7 +55,6 @@ export default function SettingsModal({ open, onClose, onSaved }: Props) { return () => document.removeEventListener("keydown", onKey); }, [open, onClose]); - // Close on backdrop click function handleBackdrop(e: React.MouseEvent) { if (panelRef.current && !panelRef.current.contains(e.target as Node)) { onClose(); @@ -336,7 +107,6 @@ export default function SettingsModal({ open, onClose, onSaved }: Props) { ref={panelRef} className="relative w-full max-w-lg rounded-2xl bg-slate-900 border border-slate-700/60 shadow-2xl flex flex-col max-h-[90vh]" > - {/* Header */}

Settings

- {/* Body */}
{loading ? (
@@ -396,7 +165,6 @@ export default function SettingsModal({ open, onClose, onSaved }: Props) { )}
- {/* Footer */}
{saveResult === "saved" && ( diff --git a/src/components/SetupScreen.tsx b/src/components/SetupScreen.tsx new file mode 100644 index 0000000..09aacfd --- /dev/null +++ b/src/components/SetupScreen.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { useState } from "react"; +import { AppSettings, ServiceConfig, DiscordConfig } from "@/lib/settings"; +import ServiceSection from "@/components/settings/ServiceSection"; +import DiscordSection from "@/components/settings/DiscordSection"; + +const EMPTY_CONFIG: ServiceConfig = { url: "", apiKey: "" }; +const EMPTY_SETTINGS: AppSettings = { + radarr: EMPTY_CONFIG, + sonarr: EMPTY_CONFIG, + seerr: EMPTY_CONFIG, + tautulli: EMPTY_CONFIG, + discord: { webhookUrl: "" }, +}; + +function filled(c: ServiceConfig) { + return c.url.trim() !== "" && c.apiKey.trim() !== ""; +} + +export default function SetupScreen({ onComplete }: { onComplete: () => void }) { + const [settings, setSettings] = useState(EMPTY_SETTINGS); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + function patch(service: "radarr" | "sonarr" | "seerr" | "tautulli", partial: Partial) { + setError(null); + setSettings((prev) => ({ ...prev, [service]: { ...prev[service], ...partial } })); + } + + function patchDiscord(partial: Partial) { + setError(null); + setSettings((prev) => ({ ...prev, discord: { ...prev.discord, ...partial } })); + } + + const canComplete = filled(settings.radarr) && filled(settings.sonarr) && filled(settings.seerr); + + async function handleSave() { + setSaving(true); + setError(null); + try { + const res = await fetch("/api/settings", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(settings), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + onComplete(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setSaving(false); + } + } + + return ( +
+
+
+

+ Welcome to OverSnitch +

+

+ Connect your services to get started. You can change any of these later in Settings. +

+
+ +
+ patch("radarr", p)} + /> + patch("sonarr", p)} + /> + patch("seerr", p)} + /> + +
+ Optional +
+ + patch("tautulli", p)} + /> + patchDiscord(p)} + /> +
+ +
+ {error && ( +
Save failed: {error}
+ )} + + {!canComplete && ( +

+ Fill in URL and API key for Radarr, Sonarr, and Overseerr to continue. +

+ )} +
+
+
+ ); +} diff --git a/src/components/settings/DiscordSection.tsx b/src/components/settings/DiscordSection.tsx new file mode 100644 index 0000000..f32c5f6 --- /dev/null +++ b/src/components/settings/DiscordSection.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { useState } from "react"; +import { DiscordConfig } from "@/lib/settings"; + +interface Props { + config: DiscordConfig; + onChange: (patch: Partial) => void; +} + +export default function DiscordSection({ config, onChange }: Props) { + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null); + + async function handleTest() { + setTesting(true); + setTestResult(null); + try { + const res = await fetch("/api/settings/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ service: "discord", url: config.webhookUrl.trim(), apiKey: "" }), + }); + const data = await res.json() as { ok: boolean; message: string }; + setTestResult(data); + } catch { + setTestResult({ ok: false, message: "Network error" }); + } finally { + setTesting(false); + } + } + + function handleChange(v: string) { + setTestResult(null); + onChange({ webhookUrl: v }); + } + + const canTest = config.webhookUrl.trim().length > 0; + + return ( +
+
+

Discord

+ optional +
+ +
+ +
+ handleChange(e.target.value)} + placeholder="https://discord.com/api/webhooks/…" + spellCheck={false} + autoComplete="off" + className="flex-1 rounded-lg bg-slate-800 border border-slate-700/60 focus:border-slate-500 text-sm text-white placeholder-slate-600 px-3 py-2 focus:outline-none transition-colors font-mono" + /> + +
+
+ + {testResult && ( +
+ {testResult.ok ? ( + + + + ) : ( + + + + )} + {testResult.message} +
+ )} +
+ ); +} diff --git a/src/components/settings/ServiceSection.tsx b/src/components/settings/ServiceSection.tsx new file mode 100644 index 0000000..1ee2901 --- /dev/null +++ b/src/components/settings/ServiceSection.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { useState } from "react"; +import { ServiceConfig } from "@/lib/settings"; + +function EyeIcon({ open }: { open: boolean }) { + return open ? ( + + + + + ) : ( + + + + ); +} + +export type ServiceKey = "radarr" | "sonarr" | "seerr" | "tautulli"; + +interface Props { + id: ServiceKey; + label: string; + placeholder: string; + optional?: boolean; + config: ServiceConfig; + onChange: (patch: Partial) => void; +} + +export default function ServiceSection({ id, label, placeholder, optional, config, onChange }: Props) { + const [showKey, setShowKey] = useState(false); + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null); + + async function handleTest() { + setTesting(true); + setTestResult(null); + try { + const res = await fetch("/api/settings/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ service: id, url: config.url.trim(), apiKey: config.apiKey.trim() }), + }); + const data = await res.json() as { ok: boolean; message: string }; + setTestResult(data); + } catch { + setTestResult({ ok: false, message: "Network error" }); + } finally { + setTesting(false); + } + } + + function handleUrlChange(v: string) { + setTestResult(null); + onChange({ url: v }); + } + function handleKeyChange(v: string) { + setTestResult(null); + onChange({ apiKey: v }); + } + + const canTest = config.url.trim().length > 0 && config.apiKey.trim().length > 0; + + return ( +
+
+

{label}

+ {optional && ( + optional + )} +
+ +
+
+ + handleUrlChange(e.target.value)} + placeholder={placeholder} + spellCheck={false} + autoComplete="off" + className="flex-1 rounded-lg bg-slate-800 border border-slate-700/60 focus:border-slate-500 text-sm text-white placeholder-slate-600 px-3 py-2 focus:outline-none transition-colors font-mono" + /> +
+ +
+ +
+
+ handleKeyChange(e.target.value)} + placeholder="••••••••••••••••••••••••••••••••" + spellCheck={false} + autoComplete="off" + className="w-full rounded-lg bg-slate-800 border border-slate-700/60 focus:border-slate-500 text-sm text-white placeholder-slate-700 px-3 py-2 pr-9 focus:outline-none transition-colors font-mono" + /> + +
+ +
+
+
+ + {testResult && ( +
+ {testResult.ok ? ( + + + + ) : ( + + + + )} + {testResult.message} +
+ )} +
+ ); +} diff --git a/src/lib/settings.ts b/src/lib/settings.ts index d29691e..35f2453 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -71,6 +71,12 @@ export function getSettings(): AppSettings { }; } +/** Returns true iff the three services required for the dashboard to function (Radarr, Sonarr, Overseerr/Jellyseerr) have both a URL and API key. */ +export function isConfigured(s: AppSettings = getSettings()): boolean { + const filled = (c: ServiceConfig) => c.url.trim() !== "" && c.apiKey.trim() !== ""; + return filled(s.radarr) && filled(s.sonarr) && filled(s.seerr); +} + /** Saves the provided settings to disk and returns the merged result. */ export function saveSettings(settings: AppSettings): AppSettings { if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true }); diff --git a/src/lib/statsBuilder.ts b/src/lib/statsBuilder.ts index 2c8e6be..bf6cfc0 100644 --- a/src/lib/statsBuilder.ts +++ b/src/lib/statsBuilder.ts @@ -11,6 +11,7 @@ import { buildSonarrMap } from "@/lib/sonarr"; import { fetchAllUsers, fetchUserRequests } from "@/lib/overseerr"; import { buildTautulliMap, lookupTautulliUser, fetchUserWatchHistory } from "@/lib/tautulli"; import { computeStats } from "@/lib/aggregate"; +import { isConfigured } from "@/lib/settings"; import { DashboardStats, MediaEntry, @@ -110,6 +111,7 @@ export async function getStats(force = false): Promise { async function poll() { if (refreshing) return; + if (!isConfigured()) return; refreshing = true; try { const stats = await buildStats();