mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 21:00:09 +00:00
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:
@@ -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> · </span>}
|
||||
{entry.audioFileCount > 0 && (
|
||||
<span>{entry.audioFileCount} audio file{entry.audioFileCount !== 1 ? 's' : ''}</span>
|
||||
)}
|
||||
{entry.totalSize > 0 && (
|
||||
<span> · {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 →
|
||||
</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]}`;
|
||||
}
|
||||
Reference in New Issue
Block a user