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>
271 lines
8.4 KiB
TypeScript
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>
|
|
);
|
|
}
|