Merge remote-tracking branch 'origin/main' into feat/audiobooks
# Conflicts: # go.sum
This commit is contained in:
@@ -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 />} />
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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="true"");
|
||||
expect(markup).toContain("variant="dialog"");
|
||||
});
|
||||
|
||||
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="false"");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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]");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ describe("initializeAuthSession", () => {
|
||||
username: "admin",
|
||||
email: "admin@example.com",
|
||||
role: "admin",
|
||||
permissions: [],
|
||||
download_allowed: true,
|
||||
impersonation: null,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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="grid"");
|
||||
expect(markup).toContain("libraryHadHits="true"");
|
||||
});
|
||||
|
||||
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="false"");
|
||||
});
|
||||
|
||||
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
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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<");
|
||||
});
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user