feat: toggle finished manually (#3091)
This commit is contained in:
@@ -118,13 +118,16 @@ const BookItem: React.FC<BookItemProps> = ({
|
||||
</h4>
|
||||
)}
|
||||
<div
|
||||
className={clsx('flex items-center', book.progress ? 'justify-between' : 'justify-end')}
|
||||
className={clsx(
|
||||
'flex items-center',
|
||||
book.progress || book.readingStatus ? 'justify-between' : 'justify-end',
|
||||
)}
|
||||
style={{
|
||||
height: `${iconSize15}px`,
|
||||
minHeight: `${iconSize15}px`,
|
||||
}}
|
||||
>
|
||||
{book.progress && <ReadingProgress book={book} />}
|
||||
{(book.progress || book.readingStatus) && <ReadingProgress book={book} />}
|
||||
<div className='flex items-center justify-center gap-x-2'>
|
||||
{!appService?.isMobile && (
|
||||
<button
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as React from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { PiPlus } from 'react-icons/pi';
|
||||
import { Book } from '@/types/book';
|
||||
import { Book, ReadingStatus } from '@/types/book';
|
||||
import { LibraryCoverFitType, LibraryViewModeType } from '@/types/settings';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
@@ -59,7 +59,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { appService } = useEnv();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { settings } = useSettingsStore();
|
||||
const { safeAreaInsets } = useThemeStore();
|
||||
|
||||
@@ -74,6 +74,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
const [showSelectModeActions, setShowSelectModeActions] = useState(false);
|
||||
const [bookIdsToDelete, setBookIdsToDelete] = useState<string[]>([]);
|
||||
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
|
||||
const [showStatusAlert, setShowStatusAlert] = useState(false);
|
||||
const [showGroupingModal, setShowGroupingModal] = useState(false);
|
||||
const [importBookUrl] = useState(searchParams?.get('url') || '');
|
||||
|
||||
@@ -82,7 +83,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
const iconSize15 = useResponsiveSize(15);
|
||||
const autofocusRef = useAutoFocus<HTMLDivElement>();
|
||||
|
||||
const { setCurrentBookshelf, setLibrary } = useLibraryStore();
|
||||
const { setCurrentBookshelf, setLibrary, updateBooks } = useLibraryStore();
|
||||
const { setSelectedBooks, getSelectedBooks, toggleSelectedBook } = useLibraryStore();
|
||||
const { getGroupName } = useLibraryStore();
|
||||
|
||||
@@ -247,6 +248,39 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
setShowGroupingModal(true);
|
||||
};
|
||||
|
||||
const showStatusSelection = () => {
|
||||
setShowSelectModeActions(false);
|
||||
setShowStatusAlert(true);
|
||||
};
|
||||
|
||||
const updateBooksStatus = async (status: ReadingStatus | undefined) => {
|
||||
const selectedIds = getSelectedBooks();
|
||||
const booksToUpdate: Book[] = [];
|
||||
|
||||
for (const id of selectedIds) {
|
||||
const book = filteredBooks.find((b) => b.hash === id);
|
||||
if (book) {
|
||||
booksToUpdate.push({ ...book, readingStatus: status, updatedAt: Date.now() });
|
||||
}
|
||||
}
|
||||
|
||||
if (booksToUpdate.length > 0) {
|
||||
await updateBooks(envConfig, booksToUpdate);
|
||||
}
|
||||
|
||||
setSelectedBooks([]);
|
||||
setShowStatusAlert(false);
|
||||
setShowSelectModeActions(true);
|
||||
};
|
||||
|
||||
const handleUpdateReadingStatus = useCallback(
|
||||
async (book: Book, status: ReadingStatus | undefined) => {
|
||||
const updatedBook = { ...book, readingStatus: status, updatedAt: Date.now() };
|
||||
await updateBooks(envConfig, [updatedBook]);
|
||||
},
|
||||
[envConfig, updateBooks],
|
||||
);
|
||||
|
||||
const handleDeleteBooksIntent = (event: CustomEvent) => {
|
||||
const { ids } = event.detail;
|
||||
setBookIdsToDelete(ids);
|
||||
@@ -318,6 +352,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
handleBookDelete={handleBookDelete}
|
||||
handleSetSelectMode={handleSetSelectMode}
|
||||
handleShowDetailsBook={handleShowDetailsBook}
|
||||
handleUpdateReadingStatus={handleUpdateReadingStatus}
|
||||
transferProgress={
|
||||
'hash' in item ? booksTransferProgress[(item as Book).hash] || null : null
|
||||
}
|
||||
@@ -363,6 +398,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
onOpen={openSelectedBooks}
|
||||
onGroup={groupSelectedBooks}
|
||||
onDetails={openBookDetails}
|
||||
onStatus={showStatusSelection}
|
||||
onDelete={deleteSelectedBooks}
|
||||
onCancel={() => handleSetSelectMode(false)}
|
||||
/>
|
||||
@@ -405,6 +441,90 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showStatusAlert && (
|
||||
<div
|
||||
className={clsx('status-alert fixed bottom-0 left-0 right-0 z-50 flex justify-center')}
|
||||
style={{
|
||||
paddingBottom: `${(safeAreaInsets?.bottom || 0) + 16}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center justify-between',
|
||||
'bg-base-200/95 rounded-2xl p-4 backdrop-blur-sm',
|
||||
'border-base-content/10 border',
|
||||
'shadow-lg',
|
||||
'w-auto max-w-[90vw]',
|
||||
'flex-col gap-4 sm:flex-row',
|
||||
)}
|
||||
>
|
||||
<div className='text-sm font-medium'>
|
||||
{_('Set status for {{count}} book(s)', { count: getSelectedBooks().length })}
|
||||
</div>
|
||||
<div className='flex flex-wrap items-center justify-end gap-2'>
|
||||
<button
|
||||
className={clsx(
|
||||
'flex items-center gap-2 rounded-full px-4 py-2',
|
||||
'bg-base-300 text-base-content',
|
||||
'hover:bg-base-content/10',
|
||||
'border-base-content/10 border',
|
||||
'shadow-sm',
|
||||
'transition-all duration-200 ease-out',
|
||||
'active:scale-[0.97]',
|
||||
)}
|
||||
onClick={() => {
|
||||
setShowStatusAlert(false);
|
||||
setShowSelectModeActions(true);
|
||||
}}
|
||||
>
|
||||
<span className='text-sm font-medium'>{_('Cancel')}</span>
|
||||
</button>
|
||||
<button
|
||||
className={clsx(
|
||||
'flex items-center gap-2 rounded-full px-4 py-2',
|
||||
'bg-amber-500/15 text-amber-600 dark:text-amber-400',
|
||||
'hover:bg-amber-500/25',
|
||||
'border border-amber-500/20',
|
||||
'shadow-sm',
|
||||
'transition-all duration-200 ease-out',
|
||||
'active:scale-[0.97]',
|
||||
)}
|
||||
onClick={() => updateBooksStatus('unread')}
|
||||
>
|
||||
<span className='text-sm font-medium'>{_('Mark as Unread')}</span>
|
||||
</button>
|
||||
<button
|
||||
className={clsx(
|
||||
'flex items-center gap-2 rounded-full px-4 py-2',
|
||||
'bg-success/15 text-success',
|
||||
'hover:bg-success/25',
|
||||
'border-success/20 border',
|
||||
'shadow-sm',
|
||||
'transition-all duration-200 ease-out',
|
||||
'active:scale-[0.97]',
|
||||
)}
|
||||
onClick={() => updateBooksStatus('finished')}
|
||||
>
|
||||
<span className='text-sm font-medium'>{_('Mark as Finished')}</span>
|
||||
</button>
|
||||
<button
|
||||
className={clsx(
|
||||
'flex items-center gap-2 rounded-full px-4 py-2',
|
||||
'bg-base-300 text-base-content',
|
||||
'hover:bg-base-content/10',
|
||||
'border-base-content/10 border',
|
||||
'shadow-sm',
|
||||
'transition-all duration-200 ease-out',
|
||||
'active:scale-[0.97]',
|
||||
)}
|
||||
onClick={() => updateBooksStatus(undefined)}
|
||||
>
|
||||
<span className='text-sm font-medium'>{_('Clear Status')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@ import { throttle } from '@/utils/throttle';
|
||||
import { LibraryCoverFitType, LibraryViewModeType } from '@/types/settings';
|
||||
import { BOOK_UNGROUPED_ID, BOOK_UNGROUPED_NAME } from '@/services/constants';
|
||||
import { FILE_REVEAL_LABELS, FILE_REVEAL_PLATFORMS } from '@/utils/os';
|
||||
import { Book, BooksGroup } from '@/types/book';
|
||||
import { Book, BooksGroup, ReadingStatus } from '@/types/book';
|
||||
import { md5Fingerprint } from '@/utils/md5';
|
||||
import BookItem from './BookItem';
|
||||
import GroupItem from './GroupItem';
|
||||
@@ -99,6 +99,7 @@ interface BookshelfItemProps {
|
||||
handleBookDelete: (book: Book, syncBooks?: boolean) => Promise<boolean>;
|
||||
handleSetSelectMode: (selectMode: boolean) => void;
|
||||
handleShowDetailsBook: (book: Book) => void;
|
||||
handleUpdateReadingStatus: (book: Book, status: ReadingStatus | undefined) => void;
|
||||
}
|
||||
|
||||
const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
@@ -115,6 +116,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
handleBookDownload,
|
||||
handleSetSelectMode,
|
||||
handleShowDetailsBook,
|
||||
handleUpdateReadingStatus,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
@@ -210,6 +212,24 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
handleGroupBooks();
|
||||
},
|
||||
});
|
||||
const markAsFinishedMenuItem = await MenuItem.new({
|
||||
text: _('Mark as Finished'),
|
||||
action: async () => {
|
||||
handleUpdateReadingStatus(book, 'finished');
|
||||
},
|
||||
});
|
||||
const markAsUnreadMenuItem = await MenuItem.new({
|
||||
text: _('Mark as Unread'),
|
||||
action: async () => {
|
||||
handleUpdateReadingStatus(book, 'unread');
|
||||
},
|
||||
});
|
||||
const clearStatusMenuItem = await MenuItem.new({
|
||||
text: _('Clear Status'),
|
||||
action: async () => {
|
||||
handleUpdateReadingStatus(book, undefined);
|
||||
},
|
||||
});
|
||||
const showBookInFinderMenuItem = await MenuItem.new({
|
||||
text: _(fileRevealLabel),
|
||||
action: async () => {
|
||||
@@ -244,6 +264,15 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
const menu = await Menu.new();
|
||||
menu.append(selectBookMenuItem);
|
||||
menu.append(groupBooksMenuItem);
|
||||
if (book.readingStatus === 'finished') {
|
||||
menu.append(markAsUnreadMenuItem);
|
||||
} else {
|
||||
menu.append(markAsFinishedMenuItem);
|
||||
}
|
||||
// show "Clear Status" option when book has an explicit status set
|
||||
if (book.readingStatus === 'finished' || book.readingStatus === 'unread') {
|
||||
menu.append(clearStatusMenuItem);
|
||||
}
|
||||
menu.append(showBookDetailsMenuItem);
|
||||
menu.append(showBookInFinderMenuItem);
|
||||
if (book.uploadedAt && !book.downloadedAt) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type React from 'react';
|
||||
import { memo, useMemo } from 'react';
|
||||
import type { Book } from '@/types/book';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import StatusBadge from './StatusBadge';
|
||||
|
||||
interface ReadingProgressProps {
|
||||
book: Book;
|
||||
@@ -19,8 +21,25 @@ const getProgressPercentage = (book: Book) => {
|
||||
|
||||
const ReadingProgress: React.FC<ReadingProgressProps> = memo(
|
||||
({ book }) => {
|
||||
const _ = useTranslation();
|
||||
const progressPercentage = useMemo(() => getProgressPercentage(book), [book]);
|
||||
|
||||
if (book.readingStatus === 'finished') {
|
||||
return (
|
||||
<div className='flex justify-start'>
|
||||
<StatusBadge status={book.readingStatus}>{_('Finished')}</StatusBadge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (book.readingStatus === 'unread') {
|
||||
return (
|
||||
<div className='flex justify-start'>
|
||||
<StatusBadge status={book.readingStatus}>{_('Unread')}</StatusBadge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (progressPercentage === null || Number.isNaN(progressPercentage)) {
|
||||
return null;
|
||||
}
|
||||
@@ -38,7 +57,8 @@ const ReadingProgress: React.FC<ReadingProgressProps> = memo(
|
||||
(prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.book.hash === nextProps.book.hash &&
|
||||
prevProps.book.updatedAt === nextProps.book.updatedAt
|
||||
prevProps.book.updatedAt === nextProps.book.updatedAt &&
|
||||
prevProps.book.readingStatus === nextProps.book.readingStatus
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import clsx from 'clsx';
|
||||
import { MdDelete, MdOpenInNew, MdOutlineCancel, MdInfoOutline } from 'react-icons/md';
|
||||
import {
|
||||
MdDelete,
|
||||
MdOpenInNew,
|
||||
MdOutlineCancel,
|
||||
MdInfoOutline,
|
||||
MdCheckCircleOutline,
|
||||
} from 'react-icons/md';
|
||||
import { LuFolderPlus } from 'react-icons/lu';
|
||||
import { useKeyDownActions } from '@/hooks/useKeyDownActions';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
@@ -11,6 +17,7 @@ interface SelectModeActionsProps {
|
||||
onOpen: () => void;
|
||||
onGroup: () => void;
|
||||
onDetails: () => void;
|
||||
onStatus: () => void;
|
||||
onDelete: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
@@ -21,6 +28,7 @@ const SelectModeActions: React.FC<SelectModeActionsProps> = ({
|
||||
onOpen,
|
||||
onGroup,
|
||||
onDetails,
|
||||
onStatus,
|
||||
onDelete,
|
||||
onCancel,
|
||||
}) => {
|
||||
@@ -66,6 +74,16 @@ const SelectModeActions: React.FC<SelectModeActionsProps> = ({
|
||||
<LuFolderPlus />
|
||||
<div>{_('Group')}</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={onStatus}
|
||||
className={clsx(
|
||||
'flex flex-col items-center justify-center gap-1',
|
||||
(!hasSelection || !hasValidBooks) && 'btn-disabled opacity-50',
|
||||
)}
|
||||
>
|
||||
<MdCheckCircleOutline />
|
||||
<div>{_('Status')}</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={onDetails}
|
||||
className={clsx(
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import clsx from 'clsx';
|
||||
import type { ReadingStatus } from '@/types/book';
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status?: ReadingStatus;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const StatusBadge: React.FC<StatusBadgeProps> = ({ status, children, className }) => {
|
||||
if (status !== 'finished' && status !== 'unread') return null;
|
||||
|
||||
const isFinished = status === 'finished';
|
||||
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-flex items-center justify-center',
|
||||
'rounded-[1px] px-0.5',
|
||||
'text-[8px] font-bold uppercase leading-none tracking-wider',
|
||||
'h-3.5',
|
||||
// finished: green/emerald
|
||||
isFinished && 'bg-emerald-100 dark:bg-emerald-900/90',
|
||||
isFinished && 'border border-emerald-300/50 dark:border-emerald-700/50',
|
||||
isFinished && 'text-emerald-700 dark:text-emerald-300',
|
||||
// unread: pastel yellow/amber
|
||||
!isFinished && 'bg-amber-100 dark:bg-amber-900/80',
|
||||
!isFinished && 'border border-amber-300/50 dark:border-amber-700/50',
|
||||
!isFinished && 'text-amber-700 dark:text-amber-300',
|
||||
className,
|
||||
)}
|
||||
role='status'
|
||||
>
|
||||
<span className='relative top-[0.5px]'>{children}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatusBadge;
|
||||
@@ -291,15 +291,33 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
||||
const pagePressInfo = bookData.isFixedLayout ? section : pageinfo;
|
||||
const progress: [number, number] = [pagePressInfo.current + 1, pagePressInfo.total];
|
||||
|
||||
// Update library book progress
|
||||
// calculate progress percentage
|
||||
const progressPercentage = Math.round((progress[0] / progress[1]) * 100);
|
||||
|
||||
// update library book progress
|
||||
const { library, setLibrary } = useLibraryStore.getState();
|
||||
const bookIndex = library.findIndex((b) => b.hash === id);
|
||||
if (bookIndex !== -1) {
|
||||
const updatedLibrary = [...library];
|
||||
const existingBook = updatedLibrary[bookIndex]!;
|
||||
|
||||
// determine new reading status
|
||||
let newReadingStatus = existingBook.readingStatus;
|
||||
|
||||
// auto-clear 'unread' status when user starts reading (progress changes)
|
||||
if (existingBook.readingStatus === 'unread') {
|
||||
newReadingStatus = undefined;
|
||||
}
|
||||
|
||||
// auto mark as 'finished' when progress reaches 100%
|
||||
if (progressPercentage >= 100 && existingBook.readingStatus !== 'finished') {
|
||||
newReadingStatus = 'finished';
|
||||
}
|
||||
|
||||
updatedLibrary[bookIndex] = {
|
||||
...existingBook,
|
||||
progress,
|
||||
readingStatus: newReadingStatus,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
setLibrary(updatedLibrary);
|
||||
|
||||
@@ -14,6 +14,7 @@ export type BookFormat =
|
||||
| 'TXT'
|
||||
| 'MD';
|
||||
export type BookNoteType = 'bookmark' | 'annotation' | 'excerpt';
|
||||
export type ReadingStatus = 'unread' | 'reading' | 'finished';
|
||||
export type HighlightStyle = 'highlight' | 'underline' | 'squiggly';
|
||||
// Predefined highlight colors, can be extended with custom hex colors
|
||||
export type HighlightColor = 'red' | 'yellow' | 'green' | 'blue' | 'violet' | string;
|
||||
@@ -51,6 +52,7 @@ export interface Book {
|
||||
|
||||
lastUpdated?: number; // deprecated in favor of updatedAt
|
||||
progress?: [number, number]; // Add progress field: [current, total], 1-based page number
|
||||
readingStatus?: ReadingStatus;
|
||||
primaryLanguage?: string;
|
||||
|
||||
metadata?: BookMetadata;
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface DBBook {
|
||||
group_name?: string;
|
||||
tags?: string[];
|
||||
progress?: [number, number];
|
||||
reading_status?: string;
|
||||
|
||||
metadata?: string | null;
|
||||
created_at?: string;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
BookNoteType,
|
||||
HighlightColor,
|
||||
HighlightStyle,
|
||||
ReadingStatus,
|
||||
} from '@/types/book';
|
||||
import { DBBookConfig, DBBook, DBBookNote } from '@/types/records';
|
||||
import { sanitizeString } from './sanitize';
|
||||
@@ -70,6 +71,7 @@ export const transformBookToDB = (book: unknown, userId: string): DBBook => {
|
||||
groupName,
|
||||
tags,
|
||||
progress,
|
||||
readingStatus,
|
||||
metadata,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
@@ -88,6 +90,7 @@ export const transformBookToDB = (book: unknown, userId: string): DBBook => {
|
||||
group_name: sanitizeString(groupName),
|
||||
tags: tags,
|
||||
progress: progress,
|
||||
reading_status: readingStatus,
|
||||
source_title: sanitizeString(sourceTitle),
|
||||
metadata: metadata ? sanitizeString(JSON.stringify(metadata)) : null,
|
||||
created_at: new Date(createdAt ?? Date.now()).toISOString(),
|
||||
@@ -108,6 +111,7 @@ export const transformBookFromDB = (dbBook: DBBook): Book => {
|
||||
group_name,
|
||||
tags,
|
||||
progress,
|
||||
reading_status,
|
||||
source_title,
|
||||
metadata,
|
||||
created_at,
|
||||
@@ -126,6 +130,7 @@ export const transformBookFromDB = (dbBook: DBBook): Book => {
|
||||
groupName: group_name,
|
||||
tags: tags,
|
||||
progress: progress,
|
||||
readingStatus: reading_status as ReadingStatus,
|
||||
sourceTitle: source_title,
|
||||
metadata: metadata ? JSON.parse(metadata) : null,
|
||||
createdAt: new Date(created_at!).getTime(),
|
||||
|
||||
Reference in New Issue
Block a user