feat: toggle finished manually (#3091)

This commit is contained in:
Mohammed Efaz
2026-01-28 06:26:57 +01:00
committed by GitHub
parent 09c62d442f
commit 01b4e3530d
38 changed files with 516 additions and 37 deletions
@@ -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;
+19 -1
View File
@@ -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);
+2
View File
@@ -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;
+1
View File
@@ -10,6 +10,7 @@ export interface DBBook {
group_name?: string;
tags?: string[];
progress?: [number, number];
reading_status?: string;
metadata?: string | null;
created_at?: string;
+5
View File
@@ -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(),