Migrate component state to Jotai atoms across settings, explore, and dashboard

Replace ad-hoc useState/useEffect fetch patterns with Jotai atoms and
loadables in StatsDisplay, FilterableTitleRow, BackupScheduleSection,
IntegrationsSection, and CommandPalette. Each component now gets a
scoped Jotai Provider with a pre-initialized store so server-rendered
initial values hydrate correctly. Async data fetching moves into
atom-level loadables, eliminating manual loading flags and cancellation
logic throughout.
This commit is contained in:
2026-03-05 10:09:47 -05:00
parent bafa45ec79
commit dfc0f67527
13 changed files with 443 additions and 372 deletions
@@ -7,8 +7,8 @@ import {
IconMovie,
IconPlayerPlay,
} from "@tabler/icons-react";
import { useAtom, useAtomValue } from "jotai";
import { motion } from "motion/react";
import { useEffect, useState } from "react";
import {
DropdownMenu,
DropdownMenuContent,
@@ -16,6 +16,12 @@ import {
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
episodePeriodAtom,
episodeStatsLoadable,
moviePeriodAtom,
movieStatsLoadable,
} from "@/lib/atoms/stats";
import type {
DashboardStats,
HistoryBucket,
@@ -122,50 +128,27 @@ function PeriodSelector({
);
}
async function fetchStats(
type: "movies" | "episodes",
period: TimePeriod,
): Promise<{ count: number; history: HistoryBucket[] }> {
const res = await fetch(
`/api/stats?type=${type}&period=${period}&history=true`,
);
return res.json();
}
export function StatsDisplay({ stats }: { stats: DashboardStats }) {
const [moviePeriod, setMoviePeriod] = useState<TimePeriod>("this_month");
const [episodePeriod, setEpisodePeriod] = useState<TimePeriod>("this_week");
const [movieCount, setMovieCount] = useState(stats.moviesThisMonth);
const [episodeCount, setEpisodeCount] = useState(stats.episodesThisWeek);
const [movieLoading, setMovieLoading] = useState(false);
const [episodeLoading, setEpisodeLoading] = useState(false);
const [movieHistory, setMovieHistory] = useState<HistoryBucket[]>();
const [episodeHistory, setEpisodeHistory] = useState<HistoryBucket[]>();
const [moviePeriod, setMoviePeriod] = useAtom(moviePeriodAtom);
const [episodePeriod, setEpisodePeriod] = useAtom(episodePeriodAtom);
const movieStats = useAtomValue(movieStatsLoadable);
const episodeStats = useAtomValue(episodeStatsLoadable);
useEffect(() => {
fetchStats("movies", "this_month").then((d) => setMovieHistory(d.history));
fetchStats("episodes", "this_week").then((d) =>
setEpisodeHistory(d.history),
);
}, []);
const movieLoading = movieStats.state === "loading";
const movieCount =
movieStats.state === "hasData"
? movieStats.data.count
: stats.moviesThisMonth;
const movieHistory =
movieStats.state === "hasData" ? movieStats.data.history : undefined;
async function handleMoviePeriodChange(period: TimePeriod) {
setMoviePeriod(period);
setMovieLoading(true);
const data = await fetchStats("movies", period);
setMovieCount(data.count);
setMovieHistory(data.history);
setMovieLoading(false);
}
async function handleEpisodePeriodChange(period: TimePeriod) {
setEpisodePeriod(period);
setEpisodeLoading(true);
const data = await fetchStats("episodes", period);
setEpisodeCount(data.count);
setEpisodeHistory(data.history);
setEpisodeLoading(false);
}
const episodeLoading = episodeStats.state === "loading";
const episodeCount =
episodeStats.state === "hasData"
? episodeStats.data.count
: stats.episodesThisWeek;
const episodeHistory =
episodeStats.state === "hasData" ? episodeStats.data.history : undefined;
return (
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
@@ -181,7 +164,7 @@ export function StatsDisplay({ stats }: { stats: DashboardStats }) {
<PeriodSelector
noun="Movies"
period={moviePeriod}
onPeriodChange={handleMoviePeriodChange}
onPeriodChange={setMoviePeriod}
/>
}
/>
@@ -197,7 +180,7 @@ export function StatsDisplay({ stats }: { stats: DashboardStats }) {
<PeriodSelector
noun="Episodes"
period={episodePeriod}
onPeriodChange={handleEpisodePeriodChange}
onPeriodChange={setEpisodePeriod}
/>
}
/>
+39 -30
View File
@@ -1,8 +1,9 @@
"use client";
import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures";
import { createStore, Provider, useAtom, useAtomValue } from "jotai";
import { motion } from "motion/react";
import { useEffect, useState } from "react";
import { useState } from "react";
import { TitleCardSkeleton } from "@/components/skeletons";
import { TitleCard } from "@/components/title-card";
import {
@@ -10,6 +11,12 @@ import {
CarouselContent,
CarouselItem,
} from "@/components/ui/carousel";
import {
defaultItemsAtom,
genreResultsLoadable,
mediaTypeAtom,
selectedGenreAtom,
} from "@/lib/atoms/filterable-row";
interface Genre {
id: number;
@@ -55,38 +62,40 @@ export function FilterableTitleRow({
defaultItems,
genres,
}: FilterableTitleRowProps) {
const [selectedGenre, setSelectedGenre] = useState<number | null>(null);
const [results, setResults] = useState<TitleRowItem[]>([]);
const [loading, setLoading] = useState(false);
const [store] = useState(() => {
const s = createStore();
s.set(mediaTypeAtom, mediaType);
s.set(defaultItemsAtom, defaultItems);
return s;
});
const items = selectedGenre === null ? defaultItems : results;
return (
<Provider store={store}>
<FilterableTitleRowInner heading={heading} icon={icon} genres={genres} />
</Provider>
);
}
useEffect(() => {
if (selectedGenre === null) {
setResults([]);
return;
}
function FilterableTitleRowInner({
heading,
icon,
genres,
}: {
heading: string;
icon: React.ReactNode;
genres: Genre[];
}) {
const [selectedGenre, setSelectedGenre] = useAtom(selectedGenreAtom);
const defaults = useAtomValue(defaultItemsAtom);
const genreResults = useAtomValue(genreResultsLoadable);
let cancelled = false;
setLoading(true);
fetch(
`/api/explore/discover?type=${mediaType}&genre=${selectedGenre}&sort_by=popularity.desc`,
)
.then((r) => r.json())
.then((data) => {
if (!cancelled) {
setResults(data.results ?? []);
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [selectedGenre, mediaType]);
const loading = selectedGenre !== null && genreResults.state === "loading";
const items =
selectedGenre === null
? defaults
: genreResults.state === "hasData" && genreResults.data !== null
? genreResults.data
: [];
function toggleGenre(genreId: number) {
setSelectedGenre(selectedGenre === genreId ? null : genreId);
@@ -2,9 +2,9 @@
import { IconCalendarRepeat, IconChevronDown } from "@tabler/icons-react";
import { format, formatDistanceToNow } from "date-fns";
import { createStore, Provider, useAtomValue } from "jotai";
import { AnimatePresence, motion } from "motion/react";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { CardContent, CardDescription, CardTitle } from "@/components/ui/card";
import {
@@ -16,10 +16,11 @@ import {
} from "@/components/ui/dropdown-menu";
import { Switch } from "@/components/ui/switch";
import {
setBackupScheduleAction,
setMaxBackupsAction,
setScheduledBackupAction,
} from "@/lib/actions/settings";
backupScheduleAtom,
savingScheduleAtom,
togglingScheduleAtom,
useBackupScheduleActions,
} from "@/lib/atoms/backup-schedule";
import type { BackupFrequency } from "@/lib/cron";
const FREQUENCY_OPTIONS: { value: BackupFrequency; label: string }[] = [
@@ -115,68 +116,33 @@ export function BackupScheduleSection({
initialTime: string;
initialDow: number;
}) {
const [scheduledEnabled, setScheduledEnabled] = useState(
initialScheduledEnabled,
const [store] = useState(() => {
const s = createStore();
s.set(backupScheduleAtom, {
enabled: initialScheduledEnabled,
maxRetention: initialMaxRetention,
frequency: initialFrequency,
time: initialTime,
dow: initialDow,
});
return s;
});
return (
<Provider store={store}>
<BackupScheduleInner />
</Provider>
);
const [maxRetention, setMaxRetention] = useState(initialMaxRetention);
const [frequency, setFrequency] = useState<BackupFrequency>(initialFrequency);
const [time, setTime] = useState(initialTime);
const [dow, setDow] = useState(initialDow);
const [savingSchedule, setSavingSchedule] = useState(false);
const [togglingSchedule, setTogglingSchedule] = useState(false);
}
async function handleToggleScheduled(checked: boolean) {
const previous = scheduledEnabled;
setScheduledEnabled(checked);
setTogglingSchedule(true);
try {
await setScheduledBackupAction(checked);
toast.success(
checked ? "Scheduled backups enabled" : "Scheduled backups disabled",
);
} catch {
setScheduledEnabled(previous);
toast.error("Failed to update scheduled backup setting");
} finally {
setTogglingSchedule(false);
}
}
function BackupScheduleInner() {
const schedule = useAtomValue(backupScheduleAtom);
const savingSchedule = useAtomValue(savingScheduleAtom);
const togglingSchedule = useAtomValue(togglingScheduleAtom);
const { toggleScheduled, changeMaxRetention, changeSchedule } =
useBackupScheduleActions();
async function handleMaxRetentionChange(value: number) {
const previous = maxRetention;
setMaxRetention(value);
try {
await setMaxBackupsAction(value);
} catch {
setMaxRetention(previous);
toast.error("Failed to update retention setting");
}
}
async function handleScheduleChange(
newFrequency: BackupFrequency,
newTime: string,
newDow = dow,
) {
const prevFreq = frequency;
const prevTime = time;
const prevDow = dow;
setFrequency(newFrequency);
setTime(newTime);
setDow(newDow);
setSavingSchedule(true);
try {
await setBackupScheduleAction(newFrequency, newTime, newDow);
toast.success("Schedule updated");
} catch {
setFrequency(prevFreq);
setTime(prevTime);
setDow(prevDow);
toast.error("Failed to update schedule");
} finally {
setSavingSchedule(false);
}
}
const { enabled, maxRetention, frequency, time, dow } = schedule;
return (
<>
@@ -189,7 +155,7 @@ export function BackupScheduleSection({
<div>
<CardTitle>Backup schedule</CardTitle>
<CardDescription>
{scheduledEnabled ? (
{enabled ? (
<span className="inline-flex flex-wrap items-baseline gap-1">
<span suppressHydrationWarning>
{formatNextBackup(frequency, time, dow)}.
@@ -205,9 +171,7 @@ export function BackupScheduleSection({
<DropdownMenuContent align="start">
<DropdownMenuRadioGroup
value={String(maxRetention)}
onValueChange={(v) =>
handleMaxRetentionChange(Number(v))
}
onValueChange={(v) => changeMaxRetention(Number(v))}
>
{[3, 5, 7, 14, 30, 0].map((n) => (
<DropdownMenuRadioItem key={n} value={String(n)}>
@@ -226,15 +190,15 @@ export function BackupScheduleSection({
</div>
</div>
<Switch
checked={scheduledEnabled}
onCheckedChange={handleToggleScheduled}
checked={enabled}
onCheckedChange={toggleScheduled}
disabled={togglingSchedule}
/>
</div>
</CardContent>
<AnimatePresence initial={false}>
{scheduledEnabled && (
{enabled && (
<CardContent className="border-t border-border/30 pt-4">
<motion.div
initial={{ opacity: 0, height: 0 }}
@@ -256,7 +220,7 @@ export function BackupScheduleSection({
variant="outline"
size="sm"
disabled={savingSchedule}
onClick={() => handleScheduleChange(opt.value, time)}
onClick={() => changeSchedule(opt.value, time)}
className={
frequency === opt.value
? "border-primary/50 bg-primary text-primary-foreground shadow-sm hover:bg-primary/90 hover:text-primary-foreground"
@@ -291,7 +255,7 @@ export function BackupScheduleSection({
<DropdownMenuRadioGroup
value={String(dow)}
onValueChange={(v) =>
handleScheduleChange(frequency, time, Number(v))
changeSchedule(frequency, time, Number(v))
}
>
{DAYS_OF_WEEK.map((day, i) => (
@@ -341,9 +305,7 @@ export function BackupScheduleSection({
<DropdownMenuContent align="start">
<DropdownMenuRadioGroup
value={time}
onValueChange={(v) =>
handleScheduleChange(frequency, v)
}
onValueChange={(v) => changeSchedule(frequency, v)}
>
{HOURS.map((h) => {
const val = `${String(h).padStart(2, "0")}:00`;
@@ -1,13 +1,9 @@
"use client";
import { IconWebhook } from "@tabler/icons-react";
import { createStore, Provider } from "jotai";
import { useState } from "react";
import { toast } from "sonner";
import {
deleteWebhookConnection,
regenerateWebhookToken,
saveWebhookConnection,
} from "@/lib/actions/settings";
import { connectionsAtom } from "@/lib/atoms/integrations";
import { WebhookCard, type WebhookConnection } from "./webhook-card";
export function IntegrationsSection({
@@ -15,109 +11,27 @@ export function IntegrationsSection({
}: {
initialConnections: WebhookConnection[];
}) {
const [connections, setConnections] =
useState<WebhookConnection[]>(initialConnections);
const plexConnection = connections.find((c) => c.provider === "plex") ?? null;
const jellyfinConnection =
connections.find((c) => c.provider === "jellyfin") ?? null;
const embyConnection = connections.find((c) => c.provider === "emby") ?? null;
async function handleConnect(provider: "plex" | "jellyfin" | "emby") {
const label =
provider === "plex" ? "Plex" : provider === "emby" ? "Emby" : "Jellyfin";
try {
const result = await saveWebhookConnection(provider);
setConnections((prev) => [...prev, { ...result, recentEvents: [] }]);
toast.success(`${label} connected`);
} catch {
toast.error(`Failed to connect ${label}`);
}
}
async function handleDelete(provider: "plex" | "jellyfin" | "emby") {
const label =
provider === "plex" ? "Plex" : provider === "emby" ? "Emby" : "Jellyfin";
const previous = connections;
setConnections((prev) => prev.filter((c) => c.provider !== provider));
try {
await deleteWebhookConnection(provider);
toast.success(`${label} disconnected`);
} catch {
setConnections(previous);
toast.error(`Failed to disconnect ${label}`);
}
}
async function handleRegenerateToken(provider: "plex" | "jellyfin" | "emby") {
const label =
provider === "plex" ? "Plex" : provider === "emby" ? "Emby" : "Jellyfin";
try {
const result = await regenerateWebhookToken(provider);
setConnections((prev) =>
prev.map((c) =>
c.provider === provider ? { ...c, token: result.token } : c,
),
);
toast.success(`${label} webhook URL regenerated`);
} catch {
toast.error(`Failed to regenerate ${label} URL`);
}
}
async function handleToggle(
provider: "plex" | "jellyfin" | "emby",
enabled: boolean,
) {
const label =
provider === "plex" ? "Plex" : provider === "emby" ? "Emby" : "Jellyfin";
const previous = connections;
setConnections((prev) =>
prev.map((c) => (c.provider === provider ? { ...c, enabled } : c)),
);
try {
await saveWebhookConnection(provider, enabled);
toast.success(`${label} webhook ${enabled ? "enabled" : "disabled"}`);
} catch {
setConnections(previous);
toast.error(`Failed to update ${label}`);
}
}
const [store] = useState(() => {
const s = createStore();
s.set(connectionsAtom, initialConnections);
return s;
});
return (
<div>
<div className="mb-3 flex items-center gap-2">
<IconWebhook className="size-4 text-muted-foreground" />
<h2 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Integrations
</h2>
<Provider store={store}>
<div>
<div className="mb-3 flex items-center gap-2">
<IconWebhook className="size-4 text-muted-foreground" />
<h2 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Integrations
</h2>
</div>
<div className="space-y-3">
<WebhookCard provider="plex" />
<WebhookCard provider="jellyfin" />
<WebhookCard provider="emby" />
</div>
</div>
<div className="space-y-3">
<WebhookCard
provider="plex"
connection={plexConnection}
onConnect={handleConnect}
onDelete={handleDelete}
onRegenerateToken={handleRegenerateToken}
onToggle={handleToggle}
/>
<WebhookCard
provider="jellyfin"
connection={jellyfinConnection}
onConnect={handleConnect}
onDelete={handleDelete}
onRegenerateToken={handleRegenerateToken}
onToggle={handleToggle}
/>
<WebhookCard
provider="emby"
connection={embyConnection}
onConnect={handleConnect}
onDelete={handleDelete}
onRegenerateToken={handleRegenerateToken}
onToggle={handleToggle}
/>
</div>
</div>
</Provider>
);
}
@@ -31,6 +31,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useConnectionActions } from "@/lib/atoms/integrations";
import { EmbyIcon, JellyfinIcon, PlexIcon } from "./icons";
export interface WebhookConnection {
@@ -51,22 +52,16 @@ export interface WebhookConnection {
export function WebhookCard({
provider,
connection,
onConnect,
onDelete,
onRegenerateToken,
onToggle,
}: {
provider: "plex" | "jellyfin" | "emby";
connection: WebhookConnection | null;
onConnect: (provider: "plex" | "jellyfin" | "emby") => Promise<void>;
onDelete: (provider: "plex" | "jellyfin" | "emby") => Promise<void>;
onRegenerateToken: (provider: "plex" | "jellyfin" | "emby") => Promise<void>;
onToggle: (
provider: "plex" | "jellyfin" | "emby",
enabled: boolean,
) => Promise<void>;
}) {
const {
connection,
handleConnect,
handleDelete,
handleRegenerateToken,
handleToggle,
} = useConnectionActions(provider);
const [connecting, setConnecting] = useState(false);
const [copied, setCopied] = useState(false);
const [setupOpen, setSetupOpen] = useState(false);
@@ -81,10 +76,10 @@ export function WebhookCard({
? `${window.location.origin}/api/webhooks/${connection.token}`
: null;
async function handleConnect() {
async function onConnect() {
setConnecting(true);
try {
await onConnect(provider);
await handleConnect();
} finally {
setConnecting(false);
}
@@ -132,7 +127,7 @@ export function WebhookCard({
</span>
<Switch
checked={connection.enabled}
onCheckedChange={(checked) => onToggle(provider, checked)}
onCheckedChange={(checked) => handleToggle(checked)}
/>
</div>
)}
@@ -177,7 +172,7 @@ export function WebhookCard({
{!connection ? (
<Button
onClick={handleConnect}
onClick={onConnect}
disabled={connecting}
className="w-full"
>
@@ -231,7 +226,7 @@ export function WebhookCard({
<Button
variant="outline"
size="sm"
onClick={() => onRegenerateToken(provider)}
onClick={() => handleRegenerateToken()}
>
<IconRefresh />
Regenerate URL
@@ -239,7 +234,7 @@ export function WebhookCard({
<Button
variant="destructive"
size="sm"
onClick={() => onDelete(provider)}
onClick={() => handleDelete()}
>
<IconTrash />
Disconnect