mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 03:55:38 -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:
@@ -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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user