Add settings UI, Discord notifications, and alert detail improvements
- Settings modal (gear icon) lets you configure all service URLs and API keys from the dashboard; values persist to data/settings.json with process.env as fallback so existing .env.local setups keep working - Per-service Test button hits each service's status endpoint and reports the version on success - Discord webhook support: structured embeds per alert category (requesters, approval age, episode progress, watch-rate stats) sent on new/reopened alerts only — already-open alerts don't re-notify - Alert detail page restructured: prose descriptions replaced with labelled fields, episode progress bar for partial TV, watch-rate stat block, View in Radarr/Sonarr/Seerr action buttons, requester names link to Overseerr profiles, timestamps moved inline with status - Tab state is pure client state (no ?tab= in URL); router.back() used on alert detail for clean browser history Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback, useRef, Suspense } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { DashboardStats } from "@/lib/types";
|
||||
import SummaryCards from "@/components/SummaryCards";
|
||||
import LeaderboardTable from "@/components/LeaderboardTable";
|
||||
import AlertsPanel from "@/components/AlertsPanel";
|
||||
import RefreshButton from "@/components/RefreshButton";
|
||||
import SettingsModal from "@/components/SettingsModal";
|
||||
|
||||
type Tab = "leaderboard" | "alerts";
|
||||
const LS_KEY = "oversnitch_stats";
|
||||
@@ -21,15 +21,13 @@ function timeAgo(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
|
||||
function DashboardContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const tab = (searchParams.get("tab") ?? "leaderboard") as Tab;
|
||||
|
||||
export default function Page() {
|
||||
const [tab, setTab] = useState<Tab>("leaderboard");
|
||||
const [data, setData] = useState<DashboardStats | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const didInit = useRef(false);
|
||||
|
||||
const load = useCallback(async (force = false) => {
|
||||
@@ -67,18 +65,13 @@ function DashboardContent() {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
function setTab(t: Tab) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("tab", t);
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
}
|
||||
|
||||
const hasTautulli = data?.summary.totalWatchHours !== null;
|
||||
const openAlertCount = data?.summary.openAlertCount ?? 0;
|
||||
const generatedAt = data?.generatedAt ?? null;
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-6xl px-4 py-8 space-y-6">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
@@ -89,7 +82,20 @@ function DashboardContent() {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-1.5 shrink-0">
|
||||
<RefreshButton onRefresh={() => load(true)} loading={refreshing || loading} />
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
className="rounded-lg border border-slate-700/60 bg-slate-800/40 hover:bg-slate-800 p-2 text-slate-500 hover:text-slate-300 transition-colors"
|
||||
aria-label="Settings"
|
||||
title="Settings"
|
||||
>
|
||||
<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="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.43l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<RefreshButton onRefresh={() => load(true)} loading={refreshing || loading} />
|
||||
</div>
|
||||
{generatedAt && (
|
||||
<span className="text-xs text-slate-600">
|
||||
{refreshing
|
||||
@@ -175,14 +181,12 @@ function DashboardContent() {
|
||||
{tab === "alerts" && <AlertsPanel />}
|
||||
</>
|
||||
)}
|
||||
|
||||
<SettingsModal
|
||||
open={settingsOpen}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
onSaved={() => load(true)}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<Suspense>
|
||||
<DashboardContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user