Updated Library page

This commit is contained in:
OkinawaBoss 2025-09-17 18:57:06 -07:00
parent 0353362311
commit 2e4dec6de3
2 changed files with 265 additions and 115 deletions

View file

@ -1,6 +1,8 @@
import React, { useEffect } from 'react';
import React, { useEffect, useMemo } from 'react';
import {
ActionIcon,
Badge,
Button,
Drawer,
Group,
Loader,
@ -8,8 +10,11 @@ import {
ScrollArea,
Stack,
Text,
Title,
Tooltip,
} from '@mantine/core';
import { Timeline } from '@mantine/core';
import { Ban, Play, RefreshCcw, Trash2, ScanSearch } from 'lucide-react';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
@ -19,10 +24,10 @@ dayjs.extend(relativeTime);
const EMPTY_SCAN_LIST = [];
const statusColor = {
pending: 'gray',
queued: 'gray',
scheduled: 'gray',
running: 'blue',
started: 'blue',
discovered: 'indigo',
@ -32,25 +37,75 @@ const statusColor = {
cancelled: 'yellow',
};
const LibraryScanDrawer = ({ opened, onClose, libraryId }) => {
const scansLoading = useLibraryStore((state) => state.scansLoading);
const scans =
useLibraryStore((state) => state.scans[libraryId || 'all']) ?? EMPTY_SCAN_LIST;
const isRunning = (s) =>
s === 'running' || s === 'started' || s === 'progress' || s === 'discovered';
const isQueued = (s) => s === 'pending' || s === 'queued' || s === 'scheduled';
const LibraryScanDrawer = ({
opened,
onClose,
libraryId,
// Optional actions provided by parent (no-ops by default)
onCancelJob = async () => {},
onDeleteQueuedJob = async () => {},
onStartScan = null, // () => void
onStartFullScan = null, // () => void
}) => {
const scansLoading = useLibraryStore((s) => s.scansLoading);
const scans = useLibraryStore((s) => s.scans[libraryId || 'all']) ?? EMPTY_SCAN_LIST;
const fetchScans = useLibraryStore((s) => s.fetchScans);
// Fetch once when opened (WebSockets keep it live afterward)
useEffect(() => {
if (opened) {
useLibraryStore.getState().fetchScans(libraryId);
fetchScans(libraryId);
}
}, [opened, libraryId]);
}, [opened, libraryId, fetchScans]);
const handleRefresh = () => fetchScans(libraryId);
const header = useMemo(
() => (
<Group justify="space-between" align="center" mb="sm">
<Group gap="xs" align="center">
<ScanSearch size={18} />
<Title order={5} style={{ lineHeight: 1 }}>Library scans</Title>
</Group>
<Group gap="xs">
{onStartScan && (
<Tooltip label="Start quick scan">
<ActionIcon variant="light" onClick={onStartScan}>
<Play size={16} />
</ActionIcon>
</Tooltip>
)}
{onStartFullScan && (
<Button variant="light" size="xs" onClick={onStartFullScan}>
Full scan
</Button>
)}
<Tooltip label="Refresh">
<ActionIcon variant="light" onClick={handleRefresh}>
<RefreshCcw size={16} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
),
[onStartScan, onStartFullScan]
);
return (
<Drawer
opened={opened}
onClose={onClose}
title="Library scans"
position="right"
size="md"
overlayProps={{ backgroundOpacity: 0.55, blur: 6 }}
withCloseButton
title={header}
>
<ScrollArea style={{ height: '100%' }}>
{scansLoading ? (
@ -58,29 +113,69 @@ const LibraryScanDrawer = ({ opened, onClose, libraryId }) => {
<Loader />
</Group>
) : scans.length === 0 ? (
<Text c="dimmed">No scans recorded yet.</Text>
<Stack align="center" py="lg" gap={4}>
<Text c="dimmed">No scans recorded yet.</Text>
{onStartScan && (
<Button size="xs" onClick={onStartScan} mt="xs">
Start a scan
</Button>
)}
</Stack>
) : (
<Timeline active={0} reverseActive bulletSize={20} lineWidth={2}>
{scans.map((scan) => (
<Timeline.Item
key={scan.id}
title={dayjs(scan.created_at).format('MMM D, YYYY HH:mm')}
bullet={<Badge color={statusColor[scan.status] || 'gray'}>{scan.status}</Badge>}
>
<Stack spacing={4} mt="xs">
{(() => {
const total = scan.total_files ?? scan.files ?? 0;
if (!total) return null;
const processedRaw =
scan.status === 'completed'
? scan.total_files ?? scan.processed_files ?? scan.processed ?? 0
: scan.processed_files ?? scan.processed ?? 0;
const processed = Math.min(processedRaw, total);
const percent = total
? Math.min(100, Math.round((processed / total) * 100))
: 0;
return (
<Stack spacing={2}>
<Timeline active={0} reverseActive bulletSize={18} lineWidth={2}>
{scans.map((scan) => {
const status = scan.status || 'pending';
const total = scan.total_files ?? scan.files ?? 0;
const processedRaw =
status === 'completed'
? scan.total_files ?? scan.processed_files ?? scan.processed ?? 0
: scan.processed_files ?? scan.processed ?? 0;
const processed = Math.min(processedRaw || 0, total || 0);
const percent = total ? Math.min(100, Math.round((processed / total) * 100)) : 0;
return (
<Timeline.Item
key={scan.id}
title={dayjs(scan.created_at).format('MMM D, YYYY HH:mm')}
bullet={
<Badge color={statusColor[status] || 'gray'} variant="filled">
{status}
</Badge>
}
>
<Stack gap={6} mt="xs">
{/* Summary + row actions */}
<Group justify="space-between" align="center">
<Text size="sm" fw={500}>{scan.summary || 'Scan'}</Text>
<Group gap="xs">
{isRunning(status) && (
<Tooltip label="Cancel running scan">
<ActionIcon
color="yellow"
variant="light"
onClick={() => onCancelJob(scan.id)}
>
<Ban size={16} />
</ActionIcon>
</Tooltip>
)}
{isQueued(status) && (
<Tooltip label="Remove from queue">
<ActionIcon
color="red"
variant="light"
onClick={() => onDeleteQueuedJob(scan.id)}
>
<Trash2 size={16} />
</ActionIcon>
</Tooltip>
)}
</Group>
</Group>
{/* Progress */}
{total > 0 && (
<Stack gap={2}>
<Group justify="space-between">
<Text size="xs" fw={500}>
{processed} / {total} processed
@ -93,36 +188,34 @@ const LibraryScanDrawer = ({ opened, onClose, libraryId }) => {
value={percent}
size="md"
striped
animated={scan.status !== 'completed' && scan.status !== 'failed'}
animated={isRunning(status)}
/>
</Stack>
);
})()}
<Text size="sm">
Summary: {scan.summary || 'No summary yet'}
</Text>
<Text size="xs" c="dimmed">
Started {scan.started_at ? dayjs(scan.started_at).fromNow() : 'n/a'}
</Text>
<Text size="xs" c="dimmed">
Finished {scan.finished_at ? dayjs(scan.finished_at).fromNow() : 'n/a'}
</Text>
<Text size="xs" c="dimmed">
Files processed: {scan.total_files ?? '—'} · New {scan.new_files ?? '—'} · Updated {scan.updated_files ?? '—'} · Removed {scan.removed_files ?? '—'}
</Text>
{scan.unmatched_files > 0 && (
<Text size="xs" c="yellow.4">
Unmatched files: {scan.unmatched_files}
)}
{/* Meta */}
<Text size="xs" c="dimmed">
Started {scan.started_at ? dayjs(scan.started_at).fromNow() : 'n/a'} · Finished{' '}
{scan.finished_at ? dayjs(scan.finished_at).fromNow() : 'n/a'}
</Text>
)}
{scan.log && (
<Text size="xs" c="dimmed" style={{ whiteSpace: 'pre-wrap' }}>
{scan.log}
<Text size="xs" c="dimmed">
Files {scan.total_files ?? '—'} · New {scan.new_files ?? '—'} · Updated{' '}
{scan.updated_files ?? '—'} · Removed {scan.removed_files ?? '—'}
</Text>
)}
</Stack>
</Timeline.Item>
))}
{scan.unmatched_files > 0 && (
<Text size="xs" c="yellow.4">
Unmatched files: {scan.unmatched_files}
</Text>
)}
{scan.log && (
<Text size="xs" c="dimmed" style={{ whiteSpace: 'pre-wrap' }}>
{scan.log}
</Text>
)}
</Stack>
</Timeline.Item>
);
})}
</Timeline>
)}
</ScrollArea>

View file

@ -59,7 +59,8 @@ const parseDate = (value) => {
const LibraryPage = () => {
const navigate = useNavigate();
const { mediaType } = useParams();
const normalizedMediaType = mediaType === 'shows' ? 'shows' : mediaType === 'movies' ? 'movies' : null;
const normalizedMediaType =
mediaType === 'shows' ? 'shows' : mediaType === 'movies' ? 'movies' : null;
useEffect(() => {
if (!normalizedMediaType) {
@ -388,13 +389,21 @@ const LibraryPage = () => {
return () => document.removeEventListener('click', handleOutsideClick);
}, [contextMenu]);
const handleScanLibrary = async () => {
// --- SCAN CONTROLS ---
// Open the drawer only (do NOT start a scan)
const handleOpenScanDrawer = () => setScanDrawerOpen(true);
// Explicitly start a scan (quick or full)
const handleStartScan = async (full = false) => {
if (!selectedLibraryId) return;
try {
await triggerScan(selectedLibraryId, { full: false });
await triggerScan(selectedLibraryId, { full });
notifications.show({
title: 'Scan started',
message: 'Library scan has been queued.',
title: full ? 'Full scan started' : 'Scan started',
message: full
? 'A full library scan has been queued.'
: 'Library scan has been queued.',
color: 'blue',
});
setScanDrawerOpen(true);
@ -408,6 +417,44 @@ const LibraryPage = () => {
}
};
// Cancel a running scan by job id
const handleCancelScanJob = async (jobId) => {
try {
await API.cancelLibraryScan(jobId); // implement in API
notifications.show({
title: 'Scan canceled',
message: 'The running scan has been stopped.',
color: 'yellow',
});
} catch (e) {
console.error(e);
notifications.show({
title: 'Cancel failed',
message: 'Could not cancel this scan.',
color: 'red',
});
}
};
// Remove a queued scan by job id
const handleDeleteQueuedScan = async (jobId) => {
try {
await API.deleteLibraryScan(jobId); // implement in API
notifications.show({
title: 'Removed from queue',
message: 'The queued scan was removed.',
color: 'green',
});
} catch (e) {
console.error(e);
notifications.show({
title: 'Remove failed',
message: 'Could not remove this queued scan.',
color: 'red',
});
}
};
const recommendedView = (
<Stack spacing="xl">
<MediaCarousel
@ -443,51 +490,51 @@ const LibraryPage = () => {
<Select
label="Sort by"
data={SORT_OPTIONS}
value={sortOption}
onChange={(value) => setSortOption(value || 'default')}
w={220}
/>
<Button
variant="subtle"
leftSection={<RefreshCcw size={16} />}
onClick={() => fetchItems(selectedLibraryId)}
>
Refresh
</Button>
</Group>
{sortOption === 'default' ? (
recommendedView
) : sortOption === 'genre' ? (
<Stack spacing="xl">
{genreCarousels.map(({ genre, items: genreItems }) => (
<MediaCarousel
key={genre}
title={genre}
items={genreItems}
value={sortOption}
onChange={(value) => setSortOption(value || 'default')}
w={220}
/>
<Button
variant="subtle"
leftSection={<RefreshCcw size={16} />}
onClick={() => fetchItems(selectedLibraryId)}
>
Refresh
</Button>
</Group>
{sortOption === 'default' ? (
recommendedView
) : sortOption === 'genre' ? (
<Stack spacing="xl">
{genreCarousels.map(({ genre, items: genreItems }) => (
<MediaCarousel
key={genre}
title={genre}
items={genreItems}
onSelect={handleOpenItem}
onContextMenu={handleContextMenu}
/>
))}
</Stack>
) : (
<Box style={{ position: 'relative' }}>
{sortOption === 'alpha' && availableLetters.size > 0 && (
<AlphabetSidebar available={availableLetters} onSelect={handleLetterSelect} />
)}
<MediaGrid
items={sortedLibraryItems}
loading={itemsLoading}
onSelect={handleOpenItem}
onContextMenu={handleContextMenu}
groupByLetter={sortOption === 'alpha'}
letterRefs={letterRefs}
cardSize="md"
/>
))}
</Stack>
) : (
<Box style={{ position: 'relative' }}>
{sortOption === 'alpha' && availableLetters.size > 0 && (
<AlphabetSidebar available={availableLetters} onSelect={handleLetterSelect} />
)}
<MediaGrid
items={sortedLibraryItems}
loading={itemsLoading}
onSelect={handleOpenItem}
onContextMenu={handleContextMenu}
groupByLetter={sortOption === 'alpha'}
letterRefs={letterRefs}
cardSize="md"
/>
</Box>
)}
</Stack>
</Box>
);
</Box>
)}
</Stack>
</Box>
);
})();
const categoriesView = (
@ -533,29 +580,34 @@ const LibraryPage = () => {
w={220}
disabled={librariesLoading || libraries.length === 0}
/>
<Button
leftSection={<Plus size={16} />}
onClick={() => setFormOpen(true)}
>
<Button leftSection={<Plus size={16} />} onClick={() => setFormOpen(true)}>
Add Library
</Button>
{/* Open scan drawer ONLY */}
<ActionIcon
variant="light"
color="blue"
onClick={handleScanLibrary}
onClick={handleOpenScanDrawer}
title="View recent scans"
>
<ListChecks size={18} />
</ActionIcon>
{/* Start a scan explicitly */}
<ActionIcon
variant="filled"
color="blue"
onClick={() => handleStartScan(false)}
title="Start library scan"
>
<RefreshCcw size={18} />
</ActionIcon>
</Group>
</Group>
<Group justify="space-between" align="center" wrap="wrap">
<SegmentedControl
value={activeTab}
onChange={setActiveTab}
data={TABS}
/>
<SegmentedControl value={activeTab} onChange={setActiveTab} data={TABS} />
<Group align="center" gap="sm">
<TextInput
leftSection={<Search size={16} />}
@ -608,6 +660,11 @@ const LibraryPage = () => {
opened={scanDrawerOpen}
onClose={() => setScanDrawerOpen(false)}
libraryId={selectedLibraryId}
// NEW: enable controls inside the drawer
onCancelJob={handleCancelScanJob}
onDeleteQueuedJob={handleDeleteQueuedScan}
onStartScan={() => handleStartScan(false)}
onStartFullScan={() => handleStartScan(true)}
/>
<MediaDetailModal