Files
readest/apps/readest-app/src/app/user/components/StorageManager.tsx
T
Huang Xin 981579c255 feat(sync): cross-device custom font sync (#4077)
* refactor(sync): kind-agnostic replica primitives

Extract dict-only sync into shared primitives (registry, pull/apply
orchestrator, persist env, schema allowlist) so other kinds can plug
in. Companion changes: per-replica Storage Manager grouping,
useReplicaPull boot-race recovery, manifest=null reconciliation on
every boot pull, copyFile takes explicit srcBase + dstBase, settled-
event helpers, lenient webDownload Content-Length (R2/S3 signed URLs
commonly omit it), and generic "File" transfer toast copy any replica
kind can share.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(sync): cross-device custom font sync

Plug the font replica adapter into the kind-agnostic primitives:
font store gains replica wiring, custom font import publishes the
replica row + queues a binary upload, and bootstrap registers the
font adapter and download-complete handler.

Includes legacy flat-path migration so pre-existing fonts sync
without re-import, full @font-face activation on auto-download
(load + mount the rule, mirroring manual import), and a fix to
createCustomFont so contentId / bundleDir / byteSize survive the
trip through addFont — otherwise import-time publish silently
no-oped on missing contentId.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 08:28:44 +02:00

588 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import clsx from 'clsx';
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useLibraryStore } from '@/store/libraryStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useLibrary } from '@/hooks/useLibrary';
import {
listFiles,
getStorageStats,
purgeFiles,
type FileRecord,
type StorageStats,
type ListFilesParams,
} from '@/libs/storage';
import { eventDispatcher } from '@/utils/event';
import { debounce } from '@/utils/debounce';
import Spinner from '@/components/Spinner';
import Alert from '@/components/Alert';
const StorageManager = () => {
const _ = useTranslation();
const { appService } = useEnv();
const { libraryLoaded } = useLibrary();
const { safeAreaInsets } = useThemeStore();
const [loading, setLoading] = useState(false);
const [filesLoaded, setFilesLoaded] = useState(false);
const [files, setFiles] = useState<FileRecord[]>([]);
const [stats, setStats] = useState<StorageStats | null>(null);
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const [searchQuery, setSearchQuery] = useState('');
const [searchInput, setSearchInput] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [sortBy, setSortBy] = useState<ListFilesParams['sortBy']>('created_at');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const [showConfirmDelete, setShowConfirmDelete] = useState(false);
const [expandedBooks, setExpandedBooks] = useState<Set<string>>(new Set());
const loadFiles = useCallback(async () => {
setLoading(true);
try {
const params: ListFilesParams = {
page: currentPage,
pageSize: 20,
sortBy,
sortOrder,
};
if (searchQuery.trim()) {
params.search = searchQuery.trim();
}
const response = await listFiles(params);
setFiles(response.files);
setTotalPages(response.totalPages);
setFilesLoaded(true);
} catch (error) {
console.error('Failed to load files:', error);
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('Failed to load files'),
});
} finally {
setLoading(false);
}
}, [currentPage, sortBy, sortOrder, searchQuery, _]);
const loadStats = useCallback(async () => {
setLoading(true);
try {
const statsData = await getStorageStats();
setStats(statsData);
} catch (error) {
console.error('Failed to load stats:', error);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadFiles();
loadStats();
}, [loadFiles, loadStats]);
// Group files by their natural row identity:
// - book_hash for book uploads
// - replica_kind:replica_id for replica binaries (each dictionary,
// font, etc. clusters under its own row)
// - 'no-group' as a final catch-all
const groupKeyFor = (file: FileRecord): string => {
if (file.book_hash) return file.book_hash;
if (file.replica_id) return `replica:${file.replica_kind ?? '?'}:${file.replica_id}`;
return 'no-group';
};
const groupedFiles = React.useMemo(() => {
const groups = new Map<string, FileRecord[]>();
files.forEach((file) => {
const key = groupKeyFor(file);
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(file);
});
return groups;
}, [files]);
const getFileName = (fileKey: string): string => {
const parts = fileKey.split('/');
return parts[parts.length - 1] || fileKey;
};
const isCoverFile = (file: FileRecord): boolean => {
return getFileName(file.file_key).toLowerCase() === 'cover.png';
};
// Pick the file whose name labels the group:
// - book groups: first non-cover file (existing behavior).
// - replica groups: the largest file. The primary binary
// (dictionary .mdx, font .ttf, etc.) dominates the bundle by
// size, so labelling by it surfaces the natural identifier
// rather than a tiny sidecar like dacihai.css.
const getMainBookFile = (groupFiles: FileRecord[]): FileRecord | null => {
if (groupFiles.length === 0) return null;
const isReplicaGroup = !groupFiles[0]!.book_hash && !!groupFiles[0]!.replica_id;
if (isReplicaGroup) {
return groupFiles.reduce((a, b) => (b.file_size > a.file_size ? b : a));
}
return groupFiles.find((f) => !isCoverFile(f)) || groupFiles[0] || null;
};
// Get all files for a book including covers
const getAllBookFiles = (bookFiles: FileRecord[]): FileRecord[] => {
return bookFiles;
};
const toggleBookExpansion = (bookHash: string) => {
const newExpanded = new Set(expandedBooks);
if (newExpanded.has(bookHash)) {
newExpanded.delete(bookHash);
} else {
newExpanded.add(bookHash);
}
setExpandedBooks(newExpanded);
};
const handleSelectAll = (checked: boolean) => {
if (checked) {
setSelectedFiles(new Set(files.map((f) => f.file_key)));
} else {
setSelectedFiles(new Set());
}
};
const handleSelectBook = (bookFiles: FileRecord[], checked: boolean) => {
const newSelected = new Set(selectedFiles);
const allBookFiles = getAllBookFiles(bookFiles);
allBookFiles.forEach((file) => {
if (checked) {
newSelected.add(file.file_key);
} else {
newSelected.delete(file.file_key);
}
});
setSelectedFiles(newSelected);
};
const handleSelectFile = (fileKey: string, checked: boolean) => {
const newSelected = new Set(selectedFiles);
if (checked) {
newSelected.add(fileKey);
} else {
newSelected.delete(fileKey);
}
setSelectedFiles(newSelected);
};
const isBookSelected = (bookFiles: FileRecord[]): boolean => {
const allBookFiles = getAllBookFiles(bookFiles);
return allBookFiles.length > 0 && allBookFiles.every((f) => selectedFiles.has(f.file_key));
};
const isBookPartiallySelected = (bookFiles: FileRecord[]): boolean => {
const allBookFiles = getAllBookFiles(bookFiles);
const selectedCount = allBookFiles.filter((f) => selectedFiles.has(f.file_key)).length;
return selectedCount > 0 && selectedCount < allBookFiles.length;
};
const handleDeleteSelected = async () => {
if (selectedFiles.size === 0) return;
if (!libraryLoaded || !appService) return;
setLoading(true);
try {
const fileKeys = Array.from(selectedFiles);
const fileRecords = files.filter((f) => selectedFiles.has(f.file_key));
const selectedBookHashes = new Set(
fileRecords.map((f) => f.book_hash).filter((hash): hash is string => !!hash),
);
const result = await purgeFiles(fileKeys, true);
const { library, setLibrary } = useLibraryStore.getState();
library
.filter((book) => selectedBookHashes.has(book.hash))
.forEach((book) => {
book.uploadedAt = null;
book.updatedAt = Date.now();
});
setLibrary(library);
appService.saveLibraryBooks(library);
if (result.deletedCount > 0) {
await loadFiles();
await loadStats();
setSelectedFiles(new Set());
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('Deleted {{count}} file(s)', { count: result.deletedCount }),
});
if (result.failedCount > 0) {
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('Failed to delete {{count}} file(s)', { count: result.failedCount }),
});
}
}
} catch (error) {
console.error('Failed to delete files:', error);
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('Failed to delete files'),
});
} finally {
setLoading(false);
setShowConfirmDelete(false);
}
};
const handleSearchChange = useMemo(
() =>
debounce((value: string) => {
setSearchQuery(value);
setCurrentPage(1);
}, 1000),
[setSearchQuery, setCurrentPage],
);
useEffect(() => {
handleSearchChange(searchInput);
}, [searchInput, handleSearchChange]);
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1e6) return `${Math.round((bytes / 1024) * 10) / 10} KB`;
const inGB = bytes > 1e9;
const value = bytes / 1024 / 1024 / (inGB ? 1024 : 1);
return `${Math.round(value * 10) / 10} ${inGB ? 'GB' : 'MB'}`;
};
const formatDate = (dateString: string): string => {
return new Date(dateString).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
const getBookTotalSize = (bookFiles: FileRecord[]): number => {
return bookFiles.reduce((sum, file) => sum + file.file_size, 0);
};
const isAllSelected = files.length > 0 && selectedFiles.size === files.length;
return (
<div className='flex flex-col gap-6'>
{/* Stats Section */}
{stats ? (
<div className='bg-base-100 border-base-300 rounded-lg border p-4'>
<h3 className='text-base-content mb-4 text-lg font-semibold'>
{_('Cloud Storage Usage')}
</h3>
<div className='grid grid-cols-2 gap-4 sm:grid-cols-4'>
<div>
<div className='text-base-content/60 text-sm'>{_('Total Files')}</div>
<div className='text-base-content text-xl font-semibold'>{stats.totalFiles}</div>
</div>
<div>
<div className='text-base-content/60 text-sm'>{_('Total Size')}</div>
<div className='text-base-content text-xl font-semibold'>
{formatFileSize(stats.totalSize)}
</div>
</div>
<div>
<div className='text-base-content/60 text-sm'>{_('Quota')}</div>
<div className='text-base-content text-xl font-semibold'>
{formatFileSize(stats.quota)}
</div>
</div>
<div>
<div className='text-base-content/60 text-sm'>{_('Used')}</div>
<div className='text-base-content text-xl font-semibold'>
{stats.usagePercentage}%
</div>
</div>
</div>
<div className='bg-base-300 mt-4 h-2 w-full overflow-hidden rounded-full'>
<div
className='bg-primary h-full transition-all'
style={{ width: `${Math.min(stats.usagePercentage, 100)}%` }}
/>
</div>
</div>
) : (
<div className='bg-base-100 border-base-300 rounded-lg border p-4'>
<div className='skeleton mb-4 h-6 w-32'></div>
<div className='grid grid-cols-2 gap-4 sm:grid-cols-4'>
<div className='skeleton h-16 w-full'></div>
<div className='skeleton h-16 w-full'></div>
<div className='skeleton h-16 w-full'></div>
<div className='skeleton h-16 w-full'></div>
</div>
<div className='skeleton mt-4 h-2 w-full rounded-full'></div>
</div>
)}
{/* Files Section */}
<div className='bg-base-100 border-base-300 rounded-lg border'>
<div className='border-base-300 flex flex-col gap-4 border-b p-4 sm:flex-row sm:items-center sm:justify-between'>
<div className='hidden items-center justify-center sm:flex'>
<h3 className='text-base-content text-lg font-semibold'>{_('Files')}</h3>
</div>
<div className='flex flex-col gap-2 sm:flex-row'>
<input
type='text'
placeholder={_('Search files...')}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className='input input-bordered input-sm w-full sm:w-64'
disabled={loading}
/>
<select
value={`${sortBy}-${sortOrder}`}
onChange={(e) => {
const [newSortBy, newSortOrder] = e.target.value.split('-');
setSortBy(newSortBy as ListFilesParams['sortBy']);
setSortOrder(newSortOrder as 'asc' | 'desc');
}}
disabled={loading}
className='select select-bordered select-sm'
>
<option value='created_at-desc'>{_('Newest First')}</option>
<option value='created_at-asc'>{_('Oldest First')}</option>
<option value='file_size-desc'>{_('Largest First')}</option>
<option value='file_size-asc'>{_('Smallest First')}</option>
<option value='file_key-asc'>{_('Name A-Z')}</option>
<option value='file_key-desc'>{_('Name Z-A')}</option>
</select>
</div>
</div>
{/* Actions Bar */}
<div className='bg-base-200 border-base-300 flex items-center justify-between border-b p-4'>
<span className='text-base-content text-sm'>
{_('{{count}} selected', { count: selectedFiles.size })}
</span>
<button
onClick={() => setShowConfirmDelete(true)}
className='btn btn-error btn-sm'
disabled={loading || selectedFiles.size === 0}
>
{_('Delete Selected')}
</button>
</div>
{loading && <Spinner loading />}
{/* Files List - Grouped by Book */}
<div className='w-full'>
<table className='table-sm table w-full [&_td]:px-2 [&_td]:py-1 [&_th]:px-2 [&_th]:py-1'>
<thead className='h-10'>
<tr>
<th className='w-12'>
<div className='flex items-center'>
<input
type='checkbox'
checked={isAllSelected}
onChange={(e) => handleSelectAll(e.target.checked)}
className='checkbox checkbox-sm'
disabled={!filesLoaded || loading}
/>
</div>
</th>
<th className='!ps-0'>{_('File Name')}</th>
<th className='hidden sm:table-cell'>{_('Size')}</th>
<th className='hidden sm:table-cell'>{_('Created')}</th>
</tr>
</thead>
<tbody>
{!filesLoaded ? (
<>
{[...Array(5)].map((_, i) => (
<tr key={i}>
<td className='min-w-16'>
<div className='skeleton h-5 w-5'></div>
</td>
<td className='max-w-0 !ps-0 sm:w-[80%]'>
<div className='flex flex-col gap-2'>
<div className='skeleton h-4 w-3/4'></div>
<div className='skeleton h-3 w-1/2 sm:hidden'></div>
</div>
</td>
<td className='hidden sm:table-cell'>
<div className='skeleton h-4 w-16'></div>
</td>
<td className='hidden sm:table-cell'>
<div className='skeleton h-4 w-20'></div>
</td>
</tr>
))}
</>
) : groupedFiles.size === 0 ? (
<tr>
<td colSpan={4} className='text-center'>
<div className='text-base-content/60 py-8'>
{searchQuery ? _('No files found') : _('No files uploaded yet')}
</div>
</td>
</tr>
) : (
Array.from(groupedFiles.entries()).map(([bookHash, bookFiles]) => {
const mainFile = getMainBookFile(bookFiles);
const isExpanded = expandedBooks.has(bookHash);
const hasMultipleFiles = bookFiles.length > 1;
const bookSelected = isBookSelected(bookFiles);
const bookPartiallySelected = isBookPartiallySelected(bookFiles);
if (!mainFile) return null;
return (
<React.Fragment key={bookHash}>
{/* Main book row */}
<tr className='hover'>
<td>
<div className='flex items-center gap-1'>
<input
type='checkbox'
checked={bookSelected}
ref={(el) => {
if (el) el.indeterminate = bookPartiallySelected;
}}
onChange={(e) => handleSelectBook(bookFiles, e.target.checked)}
disabled={loading}
className='checkbox checkbox-sm'
/>
{hasMultipleFiles && (
<button
onClick={() => toggleBookExpansion(bookHash)}
className='btn btn-ghost btn-xs'
>
{isExpanded ? '' : '+'}
</button>
)}
</div>
</td>
<td className='max-w-0 !ps-0 sm:w-[80%]'>
<div className='flex flex-col'>
<div className='flex items-center gap-2'>
<span className='text-base-content block max-w-full truncate font-medium'>
{getFileName(mainFile.file_key)}
</span>
{hasMultipleFiles && (
<span className='text-base-content/60 flex-shrink-0 whitespace-nowrap text-xs'>
({bookFiles.length} {_('files')})
</span>
)}
</div>
<span className='text-base-content/60 text-xs sm:hidden'>
{formatFileSize(getBookTotalSize(bookFiles))} ·{' '}
{formatDate(mainFile.created_at)}
</span>
</div>
</td>
<td className='hidden whitespace-nowrap sm:table-cell'>
{formatFileSize(getBookTotalSize(bookFiles))}
</td>
<td className='hidden whitespace-nowrap sm:table-cell'>
{formatDate(mainFile.created_at)}
</td>
</tr>
{/* Expanded files (excluding covers unless expanded) */}
{isExpanded &&
bookFiles.map((file) => (
<tr key={file.file_key} className='hover bg-base-200/50'>
<td>
<div className='pl-4'>
<input
type='checkbox'
checked={selectedFiles.has(file.file_key)}
onChange={(e) =>
handleSelectFile(file.file_key, e.target.checked)
}
disabled={loading}
className='checkbox checkbox-sm'
/>
</div>
</td>
<td className='max-w-0 !ps-0 sm:w-[80%]'>
<div className='flex flex-col'>
<span className='text-base-content/80 text-xs'>
{getFileName(file.file_key)}
</span>
</div>
</td>
<td className='hidden sm:table-cell'>
{formatFileSize(file.file_size)}
</td>
<td className='hidden sm:table-cell'>{formatDate(file.created_at)}</td>
</tr>
))}
</React.Fragment>
);
})
)}
</tbody>
</table>
</div>
{/* Pagination */}
<div className='border-base-300 flex items-center justify-between border-t p-4'>
<button
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
className='btn btn-sm'
>
{_('Previous')}
</button>
<span className='text-base-content text-sm'>
{_('Page {{current}} of {{total}}', { current: currentPage, total: totalPages })}
</span>
<button
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className='btn btn-sm'
>
{_('Next')}
</button>
</div>
</div>
{/* Confirm Delete Modal */}
{showConfirmDelete && (
<div
className={clsx('fixed bottom-0 left-0 right-0 z-50 flex justify-center')}
style={{
paddingBottom: `${(safeAreaInsets?.bottom || 0) + 16}px`,
}}
>
<Alert
title={_('Confirm Deletion')}
message={_('Are you sure to delete {{count}} selected file(s)?', {
count: selectedFiles.size,
})}
onCancel={() => {
setShowConfirmDelete(false);
}}
onConfirm={() => {
handleDeleteSelected();
setShowConfirmDelete(false);
}}
/>
</div>
)}
</div>
);
};
export default StorageManager;