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
+27 -54
View File
@@ -6,9 +6,10 @@
"name": "sofa",
"dependencies": {
"@base-ui/react": "1.2.0",
"@better-auth/drizzle-adapter": "1.5.3",
"@tabler/icons-react": "3.38.0",
"@tanstack/react-hotkeys": "0.3.1",
"better-auth": "1.5.2",
"better-auth": "1.5.3",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "1.1.1",
@@ -18,13 +19,13 @@
"embla-carousel-react": "8.6.0",
"embla-carousel-wheel-gestures": "8.1.0",
"jotai": "2.18.0",
"motion": "12.34.5",
"motion": "12.35.0",
"next": "16.1.6",
"node-vibrant": "4.0.4",
"react": "19.2.4",
"react-day-picker": "9.14.0",
"react-dom": "19.2.4",
"react-resizable-panels": "4.7.0",
"react-resizable-panels": "4.7.1",
"recharts": "3.7.0",
"shadcn": "3.8.5",
"sonner": "2.0.7",
@@ -34,7 +35,7 @@
"zod": "4.3.6",
},
"devDependencies": {
"@biomejs/biome": "2.4.5",
"@biomejs/biome": "2.4.6",
"@tailwindcss/postcss": "4.2.1",
"@types/bun": "1.3.10",
"@types/node": "25.3.3",
@@ -148,41 +149,37 @@
"@base-ui/utils": ["@base-ui/utils@0.2.5", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@floating-ui/utils": "^0.2.10", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-oYC7w0gp76RI5MxprlGLV0wze0SErZaRl3AAkeP3OnNB/UBMb6RqNf6ZSIlxOc9Qp68Ab3C2VOcJQyRs7Xc7Vw=="],
"@better-auth/core": ["@better-auth/core@1.5.2", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "better-call": "1.3.2", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types"] }, "sha512-svaKRVN/p3+g++kljLEedHC+RgDlGsVr87tKiATr5xIE7xqLO1If906pMTNMfhF08N5r7pMbix/mRYdObuPKHA=="],
"@better-auth/core": ["@better-auth/core@1.5.3", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "better-call": "1.3.2", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types"] }, "sha512-fORsQjNZ6BQ7o96xMe7elz3Y4Y8DsqXmQrdyzt289G9rmzX4auwBCPTtE2cXTRTYGiVvH9bv0b97t1Uo/OWynQ=="],
"@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.5.2", "", { "peerDependencies": { "@better-auth/core": "1.5.2", "@better-auth/utils": "^0.3.0", "drizzle-orm": ">=0.41.0" } }, "sha512-29e7UCwqTriIuDdEr1xbSx4qGg6Ag3aTopzRavPyOCYJyzTwePw8iZ9zaJF1fsLmLeany7LW069NMDf6+3tz/w=="],
"@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.5.3", "", { "peerDependencies": { "@better-auth/core": "1.5.3", "@better-auth/utils": "^0.3.0", "drizzle-orm": ">=0.41.0" } }, "sha512-dib9V1vpwDu+TKLC+L+8Q5bLNS0uE3JCT4pGotw52pnpiQF8msoMK4eEfri19f8DtNltpb2F2yzyIsTugBBYNQ=="],
"@better-auth/kysely-adapter": ["@better-auth/kysely-adapter@1.5.2", "", { "peerDependencies": { "@better-auth/core": "1.5.2", "@better-auth/utils": "^0.3.0", "kysely": "^0.27.0 || ^0.28.0" } }, "sha512-PaP+KPJ6Cw0DxzuZLH6eR0oFhS5Iq/KjYvmZt76fCl5Q7Ys9Cct0kXVVlrD1bftiznTpV+UKMfwjcCWvtc1l4w=="],
"@better-auth/kysely-adapter": ["@better-auth/kysely-adapter@1.5.3", "", { "peerDependencies": { "@better-auth/core": "1.5.3", "@better-auth/utils": "^0.3.0", "kysely": "^0.27.0 || ^0.28.0" } }, "sha512-eAm1KPrlPXkH/qXUXnGBcHPDgCX153b6BSlc2QJ2IeqmiWym9D/6XORqBIZOl71JiP0Cifzocr2GLpnz0gt31Q=="],
"@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.5.2", "", { "peerDependencies": { "@better-auth/core": "1.5.2", "@better-auth/utils": "^0.3.0" } }, "sha512-vCbmMpUAisISRVxzNFIxkvO3wm63fzwSxAOO9Xktl77VyEyh8zliBNcW8S6+9DOeolVRKhjWEg7UaBOtIhFX/Q=="],
"@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.5.3", "", { "peerDependencies": { "@better-auth/core": "1.5.3", "@better-auth/utils": "^0.3.0" } }, "sha512-QdeTI3bvUmaPkHsjcSMfroXyuGsgnxobv7wZVl57e+ox6yQVR1j4VKbqmCILP6PL6Rr2gpcBH/liHr8v5gqY5Q=="],
"@better-auth/mongo-adapter": ["@better-auth/mongo-adapter@1.5.2", "", { "peerDependencies": { "@better-auth/core": "1.5.2", "@better-auth/utils": "^0.3.0", "mongodb": "^6.0.0 || ^7.0.0" } }, "sha512-fJd+Z7fgRFgK2W7oxbFYM9K10yEUs/hlALzihCdDgSyRg9XFLBMJkMcECRqBsop/cZUjwZxLBTQPsIpgx9ix1g=="],
"@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.5.2", "", { "dependencies": { "@prisma/client": "^7.4.1" }, "peerDependencies": { "@better-auth/core": "1.5.2", "@better-auth/utils": "^0.3.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-9GB4q91Q16wSSsi1vFP/RZNW0GNmdxTiDcE2MX1fC4Hlmo6UuT4WRv+94YPYs3/tPhsJ17k5Lt+rKTOK8S6Kbw=="],
"@better-auth/telemetry": ["@better-auth/telemetry@1.5.2", "", { "dependencies": { "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21" }, "peerDependencies": { "@better-auth/core": "1.5.2" } }, "sha512-Cukyj5yciFNGtx5SkQCop2wOjpHuq2ZHL3V7ydVFe51WUNCXd+v1Sbed2sIfT/8hOcSB+NefALXNv1yTSxKkAQ=="],
"@better-auth/telemetry": ["@better-auth/telemetry@1.5.3", "", { "dependencies": { "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21" }, "peerDependencies": { "@better-auth/core": "1.5.3" } }, "sha512-ZX/r8AsWdB6BwH+Rb7H/SyJnGtPN6EDWrNxBQEDsqRrBJVcDLwAIz165P57RXci0WwtY872T0guKq+XVyy5rkA=="],
"@better-auth/utils": ["@better-auth/utils@0.3.1", "", {}, "sha512-+CGp4UmZSUrHHnpHhLPYu6cV+wSUSvVbZbNykxhUDocpVNTo9uFFxw/NqJlh1iC4wQ9HKKWGCKuZ5wUgS0v6Kg=="],
"@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="],
"@biomejs/biome": ["@biomejs/biome@2.4.5", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.5", "@biomejs/cli-darwin-x64": "2.4.5", "@biomejs/cli-linux-arm64": "2.4.5", "@biomejs/cli-linux-arm64-musl": "2.4.5", "@biomejs/cli-linux-x64": "2.4.5", "@biomejs/cli-linux-x64-musl": "2.4.5", "@biomejs/cli-win32-arm64": "2.4.5", "@biomejs/cli-win32-x64": "2.4.5" }, "bin": { "biome": "bin/biome" } }, "sha512-OWNCyMS0Q011R6YifXNOg6qsOg64IVc7XX6SqGsrGszPbkVCoaO7Sr/lISFnXZ9hjQhDewwZ40789QmrG0GYgQ=="],
"@biomejs/biome": ["@biomejs/biome@2.4.6", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.6", "@biomejs/cli-darwin-x64": "2.4.6", "@biomejs/cli-linux-arm64": "2.4.6", "@biomejs/cli-linux-arm64-musl": "2.4.6", "@biomejs/cli-linux-x64": "2.4.6", "@biomejs/cli-linux-x64-musl": "2.4.6", "@biomejs/cli-win32-arm64": "2.4.6", "@biomejs/cli-win32-x64": "2.4.6" }, "bin": { "biome": "bin/biome" } }, "sha512-QnHe81PMslpy3mnpL8DnO2M4S4ZnYPkjlGCLWBZT/3R9M6b5daArWMMtEfP52/n174RKnwRIf3oT8+wc9ihSfQ=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lGS4Nd5O3KQJ6TeWv10mElnx1phERhBxqGP/IKq0SvZl78kcWDFMaTtVK+w3v3lusRFxJY78n07PbKplirsU5g=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-NW18GSyxr+8sJIqgoGwVp5Zqm4SALH4b4gftIA0n62PTuBs6G2tHlwNAOj0Vq0KKSs7Sf88VjjmHh0O36EnzrQ=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-6MoH4tyISIBNkZ2Q5T1R7dLd5BsITb2yhhhrU9jHZxnNSNMWl+s2Mxu7NBF8Y3a7JJcqq9nsk8i637z4gqkJxQ=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-4uiE/9tuI7cnjtY9b07RgS7gGyYOAfIAGeVJWEfeCnAarOAS7qVmuRyX6d7JTKw28/mt+rUzMasYeZ+0R/U1Mw=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-U1GAG6FTjhAO04MyH4xn23wRNBkT6H7NentHh+8UxD6ShXKBm5SY4RedKJzkUThANxb9rUKIPc7B8ew9Xo/cWg=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-kMLaI7OF5GN1Q8Doymjro1P8rVEoy7BKQALNz6fiR8IC1WKduoNyteBtJlHT7ASIL0Cx2jR6VUOBIbcB1B8pew=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-iqLDgpzobG7gpBF0fwEVS/LT8kmN7+S0E2YKFDtqliJfzNLnAiV2Nnyb+ehCDCJgAZBASkYHR2o60VQWikpqIg=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-F/JdB7eN22txiTqHM5KhIVt0jVkzZwVYrdTR1O3Y4auBOQcXxHK4dxULf4z43QyZI5tsnQJrRBHZy7wwtL+B3A=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-NdODlSugMzTlENPTa4z0xB82dTUlCpsrOxc43///aNkTLblIYH4XpYflBbf5ySlQuP8AA4AZd1qXhV07IdrHdQ=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.6", "", { "os": "linux", "cpu": "x64" }, "sha512-oHXmUFEoH8Lql1xfc3QkFLiC1hGR7qedv5eKNlC185or+o4/4HiaU7vYODAH3peRCfsuLr1g6v2fK9dFFOYdyw=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-NlKa7GpbQmNhZf9kakQeddqZyT7itN7jjWdakELeXyTU3pg/83fTysRRDPJD0akTfKDl6vZYNT9Zqn4MYZVBOA=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.6", "", { "os": "linux", "cpu": "x64" }, "sha512-C9s98IPDu7DYarjlZNuzJKTjVHN03RUnmHV5htvqsx6vEUXCDSJ59DNwjKVD5XYoSS4N+BYhq3RTBAL8X6svEg=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-EBfrTqRIWOFSd7CQb/0ttjHMR88zm3hGravnDwUA9wHAaCAYsULKDebWcN5RmrEo1KBtl/gDVJMrFjNR0pdGUw=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-xzThn87Pf3YrOGTEODFGONmqXpTwUNxovQb72iaUOdcw8sBSY3+3WD8Hm9IhMYLnPi0n32s3L3NWU6+eSjfqFg=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-Pmhv9zT95YzECfjEHNl3mN9Vhusw9VA5KHY0ZvlGsxsjwS5cb7vpRnHzJIv0vG7jB0JI7xEaMH9ddfZm/RozBw=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.6", "", { "os": "win32", "cpu": "x64" }, "sha512-7++XhnsPlr1HDbor5amovPjOH6vsrFOCdp93iKXhFn6bcMUI6soodj3WWKfgEO6JosKU1W5n3uky3WW9RlRjTg=="],
"@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@10.5.0", "", { "dependencies": { "@chevrotain/gast": "10.5.0", "@chevrotain/types": "10.5.0", "lodash": "4.17.21" } }, "sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw=="],
@@ -366,8 +363,6 @@
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="],
"@mongodb-js/saslprep": ["@mongodb-js/saslprep@1.4.6", "", { "dependencies": { "sparse-bitfield": "^3.0.3" } }, "sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g=="],
"@mrleebo/prisma-ast": ["@mrleebo/prisma-ast@0.13.1", "", { "dependencies": { "chevrotain": "^10.5.0", "lilconfig": "^2.1.0" } }, "sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw=="],
"@mswjs/interceptors": ["@mswjs/interceptors@0.41.3", "", { "dependencies": { "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "strict-event-emitter": "^0.5.1" } }, "sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA=="],
@@ -562,10 +557,6 @@
"@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="],
"@types/webidl-conversions": ["@types/webidl-conversions@7.0.3", "", {}, "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA=="],
"@types/whatwg-url": ["@types/whatwg-url@13.0.0", "", { "dependencies": { "@types/webidl-conversions": "*" } }, "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q=="],
"@typespec/ts-http-runtime": ["@typespec/ts-http-runtime@0.3.3", "", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "tslib": "^2.6.2" } }, "sha512-91fp6CAAJSRtH5ja95T1FHSKa8aPW9/Zw6cta81jlZTUw/+Vq8jM/AfF/14h2b71wwR84JUTW/3Y8QPhDAawFA=="],
"@vibrant/color": ["@vibrant/color@4.0.4", "", {}, "sha512-Fq2tAszz4QOPWfHZ+KuEAchXUD8i594BM2fOJt8dI/fvYbiVoBycBF/BlNH6F4IWBubxXoPqD4JmmAHvFYbNew=="],
@@ -624,7 +615,7 @@
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="],
"better-auth": ["better-auth@1.5.2", "", { "dependencies": { "@better-auth/core": "1.5.2", "@better-auth/drizzle-adapter": "1.5.2", "@better-auth/kysely-adapter": "1.5.2", "@better-auth/memory-adapter": "1.5.2", "@better-auth/mongo-adapter": "1.5.2", "@better-auth/prisma-adapter": "1.5.2", "@better-auth/telemetry": "1.5.2", "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.3.2", "defu": "^6.1.4", "jose": "^6.1.3", "kysely": "^0.28.11", "nanostores": "^1.1.1", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-xEnt/RBsu/cjcN+IEsgeBqka/6QRaGzPFl5hnnJfZU/JxRGnDsVUDHXvJQRfecj8qA/1nPJOQjZ6qQL1Z7aN+Q=="],
"better-auth": ["better-auth@1.5.3", "", { "dependencies": { "@better-auth/core": "1.5.3", "@better-auth/kysely-adapter": "1.5.3", "@better-auth/memory-adapter": "1.5.3", "@better-auth/telemetry": "1.5.3", "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.3.2", "defu": "^6.1.4", "jose": "^6.1.3", "kysely": "^0.28.11", "nanostores": "^1.1.1", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/drizzle-adapter": "1.5.3", "@better-auth/mongo-adapter": "1.5.3", "@better-auth/prisma-adapter": "1.5.3", "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@better-auth/drizzle-adapter", "@better-auth/mongo-adapter", "@better-auth/prisma-adapter", "@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-E+9kA9GMX1+gT3FfMCqRz0NufT4X/+tNhpOsHW1jLmyPZKinkHtfZkUffSBnG5qGkvfBaH/slT5c1fKttnmF5w=="],
"better-call": ["better-call@1.3.2", "", { "dependencies": { "@better-auth/utils": "^0.3.1", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-4cZIfrerDsNTn3cm+MhLbUePN0gdwkhSXEuG7r/zuQ8c/H7iU0/jSK5TD3FW7U0MgKHce/8jGpPYNO4Ve+4NBw=="],
@@ -640,8 +631,6 @@
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
"bson": ["bson@7.2.0", "", {}, "sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ=="],
"buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
@@ -882,7 +871,7 @@
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"framer-motion": ["framer-motion@12.34.5", "", { "dependencies": { "motion-dom": "^12.34.5", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-Z2dQ+o7BsfpJI3+u0SQUNCrN+ajCKJen1blC4rCHx1Ta2EOHs+xKJegLT2aaD9iSMbU3OoX+WabQXkloUbZmJQ=="],
"framer-motion": ["framer-motion@12.35.0", "", { "dependencies": { "motion-dom": "^12.35.0", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-w8hghCMQ4oq10j6aZh3U2yeEQv5K69O/seDI/41PK4HtgkLrcBovUNc0ayBC3UyyU7V1mrY2yLzvYdWJX9pGZQ=="],
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
@@ -1104,8 +1093,6 @@
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
"memory-pager": ["memory-pager@1.5.0", "", {}, "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg=="],
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
@@ -1126,13 +1113,9 @@
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"mongodb": ["mongodb@7.1.0", "", { "dependencies": { "@mongodb-js/saslprep": "^1.3.0", "bson": "^7.1.1", "mongodb-connection-string-url": "^7.0.0" }, "peerDependencies": { "@aws-sdk/credential-providers": "^3.806.0", "@mongodb-js/zstd": "^7.0.0", "gcp-metadata": "^7.0.1", "kerberos": "^7.0.0", "mongodb-client-encryption": ">=7.0.0 <7.1.0", "snappy": "^7.3.2", "socks": "^2.8.6" }, "optionalPeers": ["@aws-sdk/credential-providers", "@mongodb-js/zstd", "gcp-metadata", "kerberos", "mongodb-client-encryption", "snappy", "socks"] }, "sha512-kMfnKunbolQYwCIyrkxNJFB4Ypy91pYqua5NargS/f8ODNSJxT03ZU3n1JqL4mCzbSih8tvmMEMLpKTT7x5gCg=="],
"motion": ["motion@12.35.0", "", { "dependencies": { "framer-motion": "^12.35.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-BQUhNUIGvUcwXCzwmnT1JpjUqab34lIwxHnXUyWRht1WC1vAyp7/4qgMiUXxN3K6hgUhyoR+HNnLeQMwUZjVjw=="],
"mongodb-connection-string-url": ["mongodb-connection-string-url@7.0.1", "", { "dependencies": { "@types/whatwg-url": "^13.0.0", "whatwg-url": "^14.1.0" } }, "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ=="],
"motion": ["motion@12.34.5", "", { "dependencies": { "framer-motion": "^12.34.5", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-N06NLJ9IeBHeielRqIvYvjPfXuRdyTxa+9++BgpGa+hY2D7TcMkI6QzV3jaRuv0aZRXgMa7cPy9YcBUBisPzAQ=="],
"motion-dom": ["motion-dom@12.34.5", "", { "dependencies": { "motion-utils": "^12.29.2" } }, "sha512-k33CsnxO2K3gBRMUZT+vPmc4Utlb5menKdG0RyVNLtlqRaaJPRWlE9fXl8NTtfZ5z3G8TDvqSu0MENLqSTaHZA=="],
"motion-dom": ["motion-dom@12.35.0", "", { "dependencies": { "motion-utils": "^12.29.2" } }, "sha512-FFMLEnIejK/zDABn+vqGVAUN4T0+3fw+cVAY8MMT65yR+j5uMuvWdd4npACWhh94OVWQs79CrBBuwOwGRZAQiA=="],
"motion-utils": ["motion-utils@12.29.2", "", {}, "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A=="],
@@ -1250,8 +1233,6 @@
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="],
"qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="],
@@ -1278,7 +1259,7 @@
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
"react-resizable-panels": ["react-resizable-panels@4.7.0", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-QJTeh8KJR6lKzDwVez+WGPPUPuarIjR3ptg23thJ0G5dAMYJH4iSN1AnNY0DuSP+PgH8s9eYfwrR7AlmX6TvYA=="],
"react-resizable-panels": ["react-resizable-panels@4.7.1", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-RYBRgvdZhnUds5jJWYr1up0hYFofgN1dqSwhxfBl9Savoxms0gyGF0AfaXskhxTYkXrlwc+TlQPe5UkoV+1neg=="],
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
@@ -1372,8 +1353,6 @@
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"sparse-bitfield": ["sparse-bitfield@3.0.3", "", { "dependencies": { "memory-pager": "^1.0.2" } }, "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ=="],
"sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="],
"sqlstring": ["sqlstring@2.3.3", "", {}, "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg=="],
@@ -1436,7 +1415,7 @@
"tough-cookie": ["tough-cookie@6.0.0", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w=="],
"tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="],
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"ts-morph": ["ts-morph@26.0.0", "", { "dependencies": { "@ts-morph/common": "~0.27.0", "code-block-writer": "^13.0.3" } }, "sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug=="],
@@ -1488,11 +1467,11 @@
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
"webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="],
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"whatwg-fetch": ["whatwg-fetch@3.6.20", "", {}, "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="],
"whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="],
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
"wheel-gestures": ["wheel-gestures@2.2.48", "", {}, "sha512-f+Gy33Oa5Z14XY9679Zze+7VFhbsQfBFXodnU2x589l4kxGM9L5Y8zETTmcMR5pWOPQyRv4Z0lNax6xCO0NSlA=="],
@@ -1646,8 +1625,6 @@
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"isomorphic-fetch/node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
"node-vibrant/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"wrap-ansi/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
@@ -1658,10 +1635,6 @@
"yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"isomorphic-fetch/node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"isomorphic-fetch/node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
}
}
+17 -38
View File
@@ -36,6 +36,8 @@ import { useDebounce } from "@/hooks/use-debounce";
import {
commandPaletteOpenAtom,
helpOpenAtom,
MAX_RECENT,
recentSearchesAtom,
} from "@/lib/atoms/command-palette";
import { SHORTCUT_DESCRIPTIONS } from "@/lib/constants/shortcuts";
@@ -48,43 +50,16 @@ interface SearchResult {
voteAverage: number;
}
const RECENT_KEY = "cp:recent-searches";
const MAX_RECENT = 5;
function getRecentSearches(): string[] {
if (typeof window === "undefined") return [];
try {
return JSON.parse(localStorage.getItem(RECENT_KEY) ?? "[]");
} catch {
return [];
}
}
function addRecentSearch(query: string) {
const recent = getRecentSearches().filter((q) => q !== query);
recent.unshift(query);
localStorage.setItem(RECENT_KEY, JSON.stringify(recent.slice(0, MAX_RECENT)));
}
function removeRecentSearch(query: string) {
const recent = getRecentSearches().filter((q) => q !== query);
localStorage.setItem(RECENT_KEY, JSON.stringify(recent));
}
function clearRecentSearches() {
localStorage.removeItem(RECENT_KEY);
}
export function CommandPalette() {
const router = useRouter();
const [commandPaletteOpen, setCommandPaletteOpen] = useAtom(
commandPaletteOpenAtom,
);
const [helpOpen, setHelpOpen] = useAtom(helpOpenAtom);
const [recentSearches, setRecentSearches] = useAtom(recentSearchesAtom);
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [recentSearches, setRecentSearches] = useState<string[]>([]);
const debouncedQuery = useDebounce(query, 300);
const enabled = !commandPaletteOpen;
@@ -100,10 +75,9 @@ export function CommandPalette() {
timeout: 500,
});
// Load recent searches when palette opens
// Reset ephemeral state when palette opens
useEffect(() => {
if (commandPaletteOpen) {
setRecentSearches(getRecentSearches());
setQuery("");
setResults([]);
}
@@ -122,7 +96,11 @@ export function CommandPalette() {
.then((data) => {
if (!cancelled) {
setResults((data.results ?? []).slice(0, 8));
addRecentSearch(debouncedQuery.trim());
const trimmed = debouncedQuery.trim();
setRecentSearches((prev) => {
const filtered = prev.filter((q) => q !== trimmed);
return [trimmed, ...filtered].slice(0, MAX_RECENT);
});
}
})
.finally(() => {
@@ -131,7 +109,7 @@ export function CommandPalette() {
return () => {
cancelled = true;
};
}, [debouncedQuery]);
}, [debouncedQuery, setRecentSearches]);
const handleSelect = useCallback(
(result: SearchResult) => {
@@ -145,15 +123,16 @@ export function CommandPalette() {
setQuery(q);
}, []);
const handleRemoveRecent = useCallback((q: string) => {
removeRecentSearch(q);
setRecentSearches((prev) => prev.filter((s) => s !== q));
}, []);
const handleRemoveRecent = useCallback(
(q: string) => {
setRecentSearches((prev) => prev.filter((s) => s !== q));
},
[setRecentSearches],
);
const handleClearRecent = useCallback(() => {
clearRecentSearches();
setRecentSearches([]);
}, []);
}, [setRecentSearches]);
const hasQuery = query.trim().length > 0;
+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);
+5 -4
View File
@@ -16,9 +16,10 @@
},
"dependencies": {
"@base-ui/react": "1.2.0",
"@better-auth/drizzle-adapter": "1.5.3",
"@tabler/icons-react": "3.38.0",
"@tanstack/react-hotkeys": "0.3.1",
"better-auth": "1.5.2",
"better-auth": "1.5.3",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "1.1.1",
@@ -28,13 +29,13 @@
"embla-carousel-react": "8.6.0",
"embla-carousel-wheel-gestures": "8.1.0",
"jotai": "2.18.0",
"motion": "12.34.5",
"motion": "12.35.0",
"next": "16.1.6",
"node-vibrant": "4.0.4",
"react": "19.2.4",
"react-day-picker": "9.14.0",
"react-dom": "19.2.4",
"react-resizable-panels": "4.7.0",
"react-resizable-panels": "4.7.1",
"recharts": "3.7.0",
"shadcn": "3.8.5",
"sonner": "2.0.7",
@@ -44,7 +45,7 @@
"zod": "4.3.6"
},
"devDependencies": {
"@biomejs/biome": "2.4.5",
"@biomejs/biome": "2.4.6",
"@types/bun": "1.3.10",
"@tailwindcss/postcss": "4.2.1",
"@types/node": "25.3.3",