mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 01:35:39 -04:00
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:
@@ -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 };
|
||||
}
|
||||
@@ -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, []);
|
||||
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user