Files
ERitors-Scribe-Desktop/components/series/settings/SeriesBooksManager.tsx
natreex dbbe33b19b Refactor and extend offline synchronization logic across components and services
- Integrated sync queue mechanisms with `LocalSyncQueueContext` for offline data handling.
- Updated key sync-related services (e.g., book, chapter, series) to support offline-first functionality.
- Removed redundant database fetch methods to optimize repository logic and improve maintainability.
- Enhanced Tauri IPC usage for sync operations and removed legacy methods in Rust services.
2026-03-30 21:06:58 -04:00

277 lines
12 KiB
TypeScript

'use client'
import {forwardRef, useContext, useEffect, useImperativeHandle, useState} from "react";
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
import {isDesktop} from '@/lib/configs';
import {apiDelete, apiGet, apiPost, apiPut} from '@/lib/api/client';
import * as tauri from '@/lib/tauri';
import {AlertContext, AlertContextProps} from "@/context/AlertContext";
import {SessionContext, SessionContextProps} from "@/context/SessionContext";
import {useTranslations} from '@/lib/i18n';
import {LangContext, LangContextProps} from "@/context/LangContext";
import {SeriesContext, SeriesContextProps} from "@/context/SeriesContext";
import {SeriesBookProps} from "@/lib/types/series";
import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext";
import {SyncedBook} from "@/lib/types/synced-book";
import {ArrowDown, ArrowUp, Book, Trash2} from 'lucide-react';
import PulseLoader from '@/components/ui/PulseLoader';
import InputField from '@/components/form/InputField';
import SelectBox from '@/components/form/SelectBox';
import IconButton from "@/components/ui/IconButton";
import EmptyState from "@/components/ui/EmptyState";
import Badge from "@/components/ui/Badge";
import EntityListItem from "@/components/ui/EntityListItem";
function SeriesBooksManager(props: object, ref: React.Ref<{ handleSave: () => Promise<void> }>) {
const t = useTranslations();
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext);
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
const {seriesId}: SeriesContextProps = useContext<SeriesContextProps>(SeriesContext);
const {
serverSyncedBooks,
setServerSyncedBooks,
localSyncedBooks
}: BooksSyncContextProps = useContext<BooksSyncContextProps>(BooksSyncContext);
const userToken: string = session?.accessToken ? session?.accessToken : '';
const {errorMessage, successMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
const {isCurrentlyOffline} = useContext(OfflineContext);
const useLocal: boolean = isDesktop && isCurrentlyOffline();
const [isLoading, setIsLoading] = useState<boolean>(true);
const [seriesBooks, setSeriesBooks] = useState<SeriesBookProps[]>([]);
const [selectedBookToAdd, setSelectedBookToAdd] = useState<string>('');
const [availableBooks, setAvailableBooks] = useState<SyncedBook[]>([]);
useEffect(function () {
if (seriesId) {
loadSeriesBooks();
}
}, [seriesId]);
useEffect(function () {
const booksInSeries: string[] = seriesBooks.map((book: SeriesBookProps) => book.bookId);
const allBooks: SyncedBook[] = useLocal ? localSyncedBooks : serverSyncedBooks;
const filteredBooks: SyncedBook[] = allBooks.filter(
(book: SyncedBook) => !booksInSeries.includes(book.id)
);
setAvailableBooks(filteredBooks);
}, [seriesBooks, serverSyncedBooks, localSyncedBooks, useLocal]);
async function loadSeriesBooks(): Promise<void> {
setIsLoading(true);
try {
const response: SeriesBookProps[] = useLocal
? await tauri.getSeriesBooks(seriesId) as SeriesBookProps[]
: await apiGet<SeriesBookProps[]>('series/book/list', userToken, lang, {seriesid: seriesId});
if (response) {
setSeriesBooks(response);
}
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t('seriesBooks.error.unknown'));
}
} finally {
setIsLoading(false);
}
}
useImperativeHandle(ref, function () {
return {
handleSave: handleSave
};
});
async function handleSave(): Promise<void> {
successMessage(t('seriesBooks.success.saved'));
}
async function handleAddBook(): Promise<void> {
if (!selectedBookToAdd) {
errorMessage(t('seriesBooks.error.selectBook'));
return;
}
try {
const response: boolean = useLocal
? await tauri.addBookToSeries(seriesId, selectedBookToAdd)
: await apiPost<boolean>('series/book/add', {seriesId: seriesId, bookId: selectedBookToAdd}, userToken, lang);
if (response) {
const allBooks: SyncedBook[] = useLocal ? localSyncedBooks : serverSyncedBooks;
const addedBook: SyncedBook | undefined = allBooks.find(
(book: SyncedBook) => book.id === selectedBookToAdd
);
if (addedBook) {
const newSeriesBook: SeriesBookProps = {
bookId: addedBook.id,
title: addedBook.title,
order: seriesBooks.length + 1,
coverImage: null
};
setSeriesBooks([...seriesBooks, newSeriesBook]);
setServerSyncedBooks((prev: SyncedBook[]): SyncedBook[] =>
prev.map((book: SyncedBook): SyncedBook =>
book.id === selectedBookToAdd
? {...book, seriesId: seriesId}
: book
)
);
}
setSelectedBookToAdd('');
}
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t('seriesBooks.error.unknown'));
}
}
}
async function handleRemoveBook(bookId: string): Promise<void> {
try {
const deletedAt: number = Math.floor(Date.now() / 1000);
const response: boolean = useLocal
? await tauri.removeBookFromSeries(seriesId, bookId, deletedAt)
: await apiDelete<boolean>('series/book/remove', {seriesId: seriesId, bookId: bookId}, userToken, lang);
if (response) {
const updatedBooks: SeriesBookProps[] = seriesBooks
.filter((book: SeriesBookProps) => book.bookId !== bookId)
.map((book: SeriesBookProps, index: number) => ({
...book,
order: index + 1
}));
setSeriesBooks(updatedBooks);
setServerSyncedBooks((prev: SyncedBook[]): SyncedBook[] =>
prev.map((book: SyncedBook): SyncedBook =>
book.id === bookId
? {...book, seriesId: null}
: book
)
);
}
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t('seriesBooks.error.unknown'));
}
}
}
async function handleMoveBook(bookId: string, direction: 'up' | 'down'): Promise<void> {
const currentIndex: number = seriesBooks.findIndex((book: SeriesBookProps) => book.bookId === bookId);
if (currentIndex === -1) return;
const newIndex: number = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
if (newIndex < 0 || newIndex >= seriesBooks.length) return;
const reorderedBooks: SeriesBookProps[] = [...seriesBooks];
const [movedBook] = reorderedBooks.splice(currentIndex, 1);
reorderedBooks.splice(newIndex, 0, movedBook);
const updatedBooks: SeriesBookProps[] = reorderedBooks.map((book: SeriesBookProps, index: number) => ({
...book,
order: index + 1
}));
try {
const bookIds: string[] = updatedBooks.map((book: SeriesBookProps) => book.bookId);
const response: boolean = useLocal
? await tauri.reorderSeriesBooks(seriesId, bookIds)
: await apiPut<boolean>('series/book/reorder', {seriesId: seriesId, booksOrder: updatedBooks.map((book: SeriesBookProps) => ({bookId: book.bookId, order: book.order}))}, userToken, lang);
if (response) {
setSeriesBooks(updatedBooks);
}
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t('seriesBooks.error.unknown'));
}
}
}
if (isLoading) {
return <PulseLoader/>;
}
return (
<div className="space-y-6">
<InputField
fieldName={t('seriesBooks.addBook')}
input={
<SelectBox
onChangeCallBack={(e) => setSelectedBookToAdd(e.target.value)}
data={availableBooks.map((book: SyncedBook) => ({label: book.title, value: book.id}))}
defaultValue={selectedBookToAdd}
placeholder={t('seriesBooks.selectBookPlaceholder')}
/>
}
addButtonCallBack={handleAddBook}
isAddButtonDisabled={!selectedBookToAdd}
/>
<div>
<p className="text-text-secondary text-sm font-medium mb-3">
{t('seriesBooks.booksInSeries')} ({seriesBooks.length})
</p>
{seriesBooks.length === 0 ? (
<EmptyState icon={Book} title={t('seriesBooks.noBooks')}/>
) : (
<div className="space-y-2">
{seriesBooks
.sort((a: SeriesBookProps, b: SeriesBookProps) => a.order - b.order)
.map((book: SeriesBookProps, index: number) => (
<EntityListItem
key={book.bookId}
onClick={function (): void {
}}
size="sm"
avatar={<Badge variant="primary" size="sm">{book.order}</Badge>}
title={book.title}
extra={
<div className="flex items-center gap-1">
<IconButton
icon={ArrowUp}
variant="ghost"
size="sm"
onClick={() => handleMoveBook(book.bookId, 'up')}
disabled={index === 0}
tooltip={t('seriesBooks.moveUp')}
/>
<IconButton
icon={ArrowDown}
variant="ghost"
size="sm"
onClick={() => handleMoveBook(book.bookId, 'down')}
disabled={index === seriesBooks.length - 1}
tooltip={t('seriesBooks.moveDown')}
/>
<IconButton
icon={Trash2}
variant="danger"
size="sm"
onClick={() => handleRemoveBook(book.bookId)}
tooltip={t('seriesBooks.removeBook')}
/>
</div>
}
/>
))}
</div>
)}
</div>
</div>
);
}
export default forwardRef(SeriesBooksManager);