501 lines
19 KiB
TypeScript
501 lines
19 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import {
|
|
BrowserRouter,
|
|
Routes,
|
|
Route,
|
|
Navigate,
|
|
useLocation,
|
|
useNavigate,
|
|
useParams,
|
|
useSearchParams,
|
|
} from "react-router";
|
|
import { QueryClientProvider, useQueryClient } from "@tanstack/react-query";
|
|
import { queryClient } from "@/lib/query-client";
|
|
import { AuthProvider, useAuth } from "@/hooks/useAuth";
|
|
import { ThemeProvider } from "@/hooks/useTheme";
|
|
import { CustomThemeProvider } from "@/contexts/CustomThemeProvider";
|
|
import { ErrorBoundary } from "@/components/ErrorBoundary";
|
|
import ImpersonationBanner from "@/components/ImpersonationBanner";
|
|
import { loadStoredImpersonationAdminSession } from "@/lib/impersonationSession";
|
|
import { Toaster } from "@/components/ui/sonner";
|
|
import Layout from "@/components/Layout";
|
|
import AdminLayout from "@/components/AdminLayout";
|
|
import Home from "@/pages/Home";
|
|
import Login from "@/pages/Login";
|
|
import OAuthComplete from "@/pages/OAuthComplete";
|
|
import ActivateDevice from "@/pages/ActivateDevice";
|
|
import SetupWizard from "@/pages/SetupWizard";
|
|
import Profiles from "@/pages/Profiles";
|
|
import Catalog from "@/pages/Catalog";
|
|
import LibraryPage from "@/pages/LibraryPage";
|
|
import ItemDetail from "@/pages/ItemDetail/index";
|
|
import PersonDetail from "@/pages/PersonDetail";
|
|
import Collections from "@/pages/Collections";
|
|
import CollectionEditor from "@/pages/CollectionEditor";
|
|
import AdminDashboard from "@/pages/AdminDashboard";
|
|
import AdminActivity from "@/pages/AdminActivity";
|
|
import AdminLogs from "@/pages/AdminLogs";
|
|
import AdminUsers from "@/pages/AdminUsers";
|
|
import AdminDevices from "@/pages/AdminDevices";
|
|
import AdminLibraries from "@/pages/AdminLibraries";
|
|
import AdminSettingsLayout from "@/pages/admin-settings/AdminSettingsLayout";
|
|
import AdminNodes from "@/pages/AdminNodes";
|
|
import AdminSections from "@/pages/AdminSections";
|
|
import AdminCollections from "@/pages/AdminCollections";
|
|
import AdminCollectionEditor from "@/pages/AdminCollectionEditor";
|
|
import AdminPlaybackHistory from "@/pages/AdminPlaybackHistory";
|
|
import AdminMaintenance from "@/pages/AdminMaintenance";
|
|
import AdminApiKeys from "@/pages/AdminApiKeys";
|
|
import AdminUserDetail from "@/pages/AdminUserDetail";
|
|
import AdminTasks from "@/pages/AdminTasks";
|
|
import AdminTaskDetail from "@/pages/AdminTaskDetail";
|
|
import AdminPlugins from "@/pages/AdminPlugins";
|
|
import AdminHistoryImport from "@/pages/AdminHistoryImport";
|
|
import AdminRecommendations from "@/pages/AdminRecommendations";
|
|
import Recommendations from "@/pages/Recommendations";
|
|
import RecommendationsSection from "@/pages/RecommendationsSection";
|
|
import Calendar from "@/pages/Calendar";
|
|
import Signup from "@/pages/Signup";
|
|
import TasteSeed from "@/pages/TasteSeed";
|
|
import { useFavorites } from "@/hooks/queries/favorites";
|
|
import { isTasteSeedDismissed } from "@/lib/tasteSeed";
|
|
import SettingsLayout from "@/pages/SettingsLayout";
|
|
import AppearanceSettings from "@/pages/settings/AppearanceSettings";
|
|
import AccessibilitySettings from "@/pages/settings/AccessibilitySettings";
|
|
import PlaybackSettings from "@/pages/settings/PlaybackSettings";
|
|
import ProfilesSettings from "@/pages/settings/ProfilesSettings";
|
|
import LibrarySettings from "@/pages/settings/LibrarySettings";
|
|
import HistoryImportSettings from "@/pages/settings/HistoryImportSettings";
|
|
import WebhookSyncSettings from "@/pages/settings/WebhookSyncSettings";
|
|
import WatchProvidersSettings from "@/pages/settings/WatchProvidersSettings";
|
|
import SubtitleAppearanceSettings from "@/pages/settings/SubtitleAppearanceSettings";
|
|
import HomeScreenSettings from "@/pages/settings/HomeScreenSettings";
|
|
import ThemeEditorSettings from "@/pages/settings/ThemeEditorSettings";
|
|
import CardOverlaySettings from "@/pages/settings/CardOverlaySettings";
|
|
import PersonalizeSettings from "@/pages/settings/PersonalizeSettings";
|
|
import WatchTogetherJoin from "@/pages/WatchTogetherJoin";
|
|
import WatchTogetherRoomPage from "@/pages/WatchTogetherRoomPage";
|
|
import WatchRoute from "@/pages/WatchRoute";
|
|
import ProfileCustomizeHome from "@/pages/ProfileCustomizeHome";
|
|
import {
|
|
WatchPlaybackBar,
|
|
WatchPlaybackHost,
|
|
WatchPlaybackProvider,
|
|
} from "@/playback/WatchPlaybackChrome";
|
|
import type { ReactNode } from "react";
|
|
import {
|
|
buildLegacyBrowseCatalogHref,
|
|
buildPersonalCatalogHref,
|
|
buildQueryCatalogHref,
|
|
buildUserCollectionCatalogHref,
|
|
} from "@/pages/catalogSearchParams";
|
|
import { buildLegacyWebhookSyncRedirectTarget } from "@/lib/webhookSync";
|
|
import { toast } from "sonner";
|
|
|
|
/** Scrolls to top on pathname change (custom replacement for ScrollRestoration which requires data router). */
|
|
function useScrollRestoration() {
|
|
const { pathname } = useLocation();
|
|
useEffect(() => {
|
|
window.scrollTo(0, 0);
|
|
}, [pathname]);
|
|
}
|
|
|
|
/** Announces route changes to screen readers via an aria-live region. */
|
|
function RouteAnnouncer() {
|
|
const location = useLocation();
|
|
const [announcement, setAnnouncement] = useState("");
|
|
|
|
useEffect(() => {
|
|
// Small delay so document.title has time to update via useDocumentTitle hooks
|
|
const id = setTimeout(() => {
|
|
setAnnouncement(document.title || "Page loaded");
|
|
}, 100);
|
|
return () => clearTimeout(id);
|
|
}, [location.pathname]);
|
|
|
|
return (
|
|
<div aria-live="assertive" role="status" className="sr-only">
|
|
{announcement}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ScrollRestorationManager() {
|
|
useScrollRestoration();
|
|
return null;
|
|
}
|
|
|
|
function RequireAuth({ children }: { children: ReactNode }) {
|
|
const { user, loading, setupLoading } = useAuth();
|
|
if (loading || setupLoading) {
|
|
return (
|
|
<div className="p-8" role="status" aria-live="polite">
|
|
<span className="sr-only">Loading application</span>
|
|
Loading...
|
|
</div>
|
|
);
|
|
}
|
|
if (!user) return <Navigate to="/login" replace />;
|
|
return <>{children}</>;
|
|
}
|
|
|
|
function SetupGate({ children }: { children: ReactNode }) {
|
|
const { user, setupLoading, setupRequired } = useAuth();
|
|
if (setupLoading) {
|
|
return (
|
|
<div className="p-8" role="status" aria-live="polite">
|
|
<span className="sr-only">Loading application</span>
|
|
Loading...
|
|
</div>
|
|
);
|
|
}
|
|
if (setupRequired && !user) return <Navigate to="/setup" replace />;
|
|
return <>{children}</>;
|
|
}
|
|
|
|
function RequireProfile({ children }: { children: ReactNode }) {
|
|
const { profile } = useAuth();
|
|
if (!profile) return <Navigate to="/profiles" replace />;
|
|
return <>{children}</>;
|
|
}
|
|
|
|
function RequireAdmin({ children }: { children: ReactNode }) {
|
|
const { user } = useAuth();
|
|
if (user?.role !== "admin") return <Navigate to="/" replace />;
|
|
return <>{children}</>;
|
|
}
|
|
|
|
function RequirePrimaryOrAdmin({ children }: { children: ReactNode }) {
|
|
const { user, profile } = useAuth();
|
|
if (user?.role !== "admin" && profile?.is_primary !== true) {
|
|
return <Navigate to="/" replace />;
|
|
}
|
|
return <>{children}</>;
|
|
}
|
|
|
|
/**
|
|
* Redirects new profiles (no favorites yet, no skip flag) to the taste-seed
|
|
* onboarding screen the first time they land on Home. Only checks on Home so
|
|
* deep-links to other pages aren't blocked. Once the user picks any items
|
|
* (or favorites anything by normal use), or explicitly skips, the gate stops
|
|
* redirecting.
|
|
*/
|
|
function TasteSeedGate({ children }: { children: ReactNode }) {
|
|
const { profile } = useAuth();
|
|
const { data: favorites, isPending, isError } = useFavorites();
|
|
|
|
if (isPending || isError || !profile) return <>{children}</>;
|
|
|
|
const hasFavorites = (favorites?.length ?? 0) > 0;
|
|
const dismissed = isTasteSeedDismissed(profile.id);
|
|
|
|
if (!hasFavorites && !dismissed) {
|
|
return <Navigate to="/taste-seed" replace />;
|
|
}
|
|
return <>{children}</>;
|
|
}
|
|
|
|
/** Clears user-scoped query caches on profile switch or logout. */
|
|
function QueryCacheManager() {
|
|
const { user, profile } = useAuth();
|
|
const qc = useQueryClient();
|
|
const prevProfileId = useRef(profile?.id);
|
|
|
|
useEffect(() => {
|
|
if (!user) {
|
|
qc.clear();
|
|
prevProfileId.current = undefined;
|
|
return;
|
|
}
|
|
if (prevProfileId.current && prevProfileId.current !== profile?.id) {
|
|
qc.removeQueries({ queryKey: ["favorites"] });
|
|
qc.removeQueries({ queryKey: ["watchlist"] });
|
|
qc.removeQueries({ queryKey: ["history"] });
|
|
qc.removeQueries({ queryKey: ["collections"] });
|
|
qc.removeQueries({ queryKey: ["libraryPlaybackPreferences"] });
|
|
qc.removeQueries({ queryKey: ["progress"] });
|
|
qc.removeQueries({ queryKey: ["sections"] });
|
|
qc.removeQueries({ queryKey: ["calendar"] });
|
|
// Recommendation rows include per-profile user_state (is_favorite, etc.);
|
|
// the taste-seed picker depends on this for pre-selection.
|
|
qc.removeQueries({ queryKey: ["recommendations"] });
|
|
}
|
|
prevProfileId.current = profile?.id;
|
|
}, [user, profile?.id, qc]);
|
|
|
|
return null;
|
|
}
|
|
|
|
function AppChrome() {
|
|
const { user, isImpersonating, endImpersonation } = useAuth();
|
|
const navigate = useNavigate();
|
|
|
|
if (!user?.impersonation || !isImpersonating) {
|
|
return null;
|
|
}
|
|
|
|
async function handleEndImpersonation() {
|
|
const returnPath = loadStoredImpersonationAdminSession()?.returnPath ?? "/admin/users";
|
|
|
|
try {
|
|
await endImpersonation();
|
|
navigate(returnPath, { replace: true });
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : "Failed to end impersonation");
|
|
}
|
|
}
|
|
|
|
return (
|
|
<ImpersonationBanner
|
|
userName={user.username}
|
|
impersonatorName={user.impersonation.impersonator_username}
|
|
onEnd={handleEndImpersonation}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function LegacySearchRedirect() {
|
|
const [searchParams] = useSearchParams();
|
|
return <Navigate to={buildQueryCatalogHref(searchParams.get("q") ?? undefined)} replace />;
|
|
}
|
|
|
|
function LegacyBrowseRedirect() {
|
|
const [searchParams] = useSearchParams();
|
|
const href = buildLegacyBrowseCatalogHref(searchParams);
|
|
|
|
if (!href) {
|
|
return <Navigate to={buildQueryCatalogHref()} replace />;
|
|
}
|
|
|
|
return <Navigate to={href} replace />;
|
|
}
|
|
|
|
function LegacyWebhookSyncRedirect() {
|
|
const { search } = useLocation();
|
|
return <Navigate to={buildLegacyWebhookSyncRedirectTarget(search)} replace />;
|
|
}
|
|
|
|
function LegacyPersonalCatalogRedirect({
|
|
source,
|
|
}: {
|
|
source: "favorites" | "watchlist" | "history";
|
|
}) {
|
|
return <Navigate to={buildPersonalCatalogHref(source)} replace />;
|
|
}
|
|
|
|
function LegacyUserCollectionRedirect() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const location = useLocation();
|
|
|
|
if (!id) {
|
|
return <Navigate to="/collections" replace />;
|
|
}
|
|
|
|
return (
|
|
<Navigate
|
|
to={buildUserCollectionCatalogHref(
|
|
id,
|
|
new URLSearchParams(location.search).get("title") ?? undefined,
|
|
)}
|
|
replace
|
|
/>
|
|
);
|
|
}
|
|
|
|
function AppRoutes() {
|
|
return (
|
|
<Routes>
|
|
<Route path="/login" element={<Login />} />
|
|
<Route path="/login/oauth-complete" element={<OAuthComplete />} />
|
|
<Route path="/activate" element={<ActivateDevice />} />
|
|
<Route path="/setup" element={<SetupWizard />} />
|
|
<Route path="/signup" element={<Signup />} />
|
|
<Route
|
|
path="/*"
|
|
element={
|
|
<SetupGate>
|
|
<RequireAuth>
|
|
<Routes>
|
|
<Route path="/profiles" element={<Profiles />} />
|
|
<Route
|
|
path="/taste-seed"
|
|
element={
|
|
<RequireProfile>
|
|
<TasteSeed />
|
|
</RequireProfile>
|
|
}
|
|
/>
|
|
<Route
|
|
path="/watch/:id"
|
|
element={
|
|
<RequireProfile>
|
|
<WatchRoute />
|
|
</RequireProfile>
|
|
}
|
|
/>
|
|
{/* Admin area — own layout, no profile required */}
|
|
<Route
|
|
path="/admin/*"
|
|
element={
|
|
<RequireAdmin>
|
|
<AdminLayout />
|
|
</RequireAdmin>
|
|
}
|
|
>
|
|
<Route index element={<AdminDashboard />} />
|
|
<Route path="activity" element={<AdminActivity />} />
|
|
<Route path="logs" element={<AdminLogs />} />
|
|
<Route path="libraries" element={<AdminLibraries />} />
|
|
<Route path="maintenance" element={<AdminMaintenance />} />
|
|
<Route path="collections" element={<AdminCollections />} />
|
|
<Route path="collections/new" element={<AdminCollectionEditor />} />
|
|
<Route path="collections/:id/edit" element={<AdminCollectionEditor />} />
|
|
<Route path="history" element={<AdminPlaybackHistory />} />
|
|
<Route path="history-import" element={<AdminHistoryImport />} />
|
|
<Route path="users" element={<AdminUsers />} />
|
|
<Route path="users/:id" element={<AdminUserDetail />} />
|
|
<Route path="devices" element={<AdminDevices />} />
|
|
<Route path="devices/:userId/:deviceId" element={<AdminDevices />} />
|
|
<Route path="nodes" element={<AdminNodes />} />
|
|
<Route path="sections" element={<AdminSections />} />
|
|
<Route path="plugins" element={<AdminPlugins />} />
|
|
<Route path="settings" element={<AdminSettingsLayout />} />
|
|
<Route path="recommendations" element={<AdminRecommendations />} />
|
|
<Route path="api-keys" element={<AdminApiKeys />} />
|
|
<Route path="tasks" element={<AdminTasks />} />
|
|
<Route path="tasks/:key" element={<AdminTaskDetail />} />
|
|
<Route path="stats" element={<Navigate to="/admin" replace />} />
|
|
<Route path="*" element={<Navigate to="/admin" replace />} />
|
|
</Route>
|
|
{/* Settings area — own layout, requires profile */}
|
|
<Route
|
|
path="/settings/*"
|
|
element={
|
|
<RequireProfile>
|
|
<Layout>
|
|
<SettingsLayout />
|
|
</Layout>
|
|
</RequireProfile>
|
|
}
|
|
>
|
|
<Route index element={<Navigate to="playback" replace />} />
|
|
<Route path="appearance" element={<AppearanceSettings />} />
|
|
<Route path="theme-editor" element={<ThemeEditorSettings />} />
|
|
<Route path="accessibility" element={<AccessibilitySettings />} />
|
|
<Route path="playback" element={<PlaybackSettings />} />
|
|
<Route
|
|
path="profiles"
|
|
element={
|
|
<RequirePrimaryOrAdmin>
|
|
<ProfilesSettings />
|
|
</RequirePrimaryOrAdmin>
|
|
}
|
|
/>
|
|
<Route path="libraries" element={<LibrarySettings />} />
|
|
<Route path="history-import" element={<HistoryImportSettings />} />
|
|
<Route path="plex-webhooks" element={<LegacyWebhookSyncRedirect />} />
|
|
<Route path="webhook-sync" element={<WebhookSyncSettings />} />
|
|
<Route path="watch-providers" element={<WatchProvidersSettings />} />
|
|
<Route path="subtitle-appearance" element={<SubtitleAppearanceSettings />} />
|
|
<Route path="home-screen" element={<HomeScreenSettings />} />
|
|
<Route path="card-overlays" element={<CardOverlaySettings />} />
|
|
<Route path="personalize" element={<PersonalizeSettings />} />
|
|
<Route path="*" element={<Navigate to="/settings/playback" replace />} />
|
|
</Route>
|
|
<Route
|
|
path="/*"
|
|
element={
|
|
<RequireProfile>
|
|
<Layout>
|
|
<Routes>
|
|
<Route
|
|
path="/"
|
|
element={
|
|
<TasteSeedGate>
|
|
<Home />
|
|
</TasteSeedGate>
|
|
}
|
|
/>
|
|
<Route path="/catalog" element={<Catalog />} />
|
|
<Route path="/library/:libraryId" element={<LibraryPage />} />
|
|
<Route path="/search" element={<LegacySearchRedirect />} />
|
|
<Route path="/browse" element={<LegacyBrowseRedirect />} />
|
|
<Route path="/item/:id" element={<ItemDetail />} />
|
|
<Route path="/person/:id" element={<PersonDetail />} />
|
|
<Route path="/rooms/:roomId" element={<WatchTogetherRoomPage />} />
|
|
<Route path="/rooms/join" element={<WatchTogetherJoin />} />
|
|
<Route
|
|
path="/favorites"
|
|
element={<LegacyPersonalCatalogRedirect source="favorites" />}
|
|
/>
|
|
<Route
|
|
path="/watchlist"
|
|
element={<LegacyPersonalCatalogRedirect source="watchlist" />}
|
|
/>
|
|
<Route
|
|
path="/history"
|
|
element={<LegacyPersonalCatalogRedirect source="history" />}
|
|
/>
|
|
<Route path="/collections" element={<Collections />} />
|
|
<Route path="/collections/new" element={<CollectionEditor />} />
|
|
<Route path="/collections/:id/edit" element={<CollectionEditor />} />
|
|
<Route
|
|
path="/collections/:id"
|
|
element={<LegacyUserCollectionRedirect />}
|
|
/>
|
|
<Route path="/recommendations" element={<Recommendations />} />
|
|
<Route
|
|
path="/recommendations/section/:kind"
|
|
element={<RecommendationsSection />}
|
|
/>
|
|
<Route
|
|
path="/recommendations/section/:kind/:key"
|
|
element={<RecommendationsSection />}
|
|
/>
|
|
<Route path="/calendar" element={<Calendar />} />
|
|
<Route
|
|
path="/profile/customize-home"
|
|
element={<ProfileCustomizeHome />}
|
|
/>
|
|
<Route path="*" element={<Navigate to="/" replace />} />
|
|
</Routes>
|
|
</Layout>
|
|
</RequireProfile>
|
|
}
|
|
/>
|
|
</Routes>
|
|
</RequireAuth>
|
|
</SetupGate>
|
|
}
|
|
/>
|
|
</Routes>
|
|
);
|
|
}
|
|
|
|
export default function App() {
|
|
return (
|
|
<BrowserRouter>
|
|
<AuthProvider>
|
|
<QueryClientProvider client={queryClient}>
|
|
<ErrorBoundary>
|
|
<ThemeProvider>
|
|
<CustomThemeProvider>
|
|
<WatchPlaybackProvider>
|
|
<ScrollRestorationManager />
|
|
<RouteAnnouncer />
|
|
<QueryCacheManager />
|
|
<AppChrome />
|
|
<AppRoutes />
|
|
<WatchPlaybackHost />
|
|
<WatchPlaybackBar />
|
|
<Toaster />
|
|
</WatchPlaybackProvider>
|
|
</CustomThemeProvider>
|
|
</ThemeProvider>
|
|
</ErrorBoundary>
|
|
</QueryClientProvider>
|
|
</AuthProvider>
|
|
</BrowserRouter>
|
|
);
|
|
}
|