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
+107
View File
@@ -0,0 +1,107 @@
import { atom, useAtom } from "jotai";
import { useCallback } from "react";
import { toast } from "sonner";
import {
setBackupScheduleAction,
setMaxBackupsAction,
setScheduledBackupAction,
} from "@/lib/actions/settings";
import type { BackupFrequency } from "@/lib/cron";
export interface BackupScheduleState {
enabled: boolean;
maxRetention: number;
frequency: BackupFrequency;
time: string;
dow: number;
}
export const backupScheduleAtom = atom<BackupScheduleState>({
enabled: false,
maxRetention: 7,
frequency: "1d",
time: "03:00",
dow: 0,
});
export const savingScheduleAtom = atom(false);
export const togglingScheduleAtom = atom(false);
export function useBackupScheduleActions() {
const [schedule, setSchedule] = useAtom(backupScheduleAtom);
const [, setSavingSchedule] = useAtom(savingScheduleAtom);
const [, setTogglingSchedule] = useAtom(togglingScheduleAtom);
const toggleScheduled = useCallback(
async (checked: boolean) => {
const previous = schedule.enabled;
setSchedule((prev) => ({ ...prev, enabled: checked }));
setTogglingSchedule(true);
try {
await setScheduledBackupAction(checked);
toast.success(
checked ? "Scheduled backups enabled" : "Scheduled backups disabled",
);
} catch {
setSchedule((prev) => ({ ...prev, enabled: previous }));
toast.error("Failed to update scheduled backup setting");
} finally {
setTogglingSchedule(false);
}
},
[schedule.enabled, setSchedule, setTogglingSchedule],
);
const changeMaxRetention = useCallback(
async (value: number) => {
const previous = schedule.maxRetention;
setSchedule((prev) => ({ ...prev, maxRetention: value }));
try {
await setMaxBackupsAction(value);
} catch {
setSchedule((prev) => ({ ...prev, maxRetention: previous }));
toast.error("Failed to update retention setting");
}
},
[schedule.maxRetention, setSchedule],
);
const changeSchedule = useCallback(
async (
newFrequency: BackupFrequency,
newTime: string,
newDow = schedule.dow,
) => {
const prev = {
frequency: schedule.frequency,
time: schedule.time,
dow: schedule.dow,
};
setSchedule((s) => ({
...s,
frequency: newFrequency,
time: newTime,
dow: newDow,
}));
setSavingSchedule(true);
try {
await setBackupScheduleAction(newFrequency, newTime, newDow);
toast.success("Schedule updated");
} catch {
setSchedule((s) => ({ ...s, ...prev }));
toast.error("Failed to update schedule");
} finally {
setSavingSchedule(false);
}
},
[
schedule.frequency,
schedule.time,
schedule.dow,
setSchedule,
setSavingSchedule,
],
);
return { toggleScheduled, changeMaxRetention, changeSchedule };
}
+5
View File
@@ -1,4 +1,9 @@
import { atom } from "jotai";
import { atomWithStorage } from "jotai/utils";
export const commandPaletteOpenAtom = atom(false);
export const helpOpenAtom = atom(false);
const RECENT_KEY = "cp:recent-searches";
export const MAX_RECENT = 5;
export const recentSearchesAtom = atomWithStorage<string[]>(RECENT_KEY, []);
+28
View File
@@ -0,0 +1,28 @@
import { atom } from "jotai";
import { loadable } from "jotai/utils";
interface TitleRowItem {
tmdbId: number;
type: "movie" | "tv";
title: string;
posterPath: string | null;
releaseDate: string | null;
voteAverage: number;
}
export const selectedGenreAtom = atom<number | null>(null);
export const mediaTypeAtom = atom<"movie" | "tv">("movie");
export const defaultItemsAtom = atom<TitleRowItem[]>([]);
const genreResultsAsyncAtom = atom(async (get) => {
const genre = get(selectedGenreAtom);
const mediaType = get(mediaTypeAtom);
if (genre === null) return null;
const res = await fetch(
`/api/explore/discover?type=${mediaType}&genre=${genre}&sort_by=popularity.desc`,
);
const data = await res.json();
return (data.results ?? []) as TitleRowItem[];
});
export const genreResultsLoadable = loadable(genreResultsAsyncAtom);
+86
View File
@@ -0,0 +1,86 @@
import { atom, useAtom } from "jotai";
import { useCallback } from "react";
import { toast } from "sonner";
import type { WebhookConnection } from "@/app/(pages)/settings/_components/webhook-card";
import {
deleteWebhookConnection,
regenerateWebhookToken,
saveWebhookConnection,
} from "@/lib/actions/settings";
export const connectionsAtom = atom<WebhookConnection[]>([]);
function providerLabel(provider: "plex" | "jellyfin" | "emby") {
return provider === "plex"
? "Plex"
: provider === "emby"
? "Emby"
: "Jellyfin";
}
export function useConnectionActions(provider: "plex" | "jellyfin" | "emby") {
const [connections, setConnections] = useAtom(connectionsAtom);
const label = providerLabel(provider);
const connection = connections.find((c) => c.provider === provider) ?? null;
const handleConnect = useCallback(async () => {
try {
const result = await saveWebhookConnection(provider);
setConnections((prev) => [...prev, { ...result, recentEvents: [] }]);
toast.success(`${label} connected`);
} catch {
toast.error(`Failed to connect ${label}`);
}
}, [provider, label, setConnections]);
const handleDelete = useCallback(async () => {
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}`);
}
}, [provider, label, connections, setConnections]);
const handleRegenerateToken = useCallback(async () => {
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`);
}
}, [provider, label, setConnections]);
const handleToggle = useCallback(
async (enabled: boolean) => {
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}`);
}
},
[provider, label, connections, setConnections],
);
return {
connection,
handleConnect,
handleDelete,
handleRegenerateToken,
handleToggle,
};
}
+29
View File
@@ -0,0 +1,29 @@
import { atom } from "jotai";
import { loadable } from "jotai/utils";
import type { HistoryBucket, TimePeriod } from "@/lib/services/discovery";
export const moviePeriodAtom = atom<TimePeriod>("this_month");
export const episodePeriodAtom = atom<TimePeriod>("this_week");
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();
}
const movieStatsAsyncAtom = atom(async (get) => {
const period = get(moviePeriodAtom);
return fetchStats("movies", period);
});
const episodeStatsAsyncAtom = atom(async (get) => {
const period = get(episodePeriodAtom);
return fetchStats("episodes", period);
});
export const movieStatsLoadable = loadable(movieStatsAsyncAtom);
export const episodeStatsLoadable = loadable(episodeStatsAsyncAtom);