Add manual-import and download-access features

Introduce manual import workflow and download permission support. Adds a Prisma migration and schema field (users.download_access) to track per-user download access, and updates admin UI to toggle global and per-user download access. Implements new APIs: filesystem browse, manual-import endpoint, download-access settings, audiobook download-status, and on-demand download-token generation. Adds frontend components for manual import and related tests, plus documentation for the manual-import feature and the documentation-agent prompt. Key files: prisma/migrations/20260212000000_add_download_access_permission/migration.sql, prisma/schema.prisma, src/app/api/admin/filesystem/browse/route.ts, src/app/api/admin/manual-import/route.ts, src/app/api/admin/settings/download-access/route.ts, src/app/api/requests/[id]/download-token/route.ts, src/app/api/audiobooks/[asin]/download-status/route.ts, and updated admin users pages/components and permissions util.
This commit is contained in:
kikootwo
2026-02-27 12:15:23 -05:00
parent 73c5fe14e7
commit edc56bc457
29 changed files with 2196 additions and 27 deletions
@@ -14,6 +14,8 @@ interface GlobalUserSettingsModalProps {
onToggleAutoApprove: (newValue: boolean) => void;
globalInteractiveSearch: boolean;
onToggleInteractiveSearch: (newValue: boolean) => void;
globalDownloadAccess: boolean;
onToggleDownloadAccess: (newValue: boolean) => void;
}
export function GlobalUserSettingsModal({
@@ -23,6 +25,8 @@ export function GlobalUserSettingsModal({
onToggleAutoApprove,
globalInteractiveSearch,
onToggleInteractiveSearch,
globalDownloadAccess,
onToggleDownloadAccess,
}: GlobalUserSettingsModalProps) {
return (
<Modal isOpen={isOpen} onClose={onClose} title="Global User Settings" size="sm">
@@ -84,6 +88,35 @@ export function GlobalUserSettingsModal({
</p>
</div>
</div>
{/* Download Access Setting */}
<div className="flex items-start gap-4">
<button
onClick={() => onToggleDownloadAccess(!globalDownloadAccess)}
className="relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 mt-0.5"
style={{ backgroundColor: globalDownloadAccess ? '#3b82f6' : '#d1d5db' }}
role="switch"
aria-checked={globalDownloadAccess}
aria-label="Download Access"
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
globalDownloadAccess ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
<div className="flex-1">
<label
onClick={() => onToggleDownloadAccess(!globalDownloadAccess)}
className="block text-sm font-semibold text-gray-900 dark:text-gray-100 cursor-pointer"
>
Download Access
</label>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
When enabled, all users can download audiobook files. When disabled, you can grant access per-user from the users table.
</p>
</div>
</div>
</div>
</Modal>
);
@@ -15,6 +15,7 @@ interface UserPermissionsUser {
role: 'user' | 'admin';
autoApproveRequests: boolean | null;
interactiveSearchAccess: boolean | null;
downloadAccess: boolean | null;
}
interface UserPermissionsModalProps {
@@ -23,8 +24,10 @@ interface UserPermissionsModalProps {
user: UserPermissionsUser | null;
globalAutoApprove: boolean;
globalInteractiveSearch: boolean;
globalDownloadAccess: boolean;
onToggleAutoApprove: (user: UserPermissionsUser, newValue: boolean) => void;
onToggleInteractiveSearch: (user: UserPermissionsUser, newValue: boolean) => void;
onToggleDownloadAccess: (user: UserPermissionsUser, newValue: boolean) => void;
}
interface PermissionToggleProps {
@@ -86,8 +89,10 @@ export function UserPermissionsModal({
user,
globalAutoApprove,
globalInteractiveSearch,
globalDownloadAccess,
onToggleAutoApprove,
onToggleInteractiveSearch,
onToggleDownloadAccess,
}: UserPermissionsModalProps) {
if (!user) return null;
@@ -103,6 +108,11 @@ export function UserPermissionsModal({
const isSearchDisabled = isAdmin || isSearchGlobalOverride;
const searchValue = isAdmin ? true : isSearchGlobalOverride ? true : (user.interactiveSearchAccess ?? false);
// Download Access resolution
const isDownloadGlobalOverride = !isAdmin && globalDownloadAccess;
const isDownloadDisabled = isAdmin || isDownloadGlobalOverride;
const downloadValue = isAdmin ? true : isDownloadGlobalOverride ? true : (user.downloadAccess ?? false);
const getDisabledMessage = (isAdminUser: boolean, isGlobalOverride: boolean, adminMessage: string, globalMessage: string): string | undefined => {
if (isAdminUser) return adminMessage;
if (isGlobalOverride) return globalMessage;
@@ -176,6 +186,21 @@ export function UserPermissionsModal({
description="When enabled, this user can manually search and select torrents and ebooks"
onToggle={() => onToggleInteractiveSearch(user, !searchValue)}
/>
{/* Download Access Permission */}
<PermissionToggle
label="Download Access"
ariaLabel="Download Access"
value={downloadValue}
disabled={isDownloadDisabled}
disabledMessage={getDisabledMessage(
isAdmin, isDownloadGlobalOverride,
'Admins always have download access',
'Controlled by global download access setting'
)}
description="When enabled, this user can download audiobook files directly"
onToggle={() => onToggleDownloadAccess(user, !downloadValue)}
/>
</div>
</div>
</div>
+10 -3
View File
@@ -56,8 +56,13 @@ export function AudiobookCard({
const [showToast, setShowToast] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showModal, setShowModal] = useState(false);
const [localRequestStatus, setLocalRequestStatus] = useState<string | undefined>(undefined);
const status = getStatusConfig(audiobook);
// Build a display-only audiobook with the local status override
const displayAudiobook = localRequestStatus !== undefined
? { ...audiobook, requestStatus: localRequestStatus }
: audiobook;
const status = getStatusConfig(displayAudiobook);
const handleRequest = async (e: React.MouseEvent) => {
e.stopPropagation();
@@ -69,6 +74,7 @@ export function AudiobookCard({
try {
await createRequest(audiobook);
setLocalRequestStatus('pending');
setShowToast(true);
setTimeout(() => setShowToast(false), 2500);
onRequestSuccess?.();
@@ -240,8 +246,9 @@ export function AudiobookCard({
isOpen={showModal}
onClose={() => setShowModal(false)}
onRequestSuccess={onRequestSuccess}
isRequested={audiobook.isRequested}
requestStatus={audiobook.requestStatus}
onStatusChange={(newStatus) => setLocalRequestStatus(newStatus)}
isRequested={audiobook.isRequested || localRequestStatus !== undefined}
requestStatus={displayAudiobook.requestStatus}
isAvailable={audiobook.isAvailable}
requestedByUsername={audiobook.requestedByUsername}
hasReportedIssue={audiobook.hasReportedIssue}
@@ -13,17 +13,21 @@ import Image from 'next/image';
import Link from 'next/link';
import { createPortal } from 'react-dom';
import { useAudiobookDetails } from '@/lib/hooks/useAudiobooks';
import { useCreateRequest, useEbookStatus, useFetchEbookByAsin } from '@/lib/hooks/useRequests';
import { useCreateRequest, useEbookStatus, useDownloadStatus, useFetchEbookByAsin } from '@/lib/hooks/useRequests';
import { useAuth } from '@/contexts/AuthContext';
import { usePreferences } from '@/contexts/PreferencesContext';
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
import { ReportIssueModal } from '@/components/audiobooks/ReportIssueModal';
import { ManualImportBrowser } from '@/components/audiobooks/ManualImportBrowser';
import { FolderArrowDownIcon } from '@heroicons/react/24/outline';
import { fetchWithAuth } from '@/lib/utils/api';
interface AudiobookDetailsModalProps {
asin: string;
isOpen: boolean;
onClose: () => void;
onRequestSuccess?: () => void;
onStatusChange?: (newStatus: string) => void;
isRequested?: boolean;
requestStatus?: string | null;
isAvailable?: boolean;
@@ -63,6 +67,7 @@ export function AudiobookDetailsModal({
isOpen,
onClose,
onRequestSuccess,
onStatusChange,
isRequested = false,
requestStatus = null,
isAvailable = false,
@@ -75,6 +80,7 @@ export function AudiobookDetailsModal({
const { audiobook, audibleBaseUrl, isLoading, error } = useAudiobookDetails(isOpen ? asin : null);
const { createRequest, isLoading: isRequesting } = useCreateRequest();
const { ebookStatus, revalidate: revalidateEbookStatus } = useEbookStatus(isOpen && isAvailable ? asin : null);
const { downloadAvailable, requestId } = useDownloadStatus(isOpen ? asin : null);
const { fetchEbook, isLoading: isFetchingEbook } = useFetchEbookByAsin();
const [showToast, setShowToast] = useState(false);
@@ -84,9 +90,18 @@ export function AudiobookDetailsModal({
const [showInteractiveSearch, setShowInteractiveSearch] = useState(false);
const [showInteractiveSearchEbook, setShowInteractiveSearchEbook] = useState(false);
const [showReportIssue, setShowReportIssue] = useState(false);
const [showManualImport, setShowManualImport] = useState(false);
const [asinCopied, setAsinCopied] = useState(false);
const [localRequestStatus, setLocalRequestStatus] = useState<string | null>(requestStatus ?? null);
const [isDownloading, setIsDownloading] = useState(false);
const status = getStatusInfo(isAvailable, requestStatus, requestedByUsername);
// Sync local status when the prop changes (e.g. page data refreshes)
useEffect(() => {
setLocalRequestStatus(requestStatus ?? null);
}, [requestStatus]);
const effectiveStatus = localRequestStatus;
const status = getStatusInfo(isAvailable, effectiveStatus, requestedByUsername);
const canShowEbookButtons = isAvailable && ebookStatus?.ebookSourcesEnabled && !ebookStatus?.hasActiveEbookRequest;
useEffect(() => {
@@ -119,6 +134,8 @@ export function AudiobookDetailsModal({
try {
await createRequest(audiobook);
setLocalRequestStatus('pending');
onStatusChange?.('pending');
showNotification('Request created!');
setTimeout(onClose, 1500);
onRequestSuccess?.();
@@ -160,6 +177,22 @@ export function AudiobookDetailsModal({
}
};
const handleDownload = async () => {
if (!requestId) return;
setIsDownloading(true);
try {
const res = await fetchWithAuth(`/api/requests/${requestId}/download-token`, { method: 'POST' });
if (!res.ok) throw new Error('Failed to get download link');
const { downloadUrl } = await res.json();
window.location.href = downloadUrl;
} catch (err) {
console.error('Failed to initiate download:', err);
showNotification('Failed to start download. Please try again.', 'error');
} finally {
setIsDownloading(false);
}
};
const formatDuration = (minutes?: number) => {
if (!minutes) return null;
const hours = Math.floor(minutes / 60);
@@ -461,6 +494,36 @@ export function AudiobookDetailsModal({
</svg>
</a>
</div>
{/* Download Link - subtle utility, visible from any context */}
{isAvailable && downloadAvailable && requestId && user?.permissions?.download !== false && (
<div>
<p className="text-gray-500 dark:text-gray-400">Download</p>
<button
onClick={handleDownload}
disabled={isDownloading}
className="inline-flex items-center gap-1 text-blue-600 dark:text-blue-400 hover:underline disabled:opacity-50 disabled:cursor-not-allowed transition-opacity"
aria-label={isDownloading ? 'Preparing download...' : 'Download audiobook files'}
>
{isDownloading ? (
<>
<svg className="w-3.5 h-3.5 animate-spin flex-shrink-0" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span>Preparing...</span>
</>
) : (
<>
<svg className="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<span>Download files</span>
</>
)}
</button>
</div>
)}
</div>
</div>
@@ -485,7 +548,8 @@ export function AudiobookDetailsModal({
)}
</div>
{/* Sticky Action Bar - hidden when opened from bookdate */}
{/* Sticky Action Bar - hidden when opened from read-only contexts */}
{audiobook && !isLoading && !hideRequestActions && (
<div
className="sticky bottom-0 z-20 p-4 bg-white/80 dark:bg-gray-900/80 backdrop-blur-md border-t border-gray-200/50 dark:border-gray-700/50"
@@ -556,6 +620,17 @@ export function AudiobookDetailsModal({
</button>
)}
{/* Manual Import - admin only, hidden during active processing and completed states */}
{user?.role === 'admin' && !isAvailable && !['downloading', 'processing', 'searching', 'downloaded', 'completed', 'available'].includes(effectiveStatus || '') && (
<button
onClick={() => setShowManualImport(true)}
className="p-3 rounded-xl bg-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400 hover:bg-teal-200 dark:hover:bg-teal-900/50 transition-colors"
title="Manual Import"
>
<FolderArrowDownIcon className="w-6 h-6" />
</button>
)}
{/* Ebook Buttons - only when available and enabled */}
{canShowEbookButtons && user && (
<>
@@ -674,6 +749,26 @@ export function AudiobookDetailsModal({
coverArtUrl={audiobook.coverArtUrl}
/>
)}
{/* Manual Import Browser */}
{showManualImport && audiobook && (
<ManualImportBrowser
isOpen={showManualImport}
onClose={() => setShowManualImport(false)}
onSuccess={() => {
setLocalRequestStatus('processing');
onStatusChange?.('processing');
showNotification('Import started — files are being processed');
onRequestSuccess?.();
}}
audiobook={{
asin: audiobook.asin,
title: audiobook.title,
author: audiobook.author,
coverArtUrl: audiobook.coverArtUrl,
}}
/>
)}
</>
);
}
@@ -0,0 +1,302 @@
/**
* Component: Manual Import File Browser
* Documentation: documentation/features/manual-import.md
*
* Two-phase modal for browsing server directories and importing audiobook files.
* Phase 1 (BrowsePhase): Directory navigation with audio file detection.
* Phase 2 (ConfirmPhase): Review and start import.
*
* Sub-components: manual-import/BrowsePhase.tsx, manual-import/ConfirmPhase.tsx
*/
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { fetchWithAuth } from '@/lib/utils/api';
import { FolderArrowDownIcon, XMarkIcon } from '@heroicons/react/24/outline';
import { RootEntry, DirectoryEntry, AudioFileEntry, SlideDirection } from './manual-import/types';
import { BrowsePhase } from './manual-import/BrowsePhase';
import { ConfirmPhase } from './manual-import/ConfirmPhase';
interface ManualImportBrowserProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
audiobook: {
asin: string;
title: string;
author: string;
coverArtUrl?: string;
};
}
type Phase = 'browse' | 'confirm';
export function ManualImportBrowser({
isOpen,
onClose,
onSuccess,
audiobook,
}: ManualImportBrowserProps) {
const [phase, setPhase] = useState<Phase>('browse');
const [slideDirection, setSlideDirection] = useState<SlideDirection>('right');
// Browse state
const [roots, setRoots] = useState<RootEntry[]>([]);
const [currentPath, setCurrentPath] = useState<string | null>(null);
const [entries, setEntries] = useState<DirectoryEntry[]>([]);
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [selectedAudioCount, setSelectedAudioCount] = useState(0);
const [selectedSize, setSelectedSize] = useState(0);
const [selectedAudioFiles, setSelectedAudioFiles] = useState<AudioFileEntry[]>([]);
const [currentAudioFiles, setCurrentAudioFiles] = useState<AudioFileEntry[]>([]);
const [pathHistory, setPathHistory] = useState<string[]>([]);
// Loading/error state
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isImporting, setIsImporting] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
// Hover state for folder icon swap
const [hoveredFolder, setHoveredFolder] = useState<string | null>(null);
// Fetch roots on open
useEffect(() => {
if (!isOpen) return;
setPhase('browse');
setCurrentPath(null);
setSelectedPath(null);
setPathHistory([]);
fetchRoots();
}, [isOpen]);
const fetchRoots = async () => {
setIsLoading(true);
setError(null);
try {
const res = await fetchWithAuth('/api/admin/filesystem/browse');
if (!res.ok) {
const data = await res.json().catch(() => ({ error: 'Failed to load' }));
throw new Error(data.error || 'Failed to load directories');
}
const data = await res.json();
setRoots(data.roots || []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load directories');
} finally {
setIsLoading(false);
}
};
const fetchDirectory = useCallback(async (dirPath: string) => {
setIsLoading(true);
setError(null);
try {
const res = await fetchWithAuth(
`/api/admin/filesystem/browse?path=${encodeURIComponent(dirPath)}`
);
if (!res.ok) {
const data = await res.json().catch(() => ({ error: 'Failed to load' }));
throw new Error(data.error || 'Failed to browse directory');
}
const data = await res.json();
setEntries(data.entries || []);
setCurrentAudioFiles(data.audioFiles || []);
setCurrentPath(data.path || dirPath);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to browse directory');
} finally {
setIsLoading(false);
}
}, []);
const navigateInto = (dirPath: string) => {
setSlideDirection('right');
if (currentPath) {
setPathHistory((prev) => [...prev, currentPath]);
}
setSelectedPath(null);
fetchDirectory(dirPath);
};
const navigateBack = () => {
setSlideDirection('left');
setSelectedPath(null);
if (pathHistory.length > 0) {
const prevPath = pathHistory[pathHistory.length - 1];
setPathHistory((prev) => prev.slice(0, -1));
fetchDirectory(prevPath);
} else {
setCurrentPath(null);
setEntries([]);
}
};
const navigateToRoot = () => {
setSlideDirection('left');
setSelectedPath(null);
setCurrentPath(null);
setEntries([]);
setCurrentAudioFiles([]);
setPathHistory([]);
};
const navigateToBreadcrumb = (index: number) => {
if (!currentPath) return;
setSlideDirection('left');
setSelectedPath(null);
const allPaths = [...pathHistory, currentPath];
const targetPath = allPaths[index];
if (targetPath) {
setPathHistory(allPaths.slice(0, index));
fetchDirectory(targetPath);
} else {
navigateToRoot();
}
};
const handleFolderClick = (entry: DirectoryEntry) => {
const fullPath = currentPath + '/' + entry.name;
navigateInto(fullPath);
};
const handleSelectCurrentFolder = () => {
if (!currentPath || currentAudioFiles.length === 0) return;
setSelectedPath(currentPath);
setSelectedAudioCount(currentAudioFiles.length);
setSelectedSize(currentAudioFiles.reduce((sum, f) => sum + f.size, 0));
setSelectedAudioFiles(currentAudioFiles);
setSlideDirection('right');
setPhase('confirm');
};
const handleBackToBrowse = () => {
setSlideDirection('left');
setPhase('browse');
};
const handleStartImport = async () => {
if (!selectedPath) return;
setIsImporting(true);
setImportError(null);
try {
const res = await fetchWithAuth('/api/admin/manual-import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
asin: audiobook.asin,
folderPath: selectedPath,
}),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Import failed');
}
onSuccess();
onClose();
} catch (err) {
setImportError(err instanceof Error ? err.message : 'Import failed');
} finally {
setIsImporting(false);
}
};
// Build breadcrumb segments
const breadcrumbs = (() => {
if (!currentPath) return [];
const allPaths = [...pathHistory, currentPath];
return allPaths.map((p) => {
const parts = p.replace(/\\/g, '/').split('/');
return parts[parts.length - 1] || p;
});
})();
const visibleBreadcrumbs = (() => {
if (breadcrumbs.length <= 3) return breadcrumbs.map((b, i) => ({ label: b, index: i }));
return [
{ label: breadcrumbs[0], index: 0 },
{ label: '...', index: -1 },
{ label: breadcrumbs[breadcrumbs.length - 1], index: breadcrumbs.length - 1 },
];
})();
if (!isOpen) return null;
const slideClass =
slideDirection === 'right'
? 'animate-[slideRight_200ms_ease-out]'
: 'animate-[slideLeft_200ms_ease-out]';
const modalContent = (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm"
style={{ height: '100dvh' }}
onClick={onClose}
>
<div
className="relative w-full max-w-2xl bg-white dark:bg-gray-900 rounded-2xl shadow-2xl overflow-hidden flex flex-col"
style={{ height: 'min(640px, 85vh)' }}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-gray-700/50">
<div className="flex items-center gap-2.5">
<FolderArrowDownIcon className="w-5 h-5 text-blue-600 dark:text-blue-400" />
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{phase === 'browse' ? 'Manual Import' : 'Confirm Import'}
</h2>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<XMarkIcon className="w-5 h-5 text-gray-500 dark:text-gray-400" />
</button>
</div>
{/* Content */}
<div className="flex-1 min-h-0 overflow-hidden">
{phase === 'browse' ? (
<BrowsePhase
roots={roots}
currentPath={currentPath}
entries={entries}
currentAudioFiles={currentAudioFiles}
isLoading={isLoading}
error={error}
hoveredFolder={hoveredFolder}
breadcrumbs={visibleBreadcrumbs}
slideClass={slideClass}
onNavigateInto={navigateInto}
onNavigateBack={navigateBack}
onNavigateToRoot={navigateToRoot}
onNavigateToBreadcrumb={navigateToBreadcrumb}
onFolderClick={handleFolderClick}
onSelectCurrentFolder={handleSelectCurrentFolder}
onHoverFolder={setHoveredFolder}
onRetry={currentPath ? () => fetchDirectory(currentPath) : fetchRoots}
/>
) : (
<ConfirmPhase
audiobook={audiobook}
selectedPath={selectedPath!}
audioFileCount={selectedAudioCount}
totalSize={selectedSize}
audioFiles={selectedAudioFiles}
isImporting={isImporting}
importError={importError}
slideClass={slideClass}
onBack={handleBackToBrowse}
onStartImport={handleStartImport}
/>
)}
</div>
</div>
</div>
);
return createPortal(modalContent, document.body);
}
@@ -0,0 +1,278 @@
/**
* Component: Manual Import Browse Phase
* Documentation: documentation/features/manual-import.md
*
* Directory listing with root tiles, breadcrumb navigation,
* folder metadata, audio file badges, and selection state.
*/
'use client';
import React from 'react';
import {
FolderIcon,
FolderOpenIcon,
FolderArrowDownIcon,
InboxArrowDownIcon,
HomeIcon,
ChevronRightIcon,
ArrowLeftIcon,
MusicalNoteIcon,
ExclamationTriangleIcon,
ArrowPathIcon,
} from '@heroicons/react/24/outline';
import { RootEntry, DirectoryEntry, AudioFileEntry, formatBytes } from './types';
function SkeletonRow() {
return (
<div className="flex items-center gap-3 px-4 py-3 animate-pulse">
<div className="w-5 h-5 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="flex-1 space-y-1.5">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-48" />
<div className="h-3 bg-gray-100 dark:bg-gray-800 rounded w-32" />
</div>
</div>
);
}
interface BrowsePhaseProps {
roots: RootEntry[];
currentPath: string | null;
entries: DirectoryEntry[];
currentAudioFiles: AudioFileEntry[];
isLoading: boolean;
error: string | null;
hoveredFolder: string | null;
breadcrumbs: Array<{ label: string; index: number }>;
slideClass: string;
onNavigateInto: (path: string) => void;
onNavigateBack: () => void;
onNavigateToRoot: () => void;
onNavigateToBreadcrumb: (index: number) => void;
onFolderClick: (entry: DirectoryEntry) => void;
onSelectCurrentFolder: () => void;
onHoverFolder: (name: string | null) => void;
onRetry: () => void;
}
export function BrowsePhase({
roots,
currentPath,
entries,
currentAudioFiles,
isLoading,
error,
hoveredFolder,
breadcrumbs,
slideClass,
onNavigateInto,
onNavigateBack,
onNavigateToRoot,
onNavigateToBreadcrumb,
onFolderClick,
onSelectCurrentFolder,
onHoverFolder,
onRetry,
}: BrowsePhaseProps) {
return (
<div className="flex flex-col h-full">
{/* Breadcrumb bar */}
{currentPath && (
<div className="flex items-center gap-1 px-5 py-2.5 bg-gray-50 dark:bg-gray-800/50 border-b border-gray-100 dark:border-gray-800 text-sm overflow-x-auto">
<button
onClick={onNavigateToRoot}
className="flex-shrink-0 p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
>
<HomeIcon className="w-4 h-4 text-gray-500 dark:text-gray-400" />
</button>
{breadcrumbs.map((crumb, i) => (
<React.Fragment key={i}>
<ChevronRightIcon className="w-3.5 h-3.5 text-gray-400 flex-shrink-0" />
{crumb.index === -1 ? (
<span className="text-gray-400 px-1">...</span>
) : i === breadcrumbs.length - 1 ? (
<span className="font-medium text-gray-900 dark:text-gray-100 truncate">
{crumb.label}
</span>
) : (
<button
onClick={() => onNavigateToBreadcrumb(crumb.index)}
className="text-gray-600 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 truncate transition-colors"
>
{crumb.label}
</button>
)}
</React.Fragment>
))}
</div>
)}
{/* Listing */}
<div className={`flex-1 overflow-y-auto ${slideClass}`}>
{/* Loading */}
{isLoading && (
<div className="py-2">
{[...Array(5)].map((_, i) => (
<SkeletonRow key={i} />
))}
</div>
)}
{/* Error */}
{error && !isLoading && (
<div className="flex flex-col items-center justify-center py-16 px-6">
<ExclamationTriangleIcon className="w-10 h-10 text-red-400 mb-3" />
<p className="text-gray-900 dark:text-gray-100 font-medium text-center">{error}</p>
<button
onClick={onRetry}
className="mt-4 flex items-center gap-2 px-4 py-2 text-sm font-medium text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors"
>
<ArrowPathIcon className="w-4 h-4" />
Try Again
</button>
</div>
)}
{/* Root view */}
{!currentPath && !isLoading && !error && (
<div className="p-5 grid grid-cols-2 gap-3">
{roots.map((root) => (
<button
key={root.path}
onClick={() => onNavigateInto(root.path)}
className="flex flex-col items-center gap-3 p-6 rounded-xl border border-gray-200 dark:border-gray-700 hover:border-blue-300 dark:hover:border-blue-700 hover:bg-blue-50/50 dark:hover:bg-blue-900/10 transition-all group"
>
{root.icon === 'download' ? (
<FolderArrowDownIcon className="w-10 h-10 text-blue-500 group-hover:text-blue-600 transition-colors" />
) : root.icon === 'bookdrop' ? (
<InboxArrowDownIcon className="w-10 h-10 text-amber-500 group-hover:text-amber-600 transition-colors" />
) : (
<FolderIcon className="w-10 h-10 text-emerald-500 group-hover:text-emerald-600 transition-colors" />
)}
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{root.name}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400 font-mono truncate max-w-full">
{root.path}
</span>
</button>
))}
</div>
)}
{/* Directory + audio file listing */}
{currentPath && !isLoading && !error && (entries.length > 0 || currentAudioFiles.length > 0) && (
<div className="divide-y divide-gray-100 dark:divide-gray-800">
{/* Subdirectories */}
{entries.map((entry) => {
const hasAudio = entry.audioFileCount > 0;
const isHovered = hoveredFolder === entry.name;
return (
<button
key={`dir-${entry.name}`}
onClick={() => onFolderClick(entry)}
onMouseEnter={() => onHoverFolder(entry.name)}
onMouseLeave={() => onHoverFolder(null)}
className="w-full flex items-center gap-3 px-4 py-3 text-left transition-all duration-150 hover:bg-gray-50 dark:hover:bg-gray-800/50"
>
<div className="flex-shrink-0 w-5 h-5 text-gray-400 dark:text-gray-500 transition-all duration-150">
{isHovered ? (
<FolderOpenIcon className="w-5 h-5 text-blue-500" />
) : (
<FolderIcon className="w-5 h-5" />
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{entry.name}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{entry.subfolderCount > 0 && (
<span>{entry.subfolderCount} folder{entry.subfolderCount !== 1 ? 's' : ''}</span>
)}
{entry.subfolderCount > 0 && entry.audioFileCount > 0 && <span> &middot; </span>}
{entry.audioFileCount > 0 && (
<span>{entry.audioFileCount} audio file{entry.audioFileCount !== 1 ? 's' : ''}</span>
)}
{entry.totalSize > 0 && (
<span> &middot; {formatBytes(entry.totalSize)}</span>
)}
{entry.subfolderCount === 0 && entry.audioFileCount === 0 && (
<span className="italic">Empty</span>
)}
</p>
</div>
{hasAudio && (
<span className="flex-shrink-0 inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 text-xs font-medium">
<MusicalNoteIcon className="w-3 h-3" />
{entry.audioFileCount}
</span>
)}
<ChevronRightIcon className="w-4 h-4 text-gray-300 dark:text-gray-600 flex-shrink-0" />
</button>
);
})}
{/* Audio files in current directory */}
{currentAudioFiles.length > 0 && entries.length > 0 && (
<div className="px-4 py-2 bg-gray-50/50 dark:bg-gray-800/20">
<p className="text-xs font-medium text-gray-400 dark:text-gray-500 uppercase tracking-wider">
Audio Files
</p>
</div>
)}
{currentAudioFiles.map((file) => (
<div
key={`file-${file.name}`}
className="flex items-center gap-3 px-4 py-2.5"
>
<MusicalNoteIcon className="w-4 h-4 text-blue-500 dark:text-blue-400 flex-shrink-0" />
<span className="flex-1 min-w-0 text-sm text-gray-700 dark:text-gray-300 truncate">
{file.name}
</span>
<span className="text-xs text-gray-400 dark:text-gray-500 flex-shrink-0">
{formatBytes(file.size)}
</span>
</div>
))}
</div>
)}
{/* Empty state */}
{currentPath && !isLoading && !error && entries.length === 0 && currentAudioFiles.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 px-6 text-center">
<FolderOpenIcon className="w-10 h-10 text-gray-300 dark:text-gray-600 mb-3" />
<p className="text-gray-500 dark:text-gray-400 font-medium">This folder is empty</p>
<button
onClick={onNavigateBack}
className="mt-4 flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
<ArrowLeftIcon className="w-4 h-4" />
Go back
</button>
</div>
)}
</div>
{/* Footer: Select this folder */}
{currentPath && !isLoading && currentAudioFiles.length > 0 && (
<div className="px-5 py-3.5 border-t border-gray-200 dark:border-gray-700/50 bg-gray-50/50 dark:bg-gray-800/30 flex items-center justify-between gap-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
<span className="font-medium text-gray-900 dark:text-gray-100">{currentAudioFiles.length}</span>
{' '}audio file{currentAudioFiles.length !== 1 ? 's' : ''} in this folder
</p>
<button
onClick={onSelectCurrentFolder}
className="flex-shrink-0 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-xl transition-colors"
>
Select This Folder &rarr;
</button>
</div>
)}
</div>
);
}
@@ -0,0 +1,142 @@
/**
* Component: Manual Import Confirm Phase
* Documentation: documentation/features/manual-import.md
*
* Shows book context, selected folder, pipeline steps summary,
* and start import / back actions.
*/
'use client';
import React from 'react';
import Image from 'next/image';
import { ArrowLeftIcon, ExclamationCircleIcon, MusicalNoteIcon } from '@heroicons/react/24/outline';
import { AudioFileEntry, formatBytes } from './types';
interface ConfirmPhaseProps {
audiobook: { asin: string; title: string; author: string; coverArtUrl?: string };
selectedPath: string;
audioFileCount: number;
totalSize: number;
audioFiles: AudioFileEntry[];
isImporting: boolean;
importError: string | null;
slideClass: string;
onBack: () => void;
onStartImport: () => void;
}
export function ConfirmPhase({
audiobook,
selectedPath,
audioFileCount,
totalSize,
audioFiles,
isImporting,
importError,
slideClass,
onBack,
onStartImport,
}: ConfirmPhaseProps) {
return (
<div className={`flex flex-col h-full ${slideClass}`}>
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{/* Book context */}
<div className="flex items-start gap-4">
<div className="w-16 h-16 rounded-xl overflow-hidden flex-shrink-0 bg-gray-100 dark:bg-gray-800">
{audiobook.coverArtUrl ? (
<Image
src={audiobook.coverArtUrl}
alt=""
width={64}
height={64}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<MusicalNoteIcon className="w-6 h-6 text-gray-400" />
</div>
)}
</div>
<div className="min-w-0">
<h3 className="font-semibold text-gray-900 dark:text-gray-100 truncate">
{audiobook.title}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">{audiobook.author}</p>
</div>
</div>
{/* Selected folder info */}
<div className="p-4 rounded-xl bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700">
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-1.5">
Import from
</p>
<p className="text-sm font-mono text-gray-900 dark:text-gray-100 break-all">
{selectedPath}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1.5">
{audioFileCount} audio file{audioFileCount !== 1 ? 's' : ''}
{totalSize > 0 ? ` \u00B7 ${formatBytes(totalSize)}` : ''}
</p>
</div>
{/* Audio files to import */}
<div>
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">
Files to import
</h4>
<div className="rounded-xl border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-800 overflow-hidden">
{audioFiles.map((file) => (
<div key={file.name} className="flex items-center gap-3 px-3.5 py-2.5">
<MusicalNoteIcon className="w-4 h-4 text-blue-500 dark:text-blue-400 flex-shrink-0" />
<span className="flex-1 min-w-0 text-sm text-gray-700 dark:text-gray-300 truncate">
{file.name}
</span>
<span className="text-xs text-gray-400 dark:text-gray-500 flex-shrink-0">
{formatBytes(file.size)}
</span>
</div>
))}
</div>
</div>
</div>
{/* Error display */}
{importError && (
<div className="mx-5 mb-2 p-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800/50 flex items-start gap-2.5">
<ExclamationCircleIcon className="w-5 h-5 text-red-500 dark:text-red-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-700 dark:text-red-300">{importError}</p>
</div>
)}
{/* Footer */}
<div className="px-5 py-3.5 border-t border-gray-200 dark:border-gray-700/50 bg-gray-50/50 dark:bg-gray-800/30 flex items-center justify-between gap-3">
<button
onClick={onBack}
disabled={isImporting}
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-xl transition-colors disabled:opacity-50"
>
<ArrowLeftIcon className="w-4 h-4" />
Back
</button>
<button
onClick={onStartImport}
disabled={isImporting}
className="px-5 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-xl transition-colors disabled:opacity-70 flex items-center gap-2"
>
{isImporting ? (
<>
<svg className="w-4 h-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Importing...
</>
) : (
'Start Import'
)}
</button>
</div>
</div>
);
}
@@ -0,0 +1,33 @@
/**
* Component: Manual Import Shared Types
* Documentation: documentation/features/manual-import.md
*/
export interface RootEntry {
name: string;
path: string;
icon: string;
}
export interface DirectoryEntry {
name: string;
type: 'directory';
audioFileCount: number;
subfolderCount: number;
totalSize: number;
}
export interface AudioFileEntry {
name: string;
size: number;
}
export type SlideDirection = 'left' | 'right';
export function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}
+1 -13
View File
@@ -27,7 +27,7 @@ interface RequestCardProps {
createdAt: string;
updatedAt: string;
completedAt?: string;
downloadUrl?: string | null;
downloadAvailable?: boolean;
audiobook: {
id: string;
audibleAsin?: string;
@@ -276,18 +276,6 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
</Button>
</>
)}
{isCompleted && request.downloadUrl && (
<a
href={request.downloadUrl}
className="inline-flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:underline"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Download
</a>
)}
{canCancel && (
<Button
onClick={handleCancel}