Merge remote-tracking branch 'origin/main' into feat/audiobooks

# Conflicts:
#	go.sum
This commit is contained in:
RXWatcher
2026-05-27 16:27:25 +02:00
146 changed files with 15999 additions and 1363 deletions
+2
View File
@@ -50,6 +50,7 @@ import AdminCollectionEditor from "@/pages/AdminCollectionEditor";
import AdminPlaybackHistory from "@/pages/AdminPlaybackHistory";
import AdminMaintenance from "@/pages/AdminMaintenance";
import AdminApiKeys from "@/pages/AdminApiKeys";
import AdminSubtitles from "@/pages/AdminSubtitles";
import AdminUserDetail from "@/pages/AdminUserDetail";
import AdminTasks from "@/pages/AdminTasks";
import AdminTaskDetail from "@/pages/AdminTaskDetail";
@@ -385,6 +386,7 @@ function AppRoutes() {
<Route path="settings" element={<AdminSettingsLayout />} />
<Route path="recommendations" element={<AdminRecommendations />} />
<Route path="api-keys" element={<AdminApiKeys />} />
<Route path="subtitles" element={<AdminSubtitles />} />
<Route path="tasks" element={<AdminTasks />} />
<Route path="tasks/:key" element={<AdminTaskDetail />} />
<Route path="stats" element={<Navigate to="/admin" replace />} />
+28
View File
@@ -121,6 +121,34 @@ describe("client helper inventory", () => {
});
describe("api", () => {
it("forwards AbortSignal from options to fetch", async () => {
Object.defineProperty(globalThis, "sessionStorage", {
value: {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
clear: () => {},
},
configurable: true,
});
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
const controller = new AbortController();
await api("/test", { signal: controller.signal });
expect(fetchMock).toHaveBeenCalledTimes(1);
const call = fetchMock.mock.calls[0]!;
const init = call[1] as RequestInit;
expect(init.signal).toBe(controller.signal);
});
it("treats 202 responses with an empty body as success", async () => {
Object.defineProperty(globalThis, "sessionStorage", {
value: {
+59
View File
@@ -370,6 +370,65 @@ export async function api<T>(path: string, options: RequestInit = {}): Promise<T
return JSON.parse(text) as T;
}
function buildApiHeaders(options: RequestInit = {}): Record<string, string> {
const headers: Record<string, string> = {
...(options.headers as Record<string, string>),
};
if (!(options.body instanceof FormData)) {
headers["Content-Type"] = headers["Content-Type"] ?? "application/json";
}
if (accessToken) {
headers["Authorization"] = `Bearer ${accessToken}`;
}
const profileId = getProfileId();
if (profileId) {
headers["X-Profile-Id"] = profileId;
}
const profToken = getProfileToken();
if (profToken) {
headers["X-Profile-Token"] = profToken;
}
Object.assign(headers, getDeviceHeaders());
return headers;
}
/** Downloads a binary API response and triggers a browser file save. */
export async function apiDownload(
path: string,
filename: string,
options: RequestInit = {},
): Promise<void> {
let headers = buildApiHeaders(options);
let res = await fetch(`/api/v1${path}`, { ...options, headers });
if (res.status === 401 && getRefreshToken()) {
if (!refreshPromise) {
refreshPromise = attemptRefresh().finally(() => {
refreshPromise = null;
});
}
const refreshed = await refreshPromise;
if (refreshed) {
headers = buildApiHeaders(options);
headers["Authorization"] = `Bearer ${accessToken}`;
res = await fetch(`/api/v1${path}`, { ...options, headers });
}
}
if (!res.ok) {
const apiErr = await parseApiError(res);
throw new ApiClientError(res.status, apiErr.error, apiErr.message, apiErr);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
}
// People API
export async function searchPeople(query: string, limit = 20): Promise<import("./types").Person[]> {
const params = new URLSearchParams({ q: query, limit: String(limit) });
+59
View File
@@ -97,6 +97,7 @@ export interface User {
username: string;
email: string;
role: string;
permissions: string[];
download_allowed: boolean;
impersonation?: ImpersonationInfo | null;
}
@@ -1631,6 +1632,7 @@ export interface AdminUser {
username: string;
email: string;
role: string;
permissions: string[];
enabled: boolean;
library_ids: number[] | null;
max_playback_quality: string;
@@ -1649,6 +1651,7 @@ export interface CreateUserRequest {
email: string;
password: string;
role: string;
permissions?: string[];
create_default_profile?: boolean;
default_profile_name?: string;
library_ids?: number[] | null;
@@ -1665,6 +1668,7 @@ export interface UpdateUserRequest {
email?: string;
password?: string;
role?: string;
permissions?: string[];
enabled?: boolean;
library_ids?: number[] | null;
max_playback_quality?: string;
@@ -2952,6 +2956,20 @@ export interface SubtitleDownloadRequest {
hearing_impaired: boolean;
}
export interface SubtitleUploadRequest {
media_file_id: number;
file: File;
language?: string;
language_override?: boolean;
release_name?: string;
hearing_impaired?: boolean;
}
export interface SubtitleLanguageDetection {
language: string;
source: "filename" | "metadata" | "content" | "manual";
}
export interface DownloadedSubtitle {
id: number;
media_file_id: number;
@@ -2964,6 +2982,47 @@ export interface DownloadedSubtitle {
created_at: string;
}
export interface AdminDownloadedSubtitle {
id: number;
media_file_id: number;
media_content_id?: string;
provider: string;
language: string;
format: string;
release_name: string;
score: number;
hearing_impaired: boolean;
created_at: string;
downloaded_by?: number;
uploader_username: string;
media_title: string;
media_type: string;
file_path: string;
}
export interface AdminDownloadedSubtitlesResponse {
subtitles: AdminDownloadedSubtitle[];
total: number;
uploads: number;
provider_downloads: number;
}
export interface AdminDownloadedSubtitlesFilters {
provider?: string;
language?: string;
userId?: number;
mediaFileId?: number;
q?: string;
limit?: number;
offset?: number;
}
export interface AdminUpdateDownloadedSubtitleRequest {
language?: string;
release_name?: string;
hearing_impaired?: boolean;
}
export interface SubtitleProviderConfig {
provider_name: string;
enabled: boolean;
+22
View File
@@ -1643,6 +1643,28 @@
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.04);
}
.caption-empty-state span {
display: block;
height: 0.35rem;
border-radius: 999px;
background: color-mix(in srgb, var(--foreground) 18%, transparent);
}
.caption-empty-state span:nth-child(1) {
width: 72%;
margin-inline: auto;
}
.caption-empty-state span:nth-child(2) {
width: 54%;
margin-inline: auto;
}
.caption-empty-state span:nth-child(3) {
width: 38%;
margin-inline: auto;
}
/* ── Scan Queue Accent ──────────────────────────────────── */
/* Animated gradient sweep across the top of the scan queue
to signal live activity. Only visible when scans are active. */
+6
View File
@@ -8,6 +8,7 @@ import {
Users,
MonitorSmartphone,
History,
Captions,
Download,
SlidersHorizontal,
Server,
@@ -114,6 +115,11 @@ export default function AdminSidebar({ onNavigate }: AdminSidebarProps) {
icon: <PanelsTopLeft className="h-[18px] w-[18px]" />,
href: "/admin/sections",
},
{
label: "Subtitles",
icon: <Captions className="h-[18px] w-[18px]" />,
href: "/admin/subtitles",
},
],
},
{
+23 -9
View File
@@ -13,6 +13,7 @@ import {
useRefreshItemMetadata,
type UpdateItemMetadataRequest,
} from "@/hooks/queries/items";
import { useAuth } from "@/hooks/useAuth";
import { cn } from "@/lib/utils";
// MetadataField enum values matching internal/metadata/types.go
@@ -92,6 +93,7 @@ function initFormState(item: ItemDetail) {
}
export default function EditMetadataDialog({ item, open, onOpenChange }: EditMetadataDialogProps) {
const { user } = useAuth();
const [activeSection, setActiveSection] = useState<Section>("general");
const [form, setForm] = useState(() => initFormState(item));
const [lockedFields, setLockedFields] = useState<Set<number>>(
@@ -103,7 +105,13 @@ export default function EditMetadataDialog({ item, open, onOpenChange }: EditMet
const refreshMutation = useRefreshItemMetadata();
const isLockable = item.type === "movie" || item.type === "series";
const visibleSections = SECTIONS.filter((s) => s.types.includes(item.type));
const canEditImages = user?.role === "admin";
const visibleSections = SECTIONS.filter(
(s) => s.types.includes(item.type) && (s.key !== "images" || canEditImages),
);
const effectiveActiveSection = visibleSections.some((section) => section.key === activeSection)
? activeSection
: "general";
const originalForm = useMemo(() => initFormState(item), [item]);
@@ -261,7 +269,7 @@ export default function EditMetadataDialog({ item, open, onOpenChange }: EditMet
onClick={() => setActiveSection(section.key)}
className={cn(
"px-4 py-2 text-left text-[13px] font-medium whitespace-nowrap transition-colors",
activeSection === section.key
effectiveActiveSection === section.key
? "border-primary bg-primary/8 text-primary max-sm:border-b-2 sm:border-r-2"
: "text-muted-foreground hover:text-foreground",
)}
@@ -273,7 +281,7 @@ export default function EditMetadataDialog({ item, open, onOpenChange }: EditMet
{/* Content */}
<div className="flex-1 overflow-y-auto px-4 py-4 sm:px-6 sm:py-5">
{activeSection === "general" && (
{effectiveActiveSection === "general" && (
<div className="space-y-4">
<FieldRow label="Title" lockIcon={renderLockIcon("title")}>
<Input value={form.title} onChange={(e) => setField("title", e.target.value)} />
@@ -389,7 +397,7 @@ export default function EditMetadataDialog({ item, open, onOpenChange }: EditMet
</div>
)}
{activeSection === "dates" && (
{effectiveActiveSection === "dates" && (
<div className="space-y-4">
{(item.type === "movie" || item.type === "series") && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
@@ -528,7 +536,7 @@ export default function EditMetadataDialog({ item, open, onOpenChange }: EditMet
</div>
)}
{activeSection === "tags" && (
{effectiveActiveSection === "tags" && (
<div className="space-y-4">
<FieldRow label="Genres" lockIcon={renderLockIcon("genres")}>
<TagInput
@@ -566,7 +574,7 @@ export default function EditMetadataDialog({ item, open, onOpenChange }: EditMet
</div>
)}
{activeSection === "ids" && (
{effectiveActiveSection === "ids" && (
<div className="space-y-4">
<FieldRow label="IMDb ID">
<Input
@@ -590,9 +598,15 @@ export default function EditMetadataDialog({ item, open, onOpenChange }: EditMet
</div>
)}
<div className={activeSection === "images" ? "flex h-full flex-col" : "hidden"}>
<ImageSelectorTab item={item} enabled={activeSection === "images"} />
</div>
{canEditImages && (
<div
className={
effectiveActiveSection === "images" ? "flex h-full flex-col" : "hidden"
}
>
<ImageSelectorTab item={item} enabled={effectiveActiveSection === "images"} />
</div>
)}
</div>
</div>
+229
View File
@@ -6,6 +6,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
useQuery: vi.fn(),
useCanRequest: vi.fn(),
useRequestSearch: vi.fn(),
}));
vi.mock("@tanstack/react-query", async () => {
@@ -21,6 +23,30 @@ vi.mock("@/hooks/useDebounce", () => ({
useDebounce: <T,>(v: T) => v,
}));
vi.mock("@/hooks/useCanRequest", () => ({
useCanRequest: () => mocks.useCanRequest(),
}));
vi.mock("@/hooks/queries/useRequests", () => ({
useRequestSearch: (...args: unknown[]) => mocks.useRequestSearch(...args),
}));
vi.mock("@/components/RequestToAddSection", () => ({
RequestToAddSection: ({
variant,
query,
libraryHadHits,
}: {
variant: string;
query: string;
libraryHadHits: boolean;
}) => (
<div data-testid="request-section">
{`variant="${variant}" query="${query}" libraryHadHits="${String(libraryHadHits)}"`}
</div>
),
}));
vi.mock("@/components/ui/dialog", () => ({
Dialog: ({ children, open }: { children: ReactNode; open: boolean }) =>
open ? <div data-testid="dialog">{children}</div> : null,
@@ -66,6 +92,18 @@ function renderSearchMarkup(props: Partial<Parameters<typeof GlobalSearch>[0]> =
describe("GlobalSearch", () => {
beforeEach(() => {
mocks.useQuery.mockReset();
mocks.useCanRequest.mockReset();
mocks.useRequestSearch.mockReset();
mocks.useCanRequest.mockReturnValue({
discoveryEnabled: false,
isResolving: false,
submitDisabledReason: null,
});
mocks.useRequestSearch.mockReturnValue({
data: undefined,
isLoading: false,
isError: false,
});
mocks.useQuery.mockReturnValue({
data: {
total: 50,
@@ -105,3 +143,194 @@ describe("GlobalSearch", () => {
expect(lastCall.enabled).toBe(false);
});
});
describe("GlobalSearch + RequestToAddSection wiring", () => {
beforeEach(() => {
mocks.useQuery.mockReset();
mocks.useCanRequest.mockReset();
mocks.useRequestSearch.mockReset();
mocks.useCanRequest.mockReturnValue({
discoveryEnabled: false,
isResolving: false,
submitDisabledReason: null,
});
mocks.useRequestSearch.mockReturnValue({
data: undefined,
isLoading: false,
isError: false,
});
mocks.useQuery.mockReturnValue({
data: { total: 50, has_more: true, items: [browseFixture] },
isFetching: false,
isError: false,
});
});
it("renders the section with libraryHadHits=true when library returned results", () => {
mocks.useCanRequest.mockReturnValue({
discoveryEnabled: true,
isResolving: false,
submitDisabledReason: null,
});
mocks.useRequestSearch.mockReturnValue({
data: {
page: 1,
total_pages: 1,
total_results: 1,
results: [
{
media_type: "movie",
tmdb_id: 1,
title: "X",
availability: "missing",
request: { requestable: true },
},
],
},
isLoading: false,
isError: false,
});
const markup = renderSearchMarkup({ defaultOpen: true, initialQuery: "Dune" });
expect(markup).toContain('data-testid="request-section"');
expect(markup).toContain("libraryHadHits=&quot;true&quot;");
expect(markup).toContain("variant=&quot;dialog&quot;");
});
it("renders the section with libraryHadHits=false when library returned 0 results", () => {
mocks.useCanRequest.mockReturnValue({
discoveryEnabled: true,
isResolving: false,
submitDisabledReason: null,
});
mocks.useQuery.mockReturnValue({
data: { total: 0, has_more: false, items: [] },
isFetching: false,
isError: false,
});
mocks.useRequestSearch.mockReturnValue({
data: {
page: 1,
total_pages: 1,
total_results: 1,
results: [
{
media_type: "movie",
tmdb_id: 1,
title: "X",
availability: "missing",
request: { requestable: true },
},
],
},
isLoading: false,
isError: false,
});
const markup = renderSearchMarkup({ defaultOpen: true, initialQuery: "ThisDoesNotExist" });
expect(markup).toContain("libraryHadHits=&quot;false&quot;");
});
it("does not call useRequestSearch with enabled=true when discoveryEnabled is false", () => {
mocks.useCanRequest.mockReturnValue({
discoveryEnabled: false,
isResolving: false,
submitDisabledReason: null,
});
renderSearchMarkup({ defaultOpen: true, initialQuery: "Dune" });
const call = mocks.useRequestSearch.mock.calls[mocks.useRequestSearch.mock.calls.length - 1];
expect(call?.[3]).toEqual({
enabled: false,
requireProfile: true,
staleTime: 5 * 60 * 1000,
});
});
it("does not mount RequestToAddSection when discovery is disabled", () => {
mocks.useCanRequest.mockReturnValue({
discoveryEnabled: false,
isResolving: false,
submitDisabledReason: null,
});
const markup = renderSearchMarkup({ defaultOpen: true, initialQuery: "Dune" });
expect(markup).not.toContain('data-testid="request-section"');
});
it("suppresses 'No matches' when library is empty and TMDB is still loading", () => {
mocks.useCanRequest.mockReturnValue({
discoveryEnabled: true,
isResolving: false,
submitDisabledReason: null,
});
mocks.useQuery.mockReturnValue({
data: { total: 0, has_more: false, items: [] },
isFetching: false,
isError: false,
});
mocks.useRequestSearch.mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
});
const markup = renderSearchMarkup({ defaultOpen: true, initialQuery: "Pending" });
expect(markup).not.toContain("No matches");
});
it("suppresses 'No matches' when library is empty and TMDB has missing results", () => {
mocks.useCanRequest.mockReturnValue({
discoveryEnabled: true,
isResolving: false,
submitDisabledReason: null,
});
mocks.useQuery.mockReturnValue({
data: { total: 0, has_more: false, items: [] },
isFetching: false,
isError: false,
});
mocks.useRequestSearch.mockReturnValue({
data: {
page: 1,
total_pages: 1,
total_results: 1,
results: [
{
media_type: "movie",
tmdb_id: 1,
title: "X",
availability: "missing",
request: { requestable: true },
},
],
},
isLoading: false,
isError: false,
});
const markup = renderSearchMarkup({ defaultOpen: true, initialQuery: "FoundOnTmdb" });
expect(markup).not.toContain("No matches");
});
it("still shows 'No matches' when both library and TMDB are empty", () => {
mocks.useCanRequest.mockReturnValue({
discoveryEnabled: true,
isResolving: false,
submitDisabledReason: null,
});
mocks.useQuery.mockReturnValue({
data: { total: 0, has_more: false, items: [] },
isFetching: false,
isError: false,
});
mocks.useRequestSearch.mockReturnValue({
data: { page: 1, total_pages: 1, total_results: 0, results: [] },
isLoading: false,
isError: false,
});
const markup = renderSearchMarkup({ defaultOpen: true, initialQuery: "ZzzNothing" });
expect(markup).toContain("No matches");
});
});
+63 -29
View File
@@ -7,13 +7,17 @@ import { useDebounce } from "@/hooks/useDebounce";
import { buildQueryCatalogHref } from "@/pages/catalogSearchParams";
import type { BrowseItem } from "@/api/types";
import { createCatalogSearchState, fetchCatalogPage } from "@/hooks/queries/catalog";
import { useRequestSearch } from "@/hooks/queries/useRequests";
import { useCanRequest } from "@/hooks/useCanRequest";
import { catalogKeys } from "@/hooks/queries/keys";
import { decodeThumbhash } from "@/lib/thumbhash";
import { cn } from "@/lib/utils";
import { Search } from "lucide-react";
import { RequestToAddSection } from "./RequestToAddSection";
const PREVIEW_LIMIT = 8;
const DEBOUNCE_MS = 200;
const TMDB_DEBOUNCE_MS = 400;
function typeLabel(type: BrowseItem["type"]): string {
switch (type) {
@@ -100,6 +104,24 @@ export function GlobalSearch({
const [selectedIndex, setSelectedIndex] = useState(-1);
const navigate = useViewTransitionNavigate();
const debouncedQuery = useDebounce(query.trim(), DEBOUNCE_MS);
const tmdbDebouncedQuery = useDebounce(query.trim(), TMDB_DEBOUNCE_MS);
const canRequest = useCanRequest();
const tmdbQuery = useRequestSearch("all", tmdbDebouncedQuery, 1, {
enabled: canRequest.discoveryEnabled,
requireProfile: true,
staleTime: 5 * 60 * 1000,
});
const tmdbMissingCount =
tmdbQuery.data?.results?.filter((result) => result.availability !== "available").length ?? 0;
// Cap at DIALOG_LIMIT (4) — RequestToAddSection slices results to that many rows.
const tmdbVisibleCount = Math.min(tmdbMissingCount, 4);
const tmdbStillLoading =
canRequest.discoveryEnabled && tmdbDebouncedQuery.length > 1 && tmdbQuery.isLoading;
const tmdbWillRender = canRequest.discoveryEnabled && tmdbMissingCount > 0;
// Hide empty state while the TMDB debounce trails the library debounce; otherwise
// the user sees "No matches" flash between t=200ms and t=400ms after typing.
const tmdbDebounceCatchingUp =
canRequest.discoveryEnabled && tmdbDebouncedQuery !== debouncedQuery;
const searchState = useMemo(
() => createCatalogSearchState("query", { q: debouncedQuery || undefined }),
@@ -179,7 +201,11 @@ export function GlobalSearch({
!previewQuery.isFetching &&
debouncedQuery.length > 0 &&
items.length === 0 &&
!previewQuery.isError;
!previewQuery.isError &&
!tmdbStillLoading &&
!tmdbWillRender &&
!canRequest.isResolving &&
!tmdbDebounceCatchingUp;
const showError = previewQuery.isError;
return (
@@ -236,37 +262,45 @@ export function GlobalSearch({
</form>
{showResultsPanel && (
<div className="flex min-h-0 flex-1 flex-col">
<div
role="listbox"
className="max-h-[min(22rem,55vh)] overflow-y-auto overscroll-contain px-2 py-2"
>
{showLoading && (
<div className="text-muted-foreground px-3 py-6 text-center text-sm">
Searching...
</div>
)}
{showError && (
<div className="text-destructive px-3 py-4 text-center text-sm">
Could not load results. Press Enter to open the search page.
</div>
)}
{showEmpty && (
<div className="text-muted-foreground px-3 py-6 text-center text-sm">
No matches
</div>
)}
{items.map((item, i) => (
<GlobalSearchResultRow
key={item.content_id}
item={item}
index={i}
isSelected={i === selectedIndex}
onPick={handlePickItem}
<div className="max-h-[min(22rem,55vh)] overflow-y-auto overscroll-contain px-2 py-2">
<div role="listbox">
{showLoading && (
<div className="text-muted-foreground px-3 py-6 text-center text-sm">
Searching...
</div>
)}
{showError && (
<div className="text-destructive px-3 py-4 text-center text-sm">
Could not load results. Press Enter to open the search page.
</div>
)}
{showEmpty && (
<div className="text-muted-foreground px-3 py-6 text-center text-sm">
No matches
</div>
)}
{items.map((item, i) => (
<GlobalSearchResultRow
key={item.content_id}
item={item}
index={i}
isSelected={i === selectedIndex}
onPick={handlePickItem}
/>
))}
</div>
{tmdbDebouncedQuery.length > 1 && canRequest.discoveryEnabled && (
<RequestToAddSection
variant="dialog"
query={tmdbDebouncedQuery}
libraryHadHits={items.length > 0}
/>
))}
)}
</div>
<div role="status" aria-live="polite" className="sr-only">
{items.length} results found
{tmdbVisibleCount > 0
? `${items.length} library results, ${tmdbVisibleCount} request suggestions`
: `${items.length} results found`}
</div>
<div className="text-muted-foreground border-t px-3 py-2 text-center text-xs">
{total > PREVIEW_LIMIT ? (
+116
View File
@@ -0,0 +1,116 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
navigate: vi.fn(),
}));
vi.mock("react-router", async () => {
const actual = await vi.importActual<typeof import("react-router")>("react-router");
return {
...actual,
useNavigate: () => mocks.navigate,
};
});
import PageBack from "./PageBack";
describe("PageBack", () => {
afterEach(() => {
mocks.navigate.mockClear();
window.history.replaceState(null, "");
});
it("renders a button with the default 'Go back' aria-label", () => {
render(
<MemoryRouter>
<PageBack />
</MemoryRouter>,
);
expect(screen.getByRole("button", { name: "Go back" })).toBeInTheDocument();
});
it("uses a custom label when provided", () => {
render(
<MemoryRouter>
<PageBack label="Return to library" />
</MemoryRouter>,
);
expect(screen.getByRole("button", { name: "Return to library" })).toBeInTheDocument();
});
it("falls back to the default route when there is no router history", async () => {
render(
<MemoryRouter>
<PageBack />
</MemoryRouter>,
);
await userEvent.click(screen.getByRole("button", { name: "Go back" }));
expect(mocks.navigate).toHaveBeenCalledTimes(1);
expect(mocks.navigate).toHaveBeenCalledWith("/");
});
it("uses browser history when a router history entry is available", async () => {
window.history.replaceState({ idx: 1 }, "");
render(
<MemoryRouter>
<PageBack />
</MemoryRouter>,
);
await userEvent.click(screen.getByRole("button", { name: "Go back" }));
expect(mocks.navigate).toHaveBeenCalledTimes(1);
expect(mocks.navigate).toHaveBeenCalledWith(-1);
});
it("uses the explicit target when history preference is disabled", async () => {
window.history.replaceState({ idx: 1 }, "");
render(
<MemoryRouter>
<PageBack to="/collections" preferHistory={false} />
</MemoryRouter>,
);
await userEvent.click(screen.getByRole("button", { name: "Go back" }));
expect(mocks.navigate).toHaveBeenCalledTimes(1);
expect(mocks.navigate).toHaveBeenCalledWith("/collections");
});
it("applies the documented positioning and glass styling", () => {
render(
<MemoryRouter>
<PageBack />
</MemoryRouter>,
);
const button = screen.getByRole("button", { name: "Go back" });
expect(button).toHaveClass(
"glass",
"absolute",
"top-4",
"left-2",
"z-20",
"rounded-full",
"p-1.5",
);
});
it("pins to the viewport on lg+ when floating is set", () => {
render(
<MemoryRouter>
<PageBack floating />
</MemoryRouter>,
);
const button = screen.getByRole("button", { name: "Go back" });
expect(button).toHaveClass("lg:fixed", "lg:left-[268px]");
});
});
+48
View File
@@ -0,0 +1,48 @@
import { ChevronLeft } from "lucide-react";
import { type To, useNavigate } from "react-router";
interface PageBackProps {
label?: string;
to?: To;
preferHistory?: boolean;
/**
* When true, pins the button to the viewport on lg+ so it stays visible
* while scrolling. The offset matches the app sidebar (260px) so the
* button sits just inside the page content area.
*/
floating?: boolean;
}
export default function PageBack({
label = "Go back",
to = "/",
preferHistory = true,
floating = false,
}: PageBackProps) {
const navigate = useNavigate();
const position = floating
? "absolute top-4 left-2 sm:top-6 lg:fixed lg:left-[268px]"
: "absolute top-4 left-2 sm:top-6";
function goBack() {
const historyIndex = window.history.state?.idx;
if (preferHistory && typeof historyIndex === "number" && historyIndex > 0) {
navigate(-1);
return;
}
navigate(to);
}
return (
<button
type="button"
aria-label={label}
onClick={goBack}
className={`glass text-foreground hover:bg-accent ${position} z-20 flex items-center justify-center rounded-full p-1.5 shadow-md transition-colors`}
>
<ChevronLeft className="size-5" />
</button>
);
}
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { MemoryRouter } from "react-router";
import RequestPosterCard from "./RequestPosterCard";
import type { RequestMediaResult } from "@/api/types";
const requestable: RequestMediaResult = {
media_type: "movie",
tmdb_id: 42,
title: "Test Movie",
availability: "missing",
request: { requestable: true },
};
describe("RequestPosterCard (discover variant)", () => {
it("renders the hover Request button when onRequest is provided", () => {
const markup = renderToStaticMarkup(
<MemoryRouter>
<RequestPosterCard
variant="discover"
item={requestable}
isSubmitting={false}
onRequest={() => {}}
/>
</MemoryRouter>,
);
// Must render an actual <button> with the "Request" label, not just any "Request"
// substring (the /requests/... URL would match a naive includes check).
expect(markup).toMatch(/<button[^>]*>[\s\S]*?Request[\s\S]*?<\/button>/);
});
it("does not render the hover Request button when onRequest is omitted", () => {
const markup = renderToStaticMarkup(
<MemoryRouter>
<RequestPosterCard variant="discover" item={requestable} />
</MemoryRouter>,
);
// The discover variant only contains one <button> (the hover Request action);
// its absence is the strongest signal that the button was suppressed.
expect(markup).not.toContain("<button");
});
});
+8 -6
View File
@@ -9,8 +9,10 @@ const POSTER_WIDTH = "w-[148px] sm:w-[164px] lg:w-[184px]";
type DiscoverProps = {
variant: "discover";
item: RequestMediaResult;
isSubmitting: boolean;
onRequest: () => void;
/** Called when the inline hover Request button is clicked. Omit to suppress the button. */
onRequest?: () => void;
/** Displays the spinner state on the hover Request button. Ignored when onRequest is omitted. */
isSubmitting?: boolean;
/** When true, fills the parent (use inside grids). Default: fixed carousel width. */
fluid?: boolean;
};
@@ -44,8 +46,8 @@ function DiscoverCard({
fluid,
}: {
item: RequestMediaResult;
isSubmitting: boolean;
onRequest: () => void;
isSubmitting?: boolean;
onRequest?: () => void;
fluid?: boolean;
}) {
const poster = tmdbImageURL(item.poster_path);
@@ -92,11 +94,11 @@ function DiscoverCard({
/>
</Link>
{requestable && (
{requestable && onRequest && (
<div className="pointer-events-none absolute inset-x-0 top-0 flex aspect-[2/3] translate-y-2 items-end justify-center bg-gradient-to-t from-black/85 via-black/45 to-transparent p-3 opacity-0 transition-all duration-200 ease-out group-focus-within/req-card:translate-y-0 group-focus-within/req-card:opacity-100 group-hover/req-card:translate-y-0 group-hover/req-card:opacity-100">
<button
type="button"
disabled={isSubmitting}
disabled={Boolean(isSubmitting)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
@@ -0,0 +1,335 @@
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { MemoryRouter } from "react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const mocks = vi.hoisted(() => ({
useCanRequest: vi.fn(),
useRequestSearch: vi.fn(),
useCreateMediaRequest: vi.fn(),
useDebounce: vi.fn(),
}));
vi.mock("@/hooks/useCanRequest", () => ({
useCanRequest: () => mocks.useCanRequest(),
}));
vi.mock("@/hooks/queries/useRequests", () => ({
useRequestSearch: (...args: unknown[]) => mocks.useRequestSearch(...args),
useCreateMediaRequest: () => mocks.useCreateMediaRequest(),
}));
vi.mock("@/hooks/useDebounce", () => ({
useDebounce: <T,>(v: T) => mocks.useDebounce(v) ?? v,
}));
import { RequestToAddSection } from "./RequestToAddSection";
import type { RequestMediaResult } from "@/api/types";
function render(child: ReactNode) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<MemoryRouter>{child}</MemoryRouter>
</QueryClientProvider>,
);
}
const missingResult = (overrides: Partial<RequestMediaResult> = {}): RequestMediaResult => ({
media_type: "movie",
tmdb_id: 1,
title: "Dune: Prophecy",
year: 2024,
availability: "missing",
request: { requestable: true },
...overrides,
});
const availableResult = (overrides: Partial<RequestMediaResult> = {}): RequestMediaResult => ({
media_type: "movie",
tmdb_id: 2,
title: "Dune",
year: 2021,
availability: "available",
request: { requestable: false },
...overrides,
});
describe("RequestToAddSection (dialog variant)", () => {
beforeEach(() => {
mocks.useCanRequest.mockReset();
mocks.useRequestSearch.mockReset();
mocks.useDebounce.mockReset();
mocks.useCanRequest.mockReturnValue({
discoveryEnabled: true,
isResolving: false,
submitDisabledReason: null,
});
mocks.useDebounce.mockImplementation((v: unknown) => v);
});
it("renders nothing when discovery is disabled", () => {
mocks.useCanRequest.mockReturnValue({
discoveryEnabled: false,
isResolving: false,
submitDisabledReason: null,
});
mocks.useRequestSearch.mockReturnValue({ data: undefined, isLoading: false, isError: false });
const markup = render(<RequestToAddSection variant="dialog" query="dune" libraryHadHits />);
expect(markup).toBe("");
});
it("passes enabled=false to useRequestSearch when discovery is disabled so no network call fires", () => {
mocks.useCanRequest.mockReturnValue({
discoveryEnabled: false,
isResolving: false,
submitDisabledReason: null,
});
mocks.useRequestSearch.mockReturnValue({ data: undefined, isLoading: false, isError: false });
render(<RequestToAddSection variant="dialog" query="dune" libraryHadHits />);
const call = mocks.useRequestSearch.mock.calls[mocks.useRequestSearch.mock.calls.length - 1];
expect(call?.[0]).toBe("all");
expect(call?.[1]).toBe("dune");
expect(call?.[2]).toBe(1);
expect(call?.[3]).toEqual({
enabled: false,
requireProfile: true,
staleTime: 5 * 60 * 1000,
});
});
it("passes enabled=true to useRequestSearch when discovery is enabled", () => {
mocks.useCanRequest.mockReturnValue({
discoveryEnabled: true,
isResolving: false,
submitDisabledReason: null,
});
mocks.useRequestSearch.mockReturnValue({
data: { page: 1, total_pages: 1, total_results: 0, results: [] },
isLoading: false,
isError: false,
});
render(<RequestToAddSection variant="dialog" query="dune" libraryHadHits />);
const call = mocks.useRequestSearch.mock.calls[mocks.useRequestSearch.mock.calls.length - 1];
expect(call?.[3]).toEqual({
enabled: true,
requireProfile: true,
staleTime: 5 * 60 * 1000,
});
});
it("renders 'Request to Add' header when library had hits", () => {
mocks.useRequestSearch.mockReturnValue({
data: { page: 1, total_pages: 1, total_results: 1, results: [missingResult()] },
isLoading: false,
isError: false,
});
const markup = render(<RequestToAddSection variant="dialog" query="dune" libraryHadHits />);
expect(markup).toContain("Request to Add");
expect(markup).toContain("Dune: Prophecy");
});
it("renders soft framing when library had 0 hits", () => {
mocks.useRequestSearch.mockReturnValue({
data: { page: 1, total_pages: 1, total_results: 1, results: [missingResult()] },
isLoading: false,
isError: false,
});
const markup = render(
<RequestToAddSection variant="dialog" query="dune" libraryHadHits={false} />,
);
expect(markup).toContain("Not in your library, but you can request");
expect(markup).not.toContain("Request to Add");
});
it("filters out results already available in the library", () => {
// missingResult has tmdb_id 1, availableResult has tmdb_id 2. The DialogRow
// renders item.title only as text content (never as a `title=` attribute), so
// a substring check on `title="Dune"` would pass even with the filter removed.
// Check the link target instead — it's a precise, filter-driven signal.
mocks.useRequestSearch.mockReturnValue({
data: {
page: 1,
total_pages: 1,
total_results: 2,
results: [availableResult(), missingResult()],
},
isLoading: false,
isError: false,
});
const markup = render(<RequestToAddSection variant="dialog" query="dune" libraryHadHits />);
expect(markup).toContain("/requests/movie/1");
expect(markup).not.toContain("/requests/movie/2");
});
it("renders nothing when TMDB returned an error", () => {
mocks.useRequestSearch.mockReturnValue({ data: undefined, isLoading: false, isError: true });
const markup = render(<RequestToAddSection variant="dialog" query="dune" libraryHadHits />);
expect(markup).toBe("");
});
it("keeps rendering cached TMDB results when a refetch errors", () => {
mocks.useRequestSearch.mockReturnValue({
data: { page: 1, total_pages: 1, total_results: 1, results: [missingResult()] },
isLoading: false,
isError: true,
});
const markup = render(<RequestToAddSection variant="dialog" query="dune" libraryHadHits />);
expect(markup).toContain("Dune: Prophecy");
});
it("renders nothing when all TMDB results are already in the library", () => {
mocks.useRequestSearch.mockReturnValue({
data: { page: 1, total_pages: 1, total_results: 1, results: [availableResult()] },
isLoading: false,
isError: false,
});
const markup = render(<RequestToAddSection variant="dialog" query="dune" libraryHadHits />);
expect(markup).toBe("");
});
it("limits the dialog variant to at most 4 rows", () => {
const many = Array.from({ length: 10 }, (_, i) =>
missingResult({ tmdb_id: i + 100, title: `Result ${i}` }),
);
mocks.useRequestSearch.mockReturnValue({
data: { page: 1, total_pages: 1, total_results: many.length, results: many },
isLoading: false,
isError: false,
});
const markup = render(<RequestToAddSection variant="dialog" query="dune" libraryHadHits />);
expect(markup).toContain("Result 0");
expect(markup).toContain("Result 3");
expect(markup).not.toContain("Result 4");
});
it("renders the disabled affordance and reason when a row is not requestable", () => {
mocks.useRequestSearch.mockReturnValue({
data: {
page: 1,
total_pages: 1,
total_results: 1,
results: [
missingResult({
tmdb_id: 7,
title: "Quota Capped Movie",
// formatRequestReason recognises "quota_exceeded" (not "quota_exhausted");
// assert on the produced label so a regression in that mapping is caught.
request: { requestable: false, reason: "quota_exceeded" },
}),
],
},
isLoading: false,
isError: false,
});
const markup = render(<RequestToAddSection variant="dialog" query="dune" libraryHadHits />);
expect(markup).toContain("Quota Capped Movie");
expect(markup).not.toContain("bg-amber-400/15");
expect(markup).toContain("Limit reached");
expect(markup).toContain('title="Limit reached"');
});
it("prefers request status over reason when a row is already requested", () => {
mocks.useRequestSearch.mockReturnValue({
data: {
page: 1,
total_pages: 1,
total_results: 1,
results: [
missingResult({
tmdb_id: 8,
title: "Already Pending Movie",
request: { requestable: false, reason: "blocked", status: "pending" },
}),
],
},
isLoading: false,
isError: false,
});
const markup = render(<RequestToAddSection variant="dialog" query="dune" libraryHadHits />);
expect(markup).toContain("Already Pending Movie");
expect(markup).toContain("Pending");
expect(markup).toContain('title="Pending"');
expect(markup).not.toContain('title="Blocked"');
});
});
describe("RequestToAddSection (grid variant)", () => {
beforeEach(() => {
mocks.useCanRequest.mockReset();
mocks.useRequestSearch.mockReset();
mocks.useCreateMediaRequest.mockReset();
mocks.useCanRequest.mockReturnValue({
discoveryEnabled: true,
isResolving: false,
submitDisabledReason: null,
});
mocks.useCreateMediaRequest.mockReturnValue({
mutate: vi.fn(),
isPending: false,
variables: undefined,
});
});
it("renders a card per result with the Request to Add header when library had hits", () => {
mocks.useRequestSearch.mockReturnValue({
data: {
page: 1,
total_pages: 1,
total_results: 2,
results: [
missingResult({ tmdb_id: 1, title: "Dune: Prophecy" }),
missingResult({ tmdb_id: 2, title: "Dune (1984)" }),
],
},
isLoading: false,
isError: false,
});
const markup = render(<RequestToAddSection variant="grid" query="dune" libraryHadHits />);
expect(markup).toContain("Request to Add");
expect(markup).toContain("Dune: Prophecy");
expect(markup).toContain("Dune (1984)");
});
it("renders the soft framing in the grid variant when library had 0 hits", () => {
mocks.useRequestSearch.mockReturnValue({
data: {
page: 1,
total_pages: 1,
total_results: 1,
results: [missingResult({ tmdb_id: 1, title: "Dune: Prophecy" })],
},
isLoading: false,
isError: false,
});
const markup = render(
<RequestToAddSection variant="grid" query="dune" libraryHadHits={false} />,
);
expect(markup).toContain("Not in your library, but you can request");
});
it("limits the grid to at most 20 cards", () => {
const many = Array.from({ length: 30 }, (_, i) =>
missingResult({ tmdb_id: i + 100, title: `Result ${i}` }),
);
mocks.useRequestSearch.mockReturnValue({
data: { page: 1, total_pages: 1, total_results: many.length, results: many },
isLoading: false,
isError: false,
});
const markup = render(<RequestToAddSection variant="grid" query="dune" libraryHadHits />);
expect(markup).toContain("Result 0");
expect(markup).toContain("Result 19");
expect(markup).not.toContain("Result 20");
});
});
+231
View File
@@ -0,0 +1,231 @@
import { useState } from "react";
import { Link } from "react-router";
import { Film, Sparkles, Tv } from "lucide-react";
import { useCanRequest } from "@/hooks/useCanRequest";
import { useCreateMediaRequest, useRequestSearch } from "@/hooks/queries/useRequests";
import type { RequestMediaResult } from "@/api/types";
import {
formatRequestReason,
formatRequestStatus,
requestInputFromMediaResult,
tmdbImageURL,
} from "@/lib/mediaRequests";
import { cn } from "@/lib/utils";
import RequestPosterCard from "./RequestPosterCard";
function cardKey(item: Pick<RequestMediaResult, "media_type" | "tmdb_id">): string {
return `${item.media_type}-${item.tmdb_id}`;
}
function nonRequestableLabel(item: RequestMediaResult): string {
if (item.request.status) {
return formatRequestStatus(item.request.status);
}
if (item.request.reason) {
return formatRequestReason(item.request.reason);
}
return "Blocked";
}
const DIALOG_LIMIT = 4;
const GRID_LIMIT = 20;
export type RequestToAddSectionProps = {
variant: "dialog" | "grid";
query: string;
/** True when the library search returned at least one hit. Drives header copy. */
libraryHadHits: boolean;
};
export function RequestToAddSection({ variant, query, libraryHadHits }: RequestToAddSectionProps) {
const { discoveryEnabled } = useCanRequest();
const search = useRequestSearch("all", query, 1, {
enabled: discoveryEnabled,
requireProfile: true,
staleTime: 5 * 60 * 1000,
});
if (!discoveryEnabled) return null;
if (search.isError && !search.data) return null;
const filtered = (search.data?.results ?? []).filter((item) => item.availability !== "available");
if (filtered.length === 0) return null;
const limit = variant === "dialog" ? DIALOG_LIMIT : GRID_LIMIT;
const visible = filtered.slice(0, limit);
if (variant === "dialog") {
return <DialogVariant items={visible} libraryHadHits={libraryHadHits} />;
}
return <GridVariant items={visible} libraryHadHits={libraryHadHits} />;
}
function HeaderCopy({ libraryHadHits, count }: { libraryHadHits: boolean; count: number }) {
if (libraryHadHits) {
return (
<div className="text-muted-foreground flex items-center gap-2 px-3 pt-2 pb-1 text-[10px] font-medium tracking-[0.1em] uppercase">
<span>Request to Add</span>
<span className="bg-muted text-muted-foreground rounded-full px-1.5 text-[10px]">
{count}
</span>
</div>
);
}
return (
<div className="px-3 pt-3 pb-1 text-[12px] text-amber-300/85">
Not in your library, but you can request:
</div>
);
}
function DialogVariant({
items,
libraryHadHits,
}: {
items: RequestMediaResult[];
libraryHadHits: boolean;
}) {
return (
<div className="border-t border-white/5 pt-1">
<HeaderCopy libraryHadHits={libraryHadHits} count={items.length} />
<ul className="px-1 py-1">
{items.map((item) => (
<li key={`${item.media_type}-${item.tmdb_id}`}>
<DialogRow item={item} />
</li>
))}
</ul>
</div>
);
}
function DialogRow({ item }: { item: RequestMediaResult }) {
const poster = tmdbImageURL(item.poster_path);
const Icon = item.media_type === "series" ? Tv : Film;
const requestable = item.request.requestable;
const unavailableLabel = requestable ? null : nonRequestableLabel(item);
return (
<Link
to={`/requests/${item.media_type}/${item.tmdb_id}`}
className="hover:bg-muted/80 flex w-full items-center gap-3 rounded-md px-3 py-2 text-left transition-colors"
>
<div
className={cn(
"bg-muted relative h-14 w-10 shrink-0 overflow-hidden rounded-md",
!requestable && "opacity-70",
)}
>
{poster ? (
<img src={poster} alt="" className="h-full w-full object-cover" loading="lazy" />
) : (
<div className="text-muted-foreground flex h-full items-center justify-center">
<Icon className="h-4 w-4" />
</div>
)}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{item.title}</div>
<div className="text-muted-foreground text-xs">
{item.year ? `${item.year} · ` : ""}
{item.media_type === "series" ? "Series" : "Movie"}
</div>
</div>
{requestable ? (
<span className="rounded-full border border-amber-400/30 bg-amber-400/15 px-2 py-0.5 text-[9px] font-semibold tracking-[0.5px] text-amber-300 uppercase">
Request
</span>
) : (
<span
className="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-[9px] font-medium tracking-[0.5px] text-white/60 uppercase"
title={unavailableLabel ?? "Blocked"}
>
{unavailableLabel}
</span>
)}
</Link>
);
}
function GridVariant({
items,
libraryHadHits,
}: {
items: RequestMediaResult[];
libraryHadHits: boolean;
}) {
const count = items.length;
const createRequest = useCreateMediaRequest();
// Track each in-flight card key independently; the shared `useMutation`
// observer overwrites its `variables` on every `mutate` call, so rapid
// clicks on different cards would otherwise trample each other's spinner.
const [pendingKeys, setPendingKeys] = useState<ReadonlySet<string>>(new Set());
const submitCard = (item: RequestMediaResult) => {
const key = cardKey(item);
setPendingKeys((prev) => {
const next = new Set(prev);
next.add(key);
return next;
});
createRequest.mutate(requestInputFromMediaResult(item), {
onSettled: () => {
setPendingKeys((prev) => {
if (!prev.has(key)) return prev;
const next = new Set(prev);
next.delete(key);
return next;
});
},
});
};
return (
<section
className={cn(
"relative overflow-hidden rounded-[28px] border border-amber-400/[0.14]",
"bg-[radial-gradient(120%_60%_at_50%_0%,rgba(245,158,11,0.07)_0%,rgba(245,158,11,0.015)_45%,transparent_75%)]",
"shadow-[inset_0_1px_0_0_rgba(255,255,255,0.04),0_28px_60px_-44px_rgba(0,0,0,0.7)]",
"px-4 pt-7 pb-7 sm:px-7 sm:pt-8",
libraryHadHits && "mt-12! sm:mt-16!",
)}
>
<div
aria-hidden
className="pointer-events-none absolute inset-x-16 top-0 h-px bg-gradient-to-r from-transparent via-amber-300/45 to-transparent"
/>
<header className="mb-7 flex flex-wrap items-end justify-between gap-x-5 gap-y-3">
<div className="space-y-2">
<div className="flex items-center gap-2 text-amber-200/85">
<Sparkles className="h-3.5 w-3.5" strokeWidth={2.2} aria-hidden />
<span className="text-[10px] font-semibold tracking-[0.24em] uppercase">
{libraryHadHits ? "Discover · Outside your library" : "Outside your library"}
</span>
</div>
<h2 className="font-display text-foreground text-[clamp(1.25rem,1.6vw,1.55rem)] leading-tight font-semibold tracking-tight">
{libraryHadHits ? "Request to Add" : "Not in your library, but you can request"}
</h2>
</div>
<span className="inline-flex items-center gap-1.5 self-end rounded-full border border-amber-400/15 bg-amber-400/[0.06] px-2.5 py-1 text-[11px] font-medium tracking-wide text-amber-100/75 tabular-nums">
{count} {count === 1 ? "result" : "results"}
</span>
</header>
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
{items.map((item) => {
const key = cardKey(item);
return (
<RequestPosterCard
key={key}
variant="discover"
item={item}
isSubmitting={pendingKeys.has(key)}
onRequest={() => submitCard(item)}
fluid
/>
);
})}
</div>
</section>
);
}
+14 -8
View File
@@ -535,17 +535,18 @@ export function DeviceProfileTabs({
}: DeviceProfileTabsProps) {
const [activeId, setActiveId] = useState<string | null>(initialProfileId ?? null);
if (profiles.length === 0) return null;
const active: DeviceProfileTabEntry =
profiles.find((p) => p.profileId === activeId) ?? profiles[0]!;
const accent = profileAccent(active.profileId);
const rows = buildRenderedRows(active, showAllSettings, device);
const overrideCount = active.settings.length;
const active = useMemo(
() => profiles.find((p) => p.profileId === activeId) ?? profiles[0] ?? null,
[activeId, profiles],
);
const rows = useMemo(
() => (active ? buildRenderedRows(active, showAllSettings, device) : []),
[active, device, showAllSettings],
);
// Conflicts depend on the active profile's settings as a whole. Memoize
// by the active profile's reference + a content hash via the rendered
// row keys/values — recomputes on profile switch and on save.
const conflictMap = useMemo(() => detectSettingConflicts(active.settings), [active.settings]);
const conflictMap = useMemo(() => detectSettingConflicts(active?.settings ?? []), [active]);
const anomaliesByKey = useMemo(() => {
const out = new Map<string, SettingAnomaly>();
for (const { setting, isOverride } of rows) {
@@ -557,6 +558,11 @@ export function DeviceProfileTabs({
}
return out;
}, [rows, deviceStaleDays, conflictMap]);
if (!active) return null;
const accent = profileAccent(active.profileId);
const overrideCount = active.settings.length;
const anomalyCountInProfile = anomaliesByKey.size;
return (
@@ -0,0 +1,171 @@
import { useState } from "react";
import type { AdminDownloadedSubtitle } from "@/api/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Switch } from "@/components/ui/switch";
import { useAdminUpdateDownloadedSubtitle } from "@/hooks/queries/admin/subtitles";
import { LANGUAGES, getLanguageName } from "@/player/utils/languageNames";
import { cn } from "@/lib/utils";
import { languageChipClass, providerBadgeClass, providerLabel } from "./subtitleAdminStyles";
interface AdminSubtitleEditSheetProps {
subtitle: AdminDownloadedSubtitle | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export default function AdminSubtitleEditSheet({
subtitle,
open,
onOpenChange,
}: AdminSubtitleEditSheetProps) {
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex w-full flex-col sm:max-w-md">
{subtitle ? (
<AdminSubtitleEditForm
key={subtitle.id}
subtitle={subtitle}
onClose={() => onOpenChange(false)}
/>
) : (
<>
<SheetHeader>
<SheetTitle>Edit subtitle</SheetTitle>
</SheetHeader>
</>
)}
</SheetContent>
</Sheet>
);
}
function AdminSubtitleEditForm({
subtitle,
onClose,
}: {
subtitle: AdminDownloadedSubtitle;
onClose: () => void;
}) {
const updateMutation = useAdminUpdateDownloadedSubtitle();
const [language, setLanguage] = useState(subtitle.language);
const [releaseName, setReleaseName] = useState(subtitle.release_name);
const [hearingImpaired, setHearingImpaired] = useState(subtitle.hearing_impaired);
const languageChanged = language !== subtitle.language;
async function handleSave() {
await updateMutation.mutateAsync({
id: subtitle.id,
patch: {
language,
release_name: releaseName,
hearing_impaired: hearingImpaired,
},
});
onClose();
}
return (
<>
<SheetHeader>
<SheetTitle>Edit subtitle</SheetTitle>
<SheetDescription>
Update stored metadata for this subtitle record. File content is not replaced.
</SheetDescription>
</SheetHeader>
<div className="space-y-6 px-1 py-2">
<div className="surface-panel-subtle space-y-3 rounded-xl px-4 py-4">
<div className="text-sm font-semibold">{subtitle.media_title || "Unknown media"}</div>
<div className="flex flex-wrap items-center gap-2">
<span
className={cn(
"inline-flex rounded-full border px-2.5 py-1 text-xs font-medium",
languageChipClass(),
)}
>
{subtitle.language.toUpperCase()} · {getLanguageName(subtitle.language)}
</span>
<span
className={cn(
"inline-flex rounded-full border px-2.5 py-1 text-xs font-medium",
providerBadgeClass(subtitle.provider),
)}
>
{providerLabel(subtitle.provider)}
</span>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="subtitle-language">Language</Label>
<Select value={language} onValueChange={setLanguage}>
<SelectTrigger id="subtitle-language">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LANGUAGES.map((lang) => (
<SelectItem key={lang.code} value={lang.code}>
{lang.code.toUpperCase()} · {lang.label}
</SelectItem>
))}
</SelectContent>
</Select>
{languageChanged && (
<p className="text-muted-foreground text-xs leading-relaxed">
Updates stored language label; file content unchanged.
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="subtitle-release-name">Release name</Label>
<Input
id="subtitle-release-name"
value={releaseName}
onChange={(event) => setReleaseName(event.target.value)}
className="font-mono text-xs"
/>
</div>
<div className="border-border/60 flex items-center justify-between rounded-xl border px-4 py-3">
<div className="space-y-1">
<Label htmlFor="subtitle-hearing-impaired">Hearing impaired</Label>
<p className="text-muted-foreground text-xs">Marks this track as SDH/CC.</p>
</div>
<Switch
id="subtitle-hearing-impaired"
checked={hearingImpaired}
onCheckedChange={setHearingImpaired}
/>
</div>
</div>
<SheetFooter className="mt-auto gap-2 sm:justify-end">
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="button" disabled={updateMutation.isPending} onClick={() => void handleSave()}>
Save changes
</Button>
</SheetFooter>
</>
);
}
@@ -0,0 +1,118 @@
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { LANGUAGES } from "@/player/utils/languageNames";
import { SUBTITLE_PROVIDER_OPTIONS } from "./subtitleAdminStyles";
const ALL = "all";
interface AdminSubtitlesFiltersProps {
provider: string;
language: string;
userId: string;
search: string;
users: Array<{ id: number; username: string }>;
onProviderChange: (value: string) => void;
onLanguageChange: (value: string) => void;
onUserChange: (value: string) => void;
onSearchChange: (value: string) => void;
onReset: () => void;
}
export default function AdminSubtitlesFilters({
provider,
language,
userId,
search,
users,
onProviderChange,
onLanguageChange,
onUserChange,
onSearchChange,
onReset,
}: AdminSubtitlesFiltersProps) {
return (
<div className="surface-panel rounded-2xl border-0 px-3 py-3 sm:px-4">
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div className="min-w-0 flex-1">
<Input
value={search}
onChange={(event) => onSearchChange(event.target.value)}
placeholder="Search release name…"
className="font-mono text-xs sm:max-w-sm"
aria-label="Search subtitle release name"
/>
</div>
<div
className="flex flex-wrap items-center gap-1.5"
role="group"
aria-label="Filter by subtitle provider"
>
{SUBTITLE_PROVIDER_OPTIONS.map((option) => {
const active = provider === option.value;
return (
<button
key={option.value}
type="button"
aria-pressed={active}
onClick={() => onProviderChange(option.value)}
className={cn(
"rounded-full border px-3 py-1.5 text-xs font-medium transition-colors",
active
? "border-primary/40 bg-primary/15 text-foreground shadow-[inset_0_1px_0_rgb(255_255_255_/_0.06)]"
: "border-border/60 bg-muted/20 text-muted-foreground hover:bg-muted/40 hover:text-foreground",
)}
>
{option.label}
</button>
);
})}
</div>
<div className="flex flex-wrap items-center gap-2">
<Select value={language} onValueChange={onLanguageChange}>
<SelectTrigger className="w-[180px]" aria-label="Filter by language">
<SelectValue placeholder="Language" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All languages</SelectItem>
{LANGUAGES.map((lang) => (
<SelectItem key={lang.code} value={lang.code}>
{lang.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={userId} onValueChange={onUserChange}>
<SelectTrigger className="w-[200px]" aria-label="Filter by uploader">
<SelectValue placeholder="Uploader" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All uploaders</SelectItem>
{users.map((user) => (
<SelectItem key={user.id} value={String(user.id)}>
{user.username}
</SelectItem>
))}
</SelectContent>
</Select>
<Button type="button" variant="ghost" size="sm" onClick={onReset}>
Reset filters
</Button>
</div>
</div>
</div>
);
}
export { ALL as FILTER_ALL };
@@ -0,0 +1,272 @@
import { useState } from "react";
import { Link } from "react-router";
import type { AdminDownloadedSubtitle } from "@/api/types";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { downloadAdminSubtitle } from "@/hooks/queries/admin/subtitles";
import { getLanguageName } from "@/player/utils/languageNames";
import { cn } from "@/lib/utils";
import { Download, Ear, Loader2, Pencil, Trash2 } from "lucide-react";
import { toast } from "sonner";
import AdminSubtitleEditSheet from "./AdminSubtitleEditSheet";
import {
basenameFromPath,
formatChipClass,
languageChipClass,
providerBadgeClass,
providerLabel,
staggerRowClass,
} from "./subtitleAdminStyles";
interface AdminSubtitlesTableProps {
subtitles: AdminDownloadedSubtitle[];
hasActiveFilters: boolean;
onResetFilters: () => void;
onDelete: (subtitle: AdminDownloadedSubtitle) => void;
isDeleting: boolean;
}
function formatRelative(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
const deltaMs = Date.now() - date.getTime();
const minutes = Math.floor(deltaMs / 60000);
if (minutes < 1) return "just now";
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
return date.toLocaleDateString();
}
export default function AdminSubtitlesTable({
subtitles,
hasActiveFilters,
onResetFilters,
onDelete,
isDeleting,
}: AdminSubtitlesTableProps) {
const [editTarget, setEditTarget] = useState<AdminDownloadedSubtitle | null>(null);
const [deleteTarget, setDeleteTarget] = useState<AdminDownloadedSubtitle | null>(null);
const [downloadingId, setDownloadingId] = useState<number | null>(null);
async function handleDownload(subtitle: AdminDownloadedSubtitle) {
setDownloadingId(subtitle.id);
try {
await downloadAdminSubtitle(subtitle);
toast.success("Subtitle downloaded");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to download subtitle");
} finally {
setDownloadingId(null);
}
}
if (subtitles.length === 0) {
return (
<div className="surface-panel rounded-2xl border-0 px-6 py-16 text-center">
<div className="caption-empty-state mx-auto mb-5 max-w-md space-y-1.5">
<span />
<span />
<span />
</div>
<h2 className="text-lg font-semibold tracking-tight">
{hasActiveFilters ? "No subtitles match these filters" : "No stored subtitles yet"}
</h2>
<p className="text-muted-foreground mx-auto mt-2 max-w-lg text-sm leading-relaxed">
{hasActiveFilters
? "Try widening the provider, language, or uploader filters to see more results."
: "User uploads and provider downloads will appear here once subtitles are stored in S3."}
</p>
{hasActiveFilters && (
<Button type="button" variant="outline" className="mt-5" onClick={onResetFilters}>
Reset filters
</Button>
)}
</div>
);
}
return (
<>
<div className="surface-panel overflow-x-auto rounded-2xl border-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Media</TableHead>
<TableHead>File</TableHead>
<TableHead>Language</TableHead>
<TableHead>Provider</TableHead>
<TableHead>Release</TableHead>
<TableHead>Format</TableHead>
<TableHead className="w-10">HI</TableHead>
<TableHead>Uploader</TableHead>
<TableHead>Added</TableHead>
<TableHead className="w-[120px] text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{subtitles.map((subtitle, index) => (
<TableRow key={subtitle.id} className={cn("group", staggerRowClass(index))}>
<TableCell className="max-w-[220px]">
<div className="space-y-1">
{subtitle.media_content_id ? (
<Link
to={`/item/${encodeURIComponent(subtitle.media_content_id)}`}
className="hover:text-primary line-clamp-2 font-semibold transition-colors hover:underline"
>
{subtitle.media_title || subtitle.media_content_id}
</Link>
) : (
<div className="line-clamp-2 font-semibold">
{subtitle.media_title || "Unknown media"}
</div>
)}
{subtitle.media_type === "episode" && (
<Badge variant="outline" className="text-[10px] tracking-[0.12em] uppercase">
Episode
</Badge>
)}
</div>
</TableCell>
<TableCell
className="text-muted-foreground max-w-[180px] truncate font-mono text-xs"
title={subtitle.file_path}
>
{basenameFromPath(subtitle.file_path)}
</TableCell>
<TableCell>
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-medium",
languageChipClass(),
)}
>
<span className="font-semibold tracking-[0.08em] uppercase">
{subtitle.language}
</span>
<span className="text-muted-foreground hidden sm:inline">
{getLanguageName(subtitle.language)}
</span>
</span>
</TableCell>
<TableCell>
<span
className={cn(
"inline-flex rounded-full border px-2.5 py-1 text-xs font-medium",
providerBadgeClass(subtitle.provider),
)}
>
{providerLabel(subtitle.provider)}
</span>
</TableCell>
<TableCell
className="max-w-[200px] truncate font-mono text-xs"
title={subtitle.release_name}
>
{subtitle.release_name || "—"}
</TableCell>
<TableCell>
<span className={cn("inline-flex rounded px-2 py-0.5", formatChipClass())}>
.{subtitle.format}
</span>
</TableCell>
<TableCell>
{subtitle.hearing_impaired ? (
<span className="inline-flex items-center gap-1 text-xs font-medium text-amber-200">
<Ear className="h-3.5 w-3.5" aria-hidden="true" />
HI
</span>
) : null}
</TableCell>
<TableCell className="text-sm">{subtitle.uploader_username || "—"}</TableCell>
<TableCell className="text-muted-foreground text-sm" title={subtitle.created_at}>
{formatRelative(subtitle.created_at)}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1 opacity-100 transition-opacity sm:opacity-0 sm:group-focus-within:opacity-100 sm:group-hover:opacity-100">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
aria-label={`Edit subtitle ${subtitle.id}`}
onClick={() => setEditTarget(subtitle)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
aria-label={`Download subtitle ${subtitle.id}`}
disabled={downloadingId === subtitle.id}
onClick={() => void handleDownload(subtitle)}
>
{downloadingId === subtitle.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive h-8 w-8"
aria-label={`Delete subtitle ${subtitle.id}`}
onClick={() => setDeleteTarget(subtitle)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<AdminSubtitleEditSheet
subtitle={editTarget}
open={editTarget != null}
onOpenChange={(open) => {
if (!open) setEditTarget(null);
}}
/>
<ConfirmDialog
open={deleteTarget != null}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null);
}}
title="Delete subtitle?"
description={
deleteTarget
? `Remove ${providerLabel(deleteTarget.provider)} ${deleteTarget.language.toUpperCase()} subtitles for "${deleteTarget.media_title || "this media"}"? This deletes the stored file from S3.`
: ""
}
confirmLabel="Delete"
variant="destructive"
isPending={isDeleting}
onConfirm={() => {
if (deleteTarget) {
onDelete(deleteTarget);
setDeleteTarget(null);
}
}}
/>
</>
);
}
@@ -0,0 +1,57 @@
import { cn } from "@/lib/utils";
export const SUBTITLE_PROVIDER_OPTIONS = [
{ value: "all", label: "All" },
{ value: "upload", label: "Upload" },
{ value: "opensubtitles", label: "OpenSubtitles" },
{ value: "subdl", label: "SubDL" },
{ value: "subsource", label: "SubSource" },
] as const;
export function providerBadgeClass(provider: string): string {
switch (provider) {
case "upload":
return "border-amber-500/35 bg-amber-500/12 text-amber-100";
case "opensubtitles":
return "border-sky-500/30 bg-sky-500/10 text-sky-100";
case "subdl":
return "border-emerald-500/30 bg-emerald-500/10 text-emerald-100";
case "subsource":
return "border-violet-500/30 bg-violet-500/10 text-violet-100";
default:
return "border-border/70 bg-muted/40 text-muted-foreground";
}
}
export function providerLabel(provider: string): string {
return SUBTITLE_PROVIDER_OPTIONS.find((option) => option.value === provider)?.label ?? provider;
}
export function languageChipClass(): string {
return "border-primary/25 bg-primary/10 text-foreground";
}
export function formatChipClass(): string {
return "border-border/60 bg-muted/30 font-mono text-[10px] uppercase tracking-[0.14em] text-muted-foreground";
}
export function staggerRowClass(index: number): string {
const capped = Math.min(index, 8);
return cn("motion-safe:animate-in motion-safe:fade-in motion-safe:duration-300", {
"motion-safe:delay-0": capped === 0,
"motion-safe:delay-[40ms]": capped === 1,
"motion-safe:delay-[80ms]": capped === 2,
"motion-safe:delay-[120ms]": capped === 3,
"motion-safe:delay-[160ms]": capped === 4,
"motion-safe:delay-[200ms]": capped === 5,
"motion-safe:delay-[240ms]": capped === 6,
"motion-safe:delay-[280ms]": capped === 7,
"motion-safe:delay-[320ms]": capped >= 8,
});
}
export function basenameFromPath(filePath: string): string {
if (!filePath) return "—";
const parts = filePath.split(/[/\\]/);
return parts[parts.length - 1] || filePath;
}
@@ -0,0 +1,406 @@
import { useCallback, useRef, useState } from "react";
import { Loader2, Upload } from "lucide-react";
import type { SubtitleLanguageDetection } from "@/api/types";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { LANGUAGES, getLanguageName } from "@/player/utils/languageNames";
const ACCEPTED_SUBTITLE_EXTENSIONS = ".srt,.vtt,.ass,.ssa,.sub";
const ACCEPTED_SUBTITLE_EXTENSION_LIST = ["srt", "vtt", "ass", "ssa", "sub"] as const;
export interface SubtitleUploadInput {
mediaFileId: number;
file: File;
language?: string;
languageOverride?: boolean;
hearingImpaired: boolean;
}
interface SubtitleUploadFormProps {
mediaFileId: number;
upload: (input: SubtitleUploadInput) => Promise<void>;
detectLanguage?: (file: File, fallbackLanguage?: string) => Promise<SubtitleLanguageDetection>;
onSuccess: () => void;
onError?: (message: string) => void;
variant?: "player" | "default";
defaultLanguage?: string;
}
function isAcceptedSubtitleFile(file: File): boolean {
const extension = file.name.split(".").pop()?.toLowerCase() ?? "";
return ACCEPTED_SUBTITLE_EXTENSION_LIST.includes(
extension as (typeof ACCEPTED_SUBTITLE_EXTENSION_LIST)[number],
);
}
function detectionSourceLabel(source: SubtitleLanguageDetection["source"]): string {
switch (source) {
case "filename":
return "filename";
case "metadata":
return "file metadata";
case "content":
return "subtitle text";
case "manual":
return "manual selection";
default:
return "detection";
}
}
export function SubtitleUploadForm({
mediaFileId,
upload,
detectLanguage,
onSuccess,
onError,
variant = "default",
defaultLanguage = "en",
}: SubtitleUploadFormProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const dragDepthRef = useRef(0);
const detectRequestRef = useRef(0);
const [language, setLanguage] = useState(defaultLanguage);
const [hearingImpaired, setHearingImpaired] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [detectingLanguage, setDetectingLanguage] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [detectionSource, setDetectionSource] = useState<
SubtitleLanguageDetection["source"] | null
>(null);
const [languageOverride, setLanguageOverride] = useState(false);
const [error, setError] = useState<string | null>(null);
const isPlayer = variant === "player";
const reportError = useCallback(
(message: string) => {
setError(message);
onError?.(message);
},
[onError],
);
const runLanguageDetection = useCallback(
async (file: File, fallbackLanguage: string) => {
if (!detectLanguage) {
return;
}
const requestId = ++detectRequestRef.current;
setDetectingLanguage(true);
try {
const result = await detectLanguage(file, fallbackLanguage);
if (requestId !== detectRequestRef.current) {
return;
}
if (result.language) {
setLanguage(result.language);
setDetectionSource(result.source);
setLanguageOverride(false);
}
} catch (err) {
if (requestId !== detectRequestRef.current) {
return;
}
setDetectionSource(null);
reportError(err instanceof Error ? err.message : "Failed to detect subtitle language");
} finally {
if (requestId === detectRequestRef.current) {
setDetectingLanguage(false);
}
}
},
[detectLanguage, reportError],
);
const selectFile = useCallback(
(file: File | null | undefined) => {
if (!file) {
return;
}
if (!isAcceptedSubtitleFile(file)) {
reportError("Unsupported file type. Use SRT, VTT, ASS, SSA, or SUB.");
return;
}
setSelectedFile(file);
setError(null);
void runLanguageDetection(file, language);
},
[language, reportError, runLanguageDetection],
);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
selectFile(event.target.files?.[0]);
};
const handleBrowseClick = () => {
fileInputRef.current?.click();
};
const handleDragEnter = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
dragDepthRef.current += 1;
setIsDragging(true);
};
const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = "copy";
};
const handleDragLeave = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
if (dragDepthRef.current === 0) {
setIsDragging(false);
}
};
const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
dragDepthRef.current = 0;
setIsDragging(false);
const file = event.dataTransfer.files[0];
selectFile(file);
};
const handleLanguageChange = (value: string) => {
setLanguage(value);
setDetectionSource("manual");
setLanguageOverride(true);
};
const handleUpload = async () => {
if (!selectedFile) {
reportError("Choose a subtitle file to upload");
return;
}
setUploading(true);
setError(null);
try {
await upload({
mediaFileId,
file: selectedFile,
language,
languageOverride,
hearingImpaired,
});
setSelectedFile(null);
setDetectionSource(null);
setLanguageOverride(false);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
onSuccess();
} catch (err) {
reportError(err instanceof Error ? err.message : "Upload failed");
} finally {
setUploading(false);
}
};
return (
<div
className={cn(
"space-y-3",
isPlayer ? "border-b border-white/10 px-4 py-3" : "rounded-xl border border-dashed p-4",
)}
>
<div className="space-y-1">
<p className={cn("text-sm font-medium", isPlayer ? "text-white" : "text-foreground")}>
Upload subtitle
</p>
<p className={cn("text-xs", isPlayer ? "text-white/50" : "text-muted-foreground")}>
Drag and drop or browse for SRT, VTT, ASS, SSA, or SUB files up to 5 MB. Language is
detected automatically when possible.
</p>
</div>
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_SUBTITLE_EXTENSIONS}
className="sr-only"
onChange={handleFileChange}
/>
<div
role="button"
tabIndex={0}
aria-label="Drop subtitle file here or browse"
onClick={handleBrowseClick}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
handleBrowseClick();
}
}}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
"flex cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-4 py-6 text-center transition-colors",
isPlayer
? isDragging
? "border-white/50 bg-white/10"
: "border-white/20 bg-white/5 hover:border-white/35 hover:bg-white/10"
: isDragging
? "border-primary bg-primary/5"
: "border-border/70 bg-muted/20 hover:border-border hover:bg-muted/40",
)}
>
<Upload
className={cn("size-5", isPlayer ? "text-white/70" : "text-muted-foreground")}
aria-hidden="true"
/>
<div className="space-y-1">
<p className={cn("text-sm font-medium", isPlayer ? "text-white" : "text-foreground")}>
{isDragging ? "Drop subtitle file" : "Drag and drop a subtitle file"}
</p>
<p className={cn("text-xs", isPlayer ? "text-white/50" : "text-muted-foreground")}>
or click to browse
</p>
</div>
</div>
<div className={cn("flex flex-col gap-2", !isPlayer && "sm:flex-row sm:items-center")}>
<div className={cn("space-y-1", !isPlayer && "w-full sm:w-[220px]")}>
{isPlayer ? (
<select
aria-label="Upload language"
className="w-full rounded bg-neutral-800 px-2 py-1.5 text-sm text-white focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:outline-none"
value={language}
onChange={(event) => handleLanguageChange(event.target.value)}
disabled={detectingLanguage}
>
{LANGUAGES.map((lang) => (
<option key={lang.code} value={lang.code}>
{lang.label}
</option>
))}
</select>
) : (
<Select
value={language}
onValueChange={handleLanguageChange}
disabled={detectingLanguage}
>
<SelectTrigger>
<SelectValue placeholder="Language" />
</SelectTrigger>
<SelectContent>
{LANGUAGES.map((lang) => (
<SelectItem key={lang.code} value={lang.code}>
{lang.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{detectingLanguage ? (
<p className={cn("text-xs", isPlayer ? "text-white/50" : "text-muted-foreground")}>
Detecting language…
</p>
) : detectionSource && detectionSource !== "manual" ? (
<p className={cn("text-xs", isPlayer ? "text-white/50" : "text-muted-foreground")}>
Detected {getLanguageName(language)} from {detectionSourceLabel(detectionSource)}
</p>
) : null}
</div>
{isPlayer ? (
<label className="flex items-center gap-2 text-xs text-white/70">
<input
type="checkbox"
checked={hearingImpaired}
onChange={(event) => setHearingImpaired(event.target.checked)}
className="rounded border-white/20 bg-neutral-800"
/>
Hearing impaired (HI)
</label>
) : (
<div className="flex items-center gap-2">
<Switch
id={`subtitle-upload-hi-${mediaFileId}`}
checked={hearingImpaired}
onCheckedChange={setHearingImpaired}
/>
<Label htmlFor={`subtitle-upload-hi-${mediaFileId}`} className="text-sm font-normal">
Hearing impaired (HI)
</Label>
</div>
)}
{isPlayer ? (
<button
type="button"
className="inline-flex items-center justify-center gap-2 rounded bg-white/10 px-3 py-1.5 text-sm font-medium hover:bg-white/20 focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:outline-none disabled:opacity-50"
onClick={handleUpload}
disabled={uploading || detectingLanguage || !selectedFile}
>
{uploading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Upload className="size-4" />
)}
{uploading ? "Uploading…" : "Upload"}
</button>
) : (
<Button
onClick={handleUpload}
disabled={uploading || detectingLanguage || !selectedFile}
className="sm:ml-auto"
>
{uploading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Upload className="size-4" />
)}
{uploading ? "Uploading…" : "Upload"}
</Button>
)}
</div>
{selectedFile && (
<p className={cn("truncate text-xs", isPlayer ? "text-white/60" : "text-muted-foreground")}>
Selected: {selectedFile.name}
</p>
)}
{error && (
<div
role="alert"
className={cn(
"rounded px-3 py-2 text-xs",
isPlayer
? "bg-red-900/40 text-red-300"
: "bg-rose-500/10 text-rose-700 dark:text-rose-300",
)}
>
{error}
</div>
)}
</div>
);
}
+70 -1
View File
@@ -1,6 +1,10 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "@/api/client";
import { api, apiDownload } from "@/api/client";
import type {
AdminDownloadedSubtitle,
AdminDownloadedSubtitlesFilters,
AdminDownloadedSubtitlesResponse,
AdminUpdateDownloadedSubtitleRequest,
SubtitleProviderConfig,
SubtitleProviderUpdateRequest,
SubtitleProviderTestResponse,
@@ -10,6 +14,71 @@ import { toast } from "sonner";
const ADMIN_STALE_TIME = 30_000;
function buildDownloadedSubtitlesQuery(filters: AdminDownloadedSubtitlesFilters): string {
const params = new URLSearchParams();
if (filters.provider) params.set("provider", filters.provider);
if (filters.language) params.set("language", filters.language);
if (filters.userId != null) params.set("user_id", String(filters.userId));
if (filters.mediaFileId != null) params.set("media_file_id", String(filters.mediaFileId));
if (filters.q) params.set("q", filters.q);
params.set("limit", String(filters.limit ?? 50));
params.set("offset", String(filters.offset ?? 0));
const query = params.toString();
return query ? `/admin/subtitles?${query}` : "/admin/subtitles";
}
export function useAdminDownloadedSubtitles(filters: AdminDownloadedSubtitlesFilters) {
return useQuery({
queryKey: adminKeys.downloadedSubtitles(filters),
queryFn: () =>
api<AdminDownloadedSubtitlesResponse>(buildDownloadedSubtitlesQuery(filters)).then(
(data) => data ?? { subtitles: [], total: 0, uploads: 0, provider_downloads: 0 },
),
staleTime: ADMIN_STALE_TIME,
});
}
export function useAdminUpdateDownloadedSubtitle() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, patch }: { id: number; patch: AdminUpdateDownloadedSubtitleRequest }) =>
api<{ subtitle: AdminDownloadedSubtitle }>(`/admin/subtitles/${id}`, {
method: "PATCH",
body: JSON.stringify(patch),
}),
onSuccess: () => {
toast.success("Subtitle updated");
queryClient.invalidateQueries({ queryKey: ["admin", "downloadedSubtitles"] });
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : "Failed to update subtitle");
},
});
}
export function useAdminDeleteDownloadedSubtitle() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) =>
api<void>(`/admin/subtitles/${id}`, {
method: "DELETE",
}),
onSuccess: () => {
toast.success("Subtitle deleted");
queryClient.invalidateQueries({ queryKey: ["admin", "downloadedSubtitles"] });
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : "Failed to delete subtitle");
},
});
}
export async function downloadAdminSubtitle(subtitle: AdminDownloadedSubtitle): Promise<void> {
const base = subtitle.release_name?.trim() || `subtitle-${subtitle.id}`;
const filename = base.includes(".") ? base : `${base}.${subtitle.format}`;
await apiDownload(`/admin/subtitles/${subtitle.id}/download`, filename);
}
export function useSubtitleProviders() {
return useQuery({
queryKey: adminKeys.subtitleProviders(),
+11 -2
View File
@@ -132,8 +132,8 @@ export const requestKeys = {
sort: string,
page: number,
) => ["requests", "discover", "browse", kind, slug, mediaType ?? "", sort, page] as const,
search: (mediaType: string, query: string, page: number) =>
["requests", "search", mediaType, query, page] as const,
search: (mediaType: string, query: string, page: number, viewerKey: string) =>
["requests", "search", viewerKey, mediaType, query, page] as const,
detail: (mediaType: string, tmdbID: number) => ["requests", "detail", mediaType, tmdbID] as const,
mine: (params: Record<string, unknown>) => ["requests", "mine", params] as const,
};
@@ -360,6 +360,15 @@ export const adminKeys = {
operationalLogs: (params: Record<string, unknown>) => ["admin", "logs", "app", params] as const,
auditLogs: (params: Record<string, unknown>) => ["admin", "logs", "audit", params] as const,
subtitleProviders: () => ["admin", "subtitleProviders"] as const,
downloadedSubtitles: (params: {
provider?: string;
language?: string;
userId?: number;
mediaFileId?: number;
q?: string;
limit?: number;
offset?: number;
}) => ["admin", "downloadedSubtitles", params] as const,
historyImportSources: () => ["admin", "historyImportSources"] as const,
historyImportExternalUsers: (sourceId: number) =>
["admin", "historyImportSources", sourceId, "users"] as const,
+70
View File
@@ -5,8 +5,10 @@ import { api } from "@/api/client";
import type {
DownloadedSubtitle,
SubtitleDownloadRequest,
SubtitleLanguageDetection,
SubtitleSearchRequest,
SubtitleSearchResponse,
SubtitleUploadRequest,
} from "@/api/types";
import { subtitleKeys } from "./keys";
@@ -15,6 +17,34 @@ interface DownloadSubtitleResponse {
subtitle: DownloadedSubtitle;
}
function buildSubtitleUploadFormData(request: SubtitleUploadRequest): FormData {
const form = new FormData();
form.set("media_file_id", String(request.media_file_id));
if (request.language) {
form.set("language", request.language);
}
if (request.language_override) {
form.set("language_override", "true");
}
form.set("file", request.file);
if (request.release_name) {
form.set("release_name", request.release_name);
}
if (request.hearing_impaired) {
form.set("hearing_impaired", "true");
}
return form;
}
function buildSubtitleDetectFormData(file: File, language?: string): FormData {
const form = new FormData();
form.set("file", file);
if (language) {
form.set("language", language);
}
return form;
}
export async function fetchDownloadedSubtitles(
mediaFileId: number,
options?: RequestInit,
@@ -48,6 +78,29 @@ export async function downloadSubtitle(
});
}
export async function uploadSubtitle(
request: SubtitleUploadRequest,
options?: RequestInit,
): Promise<DownloadSubtitleResponse> {
return api<DownloadSubtitleResponse>("/subtitles/upload", {
...options,
method: "POST",
body: buildSubtitleUploadFormData(request),
});
}
export async function detectSubtitleLanguage(
file: File,
language?: string,
options?: RequestInit,
): Promise<SubtitleLanguageDetection> {
return api<SubtitleLanguageDetection>("/subtitles/detect-language", {
...options,
method: "POST",
body: buildSubtitleDetectFormData(file, language),
});
}
export function useDownloadedSubtitles(mediaFileId: number | undefined) {
return useQuery({
queryKey: mediaFileId != null ? subtitleKeys.downloaded(mediaFileId) : subtitleKeys.all,
@@ -72,3 +125,20 @@ export function useDownloadSubtitle() {
},
});
}
export function useUploadSubtitle() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (request: SubtitleUploadRequest) => uploadSubtitle(request),
onSuccess: async (_response, request) => {
toast.success("Subtitle uploaded");
await queryClient.invalidateQueries({
queryKey: subtitleKeys.downloaded(request.media_file_id),
});
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : "Failed to upload subtitle");
},
});
}
+172
View File
@@ -0,0 +1,172 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import type { ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { requestKeys } from "./keys";
const mocks = vi.hoisted(() => ({
useQuery: vi.fn(),
useCurrentProfile: vi.fn(),
api: vi.fn(),
}));
vi.mock("@tanstack/react-query", async () => {
const actual =
await vi.importActual<typeof import("@tanstack/react-query")>("@tanstack/react-query");
return {
...actual,
useQuery: (...args: unknown[]) => mocks.useQuery(...args),
};
});
vi.mock("@/hooks/useCurrentProfile", () => ({
useCurrentProfile: () => mocks.useCurrentProfile(),
}));
vi.mock("@/api/client", () => ({
api: (...args: unknown[]) => mocks.api(...args),
}));
import { useRequestSearch } from "./useRequests";
function render(node: ReactNode) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return renderToStaticMarkup(<QueryClientProvider client={client}>{node}</QueryClientProvider>);
}
function CallHook(props: { mediaType: "movie" | "series" | "all"; q: string; page?: number }) {
useRequestSearch(props.mediaType, props.q, props.page ?? 1);
return null;
}
describe("useRequestSearch", () => {
beforeEach(() => {
mocks.useQuery.mockReset();
mocks.useCurrentProfile.mockReset();
mocks.api.mockReset();
});
it("includes the current profile id in the query key", () => {
mocks.useCurrentProfile.mockReturnValue({ profile: { id: "profile-1" } });
render(<CallHook mediaType="all" q="dune" />);
const options = mocks.useQuery.mock.calls[0]![0] as { queryKey: readonly unknown[] };
expect(options.queryKey).toEqual(["requests", "search", "profile-1", "all", "dune", 1]);
});
it("uses 'anon' as the viewer key when there is no profile", () => {
mocks.useCurrentProfile.mockReturnValue({ profile: null });
render(<CallHook mediaType="movie" q="dune" />);
const options = mocks.useQuery.mock.calls[0]![0] as { queryKey: readonly unknown[] };
expect(options.queryKey).toEqual(["requests", "search", "anon", "movie", "dune", 1]);
});
it("forwards the react-query signal to api()", async () => {
mocks.api.mockResolvedValue({ page: 1, total_pages: 0, total_results: 0, results: [] });
mocks.useCurrentProfile.mockReturnValue({ profile: { id: "profile-1" } });
render(<CallHook mediaType="all" q="dune" />);
const options = mocks.useQuery.mock.calls[0]![0] as {
queryFn: (ctx: { signal: AbortSignal }) => unknown;
};
const controller = new AbortController();
await options.queryFn({ signal: controller.signal });
expect(mocks.api).toHaveBeenCalledTimes(1);
const apiCall = mocks.api.mock.calls[0]!;
expect(apiCall[0]).toContain("/requests/search?");
const init = apiCall[1] as RequestInit;
expect(init.signal).toBe(controller.signal);
});
it("keeps the existing Requests page staleTime by default", () => {
mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p" } });
render(<CallHook mediaType="all" q="dune" />);
const options = mocks.useQuery.mock.calls[0]![0] as { staleTime: number };
expect(options.staleTime).toBe(30 * 1000);
});
it("allows callers to opt into a longer staleTime", () => {
mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p" } });
function CallHookWithStaleTime() {
useRequestSearch("all", "dune", 1, { staleTime: 5 * 60 * 1000 });
return null;
}
render(<CallHookWithStaleTime />);
const options = mocks.useQuery.mock.calls[0]![0] as { staleTime: number };
expect(options.staleTime).toBe(5 * 60 * 1000);
});
it("respects the enabled option override", () => {
mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p" } });
function CallHookWithOpt({ enabled }: { enabled: boolean }) {
useRequestSearch("all", "dune", 1, { enabled });
return null;
}
render(<CallHookWithOpt enabled={false} />);
const options = mocks.useQuery.mock.calls[0]![0] as { enabled: boolean };
expect(options.enabled).toBe(false);
});
it("does not include enabled override when option omitted (defaults to true)", () => {
mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p" } });
render(<CallHook mediaType="all" q="dune" />);
const options = mocks.useQuery.mock.calls[0]![0] as { enabled: boolean };
expect(options.enabled).toBe(true);
});
it("does not require profile by default", () => {
mocks.useCurrentProfile.mockReturnValue({ profile: null });
render(<CallHook mediaType="all" q="dune" />);
const options = mocks.useQuery.mock.calls[0]![0] as { enabled: boolean };
expect(options.enabled).toBe(true);
});
it("can require a profile before fetching", () => {
mocks.useCurrentProfile.mockReturnValue({ profile: null });
function CallHookRequiringProfile() {
useRequestSearch("all", "dune", 1, { requireProfile: true });
return null;
}
render(<CallHookRequiringProfile />);
const options = mocks.useQuery.mock.calls[0]![0] as { enabled: boolean };
expect(options.enabled).toBe(false);
});
});
describe("requestKeys.all invalidation", () => {
it("invalidates entries under requestKeys.search() when invalidating requestKeys.all", async () => {
const client = new QueryClient();
client.setQueryData(requestKeys.search("all", "dune", 1, "profile-1"), { sentinel: true });
expect(client.getQueryData(requestKeys.search("all", "dune", 1, "profile-1"))).toEqual({
sentinel: true,
});
await client.invalidateQueries({ queryKey: requestKeys.all });
const state = client.getQueryState(requestKeys.search("all", "dune", 1, "profile-1"));
expect(state?.isInvalidated).toBe(true);
});
});
describe("viewer-scoped cache isolation", () => {
it("does not return profile-1 results when keyed by profile-2", () => {
const client = new QueryClient();
client.setQueryData(requestKeys.search("all", "dune", 1, "profile-1"), {
results: [{ tmdb_id: 1 }],
});
expect(client.getQueryData(requestKeys.search("all", "dune", 1, "profile-2"))).toBeUndefined();
});
});
+34 -6
View File
@@ -1,6 +1,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { api } from "@/api/client";
import { useCurrentProfile } from "@/hooks/useCurrentProfile";
import type {
CreateMediaRequestInput,
DiscoverBrowseKind,
@@ -51,6 +52,9 @@ function buildListQuery(params: RequestListParams = {}) {
}
function invalidateRequestSurfaces(queryClient: ReturnType<typeof useQueryClient>) {
// requestKeys.all = ["requests"], so invalidating it cascades to nested keys,
// including requestKeys.search(...). Policy mutations rely on this to refresh
// viewer-scoped search results when request eligibility changes.
queryClient.invalidateQueries({ queryKey: requestKeys.all });
queryClient.invalidateQueries({ queryKey: adminKeys.requestsRoot() });
}
@@ -148,20 +152,44 @@ export function useRequestMediaDetail(mediaType: RequestMediaType, tmdbID: numbe
});
}
export function useRequestSearch(mediaType: RequestSearchMediaType, query: string, page = 1) {
export interface UseRequestSearchOptions {
/** When false, suppresses the query regardless of the query string. Default: true. */
enabled?: boolean;
/** When true, suppresses the query until the active profile is loaded. Default: false. */
requireProfile?: boolean;
/** Cache freshness window for this search surface. Default: existing Requests page timing. */
staleTime?: number;
}
export function useRequestSearch(
mediaType: RequestSearchMediaType,
query: string,
page = 1,
options: UseRequestSearchOptions = {},
) {
const normalizedQuery = query.trim();
const { profile } = useCurrentProfile();
// Use a sentinel viewerKey when there is no profile so the cache key is stable,
// but suppress the actual fetch — see the `enabled` gate below. This prevents
// any anonymous request results from being written into a bucket that could
// later be read by a different viewer.
const viewerKey = profile?.id ?? "anon";
const enabledOverride = options.enabled ?? true;
const requireProfile = options.requireProfile ?? false;
return useQuery({
queryKey: requestKeys.search(mediaType, normalizedQuery, page),
queryFn: () => {
queryKey: requestKeys.search(mediaType, normalizedQuery, page, viewerKey),
queryFn: ({ signal }) => {
const params = new URLSearchParams({
q: normalizedQuery,
media_type: mediaType,
page: String(page),
});
return api<RequestMediaPage>(`/requests/search?${params}`);
return api<RequestMediaPage>(`/requests/search?${params}`, { signal });
},
enabled: normalizedQuery.length > 1,
staleTime: REQUESTS_STALE_TIME,
enabled:
enabledOverride && normalizedQuery.length > 1 && (!requireProfile || Boolean(profile?.id)),
staleTime: options.staleTime ?? REQUESTS_STALE_TIME,
});
}
+1
View File
@@ -40,6 +40,7 @@ describe("initializeAuthSession", () => {
username: "admin",
email: "admin@example.com",
role: "admin",
permissions: [],
download_allowed: true,
impersonation: null,
});
+121
View File
@@ -0,0 +1,121 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import type { ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const mocks = vi.hoisted(() => ({
useRequestFeatureStatus: vi.fn(),
useCurrentProfile: vi.fn(),
}));
vi.mock("@/hooks/queries/useRequests", () => ({
useRequestFeatureStatus: () => mocks.useRequestFeatureStatus(),
}));
vi.mock("@/hooks/useCurrentProfile", () => ({
useCurrentProfile: () => mocks.useCurrentProfile(),
}));
import { useCanRequest } from "./useCanRequest";
function CaptureHook({ onResult }: { onResult: (r: ReturnType<typeof useCanRequest>) => void }) {
const result = useCanRequest();
onResult(result);
return null;
}
function render(child: ReactNode) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return renderToStaticMarkup(<QueryClientProvider client={client}>{child}</QueryClientProvider>);
}
describe("useCanRequest", () => {
it("returns discoveryEnabled=false when requests_enabled is false", () => {
mocks.useRequestFeatureStatus.mockReturnValue({
data: { requests_enabled: false },
isLoading: false,
});
mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p1" } });
let captured: ReturnType<typeof useCanRequest> | null = null;
render(
<CaptureHook
onResult={(r) => {
captured = r;
}}
/>,
);
expect(captured).toEqual({
discoveryEnabled: false,
isResolving: false,
submitDisabledReason: null,
});
});
it("returns discoveryEnabled=false when there is no profile", () => {
mocks.useRequestFeatureStatus.mockReturnValue({
data: { requests_enabled: true },
isLoading: false,
});
mocks.useCurrentProfile.mockReturnValue({ profile: null });
let captured: ReturnType<typeof useCanRequest> | null = null;
render(
<CaptureHook
onResult={(r) => {
captured = r;
}}
/>,
);
expect(captured).toEqual({
discoveryEnabled: false,
isResolving: false,
submitDisabledReason: null,
});
});
it("returns discoveryEnabled=true when requests are enabled and there is a profile", () => {
mocks.useRequestFeatureStatus.mockReturnValue({
data: { requests_enabled: true },
isLoading: false,
});
mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p1" } });
let captured: ReturnType<typeof useCanRequest> | null = null;
render(
<CaptureHook
onResult={(r) => {
captured = r;
}}
/>,
);
expect(captured).toEqual({
discoveryEnabled: true,
isResolving: false,
submitDisabledReason: null,
});
});
it("returns isResolving=true and discoveryEnabled=false while the feature status is still loading", () => {
mocks.useRequestFeatureStatus.mockReturnValue({ data: undefined, isLoading: true });
mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p1" } });
let captured: ReturnType<typeof useCanRequest> | null = null;
render(
<CaptureHook
onResult={(r) => {
captured = r;
}}
/>,
);
expect(captured).toEqual({
discoveryEnabled: false,
isResolving: true,
submitDisabledReason: null,
});
});
});
+26
View File
@@ -0,0 +1,26 @@
import { useRequestFeatureStatus } from "@/hooks/queries/useRequests";
import { useCurrentProfile } from "@/hooks/useCurrentProfile";
export interface CanRequestState {
discoveryEnabled: boolean;
/**
* True while we cannot yet decide if discovery is on — feature status
* is still loading. Consumers should suppress empty-state UI in this
* window to avoid a flash before the request section appears.
*/
isResolving: boolean;
submitDisabledReason: string | null;
}
export function useCanRequest(): CanRequestState {
const status = useRequestFeatureStatus();
const { profile } = useCurrentProfile();
const discoveryEnabled = Boolean(status.data?.requests_enabled) && Boolean(profile?.id);
const isResolving = status.isLoading;
return {
discoveryEnabled,
isResolving,
submitDisabledReason: null,
};
}
+1
View File
@@ -31,6 +31,7 @@ const ADMIN_TITLES: Record<string, string> = {
recommendations: "Admin Recommendations",
requests: "Admin Requests",
sections: "Admin Sections",
subtitles: "Admin Subtitles",
settings: "Admin Settings",
tasks: "Admin Tasks",
users: "Admin Users",
+30
View File
@@ -0,0 +1,30 @@
import type { User } from "@/api/types";
export const PERMISSION_METADATA_CURATION = "metadata_curation";
export function hasPermission(
user: Pick<User, "role" | "permissions"> | null | undefined,
permission: string,
) {
if (!user) return false;
if (user.role === "admin") return true;
return Array.isArray(user.permissions) && user.permissions.includes(permission);
}
export function canCurateMetadata(user: Pick<User, "role" | "permissions"> | null | undefined) {
return hasPermission(user, PERMISSION_METADATA_CURATION);
}
export function hasAssignedPermission(permissions: string[] | undefined, permission: string) {
return Array.isArray(permissions) && permissions.includes(permission);
}
export function setAssignedPermission(permissions: string[], permission: string, enabled: boolean) {
const next = new Set(permissions);
if (enabled) {
next.add(permission);
} else {
next.delete(permission);
}
return Array.from(next).sort();
}
+232 -106
View File
@@ -40,34 +40,15 @@ import type {
} from "@/api/types";
export default function AdminDashboard() {
const { data: stats, isLoading: statsLoading, refetch: refetchStats } = useAdminStats();
const {
data: sessions = [],
isLoading: sessionsLoading,
refetch: refreshSessions,
} = useAdminSessions();
const { data: libraries = [] } = useAdminLibraries();
const { data: users = [] } = useAdminUsers();
const statsQuery = useAdminStats();
const sessionsQuery = useAdminSessions();
const librariesQuery = useAdminLibraries();
const usersQuery = useAdminUsers();
const scanAll = useScanAllLibraries();
const loading = statsLoading || sessionsLoading;
if (loading)
return (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-3 lg:grid-cols-5">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-24 rounded-2xl" />
))}
</div>
<div className="grid grid-cols-1 gap-3.5 lg:grid-cols-2">
{Array.from({ length: 2 }).map((_, i) => (
<Skeleton key={i} className="h-32 rounded-2xl" />
))}
</div>
<Skeleton className="h-64 rounded-2xl" />
</div>
);
const sessions = sessionsQuery.data ?? [];
const libraries = librariesQuery.data ?? [];
const users = usersQuery.data ?? [];
return (
<div className="space-y-6 lg:space-y-8">
@@ -84,8 +65,8 @@ export default function AdminDashboard() {
variant="outline"
size="sm"
onClick={() => {
refetchStats();
void refreshSessions();
void statsQuery.refetch();
void sessionsQuery.refetch();
}}
>
<RefreshCw className="h-3.5 w-3.5" />
@@ -99,7 +80,7 @@ export default function AdminDashboard() {
scanAll.mutate();
}
}}
disabled={scanAll.isPending}
disabled={scanAll.isPending || libraries.length === 0}
>
<ScanLine className="h-3.5 w-3.5" />
Scan All Libraries
@@ -107,56 +88,67 @@ export default function AdminDashboard() {
</div>
</div>
{/* Stats row */}
{stats && <StatsRow stats={stats} sessionCount={sessions.length} />}
<StatsRow
stats={statsQuery.data}
sessionCount={sessions.length}
isLoading={statsQuery.isLoading}
error={statsQuery.error}
/>
{stats?.watch_provider_activity && (
<TraktActivityCard activity={stats.watch_provider_activity} />
{statsQuery.data?.watch_provider_activity && (
<TraktActivityCard activity={statsQuery.data.watch_provider_activity} />
)}
{/* Now Playing */}
{sessions.length > 0 && (
<div>
<div className="mb-3 flex items-center justify-between">
<div className="text-base font-bold">Now Playing</div>
<Link
to="/admin/activity"
className="text-muted-foreground hover:text-primary text-[11px] transition-colors"
>
View all {sessions.length} streams ›
</Link>
</div>
<div className="grid grid-cols-1 gap-3.5 lg:grid-cols-2">
{sessions.slice(0, 4).map((session) => (
<StreamCard key={session.session_id} session={session} />
))}
</div>
{sessions.length > 4 && (
<Link
to="/admin/activity"
className="text-muted-foreground hover:text-primary mt-2 block text-center text-[12px] transition-colors"
>
+{sessions.length - 4} more active streams
</Link>
)}
</div>
)}
<NowPlayingSection
sessions={sessions}
isLoading={sessionsQuery.isLoading}
error={sessionsQuery.error}
/>
{/* Two-column: Libraries + Users */}
<div className="grid grid-cols-1 gap-3.5 xl:grid-cols-[1.4fr_1fr]">
<LibrariesCard libraries={libraries} />
<UsersCard users={users} />
<LibrariesCard
libraries={libraries}
isLoading={librariesQuery.isLoading}
error={librariesQuery.error}
/>
<UsersCard users={users} isLoading={usersQuery.isLoading} error={usersQuery.error} />
</div>
{/* Recent Activity */}
<ActivityCard sessions={sessions} />
<ActivityCard
sessions={sessions}
isLoading={sessionsQuery.isLoading}
error={sessionsQuery.error}
/>
</div>
);
}
// --- Sub-components ---
function StatsRow({ stats, sessionCount }: { stats: AdminStats; sessionCount: number }) {
function StatsRow({
stats,
sessionCount,
isLoading,
error,
}: {
stats: AdminStats | undefined;
sessionCount: number;
isLoading: boolean;
error: unknown;
}) {
if (isLoading || !stats) {
if (error) {
return <SectionError message="Failed to load stats." />;
}
return (
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-3 lg:grid-cols-5">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-24 rounded-2xl" />
))}
</div>
);
}
const storageGB = stats.total_storage_bytes / (1024 * 1024 * 1024);
const storageTB = storageGB / 1024;
const storageDisplay =
@@ -411,7 +403,71 @@ function StreamCard({ session }: { session: AdminSession }) {
);
}
function LibrariesCard({ libraries }: { libraries: LibraryType[] }) {
function NowPlayingSection({
sessions,
isLoading,
error,
}: {
sessions: AdminSession[];
isLoading: boolean;
error: unknown;
}) {
if (error) return null;
if (isLoading) {
return (
<div>
<div className="mb-3 flex items-center justify-between">
<div className="text-base font-bold">Now Playing</div>
</div>
<div className="grid grid-cols-1 gap-3.5 lg:grid-cols-2">
{Array.from({ length: 2 }).map((_, i) => (
<Skeleton key={i} className="h-[120px] rounded-2xl" />
))}
</div>
</div>
);
}
if (sessions.length === 0) return null;
return (
<div>
<div className="mb-3 flex items-center justify-between">
<div className="text-base font-bold">Now Playing</div>
<Link
to="/admin/activity"
className="text-muted-foreground hover:text-primary text-[11px] transition-colors"
>
View all {sessions.length} streams ›
</Link>
</div>
<div className="grid grid-cols-1 gap-3.5 lg:grid-cols-2">
{sessions.slice(0, 4).map((session) => (
<StreamCard key={session.session_id} session={session} />
))}
</div>
{sessions.length > 4 && (
<Link
to="/admin/activity"
className="text-muted-foreground hover:text-primary mt-2 block text-center text-[12px] transition-colors"
>
+{sessions.length - 4} more active streams
</Link>
)}
</div>
);
}
function LibrariesCard({
libraries,
isLoading,
error,
}: {
libraries: LibraryType[];
isLoading: boolean;
error: unknown;
}) {
const scanLibrary = useScanLibrary();
return (
@@ -426,7 +482,11 @@ function LibrariesCard({ libraries }: { libraries: LibraryType[] }) {
</Link>
</CardHeader>
<CardContent className="space-y-2">
{libraries.length === 0 ? (
{isLoading ? (
<LibrarySkeletonRows />
) : error ? (
<SectionError message="Failed to load libraries." />
) : libraries.length === 0 ? (
<div className="text-muted-foreground py-4 text-center text-sm">
No libraries configured.
</div>
@@ -478,7 +538,15 @@ function LibrariesCard({ libraries }: { libraries: LibraryType[] }) {
);
}
function UsersCard({ users }: { users: AdminUser[] }) {
function UsersCard({
users,
isLoading,
error,
}: {
users: AdminUser[];
isLoading: boolean;
error: unknown;
}) {
const navigate = useNavigate();
return (
@@ -493,7 +561,11 @@ function UsersCard({ users }: { users: AdminUser[] }) {
</Link>
</CardHeader>
<CardContent>
{users.length === 0 ? (
{isLoading ? (
<UserSkeletonRows />
) : error ? (
<SectionError message="Failed to load users." />
) : users.length === 0 ? (
<div className="text-muted-foreground py-4 text-center text-sm">No users.</div>
) : (
<Table>
@@ -543,8 +615,16 @@ function UsersCard({ users }: { users: AdminUser[] }) {
);
}
function ActivityCard({ sessions }: { sessions: AdminSession[] }) {
if (sessions.length === 0) return null;
function ActivityCard({
sessions,
isLoading,
error,
}: {
sessions: AdminSession[];
isLoading: boolean;
error: unknown;
}) {
if (!isLoading && !error && sessions.length === 0) return null;
return (
<Card>
@@ -558,43 +638,49 @@ function ActivityCard({ sessions }: { sessions: AdminSession[] }) {
</Link>
</CardHeader>
<CardContent>
<div className="space-y-0">
{sessions.slice(0, 10).map((s) => {
const isEp = s.series_name && s.season_number != null && s.episode_number != null;
const title = isEp
? s.episode_name || `S${s.season_number}E${s.episode_number}`
: s.media_title || `File #${s.media_file_id}`;
const username = s.username || `User #${s.user_id}`;
return (
<div
key={s.session_id}
className="border-border/30 flex items-start gap-3 border-b py-2.5"
>
<div className="text-primary bg-primary/5 border-primary/10 flex h-[30px] w-[30px] flex-shrink-0 items-center justify-center rounded-lg border">
<Play className="h-3.5 w-3.5" />
</div>
<div className="min-w-0 flex-1">
<div className="text-muted-foreground text-xs leading-relaxed">
<span className="text-foreground font-semibold">{username}</span>
{" started watching "}
<Link
to={`/admin/history?user_id=${s.user_id}${s.profile_id ? `&profile_id=${encodeURIComponent(s.profile_id)}` : ""}`}
className="text-foreground hover:text-primary font-semibold transition-colors"
>
{title}
</Link>
{isLoading ? (
<ActivitySkeletonRows />
) : error ? (
<SectionError message="Failed to load activity." />
) : (
<div className="space-y-0">
{sessions.slice(0, 10).map((s) => {
const isEp = s.series_name && s.season_number != null && s.episode_number != null;
const title = isEp
? s.episode_name || `S${s.season_number}E${s.episode_number}`
: s.media_title || `File #${s.media_file_id}`;
const username = s.username || `User #${s.user_id}`;
return (
<div
key={s.session_id}
className="border-border/30 flex items-start gap-3 border-b py-2.5"
>
<div className="text-primary bg-primary/5 border-primary/10 flex h-[30px] w-[30px] flex-shrink-0 items-center justify-center rounded-lg border">
<Play className="h-3.5 w-3.5" />
</div>
<div className="text-muted-foreground mt-0.5 text-[10px]">
{getTimeAgo(s.started_at)}
<div className="min-w-0 flex-1">
<div className="text-muted-foreground text-xs leading-relaxed">
<span className="text-foreground font-semibold">{username}</span>
{" started watching "}
<Link
to={`/admin/history?user_id=${s.user_id}${s.profile_id ? `&profile_id=${encodeURIComponent(s.profile_id)}` : ""}`}
className="text-foreground hover:text-primary font-semibold transition-colors"
>
{title}
</Link>
</div>
<div className="text-muted-foreground mt-0.5 text-[10px]">
{getTimeAgo(s.started_at)}
</div>
</div>
<div className="flex-shrink-0">
<AdminSessionActions session={s} compact />
</div>
</div>
<div className="flex-shrink-0">
<AdminSessionActions session={s} compact />
</div>
</div>
);
})}
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
@@ -614,3 +700,43 @@ function getTimeAgo(dateStr: string): string {
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
function SectionError({ message }: { message: string }) {
return <div className="text-destructive py-4 text-center text-sm">{message}</div>;
}
function LibrarySkeletonRows() {
return (
<>
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-[60px] rounded-md" />
))}
</>
);
}
function UserSkeletonRows() {
return (
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-10 rounded-md" />
))}
</div>
);
}
function ActivitySkeletonRows() {
return (
<div className="space-y-0">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="border-border/30 flex items-start gap-3 border-b py-2.5">
<Skeleton className="h-[30px] w-[30px] rounded-lg" />
<div className="min-w-0 flex-1 space-y-1.5">
<Skeleton className="h-3 w-3/4 rounded" />
<Skeleton className="h-2 w-1/4 rounded" />
</div>
</div>
))}
</div>
);
}
+112 -61
View File
@@ -9,14 +9,14 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Skeleton } from "@/components/ui/skeleton";
import { Film, FileVideo, Users, Play } from "lucide-react";
import type { AdminSession, AdminStats } from "@/api/types";
export default function AdminStats() {
const { data: stats, isLoading: statsLoading } = useAdminStats();
const { data: sessions = [], isLoading: sessionsLoading } = useAdminSessions();
const loading = statsLoading || sessionsLoading;
if (loading) return <div className="page-shell py-8">Loading stats...</div>;
const statsQuery = useAdminStats();
const sessionsQuery = useAdminSessions();
const sessions = sessionsQuery.data ?? [];
return (
<div className="page-shell space-y-6 py-4 sm:py-6">
@@ -30,64 +30,107 @@ export default function AdminStats() {
</div>
</div>
{stats && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
<StatCard
title="Media Items"
value={stats.total_items}
icon={<Film className="h-4 w-4" />}
/>
<StatCard
title="Files"
value={stats.total_files}
icon={<FileVideo className="h-4 w-4" />}
/>
<StatCard title="Users" value={stats.total_users} icon={<Users className="h-4 w-4" />} />
<StatCard
title="Active Sessions"
value={sessions.length}
icon={<Play className="h-4 w-4" />}
/>
<StatsCards
stats={statsQuery.data}
sessionCount={sessions.length}
isLoading={statsQuery.isLoading}
error={statsQuery.error}
/>
<SessionsSection
sessions={sessions}
isLoading={sessionsQuery.isLoading}
error={sessionsQuery.error}
/>
</div>
);
}
function StatsCards({
stats,
sessionCount,
isLoading,
error,
}: {
stats: AdminStats | undefined;
sessionCount: number;
isLoading: boolean;
error: unknown;
}) {
if (error) {
return <SectionError message="Failed to load stats." />;
}
if (isLoading || !stats) {
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-[108px] rounded-2xl" />
))}
</div>
);
}
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
<StatCard title="Media Items" value={stats.total_items} icon={<Film className="h-4 w-4" />} />
<StatCard title="Files" value={stats.total_files} icon={<FileVideo className="h-4 w-4" />} />
<StatCard title="Users" value={stats.total_users} icon={<Users className="h-4 w-4" />} />
<StatCard title="Active Sessions" value={sessionCount} icon={<Play className="h-4 w-4" />} />
</div>
);
}
function SessionsSection({
sessions,
isLoading,
error,
}: {
sessions: AdminSession[];
isLoading: boolean;
error: unknown;
}) {
return (
<div className="space-y-3">
<h2 className="text-lg font-medium tracking-tight">Active playback sessions</h2>
{isLoading ? (
<div className="surface-panel space-y-2 rounded-2xl p-4">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full rounded-md" />
))}
</div>
) : error ? (
<SectionError message="Failed to load active sessions." />
) : sessions.length === 0 ? (
<div className="surface-panel text-muted-foreground rounded-2xl py-12 text-center text-sm">
No active sessions.
</div>
) : (
<div className="surface-panel overflow-x-auto rounded-2xl border-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Session ID</TableHead>
<TableHead>User ID</TableHead>
<TableHead>File ID</TableHead>
<TableHead>Method</TableHead>
<TableHead>Started</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sessions.map((s) => (
<TableRow key={s.session_id}>
<TableCell className="font-mono text-xs">{s.session_id.slice(0, 8)}...</TableCell>
<TableCell>{s.user_id}</TableCell>
<TableCell>{s.media_file_id}</TableCell>
<TableCell>{s.play_method}</TableCell>
<TableCell className="text-xs">
{new Date(s.started_at).toLocaleString()}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
<div className="space-y-3">
<h2 className="text-lg font-medium tracking-tight">Active playback sessions</h2>
{sessions.length === 0 ? (
<div className="surface-panel text-muted-foreground rounded-2xl py-12 text-center text-sm">
No active sessions.
</div>
) : (
<div className="surface-panel overflow-x-auto rounded-2xl border-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Session ID</TableHead>
<TableHead>User ID</TableHead>
<TableHead>File ID</TableHead>
<TableHead>Method</TableHead>
<TableHead>Started</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sessions.map((s) => (
<TableRow key={s.session_id}>
<TableCell className="font-mono text-xs">
{s.session_id.slice(0, 8)}...
</TableCell>
<TableCell>{s.user_id}</TableCell>
<TableCell>{s.media_file_id}</TableCell>
<TableCell>{s.play_method}</TableCell>
<TableCell className="text-xs">
{new Date(s.started_at).toLocaleString()}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
</div>
);
}
@@ -105,3 +148,11 @@ function StatCard({ title, value, icon }: { title: string; value: number; icon:
</Card>
);
}
function SectionError({ message }: { message: string }) {
return (
<div className="surface-panel text-destructive rounded-2xl py-6 text-center text-sm">
{message}
</div>
);
}
+200
View File
@@ -0,0 +1,200 @@
import { useMemo, useState } from "react";
import { useSearchParams } from "react-router";
import type { AdminDownloadedSubtitle } from "@/api/types";
import AdminSubtitlesFilters, {
FILTER_ALL,
} from "@/components/admin/subtitles/AdminSubtitlesFilters";
import AdminSubtitlesTable from "@/components/admin/subtitles/AdminSubtitlesTable";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import {
useAdminDeleteDownloadedSubtitle,
useAdminDownloadedSubtitles,
} from "@/hooks/queries/admin/subtitles";
import { useAdminUsers } from "@/hooks/queries/admin/users";
const PAGE_SIZE_OPTIONS = ["25", "50", "100"] as const;
export default function AdminSubtitles() {
const [searchParams, setSearchParams] = useSearchParams();
const { data: users = [] } = useAdminUsers();
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(25);
const deleteMutation = useAdminDeleteDownloadedSubtitle();
const provider = searchParams.get("provider") ?? FILTER_ALL;
const language = searchParams.get("language") ?? FILTER_ALL;
const userId = searchParams.get("user_id") ?? FILTER_ALL;
const search = searchParams.get("q") ?? "";
const filters = useMemo(
() => ({
provider: provider !== FILTER_ALL ? provider : undefined,
language: language !== FILTER_ALL ? language : undefined,
userId: userId !== FILTER_ALL ? Number(userId) : undefined,
q: search.trim() || undefined,
limit: pageSize,
offset: page * pageSize,
}),
[language, page, pageSize, provider, search, userId],
);
const subtitlesQuery = useAdminDownloadedSubtitles(filters);
const subtitles = subtitlesQuery.data?.subtitles ?? [];
const total = subtitlesQuery.data?.total ?? 0;
const uploads = subtitlesQuery.data?.uploads ?? 0;
const providerDownloads = subtitlesQuery.data?.provider_downloads ?? 0;
const languageCount = new Set(subtitles.map((row) => row.language)).size;
const hasActiveFilters =
provider !== FILTER_ALL ||
language !== FILTER_ALL ||
userId !== FILTER_ALL ||
search.trim().length > 0;
function updateFilter(key: string, value: string) {
const next = new URLSearchParams(searchParams);
if (value === FILTER_ALL || value.trim() === "") {
next.delete(key);
} else {
next.set(key, value);
}
setPage(0);
setSearchParams(next, { replace: true });
}
function resetFilters() {
setPage(0);
setSearchParams(new URLSearchParams(), { replace: true });
}
function handleDelete(subtitle: AdminDownloadedSubtitle) {
deleteMutation.mutate(subtitle.id);
}
const pageCount = Math.max(1, Math.ceil(total / pageSize));
const canPrev = page > 0;
const canNext = (page + 1) * pageSize < total;
if (subtitlesQuery.isLoading) {
return (
<div className="page-shell space-y-6 py-4 sm:py-6">
<div className="space-y-3">
<Skeleton className="h-12 w-72 rounded-lg" />
<Skeleton className="h-5 w-full max-w-xl rounded-lg" />
</div>
<Skeleton className="h-20 w-full rounded-2xl" />
<Skeleton className="h-24 w-full rounded-2xl" />
{Array.from({ length: 6 }).map((_, index) => (
<Skeleton key={index} className="h-12 w-full rounded-lg" />
))}
</div>
);
}
return (
<div className="page-shell space-y-6 py-4 sm:py-6">
<div className="page-header gap-5">
<div className="space-y-3">
<h1 className="page-title text-[clamp(2rem,4vw,3rem)]">Subtitles</h1>
<p className="page-subtitle text-sm sm:text-base">
Manage stored subtitle files across the library — user uploads and provider downloads.
</p>
</div>
</div>
<div className="surface-panel-subtle grid gap-4 rounded-2xl px-4 py-4 sm:grid-cols-2 xl:grid-cols-4">
<StatBlock label="Total stored" value={total} />
<StatBlock label="User uploads" value={uploads} />
<StatBlock label="Provider downloads" value={providerDownloads} />
<StatBlock label="Languages on page" value={languageCount} />
</div>
<AdminSubtitlesFilters
provider={provider}
language={language}
userId={userId}
search={search}
users={users}
onProviderChange={(value) => updateFilter("provider", value)}
onLanguageChange={(value) => updateFilter("language", value)}
onUserChange={(value) => updateFilter("user_id", value)}
onSearchChange={(value) => updateFilter("q", value)}
onReset={resetFilters}
/>
<AdminSubtitlesTable
subtitles={subtitles}
hasActiveFilters={hasActiveFilters}
onResetFilters={resetFilters}
onDelete={handleDelete}
isDeleting={deleteMutation.isPending}
/>
{total > 0 && (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-muted-foreground text-sm">
Showing {page * pageSize + 1}–{Math.min((page + 1) * pageSize, total)} of {total}
</p>
<div className="flex flex-wrap items-center gap-2">
<Select
value={String(pageSize)}
onValueChange={(value) => {
setPageSize(Number(value));
setPage(0);
}}
>
<SelectTrigger className="w-[110px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZE_OPTIONS.map((size) => (
<SelectItem key={size} value={size}>
{size} / page
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
disabled={!canPrev}
onClick={() => setPage((p) => p - 1)}
>
Previous
</Button>
<span className="text-muted-foreground px-1 text-sm">
Page {page + 1} of {pageCount}
</span>
<Button
type="button"
variant="outline"
disabled={!canNext}
onClick={() => setPage((p) => p + 1)}
>
Next
</Button>
</div>
</div>
)}
</div>
);
}
function StatBlock({ label, value }: { label: string; value: number }) {
return (
<div className="space-y-1">
<div className="text-muted-foreground text-xs font-medium tracking-[0.18em] uppercase">
{label}
</div>
<div className="text-2xl font-semibold tracking-tight">{value.toLocaleString()}</div>
</div>
);
}
+34 -1
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useId, useMemo, useState } from "react";
import type { FormEvent } from "react";
import { useParams, Link } from "react-router";
import {
@@ -66,6 +66,11 @@ import {
playbackQualityValueFromPreset,
type PlaybackQualityPreset,
} from "@/lib/playback-quality";
import {
PERMISSION_METADATA_CURATION,
hasAssignedPermission,
setAssignedPermission,
} from "@/lib/permissions";
import { RegistrySettingControl } from "@/components/settings/RegistrySettingControl";
import { formatSettingValue, getSettingDefinition } from "@/lib/settingsManifest";
import {
@@ -275,6 +280,14 @@ function OverviewTab({ user }: { user: AdminUser }) {
</div>
<div className="divide-border divide-y">
<DetailRow label="Library Access" value={libraryNames} />
<DetailRow
label="Metadata Curation"
value={
hasAssignedPermission(user.permissions, PERMISSION_METADATA_CURATION)
? "Allowed"
: "Not allowed"
}
/>
<DetailRow
label="Max Playback Quality"
value={formatPlaybackQualityPreset(user.max_playback_quality)}
@@ -878,6 +891,7 @@ function EditUserForm({ user, onClose }: { user: AdminUser; onClose: () => void
const [password, setPassword] = useState("");
const [role, setRole] = useState(user.role);
const [enabled, setEnabled] = useState(user.enabled);
const [permissions, setPermissions] = useState<string[]>(user.permissions ?? []);
const [libraryIDs, setLibraryIDs] = useState<number[] | null>(user.library_ids);
const [maxStreams, setMaxStreams] = useState(user.max_streams);
const [maxTranscodes, setMaxTranscodes] = useState(user.max_transcodes);
@@ -889,6 +903,7 @@ function EditUserForm({ user, onClose }: { user: AdminUser; onClose: () => void
const [downloadTranscodeAllowed, setDownloadTranscodeAllowed] = useState(
user.download_transcode_allowed,
);
const metadataCurationId = useId();
const updateMutation = useUpdateUser();
function handleSubmit(e: FormEvent) {
@@ -897,6 +912,7 @@ function EditUserForm({ user, onClose }: { user: AdminUser; onClose: () => void
username,
email,
role,
permissions,
enabled,
library_ids: libraryIDs,
max_streams: maxStreams,
@@ -982,6 +998,23 @@ function EditUserForm({ user, onClose }: { user: AdminUser; onClose: () => void
value={libraryIDs}
onChange={setLibraryIDs}
/>
<div className="border-border flex items-center justify-between rounded-md border px-3 py-2">
<div>
<Label htmlFor={metadataCurationId}>Metadata Curation</Label>
<p className="text-muted-foreground text-xs">
Edit, refresh, and rematch metadata within assigned libraries.
</p>
</div>
<Switch
id={metadataCurationId}
checked={hasAssignedPermission(permissions, PERMISSION_METADATA_CURATION)}
onCheckedChange={(checked) =>
setPermissions((current) =>
setAssignedPermission(current, PERMISSION_METADATA_CURATION, checked),
)
}
/>
</div>
<div className="grid gap-2 sm:grid-cols-2">
<div className="border-border flex items-center justify-between rounded-md border px-3 py-2">
<Label>Downloads Allowed</Label>
+26
View File
@@ -59,6 +59,11 @@ import {
playbackQualityValueFromPreset,
type PlaybackQualityPreset,
} from "@/lib/playback-quality";
import {
PERMISSION_METADATA_CURATION,
hasAssignedPermission,
setAssignedPermission,
} from "@/lib/permissions";
const PAGE_SIZE_OPTIONS = ["25", "50", "100"] as const;
type UserSortField = "username" | "email" | "role" | "enabled" | "created_at" | "last_active_at";
@@ -522,6 +527,7 @@ function UserForm({ user, onClose }: { user: AdminUser | null; onClose: () => vo
const [password, setPassword] = useState("");
const [role, setRole] = useState(user?.role ?? "user");
const [enabled, setEnabled] = useState(user?.enabled ?? true);
const [permissions, setPermissions] = useState<string[]>(user?.permissions ?? []);
const [libraryIDs, setLibraryIDs] = useState<number[] | null>(user?.library_ids ?? null);
const [maxStreams, setMaxStreams] = useState<number>(
user?.max_streams ?? Number(settings?.["defaults.max_streams"] ?? "6"),
@@ -549,6 +555,7 @@ function UserForm({ user, onClose }: { user: AdminUser | null; onClose: () => vo
const passwordId = useId();
const roleId = useId();
const enabledId = useId();
const metadataCurationId = useId();
const downloadAllowedId = useId();
const downloadTranscodeAllowedId = useId();
const maxStreamsId = useId();
@@ -566,6 +573,7 @@ function UserForm({ user, onClose }: { user: AdminUser | null; onClose: () => vo
username,
email,
role,
permissions,
enabled,
library_ids: libraryIDs,
max_streams: maxStreams,
@@ -583,6 +591,7 @@ function UserForm({ user, onClose }: { user: AdminUser | null; onClose: () => vo
email,
password,
role,
permissions,
create_default_profile: true,
max_streams: maxStreams,
max_transcodes: maxTranscodes,
@@ -682,6 +691,23 @@ function UserForm({ user, onClose }: { user: AdminUser | null; onClose: () => vo
value={libraryIDs}
onChange={setLibraryIDs}
/>
<div className="border-border flex items-center justify-between rounded-md border px-3 py-2">
<div>
<Label htmlFor={metadataCurationId}>Metadata Curation</Label>
<p className="text-muted-foreground text-xs">
Edit, refresh, and rematch metadata within assigned libraries.
</p>
</div>
<Switch
id={metadataCurationId}
checked={hasAssignedPermission(permissions, PERMISSION_METADATA_CURATION)}
onCheckedChange={(checked) =>
setPermissions((current) =>
setAssignedPermission(current, PERMISSION_METADATA_CURATION, checked),
)
}
/>
</div>
<div className="grid gap-2 sm:grid-cols-2">
<div className="border-border flex items-center justify-between rounded-md border px-3 py-2">
<Label htmlFor={downloadAllowedId}>Downloads Allowed</Label>
+276 -3
View File
@@ -10,6 +10,8 @@ let latestNavigateTo: string | null = null;
const mockUseCatalogWindow = vi.fn();
const mockUseCatalogFilters = vi.fn();
const mockItemGrid = vi.fn();
const mockUseCanRequest = vi.fn();
const mockUseRequestSearch = vi.fn();
vi.mock("react-router", async () => {
const actual = await vi.importActual<typeof import("react-router")>("react-router");
@@ -38,6 +40,30 @@ vi.mock("@/hooks/queries/catalog", () => ({
useCatalogMetadataFilters: (...args: unknown[]) => mockUseCatalogFilters(...args),
}));
vi.mock("@/hooks/useCanRequest", () => ({
useCanRequest: () => mockUseCanRequest(),
}));
vi.mock("@/hooks/queries/useRequests", () => ({
useRequestSearch: (...args: unknown[]) => mockUseRequestSearch(...args),
}));
vi.mock("@/components/RequestToAddSection", () => ({
RequestToAddSection: ({
variant,
query,
libraryHadHits,
}: {
variant: string;
query: string;
libraryHadHits: boolean;
}) => (
<div data-testid="request-section">
{`variant="${variant}" query="${query}" libraryHadHits="${String(libraryHadHits)}"`}
</div>
),
}));
vi.mock("@/hooks/useAuth", () => ({
AuthProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
useAuth: () => ({
@@ -89,10 +115,19 @@ vi.mock("@/components/ItemGrid", () => ({
items?: Array<{ title: string }>;
totalItems?: number;
pageSize?: number;
loading?: boolean;
onVisibleRangeChange?: (start: number, end: number) => void;
}) => {
mockItemGrid(props);
return <div data-kind="item-grid">{props.items?.map((item) => item.title).join(",")}</div>;
return (
<div
data-kind="item-grid"
data-loading={String(Boolean(props.loading))}
data-total={String(props.totalItems ?? 0)}
>
{props.items?.map((item) => item.title).join(",")}
</div>
);
},
}));
@@ -152,6 +187,14 @@ describe("Catalog page", () => {
mockUseCatalogWindow.mockReset();
mockUseCatalogFilters.mockReset();
mockItemGrid.mockReset();
mockUseCanRequest.mockReset();
mockUseRequestSearch.mockReset();
mockUseCanRequest.mockReturnValue({
discoveryEnabled: false,
isResolving: false,
submitDisabledReason: null,
});
mockUseRequestSearch.mockReturnValue({ data: undefined, isLoading: false, isError: false });
mockUseCatalogWindow.mockReturnValue({
data: {
@@ -273,7 +316,7 @@ describe("Catalog page", () => {
expect(markup).toContain("Settings");
});
it("redirects the retired user plugins settings route back to appearance settings", () => {
it("redirects the retired user plugins settings route back to playback settings", () => {
appInitialEntries = ["/settings/plugins"];
renderToStaticMarkup(
@@ -282,6 +325,236 @@ describe("Catalog page", () => {
</QueryClientProvider>,
);
expect(latestNavigateTo).toBe("appearance");
expect(latestNavigateTo).toBe("/settings/playback");
});
it("renders the request grid variant when source=query and library has results", () => {
mockUseCanRequest.mockReturnValue({
discoveryEnabled: true,
isResolving: false,
submitDisabledReason: null,
});
mockUseRequestSearch.mockReturnValue({
data: {
page: 1,
total_pages: 1,
total_results: 1,
results: [
{
media_type: "movie",
tmdb_id: 1,
title: "X",
availability: "missing",
request: { requestable: true },
},
],
},
isLoading: false,
isError: false,
});
const markup = renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<App />
</QueryClientProvider>,
);
expect(markup).toContain('data-testid="request-section"');
expect(markup).toContain("variant=&quot;grid&quot;");
expect(markup).toContain("libraryHadHits=&quot;true&quot;");
});
it("renders the request grid variant with libraryHadHits=false when library has 0 hits", () => {
mockUseCanRequest.mockReturnValue({
discoveryEnabled: true,
isResolving: false,
submitDisabledReason: null,
});
mockUseCatalogWindow.mockReturnValue({
data: { title: 'Results for "heat"', totalItems: 0, pages: new Map() },
isLoading: false,
});
mockUseRequestSearch.mockReturnValue({
data: {
page: 1,
total_pages: 1,
total_results: 1,
results: [
{
media_type: "movie",
tmdb_id: 1,
title: "X",
availability: "missing",
request: { requestable: true },
},
],
},
isLoading: false,
isError: false,
});
const markup = renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<App />
</QueryClientProvider>,
);
expect(markup).toContain("libraryHadHits=&quot;false&quot;");
});
it("does not render the request section when source is not query", () => {
appInitialEntries = ["/catalog?source=favorites"];
mockUseCanRequest.mockReturnValue({
discoveryEnabled: true,
isResolving: false,
submitDisabledReason: null,
});
mockUseCatalogWindow.mockReturnValue({
data: { title: "Favorites", totalItems: 0, pages: new Map() },
isLoading: false,
});
const markup = renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<App />
</QueryClientProvider>,
);
expect(markup).not.toContain('data-testid="request-section"');
});
it("does not render the request section when discovery is disabled", () => {
const markup = renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<App />
</QueryClientProvider>,
);
expect(markup).not.toContain('data-testid="request-section"');
});
it("passes enabled=false to useRequestSearch when discoveryEnabled is false", () => {
renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<App />
</QueryClientProvider>,
);
const call = mockUseRequestSearch.mock.calls[mockUseRequestSearch.mock.calls.length - 1];
expect(call?.[3]).toEqual({
enabled: false,
requireProfile: true,
staleTime: 5 * 60 * 1000,
});
});
it("hides ItemGrid when library is empty and TMDB is still loading (request section will rescue)", () => {
mockUseCanRequest.mockReturnValue({
discoveryEnabled: true,
isResolving: false,
submitDisabledReason: null,
});
mockUseCatalogWindow.mockReturnValue({
data: { title: 'Results for "heat"', totalItems: 0, pages: new Map() },
isLoading: false,
});
mockUseRequestSearch.mockReturnValue({ data: undefined, isLoading: true, isError: false });
const markup = renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<App />
</QueryClientProvider>,
);
// Previously this case forced ItemGrid into loading=true, rendering 24 fake
// skeletons forever above the section. Now ItemGrid is hidden entirely.
expect(markup).not.toContain('data-kind="item-grid"');
});
it("hides ItemGrid when library is empty and TMDB has missing results", () => {
mockUseCanRequest.mockReturnValue({
discoveryEnabled: true,
isResolving: false,
submitDisabledReason: null,
});
mockUseCatalogWindow.mockReturnValue({
data: { title: 'Results for "heat"', totalItems: 0, pages: new Map() },
isLoading: false,
});
mockUseRequestSearch.mockReturnValue({
data: {
page: 1,
total_pages: 1,
total_results: 1,
results: [
{
media_type: "movie",
tmdb_id: 1,
title: "X",
availability: "missing",
request: { requestable: true },
},
],
},
isLoading: false,
isError: false,
});
const markup = renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<App />
</QueryClientProvider>,
);
expect(markup).not.toContain('data-kind="item-grid"');
expect(markup).toContain('data-testid="request-section"');
});
it("hides ItemGrid when library is empty and discovery feature status is still resolving", () => {
mockUseCanRequest.mockReturnValue({
discoveryEnabled: false,
isResolving: true,
submitDisabledReason: null,
});
mockUseCatalogWindow.mockReturnValue({
data: { title: 'Results for "heat"', totalItems: 0, pages: new Map() },
isLoading: false,
});
const markup = renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<App />
</QueryClientProvider>,
);
// Avoids the empty-state flash before the feature status resolves and TMDB
// either rescues with results or confirms there are none.
expect(markup).not.toContain('data-kind="item-grid"');
});
it("renders the normal ItemGrid empty state when both library and TMDB are empty", () => {
mockUseCanRequest.mockReturnValue({
discoveryEnabled: true,
isResolving: false,
submitDisabledReason: null,
});
mockUseCatalogWindow.mockReturnValue({
data: { title: 'Results for "heat"', totalItems: 0, pages: new Map() },
isLoading: false,
});
mockUseRequestSearch.mockReturnValue({
data: { page: 1, total_pages: 1, total_results: 0, results: [] },
isLoading: false,
isError: false,
});
const markup = renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<App />
</QueryClientProvider>,
);
expect(markup).toContain('data-loading="false"');
expect(markup).toContain('data-total="0"');
});
});
+46 -10
View File
@@ -4,10 +4,14 @@ import { CheckSquare, Search, Trash2, X } from "lucide-react";
import type { BrowseItem } from "@/api/types";
import ItemGrid from "@/components/ItemGrid";
import { RequestToAddSection } from "@/components/RequestToAddSection";
import { Button } from "@/components/ui/button";
import CatalogFiltersPanel from "@/components/catalog/CatalogFiltersPanel";
import { useCatalogWindow } from "@/hooks/queries/catalog";
import { useRemoveHistory } from "@/hooks/queries/history";
import { useRequestSearch } from "@/hooks/queries/useRequests";
import { useCanRequest } from "@/hooks/useCanRequest";
import { useDebounce } from "@/hooks/useDebounce";
import { useDocumentTitle } from "@/hooks/useDocumentTitle";
import SearchBar from "@/components/SearchBar";
import { ConfirmDialog } from "@/components/ConfirmDialog";
@@ -101,6 +105,28 @@ function CatalogResults({
visibleRange,
includeTotal: showExactResultCount,
});
const canRequest = useCanRequest();
const isQuerySource = state.source === "query" && Boolean(state.q);
// Add a 200ms TMDB debounce on top of SearchBar's 200ms input debounce so the
// TMDB plugin isn't hit at the same cadence as the local library query.
const tmdbDebouncedQ = useDebounce(state.q ?? "", 200);
const tmdbQuery = useRequestSearch("all", tmdbDebouncedQ, 1, {
enabled: canRequest.discoveryEnabled && isQuerySource,
requireProfile: true,
staleTime: 5 * 60 * 1000,
});
const tmdbMissingCount =
tmdbQuery.data?.results?.filter((result) => result.availability !== "available").length ?? 0;
const libraryHasResults = (catalogQuery.data?.totalItems ?? 0) > 0;
const libraryEmpty = !catalogQuery.isLoading && !libraryHasResults;
// When the library is empty and the request section will (or might) render,
// hide ItemGrid entirely. The previous approach pinned ItemGrid's `loading`
// prop to true, which renders 24 skeleton tiles forever above the section.
const tmdbMayRescueLibrary =
isQuerySource &&
libraryEmpty &&
(canRequest.isResolving ||
(canRequest.discoveryEnabled && (tmdbQuery.isLoading || tmdbMissingCount > 0)));
const loadedHistoryItems = useMemo(() => {
if (!isHistorySource) {
return [] as BrowseItem[];
@@ -249,16 +275,26 @@ function CatalogResults({
</section>
)}
<ItemGrid
totalItems={catalogQuery.data?.totalItems ?? 0}
pages={catalogQuery.data?.pages ?? new Map()}
pageSize={limit}
loading={catalogQuery.isLoading}
onVisibleRangeChange={handleVisibleRangeChange}
selectionMode={isHistorySource && selectionMode}
selectedIds={selectedIds}
onToggleSelect={toggleHistorySelection}
/>
{tmdbMayRescueLibrary ? null : (
<ItemGrid
totalItems={catalogQuery.data?.totalItems ?? 0}
pages={catalogQuery.data?.pages ?? new Map()}
pageSize={limit}
loading={catalogQuery.isLoading}
onVisibleRangeChange={handleVisibleRangeChange}
selectionMode={isHistorySource && selectionMode}
selectedIds={selectedIds}
onToggleSelect={toggleHistorySelection}
/>
)}
{isQuerySource && canRequest.discoveryEnabled ? (
<RequestToAddSection
variant="grid"
query={tmdbDebouncedQ}
libraryHadHits={libraryHasResults}
/>
) : null}
<ConfirmDialog
open={removeConfirmOpen}
+20 -40
View File
@@ -1,8 +1,7 @@
import { Link, useNavigate, useParams } from "react-router";
import { ArrowLeft } from "lucide-react";
import { useNavigate, useParams } from "react-router";
import type { Collection, UserCollectionType } from "@/api/types";
import { Button } from "@/components/ui/button";
import PageBack from "@/components/PageBack";
import { Card, CardHeader, CardDescription, CardTitle } from "@/components/ui/card";
import { useCollections } from "@/hooks/queries/collections";
@@ -32,14 +31,9 @@ export default function CollectionEditor() {
if (id && !collection && !isLoading) {
return (
<div className="page-shell space-y-4 py-4 sm:py-6">
<Button asChild variant="ghost" className="w-fit px-0">
<Link to="/collections">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Collections
</Link>
</Button>
<Card className="surface-panel rounded-[1.7rem] border-0 shadow-none">
<div className="page-shell relative space-y-4 py-4 sm:py-6">
<PageBack to="/collections" preferHistory={false} />
<Card className="surface-panel mt-10 rounded-[1.7rem] border-0 shadow-none sm:mt-12">
<CardHeader>
<CardTitle>Collection not found</CardTitle>
<CardDescription>The selected collection could not be loaded.</CardDescription>
@@ -51,21 +45,14 @@ export default function CollectionEditor() {
if (collection && isImportedCollection(collection)) {
return (
<div className="page-shell space-y-6 py-4 sm:py-6">
<div className="space-y-3">
<Button asChild variant="ghost" className="w-fit px-0">
<Link to="/collections">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Collections
</Link>
</Button>
<div>
<h1 className="page-title text-[clamp(2rem,4vw,3rem)]">{collection.name}</h1>
<p className="page-subtitle mt-1 text-sm sm:text-base">
Edit what's local — name, libraries, sharing. Source-managed details (URL, schedule,
item ordering) are locked.
</p>
</div>
<div className="page-shell relative space-y-6 py-4 sm:py-6">
<PageBack to="/collections" preferHistory={false} />
<div className="mt-10 sm:mt-12">
<h1 className="page-title text-[clamp(2rem,4vw,3rem)]">{collection.name}</h1>
<p className="page-subtitle mt-1 text-sm sm:text-base">
Edit what's local — name, libraries, sharing. Source-managed details (URL, schedule,
item ordering) are locked.
</p>
</div>
<ImportedCollectionEditor
key={collection.id}
@@ -80,20 +67,13 @@ export default function CollectionEditor() {
// the wizard so users can see the live card preview while tuning filters.
if (collection && collection.collection_type === "manual") {
return (
<div className="page-shell space-y-6 py-4 sm:py-6">
<div className="space-y-3">
<Button asChild variant="ghost" className="w-fit px-0">
<Link to="/collections">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Collections
</Link>
</Button>
<div>
<h1 className="page-title text-[clamp(2rem,4vw,3rem)]">Edit {collection.name}</h1>
<p className="page-subtitle mt-1 text-sm sm:text-base">
Manual collections are curated by adding titles directly.
</p>
</div>
<div className="page-shell relative space-y-6 py-4 sm:py-6">
<PageBack to="/collections" preferHistory={false} />
<div className="mt-10 sm:mt-12">
<h1 className="page-title text-[clamp(2rem,4vw,3rem)]">Edit {collection.name}</h1>
<p className="page-subtitle mt-1 text-sm sm:text-base">
Manual collections are curated by adding titles directly.
</p>
</div>
<UserCollectionForm collection={collection} onClose={() => navigate("/collections")} />
<section className="space-y-3">
+3
View File
@@ -28,6 +28,7 @@ interface DetailHeroProps {
scoreRow?: ReactNode;
crewLine?: ReactNode;
variant?: "full" | "compact";
topNav?: ReactNode;
}
export default function DetailHero({
@@ -52,6 +53,7 @@ export default function DetailHero({
scoreRow,
crewLine,
variant = "full",
topNav,
}: DetailHeroProps) {
const [backdropLoaded, setBackdropLoaded] = useState(false);
const [posterLoaded, setPosterLoaded] = useState(false);
@@ -88,6 +90,7 @@ export default function DetailHero({
return (
<section className="item-detail-hero border-border/10 relative isolate overflow-hidden border-b">
{topNav}
{(backdropUrl || backdropPlaceholder) && (
<div
className="absolute inset-0 h-full w-full"
@@ -225,7 +225,7 @@ describe("EpisodeContent", () => {
mocks.useRating.mockReturnValue({ data: { rating: 3, rated_at: "2026-03-22T00:00:00Z" } });
});
it("links the season breadcrumb and back button to the resolved season page", () => {
it("links the season breadcrumb segment to the resolved season page", () => {
const markup = renderToStaticMarkup(
<MemoryRouter initialEntries={["/item/episode-1"]}>
<EpisodeContent item={makeEpisodeItem()} />
@@ -233,7 +233,7 @@ describe("EpisodeContent", () => {
);
expect(countOccurrences(markup, 'href="/item/series-1"')).toBe(1);
expect(countOccurrences(markup, 'href="/item/season-1"')).toBe(2);
expect(countOccurrences(markup, 'href="/item/season-1"')).toBe(1);
expect(markup).toContain(">Season 1<");
});
@@ -258,7 +258,7 @@ describe("EpisodeContent", () => {
</MemoryRouter>,
);
expect(countOccurrences(markup, 'href="/item/season-99"')).toBe(2);
expect(countOccurrences(markup, 'href="/item/season-99"')).toBe(1);
expect(markup).toContain(">Season 99<");
});
+19 -9
View File
@@ -16,6 +16,7 @@ import CrewList from "@/components/CrewList";
import DownloadVersionPicker from "@/components/DownloadVersionPicker";
import EditMetadataDialog from "@/components/EditMetadataDialog";
import MediaLocations from "@/components/MediaLocations";
import PageBack from "@/components/PageBack";
import EpisodeCarousel from "./components/EpisodeCarousel";
import DetailHero from "./DetailHero";
import MetadataBadges from "./components/MetadataBadges";
@@ -35,6 +36,7 @@ import {
type EpisodeNavigationState,
} from "./itemDetailLayout";
import { getWatchedActionLabel } from "./watchedState";
import { canCurateMetadata as canCurateMetadataForUser } from "@/lib/permissions";
function formatDuration(minutes: number): string {
if (minutes <= 0) return "";
@@ -49,6 +51,7 @@ export default function EpisodeContent({ item }: { item: ItemDetail & { type: "e
useAmbientColor(item.backdrop_thumbhash);
const { user } = useAuth();
const isAdmin = user?.role === "admin";
const canCurateMetadata = canCurateMetadataForUser(user);
const { profile: currentProfile } = useCurrentProfile();
const [editOpen, setEditOpen] = useState(false);
const [downloadOpen, setDownloadOpen] = useState(false);
@@ -221,6 +224,7 @@ export default function EpisodeContent({ item }: { item: ItemDetail & { type: "e
<div>
<DetailHero
title={title}
topNav={<PageBack />}
context={
<div className="space-y-3">
<DetailBreadcrumb segments={breadcrumbSegments} />
@@ -294,12 +298,15 @@ export default function EpisodeContent({ item }: { item: ItemDetail & { type: "e
watchedLabel={getWatchedActionLabel(item)}
onToggleWatched={() => watchedMutation.mutate(!(item.user_data?.played ?? false))}
isUpdatingWatched={watchedMutation.isPending}
onRefresh={(mode) =>
refreshMetadataMutation.mutate({
item,
mode,
onReplaced: (contentID) => navigate(`/item/${contentID}`, { replace: true }),
})
onRefresh={
canCurateMetadata
? (mode) =>
refreshMetadataMutation.mutate({
item,
mode,
onReplaced: (contentID) => navigate(`/item/${contentID}`, { replace: true }),
})
: undefined
}
isRefreshing={refreshMetadataMutation.isPending}
onRedetectIntro={
@@ -307,7 +314,8 @@ export default function EpisodeContent({ item }: { item: ItemDetail & { type: "e
}
isRedetectingIntro={redetectIntroMutation.isPending}
isAdmin={isAdmin}
onEditMetadata={isAdmin ? () => setEditOpen(true) : undefined}
canCurateMetadata={canCurateMetadata}
onEditMetadata={canCurateMetadata ? () => setEditOpen(true) : undefined}
versions={item.versions ?? []}
playbackVariants={item.playback_variants}
selectedVersion={selectedVersion}
@@ -340,7 +348,7 @@ export default function EpisodeContent({ item }: { item: ItemDetail & { type: "e
/>
<div className="page-shell space-y-12 py-10 sm:space-y-14">
{isAdmin && <MediaLocations title="Media locations" versions={item.versions} />}
{canCurateMetadata && <MediaLocations title="Media locations" versions={item.versions} />}
{/* More Episodes carousel — most useful, so show first */}
{siblingsLoading ? (
@@ -367,7 +375,9 @@ export default function EpisodeContent({ item }: { item: ItemDetail & { type: "e
{item.crew && item.crew.length > 0 && <CrewList crew={item.crew} />}
</div>
{isAdmin && <EditMetadataDialog item={item} open={editOpen} onOpenChange={setEditOpen} />}
{canCurateMetadata && (
<EditMetadataDialog item={item} open={editOpen} onOpenChange={setEditOpen} />
)}
<DownloadVersionPicker
open={downloadOpen}
onOpenChange={setDownloadOpen}
+21 -11
View File
@@ -16,6 +16,7 @@ import DownloadVersionPicker from "@/components/DownloadVersionPicker";
import EditMetadataDialog from "@/components/EditMetadataDialog";
import MediaLocations from "@/components/MediaLocations";
import MatchItemDialog from "@/components/MatchItemDialog";
import PageBack from "@/components/PageBack";
import RecommendationGrid from "@/components/RecommendationGrid";
import DetailHero from "./DetailHero";
import MetadataBadges from "./components/MetadataBadges";
@@ -29,6 +30,7 @@ import { selectDefaultPlaybackVariantVersion } from "./components/versionRanking
import { RecommendationGridSkeleton } from "./components/SectionSkeletons";
import { resolveLeafPrimaryAction } from "./itemDetailLayout";
import { getWatchedActionLabel } from "./watchedState";
import { canCurateMetadata as canCurateMetadataForUser } from "@/lib/permissions";
function formatDuration(minutes: number): string {
if (minutes <= 0) return "";
@@ -42,6 +44,7 @@ export default function MovieContent({ item }: { item: ItemDetail & { type: "mov
useAmbientColor(item.backdrop_thumbhash);
const { user } = useAuth();
const isAdmin = user?.role === "admin";
const canCurateMetadata = canCurateMetadataForUser(user);
const { profile: currentProfile } = useCurrentProfile();
const isFavorite = item.user_state?.is_favorite ?? false;
@@ -179,6 +182,7 @@ export default function MovieContent({ item }: { item: ItemDetail & { type: "mov
<div>
<DetailHero
title={title}
topNav={<PageBack />}
context="Movie"
studioLabel={firstStudio}
backdropUrl={item.backdrop_url}
@@ -238,17 +242,21 @@ export default function MovieContent({ item }: { item: ItemDetail & { type: "mov
isFavorite={isFavorite}
onToggleWatchlist={() => toggleWatchlistMutation.mutate(inWatchlist)}
inWatchlist={inWatchlist}
onRefresh={(mode) =>
refreshMetadataMutation.mutate({
item,
mode,
onReplaced: (contentID) => navigate(`/item/${contentID}`, { replace: true }),
})
onRefresh={
canCurateMetadata
? (mode) =>
refreshMetadataMutation.mutate({
item,
mode,
onReplaced: (contentID) => navigate(`/item/${contentID}`, { replace: true }),
})
: undefined
}
isRefreshing={refreshMetadataMutation.isPending}
isAdmin={isAdmin}
onEditMetadata={isAdmin ? () => setEditOpen(true) : undefined}
onMatchItem={isAdmin ? () => setMatchOpen(true) : undefined}
canCurateMetadata={canCurateMetadata}
onEditMetadata={canCurateMetadata ? () => setEditOpen(true) : undefined}
onMatchItem={canCurateMetadata ? () => setMatchOpen(true) : undefined}
versions={item.versions}
playbackVariants={item.playback_variants}
selectedVersion={selectedVersion}
@@ -283,7 +291,7 @@ export default function MovieContent({ item }: { item: ItemDetail & { type: "mov
/>
<div className="page-shell space-y-12 py-10 sm:space-y-14">
{isAdmin && <MediaLocations title="Media locations" versions={item.versions} />}
{canCurateMetadata && <MediaLocations title="Media locations" versions={item.versions} />}
{item.cast && item.cast.length > 0 && (
<div>
@@ -307,8 +315,10 @@ export default function MovieContent({ item }: { item: ItemDetail & { type: "mov
)
)}
</div>
{isAdmin && <EditMetadataDialog item={item} open={editOpen} onOpenChange={setEditOpen} />}
{isAdmin && (
{canCurateMetadata && (
<EditMetadataDialog item={item} open={editOpen} onOpenChange={setEditOpen} />
)}
{canCurateMetadata && (
<MatchItemDialog
key={item.content_id}
item={item}
+18 -8
View File
@@ -8,6 +8,7 @@ import { useAuth } from "@/hooks/useAuth";
import CastCarousel from "@/components/CastCarousel";
import CrewList from "@/components/CrewList";
import EditMetadataDialog from "@/components/EditMetadataDialog";
import PageBack from "@/components/PageBack";
import DetailHero from "./DetailHero";
import MetadataBadges from "./components/MetadataBadges";
import ActionBar from "./components/ActionBar";
@@ -15,6 +16,7 @@ import DetailBreadcrumb from "./components/DetailBreadcrumb";
import SeasonEpisodeGrid from "./components/SeasonEpisodeGrid";
import type { EpisodeNavigationState } from "./itemDetailLayout";
import { getWatchedActionLabel } from "./watchedState";
import { canCurateMetadata as canCurateMetadataForUser } from "@/lib/permissions";
function seasonLabel(seasonNumber: number, title?: string) {
if (title) return title;
@@ -27,6 +29,7 @@ export default function SeasonContent({ item }: { item: ItemDetail & { type: "se
useAmbientColor(item.backdrop_thumbhash);
const { user } = useAuth();
const isAdmin = user?.role === "admin";
const canCurateMetadata = canCurateMetadataForUser(user);
const [editOpen, setEditOpen] = useState(false);
const watchedMutation = useWatchedStateMutation(item);
const refreshMetadataMutation = useRefreshItemMetadata();
@@ -82,6 +85,7 @@ export default function SeasonContent({ item }: { item: ItemDetail & { type: "se
<DetailHero
variant="compact"
title={displayTitle}
topNav={<PageBack />}
context={breadcrumb}
backdropUrl={item.backdrop_url}
backdropThumbhash={item.backdrop_thumbhash}
@@ -103,16 +107,20 @@ export default function SeasonContent({ item }: { item: ItemDetail & { type: "se
watchedLabel={getWatchedActionLabel(item)}
onToggleWatched={() => watchedMutation.mutate(!(item.user_data?.played ?? false))}
isUpdatingWatched={watchedMutation.isPending}
onRefresh={(mode) =>
refreshMetadataMutation.mutate({
item,
mode,
onReplaced: (contentID) => navigate(`/item/${contentID}`, { replace: true }),
})
onRefresh={
canCurateMetadata
? (mode) =>
refreshMetadataMutation.mutate({
item,
mode,
onReplaced: (contentID) => navigate(`/item/${contentID}`, { replace: true }),
})
: undefined
}
isRefreshing={refreshMetadataMutation.isPending}
isAdmin={isAdmin}
onEditMetadata={isAdmin ? () => setEditOpen(true) : undefined}
canCurateMetadata={canCurateMetadata}
onEditMetadata={canCurateMetadata ? () => setEditOpen(true) : undefined}
/>
}
/>
@@ -144,7 +152,9 @@ export default function SeasonContent({ item }: { item: ItemDetail & { type: "se
</div>
)}
</div>
{isAdmin && <EditMetadataDialog item={item} open={editOpen} onOpenChange={setEditOpen} />}
{canCurateMetadata && (
<EditMetadataDialog item={item} open={editOpen} onOpenChange={setEditOpen} />
)}
</div>
);
}
+20 -10
View File
@@ -14,6 +14,7 @@ import CastCarousel from "@/components/CastCarousel";
import CrewList from "@/components/CrewList";
import EditMetadataDialog from "@/components/EditMetadataDialog";
import MatchItemDialog from "@/components/MatchItemDialog";
import PageBack from "@/components/PageBack";
import RecommendationGrid from "@/components/RecommendationGrid";
import DetailHero from "./DetailHero";
import SeasonCarousel from "./SeasonCarousel";
@@ -25,12 +26,14 @@ import ActionBar from "./components/ActionBar";
import { SeasonCarouselSkeleton, RecommendationGridSkeleton } from "./components/SectionSkeletons";
import { getSeasonDisplayTitle, resolveSeriesPrimaryAction } from "./itemDetailLayout";
import { getWatchedActionLabel } from "./watchedState";
import { canCurateMetadata as canCurateMetadataForUser } from "@/lib/permissions";
export default function SeriesContent({ item }: { item: ItemDetail & { type: "series" } }) {
const navigate = useNavigate();
useAmbientColor(item.backdrop_thumbhash);
const { user } = useAuth();
const isAdmin = user?.role === "admin";
const canCurateMetadata = canCurateMetadataForUser(user);
const isFavorite = item.user_state?.is_favorite ?? false;
const inWatchlist = item.user_state?.in_watchlist ?? false;
@@ -116,6 +119,7 @@ export default function SeriesContent({ item }: { item: ItemDetail & { type: "se
<div>
<DetailHero
title={title}
topNav={<PageBack />}
context="Series"
studioLabel={firstNetwork}
backdropUrl={item.backdrop_url}
@@ -156,17 +160,21 @@ export default function SeriesContent({ item }: { item: ItemDetail & { type: "se
isFavorite={isFavorite}
onToggleWatchlist={() => toggleWatchlistMutation.mutate(inWatchlist)}
inWatchlist={inWatchlist}
onRefresh={(mode) =>
refreshMetadataMutation.mutate({
item,
mode,
onReplaced: (contentID) => navigate(`/item/${contentID}`, { replace: true }),
})
onRefresh={
canCurateMetadata
? (mode) =>
refreshMetadataMutation.mutate({
item,
mode,
onReplaced: (contentID) => navigate(`/item/${contentID}`, { replace: true }),
})
: undefined
}
isRefreshing={refreshMetadataMutation.isPending}
isAdmin={isAdmin}
onEditMetadata={isAdmin ? () => setEditOpen(true) : undefined}
onMatchItem={isAdmin ? () => setMatchOpen(true) : undefined}
canCurateMetadata={canCurateMetadata}
onEditMetadata={canCurateMetadata ? () => setEditOpen(true) : undefined}
onMatchItem={canCurateMetadata ? () => setMatchOpen(true) : undefined}
rating={item.user_rating ?? null}
onRatingChange={handleRatingChange}
/>
@@ -213,8 +221,10 @@ export default function SeriesContent({ item }: { item: ItemDetail & { type: "se
)
)}
</div>
{isAdmin && <EditMetadataDialog item={item} open={editOpen} onOpenChange={setEditOpen} />}
{isAdmin && (
{canCurateMetadata && (
<EditMetadataDialog item={item} open={editOpen} onOpenChange={setEditOpen} />
)}
{canCurateMetadata && (
<MatchItemDialog
key={item.content_id}
item={item}
@@ -73,6 +73,7 @@ interface ActionBarProps {
onEditMetadata?: () => void;
onMatchItem?: () => void;
isAdmin?: boolean;
canCurateMetadata?: boolean;
versions?: FileVersion[];
playbackVariants?: PlaybackVariant[];
selectedVersion?: FileVersion | null;
@@ -121,6 +122,7 @@ export default function ActionBar({
onEditMetadata,
onMatchItem,
isAdmin = false,
canCurateMetadata = false,
versions,
playbackVariants,
selectedVersion,
@@ -226,6 +228,10 @@ export default function ActionBar({
const hasOverflowActions = Boolean(
restartHref || onToggleWatchlist || onDownload || onSearchSubtitles,
);
const hasAdminActions = Boolean(isAdmin && (contentId || onRedetectIntro));
const hasMetadataActions = Boolean(
canCurateMetadata && (onRefresh || onEditMetadata || onMatchItem),
);
const formattedResumeTime = formatPlaybackTime(resumePositionSeconds ?? 0);
const percentComplete =
@@ -379,10 +385,10 @@ export default function ActionBar({
Search Subtitles
</DropdownMenuItem>
)}
{isAdmin && (
{(hasAdminActions || hasMetadataActions) && (
<>
{hasOverflowActions && <DropdownMenuSeparator />}
{contentId && (
{isAdmin && contentId && (
<DropdownMenuItem
onSelect={() =>
navigate(`/admin/history?media_item_id=${encodeURIComponent(contentId)}`)
@@ -391,7 +397,7 @@ export default function ActionBar({
View Play History
</DropdownMenuItem>
)}
{onRefresh && (
{canCurateMetadata && onRefresh && (
<DropdownMenuItem
disabled={isRefreshing}
onSelect={() => {
@@ -402,19 +408,19 @@ export default function ActionBar({
Refresh Metadata
</DropdownMenuItem>
)}
{onRedetectIntro && (
{isAdmin && onRedetectIntro && (
<DropdownMenuItem disabled={isRedetectingIntro} onSelect={onRedetectIntro}>
<RefreshCw className={`size-4 ${isRedetectingIntro ? "animate-spin" : ""}`} />
Re-detect Intro Markers
</DropdownMenuItem>
)}
{onEditMetadata && (
{canCurateMetadata && onEditMetadata && (
<DropdownMenuItem onSelect={onEditMetadata}>
<Pencil className="size-4" />
Edit Metadata
</DropdownMenuItem>
)}
{onMatchItem && (
{canCurateMetadata && onMatchItem && (
<DropdownMenuItem onSelect={onMatchItem}>
<Search className="size-4" />
Match Item
@@ -1,5 +1,5 @@
import { Link } from "react-router";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { ChevronRight } from "lucide-react";
interface BreadcrumbSegment {
label: string;
@@ -13,44 +13,32 @@ interface DetailBreadcrumbProps {
export default function DetailBreadcrumb({ segments }: DetailBreadcrumbProps) {
if (segments.length === 0) return null;
const backSegment = [...segments].reverse().find((segment) => segment.href);
return (
<nav aria-label="Breadcrumb">
<div className="flex items-center gap-2.5">
{backSegment?.href && (
<Link
to={backSegment.href}
className="glass-subtle text-muted-foreground hover:text-foreground flex items-center justify-center rounded-full p-1.5 transition-colors"
>
<ChevronLeft className="size-5" />
</Link>
)}
<div className="flex items-center gap-1.5">
{segments.map((segment, i) => {
const isLast = i === segments.length - 1;
return (
<span key={i} className="flex items-center gap-1.5">
{i > 0 && <ChevronRight className="text-muted-foreground/40 size-4" />}
{segment.href && !isLast ? (
<Link
to={segment.href}
className="text-muted-foreground hover:text-foreground text-[13px] transition-colors"
>
{segment.label}
</Link>
) : (
<span
className="text-muted-foreground/60 text-[13px]"
{...(isLast ? { "aria-current": "page" as const } : {})}
>
{segment.label}
</span>
)}
</span>
);
})}
</div>
<div className="flex items-center gap-1.5">
{segments.map((segment, i) => {
const isLast = i === segments.length - 1;
return (
<span key={i} className="flex items-center gap-1.5">
{i > 0 && <ChevronRight className="text-muted-foreground/40 size-4" />}
{segment.href && !isLast ? (
<Link
to={segment.href}
className="text-muted-foreground hover:text-foreground text-[13px] transition-colors"
>
{segment.label}
</Link>
) : (
<span
className="text-muted-foreground/60 text-[13px]"
{...(isLast ? { "aria-current": "page" as const } : {})}
>
{segment.label}
</span>
)}
</span>
);
})}
</div>
</nav>
);
@@ -3,6 +3,7 @@ import { Link } from "react-router";
import { Check, Play, ChevronLeft, ChevronRight } from "lucide-react";
import type { EpisodeListItem } from "@/api/types";
import { decodeThumbhash } from "@/lib/thumbhash";
import { cn } from "@/lib/utils";
import MediaItemMenu from "@/components/MediaItemMenu";
import type { EpisodeNavigationState } from "../itemDetailLayout";
import { useCarouselEmbla } from "@/hooks/useCarouselEmbla";
@@ -48,7 +49,7 @@ export default function EpisodeCarousel({
</button>
)}
<div ref={emblaRef} className="embla__viewport overflow-hidden">
<div ref={emblaRef} className="embla__viewport overflow-hidden py-4 pl-4">
<ul role="list" className="embla__container flex cursor-grab list-none gap-3">
{episodes.map((ep) => {
const isCurrent = ep.episode_number === currentEpisodeNumber;
@@ -79,12 +80,16 @@ export default function EpisodeCarousel({
<Link
to={`/item/${ep.content_id}`}
state={episodeLinkState}
aria-current={isCurrent ? "page" : undefined}
className="block"
>
<div
className={`surface-panel-subtle relative aspect-video overflow-hidden rounded-[1.1rem] border ${
isCurrent ? "border-primary/40" : "border-border/30"
}`}
className={cn(
"surface-panel-subtle relative aspect-video overflow-hidden rounded-[1.1rem] border transition-[border-color,box-shadow] duration-200",
isCurrent
? "border-transparent shadow-[0_0_0_2px_var(--primary)]"
: "border-border/30",
)}
style={
thumbhashUrl
? { backgroundImage: `url(${thumbhashUrl})`, backgroundSize: "cover" }
@@ -103,6 +108,15 @@ export default function EpisodeCarousel({
<Play size={28} className="text-muted-foreground/30" />
</div>
)}
{isCurrent && (
<div className="bg-primary text-primary-foreground pointer-events-none absolute top-2.5 left-2.5 z-10 flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[10px] leading-none font-semibold tracking-[0.08em] uppercase shadow-[0_2px_8px_rgb(0_0_0/0.28)]">
<span className="relative flex size-1.5">
<span className="bg-primary-foreground absolute inline-flex h-full w-full animate-ping rounded-full opacity-60" />
<span className="bg-primary-foreground relative inline-flex size-1.5 rounded-full" />
</span>
Now Viewing
</div>
)}
{ep.user_data?.played && (
<div className="absolute top-2 right-2 rounded-full bg-black/65 p-1.5 text-green-400">
<Check className="size-4" />
@@ -135,7 +149,12 @@ export default function EpisodeCarousel({
hasPartialProgress={progress != null}
/>
</div>
<Link to={`/item/${ep.content_id}`} state={episodeLinkState} className="block">
<Link
to={`/item/${ep.content_id}`}
state={episodeLinkState}
aria-current={isCurrent ? "page" : undefined}
className="block"
>
<p className="text-muted-foreground/70 mt-2 text-xs">
Episode {ep.episode_number}
</p>
@@ -21,11 +21,14 @@ import {
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import {
searchSubtitles,
detectSubtitleLanguage,
useDownloadSubtitle,
useDownloadedSubtitles,
useUploadSubtitle,
} from "@/hooks/queries/subtitles";
import { cn } from "@/lib/utils";
import { LANGUAGES, getLanguageName } from "@/player/utils/languageNames";
import { SubtitleUploadForm } from "@/components/subtitles/SubtitleUploadForm";
import { buildQualitySummary } from "./VersionFlyout";
interface SubtitleSearchDialogProps {
@@ -39,6 +42,7 @@ const providerInfo: Record<string, { abbr: string; className: string }> = {
opensubtitles: { abbr: "OS", className: "bg-amber-500/15 text-amber-700 dark:text-amber-300" },
subdl: { abbr: "SDL", className: "bg-sky-500/15 text-sky-700 dark:text-sky-300" },
subsource: { abbr: "SS", className: "bg-rose-500/15 text-rose-700 dark:text-rose-300" },
upload: { abbr: "UP", className: "bg-violet-500/15 text-violet-700 dark:text-violet-300" },
};
function scoreTone(score: number): { text: string; ring: string; bg: string } {
@@ -77,6 +81,7 @@ export default function SubtitleSearchDialog({
title,
}: SubtitleSearchDialogProps) {
const downloadSubtitleMutation = useDownloadSubtitle();
const uploadSubtitleMutation = useUploadSubtitle();
const downloadedQuery = useDownloadedSubtitles(open ? version?.file_id : undefined);
const searchAbortRef = useRef<AbortController | null>(null);
@@ -190,13 +195,41 @@ export default function SubtitleSearchDialog({
[downloadSubtitleMutation, downloadedQuery, version],
);
const handleUpload = useCallback(
async (input: {
mediaFileId: number;
file: File;
language?: string;
languageOverride?: boolean;
hearingImpaired: boolean;
}) => {
await uploadSubtitleMutation.mutateAsync({
media_file_id: input.mediaFileId,
file: input.file,
language: input.language,
language_override: input.languageOverride,
hearing_impaired: input.hearingImpaired,
});
},
[uploadSubtitleMutation],
);
const handleDetectLanguage = useCallback(
(file: File, fallbackLanguage?: string) => detectSubtitleLanguage(file, fallbackLanguage),
[],
);
const handleUploadSuccess = useCallback(async () => {
await downloadedQuery.refetch();
}, [downloadedQuery]);
const versionLabel = version ? buildQualitySummary(version) : "";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl overflow-hidden sm:max-w-3xl">
<DialogHeader className="min-w-0">
<DialogTitle>Search Subtitles</DialogTitle>
<DialogTitle>Add Subtitles</DialogTitle>
<DialogDescription className="truncate">
{title}
{versionLabel ? ` \u00B7 ${versionLabel}` : ""}
@@ -205,28 +238,42 @@ export default function SubtitleSearchDialog({
<TooltipProvider delayDuration={250}>
<div className="min-w-0 space-y-4">
<div className="flex flex-col gap-2 sm:flex-row">
<Select value={selectedLanguage} onValueChange={setSelectedLanguage}>
<SelectTrigger className="w-full sm:w-[220px]">
<SelectValue placeholder="Language" />
</SelectTrigger>
<SelectContent>
{LANGUAGES.map((language) => (
<SelectItem key={language.code} value={language.code}>
{language.label}
</SelectItem>
))}
</SelectContent>
</Select>
{version && (
<SubtitleUploadForm
mediaFileId={version.file_id}
upload={handleUpload}
detectLanguage={handleDetectLanguage}
onSuccess={handleUploadSuccess}
onError={setSearchError}
defaultLanguage={selectedLanguage}
/>
)}
<Button onClick={handleSearch} disabled={!version || searching}>
{searching ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Search className="size-4" />
)}
Search
</Button>
<div className="space-y-2">
<p className="text-sm font-medium">Search online</p>
<div className="flex flex-col gap-2 sm:flex-row">
<Select value={selectedLanguage} onValueChange={setSelectedLanguage}>
<SelectTrigger className="w-full sm:w-[220px]">
<SelectValue placeholder="Language" />
</SelectTrigger>
<SelectContent>
{LANGUAGES.map((language) => (
<SelectItem key={language.code} value={language.code}>
{language.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={handleSearch} disabled={!version || searching}>
{searching ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Search className="size-4" />
)}
Search
</Button>
</div>
</div>
{searchError && (
+4 -2
View File
@@ -8,6 +8,7 @@ import { createEmptyQueryDefinition } from "@/api/types";
import type { CatalogSearchState } from "@/pages/catalogSearchParams";
import EditPersonDialog from "@/components/EditPersonDialog";
import ItemGrid from "@/components/ItemGrid";
import PageBack from "@/components/PageBack";
import { Button } from "@/components/ui/button";
import { useCatalogWindow } from "@/hooks/queries/catalog";
import { personKeys } from "@/hooks/queries/keys";
@@ -76,8 +77,9 @@ export default function PersonDetail() {
return (
<div>
{/* Person Header */}
<section className="page-shell pt-8 pb-6 sm:pt-10 sm:pb-8">
<div className="flex flex-col gap-6 lg:flex-row lg:gap-8">
<section className="page-shell relative pt-8 pb-6 sm:pt-10 sm:pb-8">
<PageBack />
<div className="mt-10 flex flex-col gap-6 sm:mt-12 lg:flex-row lg:gap-8">
{/* Photo */}
<div className="shrink-0 self-start">
<div className="media-card-image aspect-[2/3] w-[140px] overflow-hidden rounded-lg sm:w-[180px]">
+4 -2
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import PageBack from "@/components/PageBack";
import ProfileSectionRow from "@/components/ProfileSectionRow";
import RecipeGalleryModal from "@/components/RecipeGallery/RecipeGalleryModal";
import RecipeConfigDrawer from "@/components/RecipeGallery/RecipeConfigDrawer";
@@ -207,8 +208,9 @@ export default function ProfileCustomizeHome() {
}
return (
<div className="mx-auto max-w-3xl p-6">
<div className="flex items-center justify-between border-b border-white/10 pb-3">
<div className="relative mx-auto max-w-3xl p-6">
<PageBack />
<div className="mt-10 flex items-center justify-between border-b border-white/10 pb-3 sm:mt-12">
<h1 className="text-base font-semibold">Customize home</h1>
<div className="flex gap-2">
<button
+12 -19
View File
@@ -1,6 +1,7 @@
import { Link, useParams } from "react-router";
import { ArrowLeft, RefreshCw, Sparkles } from "lucide-react";
import { useParams } from "react-router";
import { RefreshCw, Sparkles } from "lucide-react";
import PageBack from "@/components/PageBack";
import SectionItemCard from "@/components/SectionItemCard";
import { Skeleton } from "@/components/ui/skeleton";
import { useRecommendationSection } from "@/hooks/queries/recommendations";
@@ -75,23 +76,15 @@ export default function RecommendationsSection() {
useDocumentTitle(title);
return (
<div className="space-y-6 px-4 pt-6 pb-12 sm:px-6 lg:px-10 xl:px-12">
<div className="space-y-3">
<Link
to="/recommendations"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1.5 text-xs font-medium tracking-[0.16em] uppercase transition-colors"
>
<ArrowLeft className="h-3.5 w-3.5" />
Recommendations
</Link>
<div className="flex flex-col gap-1.5">
<h1 className="text-foreground text-2xl font-bold tracking-tight sm:text-3xl">{title}</h1>
{data && data.items.length > 0 && (
<p className="text-muted-foreground text-sm">
{data.items.length} {data.items.length === 1 ? "title" : "titles"}
</p>
)}
</div>
<div className="relative space-y-6 px-4 pt-6 pb-12 sm:px-6 lg:px-10 xl:px-12">
<PageBack to="/recommendations" preferHistory={false} />
<div className="mt-10 flex flex-col gap-1.5 sm:mt-12">
<h1 className="text-foreground text-2xl font-bold tracking-tight sm:text-3xl">{title}</h1>
{data && data.items.length > 0 && (
<p className="text-muted-foreground text-sm">
{data.items.length} {data.items.length === 1 ? "title" : "titles"}
</p>
)}
</div>
{isLoading ? (
+8 -18
View File
@@ -1,5 +1,5 @@
import { Link, useParams, useSearchParams } from "react-router";
import { ArrowLeft } from "lucide-react";
import { useParams, useSearchParams } from "react-router";
import PageBack from "@/components/PageBack";
import RequestPosterCard from "@/components/RequestPosterCard";
import { Button } from "@/components/ui/button";
import {
@@ -83,29 +83,19 @@ export default function RequestBrowse({ kind }: RequestBrowseProps) {
if (browse.isError && (browse.error as { status?: number }).status === 404) {
return (
<div className="space-y-4 py-10 text-center">
<p className="text-foreground text-lg font-semibold">
<div className="relative space-y-4 py-10 text-center">
<PageBack to="/requests" preferHistory={false} />
<p className="text-foreground mt-10 text-lg font-semibold sm:mt-12">
{kind === "studio" ? "Studio" : kind === "network" ? "Network" : "Genre"} not found.
</p>
<Link
to="/requests"
className="text-muted-foreground hover:text-foreground text-sm underline"
>
Back to Requests
</Link>
</div>
);
}
return (
<div className="space-y-6 py-6 sm:py-8">
<div className="space-y-4 px-4 sm:px-6 lg:px-10 xl:px-12">
<Link
to="/requests"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-sm"
>
<ArrowLeft className="h-4 w-4" /> Back to Requests
</Link>
<div className="relative space-y-6 py-6 sm:py-8">
<PageBack to="/requests" preferHistory={false} />
<div className="mt-10 space-y-4 px-4 sm:mt-12 sm:px-6 lg:px-10 xl:px-12">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-4">
<BrowseHeaderTile browse={browse.data} kind={kind} fallback={title} />
+9 -24
View File
@@ -1,7 +1,8 @@
import { useNavigate, useParams } from "react-router";
import { ArrowLeft, Check, Clock, Loader2, Plus, Star } from "lucide-react";
import { useParams } from "react-router";
import { Check, Clock, Loader2, Plus, Star } from "lucide-react";
import CastCarousel from "@/components/CastCarousel";
import MediaCarousel from "@/components/MediaCarousel";
import PageBack from "@/components/PageBack";
import RequestPosterCard from "@/components/RequestPosterCard";
import DetailHero from "@/pages/ItemDetail/DetailHero";
import { Button } from "@/components/ui/button";
@@ -23,7 +24,6 @@ import {
} from "@/lib/mediaRequests";
export default function RequestDetail() {
const navigate = useNavigate();
const params = useParams<{ mediaType: string; tmdbId: string }>();
const mediaType = (params.mediaType === "series" ? "series" : "movie") as "movie" | "series";
const tmdbID = Number(params.tmdbId) || 0;
@@ -39,17 +39,14 @@ export default function RequestDetail() {
if (detail.isError || !detail.data) {
return (
<div className="page-shell space-y-3 py-12 text-center">
<p className="text-foreground text-base font-semibold">Couldn't load this title.</p>
<div className="page-shell relative space-y-3 py-12 text-center">
<PageBack to="/requests" />
<p className="text-foreground mt-10 text-base font-semibold sm:mt-12">
Couldn't load this title.
</p>
<p className="text-muted-foreground text-sm">
The TMDB record may be temporarily unavailable.
</p>
<div className="pt-4">
<Button variant="outline" size="sm" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
Back
</Button>
</div>
</div>
);
}
@@ -63,6 +60,7 @@ export default function RequestDetail() {
<div>
<DetailHero
title={item.title}
topNav={<PageBack to="/requests" />}
context={<RequestContext mediaType={mediaType} />}
studioLabel={studioLabel}
backdropUrl={backdropUrl}
@@ -79,7 +77,6 @@ export default function RequestDetail() {
createRequest.isPending && createRequest.variables?.tmdb_id === item.tmdb_id
}
onRequest={() => createRequest.mutate(requestInputFromMediaResult(item))}
onBack={() => navigate(-1)}
/>
}
/>
@@ -198,12 +195,10 @@ function RequestActions({
item,
isSubmitting,
onRequest,
onBack,
}: {
item: RequestMediaDetail;
isSubmitting: boolean;
onRequest: () => void;
onBack: () => void;
}) {
const requestable = item.request.requestable;
const statusLabel = item.request.status ? formatRequestStatus(item.request.status) : null;
@@ -213,16 +208,6 @@ function RequestActions({
return (
<div className="flex flex-wrap items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={onBack}
className="text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" />
Back
</Button>
{requestable ? (
<Button
onClick={onRequest}
+2 -3
View File
@@ -20,15 +20,14 @@ describe("SettingsLayout", () => {
});
});
it("includes a back link to the main app shell", () => {
it("includes a PageBack control at the top of the page", () => {
const markup = renderToStaticMarkup(
<MemoryRouter initialEntries={["/settings/playback"]}>
<SettingsLayout />
</MemoryRouter>,
);
expect(markup).toContain('href="/"');
expect(markup).toContain(">Back<");
expect(markup).toContain('aria-label="Go back"');
});
it("does not include a plugins section in personal settings", () => {
+4 -10
View File
@@ -1,6 +1,5 @@
import { Link, Outlet, useLocation } from "react-router";
import {
ArrowLeft,
Play,
Library,
Clock,
@@ -16,6 +15,7 @@ import {
} from "lucide-react";
// Sparkles is used by the Personalization nav entry below.
import type { LucideIcon } from "lucide-react";
import PageBack from "@/components/PageBack";
import { useDocumentTitle } from "@/hooks/useDocumentTitle";
import { useAuth } from "@/hooks/useAuth";
import { resolveSettingsDocumentTitle } from "@/lib/documentTitle";
@@ -155,21 +155,15 @@ export default function SettingsLayout() {
return (
<div className="min-h-[100dvh]">
<main className="page-shell-wide flex min-h-[100dvh] flex-col py-4 sm:py-6">
<div className="page-header gap-5">
<main className="page-shell-wide relative flex min-h-[100dvh] flex-col py-4 sm:py-6">
<PageBack to="/" preferHistory={false} floating />
<div className="page-header mt-10 gap-5 sm:mt-12">
<div className="min-w-0 space-y-3">
<h1 className="page-title text-[clamp(2rem,4vw,3rem)]">Settings</h1>
<p className="page-subtitle text-sm sm:text-base">
Manage your playback preferences, libraries, and display options.
</p>
</div>
<Link
to="/"
className="surface-panel-subtle text-muted-foreground hover:text-foreground inline-flex shrink-0 items-center gap-2 rounded-[1.1rem] px-3 py-2 text-sm font-medium transition-colors"
>
<ArrowLeft className="h-4 w-4" />
<span className="hidden sm:inline">Back</span>
</Link>
</div>
{/* Mobile: horizontal scrolling tab bar */}
+16 -25
View File
@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link } from "react-router";
import { ArrowLeft, ArrowRight, Check } from "lucide-react";
import type {
@@ -15,6 +14,7 @@ import { normalizeQueryDefinition } from "@/api/types";
import CatalogFiltersPanel from "@/components/catalog/CatalogFiltersPanel";
import { ImageUploadField } from "@/components/ImageUploadField";
import ItemGrid from "@/components/ItemGrid";
import PageBack from "@/components/PageBack";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -118,12 +118,13 @@ export default function SmartCollectionWizard(wizard: SmartCollectionWizardProps
}, []);
const headerTitle = isEdit ? draft.title || "Edit Collection" : "New Collection";
const backHref = wizard.mode === "user" ? "/collections" : adminBackHref(wizard);
const backTarget = wizard.mode === "user" ? "/collections" : adminBackHref(wizard);
const adminLibraries = wizard.mode === "admin" ? wizard.libraries : [];
return (
<div className="page-shell-wide space-y-6 py-4 sm:py-6">
<WizardHeader backHref={backHref} title={headerTitle} step={step} isEdit={isEdit} />
<div className="page-shell-wide relative space-y-6 py-4 sm:py-6">
<PageBack to={backTarget} preferHistory={false} />
<WizardHeader title={headerTitle} step={step} isEdit={isEdit} />
{step === 1 ? (
<Step1FiltersAndPreview
@@ -178,37 +179,27 @@ function adminBackHref(props: AdminModeProps): string {
}
function WizardHeader({
backHref,
title,
step,
isEdit,
}: {
backHref: string;
title: string;
step: WizardStep;
isEdit: boolean;
}) {
return (
<div className="space-y-3">
<Button asChild variant="ghost" className="w-fit px-0">
<Link to={backHref}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Collections
</Link>
</Button>
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<h1 className="page-title text-[clamp(1.75rem,3vw,2.5rem)]">{title}</h1>
<p className="page-subtitle mt-1 text-sm">
{step === 1
? "Tune the filters until the cards below show the collection you want."
: isEdit
? "Update naming, artwork, and sharing for this collection."
: "Give your new collection a name, artwork, and sharing rules."}
</p>
</div>
<StepIndicator step={step} />
<div className="mt-10 flex flex-wrap items-end justify-between gap-4 sm:mt-12">
<div>
<h1 className="page-title text-[clamp(1.75rem,3vw,2.5rem)]">{title}</h1>
<p className="page-subtitle mt-1 text-sm">
{step === 1
? "Tune the filters until the cards below show the collection you want."
: isEdit
? "Update naming, artwork, and sharing for this collection."
: "Give your new collection a name, artwork, and sharing rules."}
</p>
</div>
<StepIndicator step={step} />
</div>
);
}
@@ -678,7 +678,7 @@ export default function WebhookSyncSettings() {
<div className="min-w-0 space-y-0.5">
<Label className="text-sm font-medium">Default profile</Label>
<p className="text-muted-foreground text-[13px] leading-relaxed">
Activity from unmapped users goes to this profile.
The signed-in external user is linked to this profile when the connection is created.
</p>
</div>
<Select
@@ -894,7 +894,7 @@ export default function WebhookSyncSettings() {
<SelectValue placeholder="Choose a profile" />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNMAPPED_VALUE}>No fallback profile</SelectItem>
<SelectItem value={UNMAPPED_VALUE}>No default profile</SelectItem>
{profiles.map((candidate) => (
<SelectItem key={candidate.id} value={candidate.id}>
{candidate.name}
@@ -941,8 +941,7 @@ export default function WebhookSyncSettings() {
<div className="space-y-0.5">
<Label className="text-sm font-medium">Profile mapping</Label>
<p className="text-muted-foreground text-[13px] leading-relaxed">
Map each external user to a Silo profile. Unmapped users fall back to the
default profile above.
Map each external user to a Silo profile. Unmapped users are ignored.
</p>
</div>
@@ -987,7 +986,7 @@ export default function WebhookSyncSettings() {
<SelectValue placeholder="Choose a profile" />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNMAPPED_VALUE}>Use default profile</SelectItem>
<SelectItem value={UNMAPPED_VALUE}>Ignore this user</SelectItem>
{profiles.map((candidate) => (
<SelectItem key={candidate.id} value={candidate.id}>
{candidate.name}
@@ -2,7 +2,12 @@ import { useState, useEffect, useCallback, useRef } from "react";
import { createPortal } from "react-dom";
import type { PlayerConfig } from "../context/PlayerConfigContext";
import { playerFetch } from "../player-fetch";
import type { SubtitleSearchResponse, SubtitleResult } from "@/api/types";
import type {
SubtitleLanguageDetection,
SubtitleSearchResponse,
SubtitleResult,
} from "@/api/types";
import { SubtitleUploadForm } from "@/components/subtitles/SubtitleUploadForm";
import { LANGUAGES } from "../utils/languageNames";
interface SubtitleSearchModalProps {
@@ -22,6 +27,7 @@ const providerInfo: Record<string, ProviderInfo> = {
opensubtitles: { abbr: "OS", color: "#eab308" },
subdl: { abbr: "SDL", color: "#3b82f6" },
subsource: { abbr: "SS", color: "#ef4444" },
upload: { abbr: "UP", color: "#a855f7" },
};
function scoreColor(score: number): string {
@@ -132,6 +138,56 @@ export function SubtitleSearchModal({
}
}, [playerConfig, mediaFileId, selectedLang]);
const handleUpload = useCallback(
async (input: {
mediaFileId: number;
file: File;
language?: string;
languageOverride?: boolean;
hearingImpaired: boolean;
}) => {
const form = new FormData();
form.set("media_file_id", String(input.mediaFileId));
if (input.language) {
form.set("language", input.language);
}
if (input.languageOverride) {
form.set("language_override", "true");
}
form.set("file", input.file);
if (input.hearingImpaired) {
form.set("hearing_impaired", "true");
}
await playerFetch(playerConfig, "/subtitles/upload", {
method: "POST",
body: form,
});
},
[playerConfig],
);
const handleDetectLanguage = useCallback(
async (file: File, fallbackLanguage?: string): Promise<SubtitleLanguageDetection> => {
const form = new FormData();
form.set("file", file);
if (fallbackLanguage) {
form.set("language", fallbackLanguage);
}
return playerFetch<SubtitleLanguageDetection>(playerConfig, "/subtitles/detect-language", {
method: "POST",
body: form,
});
},
[playerConfig],
);
const handleUploadSuccess = useCallback(() => {
onSubtitleDownloaded();
handleClose();
}, [onSubtitleDownloaded, handleClose]);
const handleDownload = useCallback(
async (result: SubtitleResult) => {
const key = `${result.provider}:${result.id}`;
@@ -171,7 +227,7 @@ export function SubtitleSearchModal({
onClick={handleClose}
role="dialog"
aria-modal="true"
aria-label="Subtitle Search"
aria-label="Add Subtitles"
onKeyDown={handleFocusTrap}
>
<div
@@ -181,7 +237,7 @@ export function SubtitleSearchModal({
>
{/* Header */}
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3">
<h2 className="text-sm font-semibold">Search Subtitles</h2>
<h2 className="text-sm font-semibold">Add Subtitles</h2>
<button
type="button"
className="rounded text-white/60 hover:text-white focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:outline-none"
@@ -192,6 +248,20 @@ export function SubtitleSearchModal({
</button>
</div>
<SubtitleUploadForm
mediaFileId={mediaFileId}
upload={handleUpload}
detectLanguage={handleDetectLanguage}
onSuccess={handleUploadSuccess}
onError={setError}
variant="player"
defaultLanguage={selectedLang}
/>
<div className="border-b border-white/10 px-4 py-2">
<p className="text-xs font-medium text-white/60">Search online</p>
</div>
{/* Search controls */}
<div className="flex gap-2 px-4 py-3">
<select
+3 -1
View File
@@ -33,9 +33,11 @@ export async function playerFetch<T>(
options: RequestInit = {},
): Promise<T> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(options.headers as Record<string, string>),
};
if (!(options.body instanceof FormData)) {
headers["Content-Type"] = "application/json";
}
const token = config.getAccessToken();
if (token) {