diff --git a/src/app/api/admin/bulk-import/execute/route.ts b/src/app/api/admin/bulk-import/execute/route.ts index 2abc2df..e051c01 100644 --- a/src/app/api/admin/bulk-import/execute/route.ts +++ b/src/app/api/admin/bulk-import/execute/route.ts @@ -31,6 +31,7 @@ const RECYCLABLE_STATUSES = [ interface ImportItem { folderPath: string; asin: string; + audioFiles?: string[]; // Specific files to import (from scanner grouping) } interface ImportResult { @@ -105,7 +106,7 @@ export async function POST(request: NextRequest) { const results: ImportResult[] = []; for (const item of imports) { - const { folderPath, asin } = item; + const { folderPath, asin, audioFiles: itemAudioFiles } = item; try { // Validate path @@ -119,7 +120,7 @@ export async function POST(request: NextRequest) { continue; } - // Verify directory exists and has audio files + // Verify directory exists try { const stat = await fs.stat(normalizedPath); if (!stat.isDirectory()) { @@ -131,10 +132,14 @@ export async function POST(request: NextRequest) { continue; } - const hasAudio = await hasAudioFiles(normalizedPath); - if (!hasAudio) { - results.push({ folderPath, asin, success: false, error: 'No audio files' }); - continue; + // Verify audio files: if specific files provided, trust the scanner; + // otherwise fall back to folder-level check + if (!itemAudioFiles || itemAudioFiles.length === 0) { + const hasAudio = await hasAudioFiles(normalizedPath); + if (!hasAudio) { + results.push({ folderPath, asin, success: false, error: 'No audio files' }); + continue; + } } // Resolve or create audiobook record @@ -250,8 +255,15 @@ export async function POST(request: NextRequest) { requestId = newReq.id; } - // Queue organize_files job - await jobQueue.addOrganizeJob(requestId, audiobookId, normalizedPath); + // Queue organize_files job (pass specific files if scanner provided them) + await jobQueue.addOrganizeJob( + requestId, + audiobookId, + normalizedPath, + undefined, + false, + itemAudioFiles && itemAudioFiles.length > 0 ? itemAudioFiles : undefined + ); results.push({ folderPath, asin, success: true, requestId }); logger.info(`Bulk import queued: asin=${asin}, path=${normalizedPath}, request=${requestId}`); diff --git a/src/app/api/admin/bulk-import/scan/route.ts b/src/app/api/admin/bulk-import/scan/route.ts index 36bf408..a74e217 100644 --- a/src/app/api/admin/bulk-import/scan/route.ts +++ b/src/app/api/admin/bulk-import/scan/route.ts @@ -209,6 +209,7 @@ export async function POST(request: NextRequest) { totalSizeBytes: book.totalSizeBytes, metadataSource: book.metadataSource, searchTerm: book.searchTerm, + audioFiles: book.audioFiles, match: match ? { asin: match.asin, diff --git a/src/app/api/admin/filesystem/browse/route.ts b/src/app/api/admin/filesystem/browse/route.ts index 4f385e2..982573a 100644 --- a/src/app/api/admin/filesystem/browse/route.ts +++ b/src/app/api/admin/filesystem/browse/route.ts @@ -17,47 +17,6 @@ const logger = RMABLogger.create('API.Admin.Filesystem.Browse'); interface DirectoryEntry { name: string; type: 'directory'; - audioFileCount: number; - subfolderCount: number; - totalSize: number; -} - -/** - * Scan immediate children of a directory to gather audio file and subfolder stats. - */ -async function getDirectoryStats( - dirPath: string -): Promise<{ audioFileCount: number; subfolderCount: number; totalSize: number }> { - const fs = await import('fs/promises'); - const pathModule = await import('path'); - - let audioFileCount = 0; - let subfolderCount = 0; - let totalSize = 0; - - try { - const children = await fs.readdir(dirPath, { withFileTypes: true }); - for (const child of children) { - if (child.isDirectory()) { - subfolderCount++; - } else if (child.isFile()) { - const ext = pathModule.extname(child.name).toLowerCase(); - if ((AUDIO_EXTENSIONS as readonly string[]).includes(ext)) { - audioFileCount++; - try { - const stat = await fs.stat(pathModule.join(dirPath, child.name)); - totalSize += stat.size; - } catch { - /* skip unreadable files */ - } - } - } - } - } catch { - /* directory not readable */ - } - - return { audioFileCount, subfolderCount, totalSize }; } /** @@ -152,20 +111,11 @@ export async function GET(request: NextRequest) { // Read directory entries const dirEntries = await fs.readdir(normalizedPath, { withFileTypes: true }); - // Gather stats for each subdirectory (parallel for performance) - const directoryEntries = dirEntries.filter((e) => e.isDirectory()); - const statsPromises = directoryEntries.map(async (entry): Promise => { - const fullPath = pathModule.join(normalizedPath, entry.name); - const stats = await getDirectoryStats(fullPath); - return { - name: entry.name, - type: 'directory', - ...stats, - }; - }); - - const entries = await Promise.all(statsPromises); - entries.sort((a, b) => a.name.localeCompare(b.name)); + // List subdirectories (no nested stat calls — keeps browsing fast) + const entries: DirectoryEntry[] = dirEntries + .filter((e) => e.isDirectory()) + .map((entry) => ({ name: entry.name, type: 'directory' as const })) + .sort((a, b) => a.name.localeCompare(b.name)); // Gather audio files in the current directory const audioFiles: Array<{ name: string; size: number }> = []; diff --git a/src/app/api/admin/manual-import/route.ts b/src/app/api/admin/manual-import/route.ts index d7bd546..2c46c85 100644 --- a/src/app/api/admin/manual-import/route.ts +++ b/src/app/api/admin/manual-import/route.ts @@ -55,9 +55,25 @@ export async function POST(request: NextRequest) { const fs = await import('fs/promises'); const body = await request.json(); - const { folderPath, asin, cleanupSource } = body; + const { folderPath, asin, cleanupSource, selectedFiles } = body; let { audiobookId } = body; + // Validate selectedFiles if provided + if (selectedFiles !== undefined) { + if (!Array.isArray(selectedFiles) || selectedFiles.length === 0) { + return NextResponse.json( + { error: 'selectedFiles must be a non-empty array of file names' }, + { status: 400 } + ); + } + if (!selectedFiles.every((f: unknown) => typeof f === 'string')) { + return NextResponse.json( + { error: 'selectedFiles must contain only strings' }, + { status: 400 } + ); + } + } + // Validate required fields if ((!audiobookId && !asin) || !folderPath) { return NextResponse.json( @@ -120,13 +136,52 @@ export async function POST(request: NextRequest) { ); } - // Verify folder contains audio files - const audioCheck = await hasAudioFiles(normalizedPath); - if (!audioCheck.found) { - return NextResponse.json( - { error: 'No audio files found in the selected directory' }, - { status: 400 } - ); + // Verify selected files exist and are audio files, or fall back to folder scan + let audioFileCount: number; + const validatedFiles: string[] = []; + + if (selectedFiles && selectedFiles.length > 0) { + for (const fileName of selectedFiles as string[]) { + // Prevent path traversal + if (fileName.includes('/') || fileName.includes('\\') || fileName === '..' || fileName === '.') { + return NextResponse.json( + { error: `Invalid file name: ${fileName}` }, + { status: 400 } + ); + } + const ext = pathModule.extname(fileName).toLowerCase(); + if (!(AUDIO_EXTENSIONS as readonly string[]).includes(ext)) { + return NextResponse.json( + { error: `Not an audio file: ${fileName}` }, + { status: 400 } + ); + } + try { + const fileStat = await fs.stat(pathModule.join(normalizedPath, fileName)); + if (!fileStat.isFile()) { + return NextResponse.json( + { error: `Not a file: ${fileName}` }, + { status: 400 } + ); + } + validatedFiles.push(fileName); + } catch { + return NextResponse.json( + { error: `File not found: ${fileName}` }, + { status: 404 } + ); + } + } + audioFileCount = validatedFiles.length; + } else { + const audioCheck = await hasAudioFiles(normalizedPath); + if (!audioCheck.found) { + return NextResponse.json( + { error: 'No audio files found in the selected directory' }, + { status: 400 } + ); + } + audioFileCount = audioCheck.count; } // Resolve audiobook by ASIN if audiobookId not provided @@ -317,9 +372,16 @@ export async function POST(request: NextRequest) { // Queue organize_files job const jobQueue = getJobQueueService(); - await jobQueue.addOrganizeJob(requestId, audiobookId, normalizedPath, undefined, cleanupSource === true); + await jobQueue.addOrganizeJob( + requestId, + audiobookId, + normalizedPath, + undefined, + cleanupSource === true, + validatedFiles.length > 0 ? validatedFiles : undefined + ); - logger.info(`Manual import queued: request=${requestId}, path=${normalizedPath}, audioFiles=${audioCheck.count}`); + logger.info(`Manual import queued: request=${requestId}, path=${normalizedPath}, audioFiles=${audioFileCount}`); return NextResponse.json({ success: true, diff --git a/src/components/admin/BulkImportWizard.tsx b/src/components/admin/BulkImportWizard.tsx index bb6bc2b..9256cd3 100644 --- a/src/components/admin/BulkImportWizard.tsx +++ b/src/components/admin/BulkImportWizard.tsx @@ -205,6 +205,7 @@ export function BulkImportWizard({ isOpen, onClose }: BulkImportWizardProps) { imports: booksToImport.map((b) => ({ folderPath: b.folderPath, asin: b.match!.asin, + audioFiles: b.audioFiles, })), }), }); diff --git a/src/components/admin/bulk-import/ScanFolderStep.tsx b/src/components/admin/bulk-import/ScanFolderStep.tsx index fb63645..f4e5593 100644 --- a/src/components/admin/bulk-import/ScanFolderStep.tsx +++ b/src/components/admin/bulk-import/ScanFolderStep.tsx @@ -18,13 +18,12 @@ import { HomeIcon, ChevronRightIcon, ArrowLeftIcon, - MusicalNoteIcon, ExclamationTriangleIcon, ArrowPathIcon, MagnifyingGlassIcon, } from '@heroicons/react/24/outline'; import { fetchWithAuth } from '@/lib/utils/api'; -import { RootEntry, DirectoryEntry, formatBytes } from './types'; +import { RootEntry, DirectoryEntry } from './types'; function SkeletonRow() { return ( @@ -149,9 +148,8 @@ export function ScanFolderStep({ onFolderSelected }: ScanFolderStepProps) { ]; })(); - // Count total audio files and subfolders in current listing - const totalSubfolders = entries.reduce((sum, e) => sum + e.subfolderCount, 0); - const totalAudioInChildren = entries.reduce((sum, e) => sum + e.audioFileCount, 0); + // Count subfolders in current listing + const totalSubfolders = entries.length; return (
@@ -248,7 +246,6 @@ export function ScanFolderStep({ onFolderSelected }: ScanFolderStepProps) { {currentPath && !isLoading && !error && entries.length > 0 && (
{entries.map((entry) => { - const hasAudio = entry.audioFileCount > 0; const isHovered = hoveredFolder === entry.name; return ( @@ -267,33 +264,9 @@ export function ScanFolderStep({ onFolderSelected }: ScanFolderStepProps) { )}
-
-

- {entry.name} -

-

- {entry.subfolderCount > 0 && ( - {entry.subfolderCount} folder{entry.subfolderCount !== 1 ? 's' : ''} - )} - {entry.subfolderCount > 0 && entry.audioFileCount > 0 && · } - {entry.audioFileCount > 0 && ( - {entry.audioFileCount} audio file{entry.audioFileCount !== 1 ? 's' : ''} - )} - {entry.totalSize > 0 && ( - · {formatBytes(entry.totalSize)} - )} - {entry.subfolderCount === 0 && entry.audioFileCount === 0 && ( - Empty - )} -

-
- - {hasAudio && ( - - - {entry.audioFileCount} - - )} +

+ {entry.name} +

@@ -325,10 +298,7 @@ export function ScanFolderStep({ onFolderSelected }: ScanFolderStepProps) {

{currentPath}

{entries.length > 0 && (

- {entries.length} subfolder{entries.length !== 1 ? 's' : ''} - {totalAudioInChildren > 0 && ( - · {totalAudioInChildren} audio files visible - )} + {totalSubfolders} subfolder{totalSubfolders !== 1 ? 's' : ''}

)}
diff --git a/src/components/admin/bulk-import/types.ts b/src/components/admin/bulk-import/types.ts index f441b6b..b68992c 100644 --- a/src/components/admin/bulk-import/types.ts +++ b/src/components/admin/bulk-import/types.ts @@ -14,9 +14,6 @@ export interface RootEntry { export interface DirectoryEntry { name: string; type: 'directory'; - audioFileCount: number; - subfolderCount: number; - totalSize: number; } /** Audible match data for a discovered audiobook. */ @@ -39,6 +36,7 @@ export interface ScannedBook { totalSizeBytes: number; metadataSource: 'tags' | 'file_name'; searchTerm: string; + audioFiles: string[]; match: AudibleMatch | null; inLibrary: boolean; hasActiveRequest: boolean; @@ -48,7 +46,7 @@ export interface ScannedBook { /** Progress event from the SSE scan stream. */ export interface ScanProgressEvent { - phase: 'discovering' | 'reading_metadata'; + phase: 'discovering' | 'reading_metadata' | 'grouping'; foldersScanned: number; audiobooksFound: number; currentFolder?: string; diff --git a/src/components/audiobooks/ManualImportBrowser.tsx b/src/components/audiobooks/ManualImportBrowser.tsx index 9d39347..b490a3d 100644 --- a/src/components/audiobooks/ManualImportBrowser.tsx +++ b/src/components/audiobooks/ManualImportBrowser.tsx @@ -47,8 +47,6 @@ export function ManualImportBrowser({ const [currentPath, setCurrentPath] = useState(null); const [entries, setEntries] = useState([]); const [selectedPath, setSelectedPath] = useState(null); - const [selectedAudioCount, setSelectedAudioCount] = useState(0); - const [selectedSize, setSelectedSize] = useState(0); const [selectedAudioFiles, setSelectedAudioFiles] = useState([]); const [currentAudioFiles, setCurrentAudioFiles] = useState([]); const [pathHistory, setPathHistory] = useState([]); @@ -62,6 +60,9 @@ export function ManualImportBrowser({ // Cleanup source toggle const [cleanupSource, setCleanupSource] = useState(false); + // File selection state (shared between BrowsePhase and ConfirmPhase) + const [checkedFiles, setCheckedFiles] = useState>(new Set()); + // Hover state for folder icon swap const [hoveredFolder, setHoveredFolder] = useState(null); @@ -96,6 +97,7 @@ export function ManualImportBrowser({ const fetchDirectory = useCallback(async (dirPath: string) => { setIsLoading(true); setError(null); + setCheckedFiles(new Set()); try { const res = await fetchWithAuth( `/api/admin/filesystem/browse?path=${encodeURIComponent(dirPath)}` @@ -105,8 +107,9 @@ export function ManualImportBrowser({ throw new Error(data.error || 'Failed to browse directory'); } const data = await res.json(); + const audioFiles: AudioFileEntry[] = data.audioFiles || []; setEntries(data.entries || []); - setCurrentAudioFiles(data.audioFiles || []); + setCurrentAudioFiles(audioFiles); setCurrentPath(data.path || dirPath); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to browse directory'); @@ -165,12 +168,38 @@ export function ManualImportBrowser({ navigateInto(fullPath); }; - const handleSelectCurrentFolder = () => { + const handleToggleFile = (fileName: string) => { + setCheckedFiles((prev) => { + const next = new Set(prev); + if (next.has(fileName)) { + next.delete(fileName); + } else { + next.add(fileName); + } + return next; + }); + }; + + const handleToggleAll = () => { + // In confirm phase, toggle against selectedAudioFiles; in browse phase, against currentAudioFiles + const files = phase === 'confirm' ? selectedAudioFiles : currentAudioFiles; + if (checkedFiles.size === files.length) { + setCheckedFiles(new Set()); + } else { + setCheckedFiles(new Set(files.map((f) => f.name))); + } + }; + + const handleSelectFiles = () => { if (!currentPath || currentAudioFiles.length === 0) return; + // No individual selection = whole folder; otherwise only checked files + const selected = checkedFiles.size > 0 + ? currentAudioFiles.filter((f) => checkedFiles.has(f.name)) + : currentAudioFiles; setSelectedPath(currentPath); - setSelectedAudioCount(currentAudioFiles.length); - setSelectedSize(currentAudioFiles.reduce((sum, f) => sum + f.size, 0)); - setSelectedAudioFiles(currentAudioFiles); + setSelectedAudioFiles(selected); + // Ensure checkedFiles reflects what we're importing for ConfirmPhase + setCheckedFiles(new Set(selected.map((f) => f.name))); setSlideDirection('right'); setPhase('confirm'); }; @@ -185,12 +214,18 @@ export function ManualImportBrowser({ setIsImporting(true); setImportError(null); try { + // Send only the files that are still checked in ConfirmPhase + const fileNames = selectedAudioFiles + .filter((f) => checkedFiles.has(f.name)) + .map((f) => f.name); + if (fileNames.length === 0) return; const res = await fetchWithAuth('/api/admin/manual-import', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ asin: audiobook.asin, folderPath: selectedPath, + selectedFiles: fileNames, cleanupSource, }), }); @@ -268,6 +303,7 @@ export function ManualImportBrowser({ currentPath={currentPath} entries={entries} currentAudioFiles={currentAudioFiles} + checkedFiles={checkedFiles} isLoading={isLoading} error={error} hoveredFolder={hoveredFolder} @@ -278,7 +314,8 @@ export function ManualImportBrowser({ onNavigateToRoot={navigateToRoot} onNavigateToBreadcrumb={navigateToBreadcrumb} onFolderClick={handleFolderClick} - onSelectCurrentFolder={handleSelectCurrentFolder} + onSelectFiles={handleSelectFiles} + onToggleFile={handleToggleFile} onHoverFolder={setHoveredFolder} onRetry={currentPath ? () => fetchDirectory(currentPath) : fetchRoots} /> @@ -286,14 +323,15 @@ export function ManualImportBrowser({ diff --git a/src/components/audiobooks/manual-import/BrowsePhase.tsx b/src/components/audiobooks/manual-import/BrowsePhase.tsx index 3cbb5a2..a9db62b 100644 --- a/src/components/audiobooks/manual-import/BrowsePhase.tsx +++ b/src/components/audiobooks/manual-import/BrowsePhase.tsx @@ -40,6 +40,7 @@ interface BrowsePhaseProps { currentPath: string | null; entries: DirectoryEntry[]; currentAudioFiles: AudioFileEntry[]; + checkedFiles: Set; isLoading: boolean; error: string | null; hoveredFolder: string | null; @@ -50,7 +51,8 @@ interface BrowsePhaseProps { onNavigateToRoot: () => void; onNavigateToBreadcrumb: (index: number) => void; onFolderClick: (entry: DirectoryEntry) => void; - onSelectCurrentFolder: () => void; + onSelectFiles: () => void; + onToggleFile: (fileName: string) => void; onHoverFolder: (name: string | null) => void; onRetry: () => void; } @@ -60,6 +62,7 @@ export function BrowsePhase({ currentPath, entries, currentAudioFiles, + checkedFiles, isLoading, error, hoveredFolder, @@ -70,10 +73,16 @@ export function BrowsePhase({ onNavigateToRoot, onNavigateToBreadcrumb, onFolderClick, - onSelectCurrentFolder, + onSelectFiles, + onToggleFile, onHoverFolder, onRetry, }: BrowsePhaseProps) { + const hasSelection = checkedFiles.size > 0; + const totalSize = currentAudioFiles.reduce((sum, f) => sum + f.size, 0); + const checkedSize = hasSelection + ? currentAudioFiles.filter((f) => checkedFiles.has(f.name)).reduce((sum, f) => sum + f.size, 0) + : totalSize; return (
{/* Breadcrumb bar */} @@ -165,7 +174,6 @@ export function BrowsePhase({
{/* Subdirectories */} {entries.map((entry) => { - const hasAudio = entry.audioFileCount > 0; const isHovered = hoveredFolder === entry.name; return ( @@ -184,33 +192,9 @@ export function BrowsePhase({ )}
-
-

- {entry.name} -

-

- {entry.subfolderCount > 0 && ( - {entry.subfolderCount} folder{entry.subfolderCount !== 1 ? 's' : ''} - )} - {entry.subfolderCount > 0 && entry.audioFileCount > 0 && · } - {entry.audioFileCount > 0 && ( - {entry.audioFileCount} audio file{entry.audioFileCount !== 1 ? 's' : ''} - )} - {entry.totalSize > 0 && ( - · {formatBytes(entry.totalSize)} - )} - {entry.subfolderCount === 0 && entry.audioFileCount === 0 && ( - Empty - )} -

-
- - {hasAudio && ( - - - {entry.audioFileCount} - - )} +

+ {entry.name} +

@@ -221,24 +205,38 @@ export function BrowsePhase({ {currentAudioFiles.length > 0 && entries.length > 0 && (

- Audio Files + Audio Files {hasSelection && `\u00B7 click to select`}

)} - {currentAudioFiles.map((file) => ( -
- - - {file.name} - - - {formatBytes(file.size)} - -
- ))} + {currentAudioFiles.map((file) => { + const isSelected = checkedFiles.has(file.name); + return ( + + ); + })}
)} @@ -258,18 +256,33 @@ export function BrowsePhase({ )} - {/* Footer: Select this folder */} + {/* Footer */} {currentPath && !isLoading && currentAudioFiles.length > 0 && (

- {currentAudioFiles.length} - {' '}audio file{currentAudioFiles.length !== 1 ? 's' : ''} in this folder + {hasSelection ? ( + <> + {checkedFiles.size} + {' '}of {currentAudioFiles.length} file{currentAudioFiles.length !== 1 ? 's' : ''} selected + + ) : ( + <> + {currentAudioFiles.length} + {' '}audio file{currentAudioFiles.length !== 1 ? 's' : ''} in this folder + + )} + {checkedSize > 0 && ( + · {formatBytes(checkedSize)} + )}

)} diff --git a/src/components/audiobooks/manual-import/ConfirmPhase.tsx b/src/components/audiobooks/manual-import/ConfirmPhase.tsx index c5c80b5..42b67b4 100644 --- a/src/components/audiobooks/manual-import/ConfirmPhase.tsx +++ b/src/components/audiobooks/manual-import/ConfirmPhase.tsx @@ -16,14 +16,15 @@ import { AudioFileEntry, formatBytes } from './types'; interface ConfirmPhaseProps { audiobook: { asin: string; title: string; author: string; coverArtUrl?: string }; selectedPath: string; - audioFileCount: number; - totalSize: number; audioFiles: AudioFileEntry[]; + checkedFiles: Set; isImporting: boolean; importError: string | null; slideClass: string; cleanupSource: boolean; onCleanupSourceChange: (value: boolean) => void; + onToggleFile: (fileName: string) => void; + onToggleAll: () => void; onBack: () => void; onStartImport: () => void; } @@ -31,17 +32,23 @@ interface ConfirmPhaseProps { export function ConfirmPhase({ audiobook, selectedPath, - audioFileCount, - totalSize, audioFiles, + checkedFiles, isImporting, importError, slideClass, cleanupSource, onCleanupSourceChange, + onToggleFile, + onToggleAll, onBack, onStartImport, }: ConfirmPhaseProps) { + const allChecked = audioFiles.length > 0 && checkedFiles.size === audioFiles.length; + const someChecked = checkedFiles.size > 0 && !allChecked; + const checkedSize = audioFiles + .filter((f) => checkedFiles.has(f.name)) + .reduce((sum, f) => sum + f.size, 0); return (
@@ -79,28 +86,51 @@ export function ConfirmPhase({ {selectedPath}

- {audioFileCount} audio file{audioFileCount !== 1 ? 's' : ''} - {totalSize > 0 ? ` \u00B7 ${formatBytes(totalSize)}` : ''} + {checkedFiles.size} of {audioFiles.length} file{audioFiles.length !== 1 ? 's' : ''} selected + {checkedSize > 0 ? ` \u00B7 ${formatBytes(checkedSize)}` : ''}

{/* Audio files to import */}
-

- Files to import -

-
- {audioFiles.map((file) => ( -
- - - {file.name} - - - {formatBytes(file.size)} - -
- ))} +
+ { if (el) el.indeterminate = someChecked; }} + onChange={onToggleAll} + disabled={isImporting} + className="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 cursor-pointer" + /> +

+ Files to import +

+
+
+ {audioFiles.map((file) => { + const isChecked = checkedFiles.has(file.name); + return ( + + ); + })}
@@ -149,7 +179,7 @@ export function ConfirmPhase({