Add first-run setup screen when required services aren't configured
Build and Push / build (push) Successful in 1m9s
Build and Push / build (push) Successful in 1m9s
When Radarr, Sonarr, or Overseerr is missing a URL or API key, the stats API now returns 428 and the dashboard renders a full-page setup form instead of the empty shell + fetch-error UI. The form reuses the existing service/discord inputs (extracted out of the settings modal so both can share them), and the background poller skips silently until setup is complete. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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<string | null>(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 (
|
||||
<SetupScreen
|
||||
onComplete={() => {
|
||||
setNeedsSetup(false);
|
||||
load(true);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-6xl px-4 py-8 space-y-6">
|
||||
|
||||
|
||||
@@ -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 ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-4 w-4">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-4 w-4">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
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<ServiceConfig>) => 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 (
|
||||
<div className="space-y-3 pb-5 border-b border-slate-800 last:border-0 last:pb-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-slate-200">{label}</h3>
|
||||
{optional && (
|
||||
<span className="text-xs text-slate-600 font-normal">optional</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{/* URL */}
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="w-16 shrink-0 text-xs text-slate-500 text-right">URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.url}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* API Key */}
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="w-16 shrink-0 text-xs text-slate-500 text-right">API Key</label>
|
||||
<div className="flex flex-1 gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type={showKey ? "text" : "password"}
|
||||
value={config.apiKey}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowKey((s) => !s)}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-600 hover:text-slate-400 transition-colors"
|
||||
tabIndex={-1}
|
||||
aria-label={showKey ? "Hide API key" : "Show API key"}
|
||||
>
|
||||
<EyeIcon open={showKey} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTest}
|
||||
disabled={!canTest || testing}
|
||||
className="shrink-0 rounded-lg border border-slate-600 bg-slate-700/40 hover:bg-slate-700 disabled:opacity-40 disabled:cursor-not-allowed px-3 py-2 text-xs font-medium text-slate-300 hover:text-white transition-colors whitespace-nowrap"
|
||||
>
|
||||
{testing ? "Testing…" : "Test"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test result */}
|
||||
{testResult && (
|
||||
<div className={`ml-[76px] flex items-center gap-1.5 text-xs ${testResult.ok ? "text-green-400" : "text-red-400"}`}>
|
||||
{testResult.ok ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-3.5 w-3.5 shrink-0">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-3.5 w-3.5 shrink-0">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)}
|
||||
{testResult.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Discord section (webhook URL only, no API key) ────────────────────────────
|
||||
|
||||
interface DiscordSectionProps {
|
||||
config: DiscordConfig;
|
||||
onChange: (patch: Partial<DiscordConfig>) => 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 (
|
||||
<div className="space-y-3 pb-5 border-b border-slate-800 last:border-0 last:pb-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-slate-200">Discord</h3>
|
||||
<span className="text-xs text-slate-600 font-normal">optional</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="w-16 shrink-0 text-xs text-slate-500 text-right">Webhook</label>
|
||||
<div className="flex flex-1 gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={config.webhookUrl}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTest}
|
||||
disabled={!canTest || testing}
|
||||
className="shrink-0 rounded-lg border border-slate-600 bg-slate-700/40 hover:bg-slate-700 disabled:opacity-40 disabled:cursor-not-allowed px-3 py-2 text-xs font-medium text-slate-300 hover:text-white transition-colors whitespace-nowrap"
|
||||
>
|
||||
{testing ? "Sending…" : "Test"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<div className={`ml-[76px] flex items-center gap-1.5 text-xs ${testResult.ok ? "text-green-400" : "text-red-400"}`}>
|
||||
{testResult.ok ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-3.5 w-3.5 shrink-0">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-3.5 w-3.5 shrink-0">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)}
|
||||
{testResult.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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<HTMLDivElement>(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 */}
|
||||
<div className="flex items-center justify-between px-6 py-5 border-b border-slate-800 shrink-0">
|
||||
<h2 className="text-base font-semibold text-white">Settings</h2>
|
||||
<button
|
||||
@@ -348,7 +118,6 @@ export default function SettingsModal({ open, onClose, onSaved }: Props) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="overflow-y-auto flex-1 px-6 py-5">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
@@ -396,7 +165,6 @@ export default function SettingsModal({ open, onClose, onSaved }: Props) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-slate-800 shrink-0 gap-3">
|
||||
<div className="text-xs">
|
||||
{saveResult === "saved" && (
|
||||
|
||||
@@ -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<AppSettings>(EMPTY_SETTINGS);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function patch(service: "radarr" | "sonarr" | "seerr" | "tautulli", partial: Partial<ServiceConfig>) {
|
||||
setError(null);
|
||||
setSettings((prev) => ({ ...prev, [service]: { ...prev[service], ...partial } }));
|
||||
}
|
||||
|
||||
function patchDiscord(partial: Partial<DiscordConfig>) {
|
||||
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 (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-lg rounded-2xl bg-slate-900 border border-slate-700/60 shadow-2xl flex flex-col max-h-[90vh]">
|
||||
<div className="px-6 py-6 border-b border-slate-800 shrink-0">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
Welcome to Over<span className="text-yellow-400">Snitch</span>
|
||||
</h1>
|
||||
<p className="text-slate-400 text-sm mt-2">
|
||||
Connect your services to get started. You can change any of these later in Settings.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto flex-1 px-6 py-5 space-y-5">
|
||||
<ServiceSection
|
||||
id="radarr"
|
||||
label="Radarr"
|
||||
placeholder="http://radarr:7878"
|
||||
config={settings.radarr}
|
||||
onChange={(p) => patch("radarr", p)}
|
||||
/>
|
||||
<ServiceSection
|
||||
id="sonarr"
|
||||
label="Sonarr"
|
||||
placeholder="http://sonarr:8989"
|
||||
config={settings.sonarr}
|
||||
onChange={(p) => patch("sonarr", p)}
|
||||
/>
|
||||
<ServiceSection
|
||||
id="seerr"
|
||||
label="Overseerr / Jellyseerr"
|
||||
placeholder="http://overseerr:5055"
|
||||
config={settings.seerr}
|
||||
onChange={(p) => patch("seerr", p)}
|
||||
/>
|
||||
|
||||
<div className="pt-1 text-xs uppercase tracking-wider text-slate-600 font-semibold">
|
||||
Optional
|
||||
</div>
|
||||
|
||||
<ServiceSection
|
||||
id="tautulli"
|
||||
label="Tautulli"
|
||||
placeholder="http://tautulli:8181"
|
||||
optional
|
||||
config={settings.tautulli}
|
||||
onChange={(p) => patch("tautulli", p)}
|
||||
/>
|
||||
<DiscordSection
|
||||
config={settings.discord}
|
||||
onChange={(p) => patchDiscord(p)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-t border-slate-800 shrink-0 space-y-3">
|
||||
{error && (
|
||||
<div className="text-xs text-red-400">Save failed: {error}</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!canComplete || saving}
|
||||
className="w-full rounded-lg bg-yellow-500 hover:bg-yellow-400 disabled:opacity-40 disabled:cursor-not-allowed px-4 py-2.5 text-sm font-semibold text-black transition-colors"
|
||||
>
|
||||
{saving ? "Saving…" : "Complete Setup"}
|
||||
</button>
|
||||
{!canComplete && (
|
||||
<p className="text-xs text-slate-600 text-center">
|
||||
Fill in URL and API key for Radarr, Sonarr, and Overseerr to continue.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { DiscordConfig } from "@/lib/settings";
|
||||
|
||||
interface Props {
|
||||
config: DiscordConfig;
|
||||
onChange: (patch: Partial<DiscordConfig>) => 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 (
|
||||
<div className="space-y-3 pb-5 border-b border-slate-800 last:border-0 last:pb-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-slate-200">Discord</h3>
|
||||
<span className="text-xs text-slate-600 font-normal">optional</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="w-16 shrink-0 text-xs text-slate-500 text-right">Webhook</label>
|
||||
<div className="flex flex-1 gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={config.webhookUrl}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTest}
|
||||
disabled={!canTest || testing}
|
||||
className="shrink-0 rounded-lg border border-slate-600 bg-slate-700/40 hover:bg-slate-700 disabled:opacity-40 disabled:cursor-not-allowed px-3 py-2 text-xs font-medium text-slate-300 hover:text-white transition-colors whitespace-nowrap"
|
||||
>
|
||||
{testing ? "Sending…" : "Test"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<div className={`ml-[76px] flex items-center gap-1.5 text-xs ${testResult.ok ? "text-green-400" : "text-red-400"}`}>
|
||||
{testResult.ok ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-3.5 w-3.5 shrink-0">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-3.5 w-3.5 shrink-0">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)}
|
||||
{testResult.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ServiceConfig } from "@/lib/settings";
|
||||
|
||||
function EyeIcon({ open }: { open: boolean }) {
|
||||
return open ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-4 w-4">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-4 w-4">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export type ServiceKey = "radarr" | "sonarr" | "seerr" | "tautulli";
|
||||
|
||||
interface Props {
|
||||
id: ServiceKey;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
optional?: boolean;
|
||||
config: ServiceConfig;
|
||||
onChange: (patch: Partial<ServiceConfig>) => 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 (
|
||||
<div className="space-y-3 pb-5 border-b border-slate-800 last:border-0 last:pb-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-slate-200">{label}</h3>
|
||||
{optional && (
|
||||
<span className="text-xs text-slate-600 font-normal">optional</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="w-16 shrink-0 text-xs text-slate-500 text-right">URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.url}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="w-16 shrink-0 text-xs text-slate-500 text-right">API Key</label>
|
||||
<div className="flex flex-1 gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type={showKey ? "text" : "password"}
|
||||
value={config.apiKey}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowKey((s) => !s)}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-600 hover:text-slate-400 transition-colors"
|
||||
tabIndex={-1}
|
||||
aria-label={showKey ? "Hide API key" : "Show API key"}
|
||||
>
|
||||
<EyeIcon open={showKey} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTest}
|
||||
disabled={!canTest || testing}
|
||||
className="shrink-0 rounded-lg border border-slate-600 bg-slate-700/40 hover:bg-slate-700 disabled:opacity-40 disabled:cursor-not-allowed px-3 py-2 text-xs font-medium text-slate-300 hover:text-white transition-colors whitespace-nowrap"
|
||||
>
|
||||
{testing ? "Testing…" : "Test"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<div className={`ml-[76px] flex items-center gap-1.5 text-xs ${testResult.ok ? "text-green-400" : "text-red-400"}`}>
|
||||
{testResult.ok ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-3.5 w-3.5 shrink-0">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-3.5 w-3.5 shrink-0">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)}
|
||||
{testResult.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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<DashboardStats> {
|
||||
|
||||
async function poll() {
|
||||
if (refreshing) return;
|
||||
if (!isConfigured()) return;
|
||||
refreshing = true;
|
||||
try {
|
||||
const stats = await buildStats();
|
||||
|
||||
Reference in New Issue
Block a user