Files
ersatztv/web/src/screens/ImageBrowserScreen.tsx
T
timothyandClaude Fable 5 d5aa838b22 feat(web): media detail pages + image folder browser (#141/#161)
Add the SPA screens over the new detail/info/image endpoints:

- Detail pages at /app/media/{movies|shows|seasons|artists}/{id}. MediaBrowseScreen
  cards for movies/shows/artists (and season/show drill-in cards) navigate to them.
  Shows list seasons -> seasons list episodes -> artists list music videos, each via
  the browse parentId drill-in, paged. Layout: fanart/poster, title/year/plot, chip
  lists, cast row, movie path + FileNotFound/Unavailable warnings. A "Media Info"
  dialog (streams + chapters) backs onto GET /api/media-items/{id}/info. Add-to-
  collection/playlist deferred to #153/#155 (TODO left in code).
- Image folder browser at /app/media/images/browser: lazy expandable tree, per-folder
  image/subfolder counts + duration, edit dialog (PUT set/clear), and a per-folder
  search link (library_folder_id:{id}). Reachable via a "Folder Browser" button on the
  Images browse view.

New /app/media sub-paths are owned by a MediaRouteScreen wrapper that tracks pathname
locally + listens for popstate (App-level routing returns the same 'media' route object
for base and sub-paths). Client modules web/src/api/mediaDetail.ts + imageFolders.ts,
typed via the generated v1.d.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 22:56:48 +02:00

271 lines
8.4 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { ChevronDown, ChevronRight, Clock, FolderTree, Pencil, Search, TriangleAlert } from 'lucide-react';
import { Button, Card, Dialog, IconButton, Input, Spinner } from '../components';
import {
getImageFolders,
messageFromImageFolderError,
updateImageFolderDuration,
type ImageFolder
} from '../api';
import { navigateToPath } from '../routing';
function searchLink(libraryFolderId: number) {
navigateToPath(`/app/search?query=${encodeURIComponent(`library_folder_id:${libraryFolderId}`)}`);
}
function EditDurationDialog({
folder,
open,
onClose,
onSaved
}: {
folder: ImageFolder;
open: boolean;
onClose: () => void;
onSaved: (durationSeconds: number | null) => void;
}) {
// The form is mounted only while open, so its useState initializers reset each time it opens
// (no synchronous setState in an effect).
return (
<Dialog onClose={onClose} open={open} title={`Duration — ${folder.name}`} width={420}>
{open ? <EditDurationForm folder={folder} onClose={onClose} onSaved={onSaved} /> : null}
</Dialog>
);
}
function EditDurationForm({
folder,
onClose,
onSaved
}: {
folder: ImageFolder;
onClose: () => void;
onSaved: (durationSeconds: number | null) => void;
}) {
const [value, setValue] = useState(() => (folder.durationSeconds != null ? String(folder.durationSeconds) : ''));
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const save = (clear: boolean) => {
const parsed = clear ? null : Number(value);
if (!clear && (!Number.isFinite(parsed) || (parsed ?? 0) <= 0)) {
setError('Enter a duration greater than zero, or clear it.');
return;
}
setBusy(true);
setError(null);
updateImageFolderDuration(folder.libraryFolderId, parsed)
.then((result) => {
onSaved(result.durationSeconds ?? null);
onClose();
})
.catch((caught: unknown) => {
setError(messageFromImageFolderError(caught, 'Unable to update duration'));
setBusy(false);
});
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-5, 10px)' }}>
<Input
label="Seconds per image"
onChange={(event) => setValue(event.target.value)}
placeholder="Inherited from parent"
type="number"
value={value}
/>
{error ? (
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{error}</span>
</div>
) : null}
<div style={{ display: 'flex', gap: 'var(--space-4, 8px)', alignItems: 'center' }}>
<Button disabled={busy} onClick={() => save(true)} size="sm" variant="ghost">
Clear
</Button>
<span className="ctv-channels-spacer" />
<Button disabled={busy} onClick={onClose} size="sm" variant="ghost">
Cancel
</Button>
<Button disabled={busy} onClick={() => save(false)} size="sm" variant="primary">
Save
</Button>
</div>
</div>
);
}
function FolderRow({ folder, depth }: { folder: ImageFolder; depth: number }) {
const [expanded, setExpanded] = useState(false);
const [children, setChildren] = useState<ImageFolder[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [duration, setDuration] = useState<number | null>(folder.durationSeconds ?? null);
const [editing, setEditing] = useState(false);
const activeRef = useRef(true);
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
const loadChildren = () => {
setLoading(true);
setError(null);
getImageFolders(folder.libraryFolderId)
.then((result) => {
if (activeRef.current) {
setChildren(result);
setLoading(false);
}
})
.catch((caught: unknown) => {
if (activeRef.current) {
setError(messageFromImageFolderError(caught));
setLoading(false);
}
});
};
const toggle = () => {
if (folder.subfolderCount === 0) {
return;
}
if (!expanded && children === null) {
loadChildren();
}
setExpanded((current) => !current);
};
const hasChildren = folder.subfolderCount > 0;
return (
<div className="ctv-imgfolder">
<div className="ctv-imgfolder-row" style={{ paddingLeft: `${depth * 20}px` }}>
<IconButton
disabled={!hasChildren}
onClick={toggle}
size="sm"
title={hasChildren ? (expanded ? 'Collapse' : 'Expand') : 'No subfolders'}
>
{hasChildren ? (
expanded ? (
<ChevronDown aria-hidden="true" size={15} />
) : (
<ChevronRight aria-hidden="true" size={15} />
)
) : (
<FolderTree aria-hidden="true" size={15} />
)}
</IconButton>
<span className="ctv-imgfolder-name" title={folder.fullPath}>
{folder.name}
</span>
<span className="ctv-imgfolder-meta">
{folder.imageCount} image{folder.imageCount === 1 ? '' : 's'} · {folder.subfolderCount} subfolder
{folder.subfolderCount === 1 ? '' : 's'}
</span>
<span className="ctv-imgfolder-duration">
<Clock aria-hidden="true" size={13} />
{duration != null ? `${duration}s` : 'inherited'}
</span>
<span className="ctv-channels-spacer" />
<IconButton onClick={() => setEditing(true)} size="sm" title="Edit duration">
<Pencil aria-hidden="true" size={14} />
</IconButton>
<IconButton onClick={() => searchLink(folder.libraryFolderId)} size="sm" title="Search this folder">
<Search aria-hidden="true" size={14} />
</IconButton>
</div>
{error ? (
<div className="ctv-channels-error" role="alert" style={{ marginLeft: `${depth * 20 + 20}px` }}>
<TriangleAlert aria-hidden="true" size={15} />
<span>{error}</span>
</div>
) : null}
{expanded && loading ? (
<div className="ctv-collections-loading" role="status" style={{ paddingLeft: `${depth * 20 + 20}px` }}>
<Spinner size={16} />
<span>Loading</span>
</div>
) : null}
{expanded && children
? children.map((child) => (
<FolderRow depth={depth + 1} folder={child} key={child.libraryFolderId} />
))
: null}
<EditDurationDialog
folder={folder}
onClose={() => setEditing(false)}
onSaved={(next) => setDuration(next)}
open={editing}
/>
</div>
);
}
export function ImageBrowserScreen() {
const [folders, setFolders] = useState<ImageFolder[]>([]);
const [status, setStatus] = useState<'loading' | 'error' | 'success'>('loading');
const [error, setError] = useState<string | null>(null);
const activeRef = useRef(true);
useEffect(() => {
activeRef.current = true;
getImageFolders()
.then((result) => {
if (activeRef.current) {
setFolders(result);
setStatus('success');
}
})
.catch((caught: unknown) => {
if (activeRef.current) {
setError(messageFromImageFolderError(caught));
setStatus('error');
}
});
return () => {
activeRef.current = false;
};
}, []);
return (
<div className="ctv-collections">
<div className="ctv-channels-actionbar">
<Button
onClick={() => navigateToPath('/app/media?kind=images')}
size="sm"
variant="ghost"
>
Browse images
</Button>
</div>
{status === 'loading' ? (
<div className="ctv-collections-loading" role="status">
<Spinner size={18} />
<span>Loading image folders</span>
</div>
) : status === 'error' ? (
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{error}</span>
</div>
) : folders.length === 0 ? (
<Card>
<div className="ctv-collections-empty">No image libraries found.</div>
</Card>
) : (
<Card>
{folders.map((folder) => (
<FolderRow depth={0} folder={folder} key={folder.libraryFolderId} />
))}
</Card>
)}
</div>
);
}