forked from akai/readest
Resolves #4615. Re-importing updated serials left the app-generated Books/<hash>/ folder (config.json reading progress/notes, nav.json, cover) on disk after a normal delete, forcing a manual cleanup. "Purge Data" now does a Cloud & Device delete AND wipes the whole directory in one action. The book detail action row is redesigned to Edit · Download · Upload · Delete · More (hamburger): - Goodreads, Share, and Export move into the hamburger "More" menu. - Share is enabled only when signed in and the local file exists; Export is enabled when the local file exists (kept on every platform since the bottom-bar Send is mobile/macOS-only). - Purge Data is the red entry in the Delete menu, behind a strong confirm. Implementation: - DeleteAction gains 'purge'; cloudService.deleteBook('purge') removes the in-place source file and removeDir's the whole Books/<hash>/ folder, clearing downloadedAt and leaving the tombstone + queued cloud delete to the page (mirrors 'both'/'local'). - The library page wires handleBookDelete('purge'); BookDetailModal adds the purge confirm config + share/export handlers and gates Share on auth. Tests: cloud-service purge cases, BookDetailView More-menu + Purge tests. i18n: 9 new keys translated across all 33 locales. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import { Book } from '@/types/book';
|
||||
import { getBookWithUpdatedMetadata } from '@/utils/book';
|
||||
import { BookMetadata } from '@/libs/document';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useMetadataEdit } from './useMetadataEdit';
|
||||
@@ -27,6 +28,7 @@ interface BookDetailModalProps {
|
||||
handleBookDelete?: (book: Book) => void;
|
||||
handleBookDeleteCloudBackup?: (book: Book) => void;
|
||||
handleBookDeleteLocalCopy?: (book: Book) => void;
|
||||
handleBookPurge?: (book: Book) => void;
|
||||
handleBookMetadataUpdate?: (book: Book, updatedMetadata: BookMetadata) => void;
|
||||
}
|
||||
|
||||
@@ -45,10 +47,12 @@ const BookDetailModal: React.FC<BookDetailModalProps> = ({
|
||||
handleBookDelete,
|
||||
handleBookDeleteCloudBackup,
|
||||
handleBookDeleteLocalCopy,
|
||||
handleBookPurge,
|
||||
handleBookMetadataUpdate,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { user } = useAuth();
|
||||
const { safeAreaInsets } = useThemeStore();
|
||||
const [activeDeleteAction, setActiveDeleteAction] = useState<DeleteAction | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -95,6 +99,13 @@ const BookDetailModal: React.FC<BookDetailModalProps> = ({
|
||||
message: _('Are you sure to delete the local copy of the selected book?'),
|
||||
handler: handleBookDeleteLocalCopy,
|
||||
},
|
||||
purge: {
|
||||
title: _('Purge Book Data'),
|
||||
message: _(
|
||||
'This permanently erases the book and all its data, including reading progress, notes, and bookmarks. This cannot be undone.',
|
||||
),
|
||||
handler: handleBookPurge,
|
||||
},
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -168,19 +179,14 @@ const BookDetailModal: React.FC<BookDetailModalProps> = ({
|
||||
const handleDelete = () => handleDeleteAction('both');
|
||||
const handleDeleteCloudBackup = () => handleDeleteAction('cloud');
|
||||
const handleDeleteLocalCopy = () => handleDeleteAction('local');
|
||||
const handlePurge = () => handleDeleteAction('purge');
|
||||
|
||||
const handleRedownload = async () => {
|
||||
const handleShare = () => {
|
||||
// Close this modal first, then hand off to the share dialog hosted by
|
||||
// Bookshelf (it owns the login gate + ShareBookDialog). Mirrors how the
|
||||
// bookshelf context menu dispatches the same event.
|
||||
handleClose();
|
||||
if (handleBookDownload) {
|
||||
handleBookDownload(book, { redownload: true, queued: false });
|
||||
}
|
||||
};
|
||||
|
||||
const handleReupload = async () => {
|
||||
handleClose();
|
||||
if (handleBookUpload) {
|
||||
handleBookUpload(book);
|
||||
}
|
||||
eventDispatcher.dispatch('show-share-dialog', { book });
|
||||
};
|
||||
|
||||
const handleBookExport = async () => {
|
||||
@@ -197,6 +203,25 @@ const BookDetailModal: React.FC<BookDetailModalProps> = ({
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleRedownload = async () => {
|
||||
handleClose();
|
||||
if (handleBookDownload) {
|
||||
handleBookDownload(book, { redownload: true, queued: false });
|
||||
}
|
||||
};
|
||||
|
||||
const handleReupload = async () => {
|
||||
handleClose();
|
||||
if (handleBookUpload) {
|
||||
handleBookUpload(book);
|
||||
}
|
||||
};
|
||||
|
||||
// Sharing uploads the book to the Readest backend and mints a public link, so
|
||||
// it needs a signed-in user and a resolvable on-disk file. `fileSize` is only
|
||||
// non-null when getBookFileSize could actually open the local file.
|
||||
const shareEnabled = !!user && fileSize !== null;
|
||||
|
||||
const currentDeleteConfig = activeDeleteAction ? deleteConfigs[activeDeleteAction] : null;
|
||||
|
||||
return (
|
||||
@@ -235,14 +260,17 @@ const BookDetailModal: React.FC<BookDetailModalProps> = ({
|
||||
book={displayBook}
|
||||
metadata={bookMeta}
|
||||
fileSize={fileSize}
|
||||
shareEnabled={shareEnabled}
|
||||
onEdit={handleBookMetadataUpdate ? handleEditMetadata : undefined}
|
||||
onDelete={handleBookDelete ? handleDelete : undefined}
|
||||
onDeleteCloudBackup={
|
||||
handleBookDeleteCloudBackup ? handleDeleteCloudBackup : undefined
|
||||
}
|
||||
onDeleteLocalCopy={handleBookDeleteLocalCopy ? handleDeleteLocalCopy : undefined}
|
||||
onPurge={handleBookPurge ? handlePurge : undefined}
|
||||
onDownload={handleBookDownload ? handleRedownload : undefined}
|
||||
onUpload={handleBookUpload ? handleReupload : undefined}
|
||||
onShare={handleShare}
|
||||
onExport={handleBookExport}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -5,11 +5,10 @@ import {
|
||||
MdOutlineCloudUpload,
|
||||
MdOutlineDelete,
|
||||
MdOutlineEdit,
|
||||
MdSaveAlt,
|
||||
MdMenu,
|
||||
MdExpandMore,
|
||||
MdExpandLess,
|
||||
} from 'react-icons/md';
|
||||
import { FaGoodreads } from 'react-icons/fa';
|
||||
|
||||
import { Book } from '@/types/book';
|
||||
import { BookMetadata } from '@/libs/document';
|
||||
@@ -35,12 +34,15 @@ interface BookDetailViewProps {
|
||||
book: Book;
|
||||
metadata: BookMetadata | null;
|
||||
fileSize: number | null;
|
||||
shareEnabled?: boolean;
|
||||
onEdit?: () => void;
|
||||
onDelete?: () => void;
|
||||
onDeleteCloudBackup?: () => void;
|
||||
onDeleteLocalCopy?: () => void;
|
||||
onPurge?: () => void;
|
||||
onDownload?: () => void;
|
||||
onUpload?: () => void;
|
||||
onShare?: () => void;
|
||||
onExport?: () => void;
|
||||
}
|
||||
|
||||
@@ -48,18 +50,25 @@ const BookDetailView: React.FC<BookDetailViewProps> = ({
|
||||
book,
|
||||
metadata,
|
||||
fileSize,
|
||||
shareEnabled,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onDeleteCloudBackup,
|
||||
onDeleteLocalCopy,
|
||||
onPurge,
|
||||
onDownload,
|
||||
onUpload,
|
||||
onShare,
|
||||
onExport,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const { envConfig } = useEnv();
|
||||
const { settings } = useSettingsStore();
|
||||
|
||||
// Export and Share both read the book file off disk; `fileSize` is only
|
||||
// non-null when getBookFileSize could actually open the local copy.
|
||||
const hasLocalFile = fileSize !== null;
|
||||
|
||||
const toggleSeriesCollapse = () => {
|
||||
saveSysSettings(envConfig, 'metadataSeriesCollapsed', !settings.metadataSeriesCollapsed);
|
||||
};
|
||||
@@ -101,12 +110,16 @@ const BookDetailView: React.FC<BookDetailViewProps> = ({
|
||||
<MdOutlineEdit className='hover:fill-blue-500' />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => openExternalUrl(getGoodreadsSearchUrl(getBookGoodreadsQuery(book)))}
|
||||
title={_('Search on Goodreads')}
|
||||
>
|
||||
<FaGoodreads className='fill-base-content' />
|
||||
</button>
|
||||
{book.uploadedAt && onDownload && (
|
||||
<button onClick={onDownload} title={_('Download from Cloud')}>
|
||||
<MdOutlineCloudDownload className='fill-base-content' />
|
||||
</button>
|
||||
)}
|
||||
{book.downloadedAt && onUpload && (
|
||||
<button onClick={onUpload} title={_('Upload to Cloud')}>
|
||||
<MdOutlineCloudUpload className='fill-base-content' />
|
||||
</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<Dropdown
|
||||
label={_('Delete Book Options')}
|
||||
@@ -140,24 +153,65 @@ const BookDetailView: React.FC<BookDetailViewProps> = ({
|
||||
onClick={onDeleteLocalCopy}
|
||||
disabled={!book.downloadedAt}
|
||||
/>
|
||||
{onPurge && (
|
||||
<MenuItem
|
||||
noIcon
|
||||
transient
|
||||
label={_('Purge Data')}
|
||||
labelClass='text-red-500'
|
||||
tooltip={_('Erase the book and all its data, including reading progress')}
|
||||
onClick={onPurge}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Dropdown>
|
||||
)}
|
||||
{book.uploadedAt && onDownload && (
|
||||
<button onClick={onDownload} title={_('Download from Cloud')}>
|
||||
<MdOutlineCloudDownload className='fill-base-content' />
|
||||
</button>
|
||||
)}
|
||||
{book.downloadedAt && onUpload && (
|
||||
<button onClick={onUpload} title={_('Upload to Cloud')}>
|
||||
<MdOutlineCloudUpload className='fill-base-content' />
|
||||
</button>
|
||||
)}
|
||||
{book.downloadedAt && onExport && (
|
||||
<button onClick={onExport} title={_('Export Book')}>
|
||||
<MdSaveAlt className='fill-base-content' />
|
||||
</button>
|
||||
)}
|
||||
<Dropdown
|
||||
label={_('More Actions')}
|
||||
className='dropdown-bottom dropdown-center flex justify-center'
|
||||
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
|
||||
toggleButton={<MdMenu className='fill-base-content' />}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'more-menu dropdown-content no-triangle !relative',
|
||||
'border-base-300 !bg-base-200 z-20 mt-1 max-w-[90vw] shadow-2xl',
|
||||
)}
|
||||
>
|
||||
<MenuItem
|
||||
noIcon
|
||||
transient
|
||||
label={_('Search on Goodreads')}
|
||||
onClick={() =>
|
||||
openExternalUrl(getGoodreadsSearchUrl(getBookGoodreadsQuery(book)))
|
||||
}
|
||||
/>
|
||||
{onShare && (
|
||||
<MenuItem
|
||||
noIcon
|
||||
transient
|
||||
label={_('Share Book')}
|
||||
disabled={!shareEnabled}
|
||||
tooltip={
|
||||
shareEnabled
|
||||
? undefined
|
||||
: _('Sign in and make the book available to share it')
|
||||
}
|
||||
onClick={onShare}
|
||||
/>
|
||||
)}
|
||||
{onExport && (
|
||||
<MenuItem
|
||||
noIcon
|
||||
transient
|
||||
label={_('Export Book')}
|
||||
disabled={!hasLocalFile}
|
||||
tooltip={hasLocalFile ? undefined : _('Download the book to export it')}
|
||||
onClick={onExport}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user