01527ae4f0
Combine both branches into a single coherent branch: - Full mobile responsive parity (useIsMobile, MobileCardRow, SheetForm, .mobile-touch-target, mobile cards, SheetForm forms, 44px targets, dirty-state confirm, TablePagination, refetchIntervalInBackground). - Full services-as-hub IA (data-driven nav, service-page tab skeleton, new service types, Authentik directory + messaging, named dashboards, legacy routes 404, Observability split, Jellyseerr absorbed). Enhancement: service tabs now use mobile-parity primitives: - MediaTab: MobileCardRow below md (title/size/HDR/library/year) + TablePagination; DataTable at md+ (desktop branch preserved). - FilesTab: MobileCardRow below md (name/type/size/modified) + handleRowClick; DataTable at md+. - ServicePage: SheetForm branch below md (open-on-mount, sticky header + save bar, cancel navigates back to /services, dirty-state guard). - Dashboard: single-column + section anchors below md (from mobile-parity) + empty-state CTA (from services-hub). - App.tsx: useIsMobile() replaces inline matchMedia (from mobile-parity) + data-driven useNavItems (from services-hub). - Backup tables (BackupAlerts/Jobs/Runs) already have MobileCardRow from mobile-parity; JobsTab inherits mobile behavior through its sub-components. Conflict resolutions: - Backend: entirely from services-hub (mobile didn't touch it). - Deleted pages (Media/FileBrowser/Actions/Users/UsersPage/Applications/ ObservabilityPage/BackupsPage + hooks/useUsers + tests): kept deleted (services-hub deleted them; content moved into service tabs). - New service-tabs/*: from services-hub, enhanced with mobile patterns. - App.tsx: services-hub's data-driven nav + mobile-parity's useIsMobile. - Dashboard.tsx: merged (services-hub CTA + mobile-parity sections/anchors). - ServicePage.tsx: services-hub's tab skeleton + mobile-parity's SheetForm. - Primitives (useIsMobile/mobile-card/sheet-form/etc.): from mobile-parity. 117 frontend tests pass (mobile-parity's 122 - 5 deleted page tests + services-hub's new tab/dashboard tests); 271 backend tests pass; lint/ build green both sides.
564 lines
17 KiB
TypeScript
564 lines
17 KiB
TypeScript
/**
|
|
* MediaTab — operational content for the Jellyfin service page.
|
|
*
|
|
* Lifted from the old top-level `pages/Media.tsx`. The service-id source is
|
|
* changed from URL search params to the `instance` prop (the active service
|
|
* instance selected on the service page). The service-selection dropdown and
|
|
* its URL-sync effect are removed; everything else is preserved verbatim.
|
|
*/
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import type {
|
|
ColumnDef,
|
|
OnChangeFn,
|
|
PaginationState,
|
|
RowSelectionState,
|
|
VisibilityState,
|
|
} from "@tanstack/react-table";
|
|
|
|
import { DataTable } from "@/components/ui/data-table";
|
|
import {
|
|
MobileCardRow,
|
|
type MobileCardField,
|
|
} from "@/components/ui/mobile-card";
|
|
import { TablePagination } from "@/components/ui/table-pagination";
|
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Progress } from "@/components/ui/progress";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
|
|
import {
|
|
useMediaStatus,
|
|
useMediaQuery as useMediaDataQuery,
|
|
useBuildIndex,
|
|
useStopBuildIndex,
|
|
useForceStopBuildIndex,
|
|
} from "../../hooks/useMedia";
|
|
import { usePersistentState } from "../../hooks/usePersistentState";
|
|
import { useIsMobile } from "../../hooks/useIsMobile";
|
|
import type { MediaItem, ServiceInstance } from "../../types";
|
|
import { useCounts, useLibraries } from "../../hooks/useDashboard";
|
|
import { useServiceInstances } from "../../hooks/useServices";
|
|
|
|
// --- Format helpers (lifted verbatim from Media.tsx) ---
|
|
|
|
function formatDuration(seconds: number | null | undefined): string {
|
|
if (seconds == null || Number.isNaN(seconds)) return "-";
|
|
const total = Math.max(0, Math.round(seconds));
|
|
const hours = Math.floor(total / 3600);
|
|
const minutes = Math.floor((total % 3600) / 60);
|
|
const secs = total % 60;
|
|
if (hours > 0) return `${hours}h ${minutes}m ${secs}s`;
|
|
if (minutes > 0) return `${minutes}m ${secs}s`;
|
|
return `${secs}s`;
|
|
}
|
|
|
|
// --- Column definitions (lifted verbatim) ---
|
|
|
|
const mediaColumns: ColumnDef<MediaItem>[] = [
|
|
{ accessorKey: "title", header: "Title" },
|
|
{ accessorKey: "series", header: "Series" },
|
|
{ accessorKey: "season", header: "Season" },
|
|
{ accessorKey: "episode", header: "Episode" },
|
|
{ accessorKey: "type", header: "Type" },
|
|
{ accessorKey: "year", header: "Year" },
|
|
{ accessorKey: "runtime_min", header: "Runtime" },
|
|
{ accessorKey: "size", header: "Size" },
|
|
{ accessorKey: "bitrate", header: "Bitrate" },
|
|
{ accessorKey: "hdr", header: "HDR" },
|
|
{ accessorKey: "video", header: "Video codec" },
|
|
{ accessorKey: "resolution", header: "Resolution" },
|
|
{ accessorKey: "date_added", header: "Date added" },
|
|
{ accessorKey: "library", header: "Library" },
|
|
{ accessorKey: "path", header: "Path" },
|
|
];
|
|
|
|
function getMediaRowId(row: MediaItem): string {
|
|
return row.path;
|
|
}
|
|
|
|
// Mobile card fields (mobile-parity pattern): title primary + 4 key fields.
|
|
const mediaCardFields: MobileCardField<MediaItem>[] = [
|
|
{ key: "title", label: "Title", render: (r) => r.title, primary: true },
|
|
{ key: "size", label: "Size", render: (r) => r.size || "-" },
|
|
{ key: "hdr", label: "HDR", render: (r) => r.hdr || "-" },
|
|
{ key: "library", label: "Library", render: (r) => r.library || "-" },
|
|
{
|
|
key: "year",
|
|
label: "Year",
|
|
render: (r) => (r.year != null ? String(r.year) : "-"),
|
|
},
|
|
];
|
|
|
|
// --- Persistent filter/sort/pagination state (lifted verbatim) ---
|
|
|
|
const MEDIA_TAB_STATE_KEY = "manage.media.tabState";
|
|
const SMALL_BREAKPOINT = "(max-width: 900px)";
|
|
const MOBILE_HIDDEN_COLUMNS = [
|
|
"series",
|
|
"season",
|
|
"episode",
|
|
"bitrate",
|
|
"video",
|
|
"resolution",
|
|
"date_added",
|
|
"library",
|
|
"path",
|
|
];
|
|
|
|
type MediaTabState = {
|
|
search: string;
|
|
types: string;
|
|
hdrFilter: string;
|
|
sortKey: string;
|
|
sortOrder: string;
|
|
offset: number;
|
|
pageSize: number;
|
|
columnVisibility: Record<string, boolean>;
|
|
};
|
|
|
|
function defaultMediaTabState(): MediaTabState {
|
|
return {
|
|
search: "",
|
|
types: "Movie,Episode",
|
|
hdrFilter: "All",
|
|
sortKey: "title",
|
|
sortOrder: "Ascending",
|
|
offset: 0,
|
|
pageSize: 100,
|
|
columnVisibility: {},
|
|
};
|
|
}
|
|
|
|
function usePrefersSmallScreen(): boolean {
|
|
const supportsMatchMedia =
|
|
typeof window !== "undefined" && typeof window.matchMedia === "function";
|
|
const [small, setSmall] = useState(() =>
|
|
supportsMatchMedia ? window.matchMedia(SMALL_BREAKPOINT).matches : false,
|
|
);
|
|
useEffect(() => {
|
|
if (!supportsMatchMedia) return;
|
|
const mql = window.matchMedia(SMALL_BREAKPOINT);
|
|
const onChange = () => setSmall(mql.matches);
|
|
mql.addEventListener("change", onChange);
|
|
return () => mql.removeEventListener("change", onChange);
|
|
}, [supportsMatchMedia]);
|
|
return small;
|
|
}
|
|
|
|
// --- Small UI helpers (lifted verbatim) ---
|
|
|
|
function FilterSelect({
|
|
id,
|
|
label,
|
|
value,
|
|
onChange,
|
|
options,
|
|
}: {
|
|
id: string;
|
|
label: string;
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
options: { value: string; label: string }[];
|
|
}) {
|
|
return (
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label htmlFor={id}>{label}</Label>
|
|
<Select value={value} onValueChange={onChange}>
|
|
<SelectTrigger id={id} className="w-full">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{options.map((option) => (
|
|
<SelectItem key={option.value} value={option.value}>
|
|
{option.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function BuildProgress({ value }: { value: number | null }) {
|
|
if (value == null) {
|
|
return (
|
|
<div className="h-1 w-full animate-pulse rounded-full bg-muted-foreground/30" />
|
|
);
|
|
}
|
|
return <Progress value={Math.max(0, Math.min(100, value * 100))} />;
|
|
}
|
|
|
|
// --- Component ---
|
|
|
|
export function MediaTab({ instance }: { instance: ServiceInstance }) {
|
|
const navigate = useNavigate();
|
|
const { data: sshServices = [] } = useServiceInstances("ssh_tasks");
|
|
const isSmall = usePrefersSmallScreen();
|
|
const isMobile = useIsMobile();
|
|
const serviceId = instance.id;
|
|
|
|
const { data: counts } = useCounts(serviceId);
|
|
const { data: libraries } = useLibraries(serviceId);
|
|
const { data: status } = useMediaStatus(serviceId);
|
|
const buildIndex = useBuildIndex(serviceId);
|
|
const stopBuildIndex = useStopBuildIndex(serviceId);
|
|
const forceStopBuildIndex = useForceStopBuildIndex(serviceId);
|
|
|
|
const [rawMediaState, setMediaState] = usePersistentState<MediaTabState>(
|
|
MEDIA_TAB_STATE_KEY,
|
|
defaultMediaTabState,
|
|
);
|
|
const mediaState: MediaTabState = {
|
|
...defaultMediaTabState(),
|
|
...rawMediaState,
|
|
};
|
|
const { search, types, hdrFilter, sortKey, sortOrder, offset, pageSize } =
|
|
mediaState;
|
|
const updateMediaState = (patch: Partial<MediaTabState>) =>
|
|
setMediaState((current) => ({ ...current, ...patch }));
|
|
|
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
|
|
|
const { data: queryResult, isLoading } = useMediaDataQuery({
|
|
types,
|
|
search,
|
|
hdr_filter: hdrFilter,
|
|
sort_key: sortKey,
|
|
sort_order: sortOrder,
|
|
limit: pageSize,
|
|
offset,
|
|
jellyfinServiceId: serviceId,
|
|
enabled: status?.exists ?? false,
|
|
});
|
|
|
|
const pageIndex = Math.floor(offset / pageSize);
|
|
const pagination: PaginationState = { pageIndex, pageSize };
|
|
|
|
const handlePaginationChange: OnChangeFn<PaginationState> = (updater) => {
|
|
const next =
|
|
typeof updater === "function"
|
|
? updater({ pageIndex, pageSize })
|
|
: updater;
|
|
const nextPageSize = next.pageSize || pageSize;
|
|
const nextOffset =
|
|
nextPageSize !== pageSize ? 0 : next.pageIndex * nextPageSize;
|
|
setMediaState((current) => ({
|
|
...current,
|
|
offset: nextOffset,
|
|
pageSize: nextPageSize,
|
|
}));
|
|
};
|
|
|
|
const handleColumnVisibilityChange: OnChangeFn<VisibilityState> = (
|
|
updater,
|
|
) => {
|
|
setMediaState((current) => {
|
|
const prev = current.columnVisibility ?? {};
|
|
const next = typeof updater === "function" ? updater(prev) : updater;
|
|
return { ...current, columnVisibility: next };
|
|
});
|
|
};
|
|
|
|
const effectiveColumnVisibility = useMemo(() => {
|
|
const base = mediaState.columnVisibility ?? {};
|
|
if (!isSmall) return base;
|
|
const merged = { ...base };
|
|
for (const key of MOBILE_HIDDEN_COLUMNS) merged[key] = false;
|
|
return merged;
|
|
}, [mediaState.columnVisibility, isSmall]);
|
|
|
|
const handleRowClick = (row: MediaItem) => {
|
|
// Navigate to the ssh_tasks service page with the path query param.
|
|
// If an ssh_tasks instance exists, open its Files tab; otherwise land
|
|
// on the ssh_tasks type page (empty state / ServiceTypePage resolver).
|
|
const sshInstance = sshServices.find((s) => s.enabled);
|
|
const base = sshInstance
|
|
? `/services/ssh_tasks/${sshInstance.id}`
|
|
: "/services/ssh_tasks";
|
|
navigate(`${base}?path=${encodeURIComponent(row.path)}`);
|
|
};
|
|
|
|
const total = queryResult?.total ?? 0;
|
|
const totalPages = queryResult ? Math.max(1, Math.ceil(total / pageSize)) : 1;
|
|
|
|
const buildRunning = status?.build_running ?? false;
|
|
const buildProgress = status?.build_progress ?? null;
|
|
const buildLibraryProgress = status?.build_library_progress ?? null;
|
|
const buildCancelRequested = status?.build_cancel_requested ?? false;
|
|
const buildLabel = buildRunning
|
|
? status?.build_message || "Building media index..."
|
|
: status?.build_error
|
|
? `Build failed: ${status.build_error}`
|
|
: "";
|
|
const elapsedLabel = formatDuration(status?.build_elapsed_seconds);
|
|
const etaLabel =
|
|
buildRunning && status?.build_eta_seconds != null
|
|
? formatDuration(status.build_eta_seconds)
|
|
: "-";
|
|
const libraryElapsedLabel = formatDuration(
|
|
status?.build_library_elapsed_seconds,
|
|
);
|
|
const libraryEtaLabel =
|
|
buildRunning && status?.build_library_eta_seconds != null
|
|
? formatDuration(status.build_library_eta_seconds)
|
|
: "-";
|
|
const libraryLabel =
|
|
status?.build_current_library ||
|
|
(status?.build_library_index && status?.build_libraries_total
|
|
? `Library ${status.build_library_index} / ${status.build_libraries_total}`
|
|
: "Current library");
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
<div className="flex flex-row flex-wrap items-center gap-2">
|
|
{status?.exists ? (
|
|
<p className="text-sm text-muted-foreground">
|
|
Index: {status.item_count.toLocaleString()} items
|
|
{status.updated_at_label
|
|
? ` | updated ${status.updated_at_label}`
|
|
: ""}
|
|
</p>
|
|
) : (
|
|
<Alert variant="destructive" className="py-0">
|
|
<AlertDescription>No index built yet.</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{counts && (
|
|
<p className="text-sm text-muted-foreground">
|
|
Library stats: {counts.movies.toLocaleString()} movies ·{" "}
|
|
{counts.series.toLocaleString()} series ·{" "}
|
|
{counts.episodes.toLocaleString()} episodes ·{" "}
|
|
{(libraries?.length ?? 0).toLocaleString()} libraries
|
|
</p>
|
|
)}
|
|
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => buildIndex.mutate()}
|
|
disabled={
|
|
buildIndex.isPending || buildRunning || buildCancelRequested
|
|
}
|
|
>
|
|
{buildIndex.isPending || buildRunning ? "Building..." : "Build index"}
|
|
</Button>
|
|
{buildRunning && (
|
|
<>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={() => stopBuildIndex.mutate()}
|
|
disabled={stopBuildIndex.isPending || buildCancelRequested}
|
|
>
|
|
{buildCancelRequested || stopBuildIndex.isPending
|
|
? "Stopping..."
|
|
: "Stop build"}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
className="border-chart-3/40 text-chart-3 hover:bg-chart-3/10"
|
|
onClick={() => forceStopBuildIndex.mutate()}
|
|
disabled={forceStopBuildIndex.isPending}
|
|
>
|
|
{forceStopBuildIndex.isPending
|
|
? "Force stopping..."
|
|
: "Force stop"}
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{(buildRunning || status?.build_error) && (
|
|
<div className="flex w-full min-w-[260px] flex-col gap-2">
|
|
<p
|
|
className={
|
|
status?.build_error
|
|
? "text-sm text-destructive"
|
|
: "text-sm text-muted-foreground"
|
|
}
|
|
>
|
|
{buildLabel ||
|
|
(buildRunning
|
|
? "Building media index..."
|
|
: status?.build_error || "")}
|
|
</p>
|
|
|
|
<div className="flex flex-col gap-1">
|
|
<p className="text-xs text-muted-foreground">
|
|
Overall:{" "}
|
|
{buildProgress != null
|
|
? `${Math.round(buildProgress * 100)}%`
|
|
: "pending"}
|
|
{buildRunning
|
|
? ` • elapsed ${elapsedLabel} • eta ${etaLabel}`
|
|
: ""}
|
|
</p>
|
|
<BuildProgress value={buildProgress} />
|
|
<p className="text-xs text-muted-foreground">
|
|
{status?.build_items_processed?.toLocaleString() ?? 0}/
|
|
{status?.build_items_total?.toLocaleString() ?? 0} items
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-1">
|
|
<p className="text-xs text-muted-foreground">
|
|
Current: {libraryLabel}
|
|
{buildRunning
|
|
? ` • elapsed ${libraryElapsedLabel} • eta ${libraryEtaLabel}`
|
|
: ""}
|
|
</p>
|
|
<BuildProgress value={buildLibraryProgress} />
|
|
<p className="text-xs text-muted-foreground">
|
|
{status?.build_library_items_processed?.toLocaleString() ?? 0}/
|
|
{status?.build_library_items_total?.toLocaleString() ?? 0} items
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<Card>
|
|
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-12">
|
|
<div className="col-span-1 flex flex-col gap-1.5 md:col-span-4">
|
|
<Label htmlFor="media-search">Search</Label>
|
|
<Input
|
|
id="media-search"
|
|
value={search}
|
|
onChange={(e) =>
|
|
updateMediaState({ search: e.target.value, offset: 0 })
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="col-span-6 md:col-span-2">
|
|
<FilterSelect
|
|
id="media-types"
|
|
label="Types"
|
|
value={types}
|
|
onChange={(value) =>
|
|
updateMediaState({ types: value, offset: 0 })
|
|
}
|
|
options={[
|
|
{ value: "Movie,Episode", label: "Movies + Episodes" },
|
|
{ value: "Movie", label: "Movies only" },
|
|
{ value: "Episode", label: "Episodes only" },
|
|
{ value: "Movie,Episode,Video", label: "All video" },
|
|
]}
|
|
/>
|
|
</div>
|
|
<div className="col-span-6 md:col-span-2">
|
|
<FilterSelect
|
|
id="media-hdr"
|
|
label="HDR"
|
|
value={hdrFilter}
|
|
onChange={(value) =>
|
|
updateMediaState({ hdrFilter: value, offset: 0 })
|
|
}
|
|
options={[
|
|
{ value: "All", label: "All" },
|
|
{ value: "HDR only", label: "HDR only" },
|
|
{ value: "SDR/unknown only", label: "SDR/unknown only" },
|
|
]}
|
|
/>
|
|
</div>
|
|
<div className="col-span-6 md:col-span-2">
|
|
<FilterSelect
|
|
id="media-sort"
|
|
label="Sort"
|
|
value={sortKey}
|
|
onChange={(value) => updateMediaState({ sortKey: value })}
|
|
options={[
|
|
{ value: "title", label: "Title" },
|
|
{ value: "series", label: "Series" },
|
|
{ value: "size", label: "Size" },
|
|
{ value: "bitrate", label: "Bitrate" },
|
|
{ value: "runtime", label: "Runtime" },
|
|
{ value: "year", label: "Year" },
|
|
{ value: "date_added", label: "Date added" },
|
|
{ value: "resolution", label: "Resolution" },
|
|
]}
|
|
/>
|
|
</div>
|
|
<div className="col-span-6 md:col-span-2">
|
|
<FilterSelect
|
|
id="media-order"
|
|
label="Order"
|
|
value={sortOrder}
|
|
onChange={(value) => updateMediaState({ sortOrder: value })}
|
|
options={[
|
|
{ value: "Ascending", label: "Ascending" },
|
|
{ value: "Descending", label: "Descending" },
|
|
]}
|
|
/>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{queryResult && (
|
|
<p className="text-xs text-muted-foreground">
|
|
Showing {queryResult.items.length} of {total.toLocaleString()} items |
|
|
Page {pageIndex + 1} of {totalPages}
|
|
</p>
|
|
)}
|
|
|
|
{status?.exists &&
|
|
(isMobile ? (
|
|
<div className="rounded-lg border bg-card">
|
|
<div className="p-4">
|
|
<MobileCardRow
|
|
rows={queryResult?.items ?? []}
|
|
fields={mediaCardFields}
|
|
getRowId={getMediaRowId}
|
|
onRowClick={handleRowClick}
|
|
/>
|
|
</div>
|
|
{queryResult && (
|
|
<TablePagination
|
|
pageIndex={pageIndex}
|
|
pageSize={pageSize}
|
|
pageSizeOptions={[50, 100, 200]}
|
|
totalRows={total}
|
|
pageCount={totalPages}
|
|
onPaginationChange={handlePaginationChange}
|
|
className="p-4"
|
|
/>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="rounded-lg border bg-card">
|
|
<DataTable
|
|
columns={mediaColumns}
|
|
data={queryResult?.items ?? []}
|
|
getRowId={getMediaRowId}
|
|
enableRowSelection
|
|
rowSelection={rowSelection}
|
|
onRowSelectionChange={setRowSelection}
|
|
onRowClick={handleRowClick}
|
|
enableColumnVisibilityToggle
|
|
columnVisibility={effectiveColumnVisibility}
|
|
onColumnVisibilityChange={handleColumnVisibilityChange}
|
|
enablePagination
|
|
manualPagination
|
|
pagination={pagination}
|
|
onPaginationChange={handlePaginationChange}
|
|
pageSizeOptions={[50, 100, 200]}
|
|
rowCount={total}
|
|
emptyMessage={
|
|
isLoading
|
|
? "Loading media..."
|
|
: "No media items match these filters."
|
|
}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|