- Replaced `window.electron.invoke` calls with equivalent `tauri` function calls for all IPC interactions. - Removed `electron.d.ts` TypeScript definitions as they are no longer needed. - Updated related logic for offline/online state synchronization. - Added `types.rs` and `shared/mod.rs` modules to support Tauri IPC integration with Rust enums and shared logic. - Refactored IPC request queues to use updated handler names for consistency with Tauri.
375 lines
17 KiB
TypeScript
375 lines
17 KiB
TypeScript
'use client'
|
|
import {forwardRef, useContext, useEffect, useImperativeHandle, useState} from "react";
|
|
import System from "@/lib/models/System";
|
|
import {AlertContext} from "@/context/AlertContext";
|
|
import {SessionContext} from "@/context/SessionContext";
|
|
import {useTranslations} from "next-intl";
|
|
import {LangContext, LangContextProps} from "@/context/LangContext";
|
|
import {SeriesContext, SeriesContextProps} from "@/context/SeriesContext";
|
|
import {SeriesBookProps} from "@/lib/models/Series";
|
|
import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext";
|
|
import {SyncedBook} from "@/lib/models/SyncedBook";
|
|
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
|
import {faArrowDown, faArrowUp, faBook, faPlus, faSpinner, faTrash} from "@fortawesome/free-solid-svg-icons";
|
|
import OfflineContext, {OfflineContextType} from "@/context/OfflineContext";
|
|
import {LocalSyncQueueContext, LocalSyncQueueContextProps} from "@/context/SyncQueueContext";
|
|
import {SeriesSyncContext, SeriesSyncContextProps} from "@/context/SeriesSyncContext";
|
|
import {SyncedSeries, SyncedSeriesBook} from "@/lib/models/SyncedSeries";
|
|
import * as tauri from '@/lib/tauri';
|
|
|
|
function SeriesBooksManager(props: object, ref: React.Ref<{ handleSave: () => Promise<void> }>) {
|
|
const t = useTranslations();
|
|
const {lang} = useContext<LangContextProps>(LangContext);
|
|
|
|
const {session} = useContext(SessionContext);
|
|
const {seriesId, localSeries} = useContext<SeriesContextProps>(SeriesContext);
|
|
const {serverSyncedBooks, setServerSyncedBooks, localSyncedBooks} = useContext<BooksSyncContextProps>(BooksSyncContext);
|
|
const userToken: string = session?.accessToken ? session?.accessToken : '';
|
|
const {errorMessage, successMessage} = useContext(AlertContext);
|
|
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
|
|
const {addToQueue} = useContext<LocalSyncQueueContextProps>(LocalSyncQueueContext);
|
|
const {localSyncedSeries, serverSyncedSeries} = useContext<SeriesSyncContextProps>(SeriesSyncContext);
|
|
|
|
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 booksInThisSeries: string[] = seriesBooks.map((book: SeriesBookProps) => book.bookId);
|
|
let allBooks: SyncedBook[];
|
|
let allSeries: SyncedSeries[];
|
|
|
|
if (isCurrentlyOffline() || localSeries) {
|
|
allBooks = localSyncedBooks;
|
|
allSeries = localSyncedSeries;
|
|
} else {
|
|
allBooks = serverSyncedBooks;
|
|
allSeries = serverSyncedSeries;
|
|
}
|
|
|
|
// Get all bookIds in OTHER series (not this one)
|
|
const booksInOtherSeries: Set<string> = new Set(
|
|
allSeries
|
|
.filter((series: SyncedSeries): boolean => series.id !== seriesId)
|
|
.flatMap((series: SyncedSeries): string[] =>
|
|
series.books.map((book: SyncedSeriesBook): string => book.bookId)
|
|
)
|
|
);
|
|
|
|
// Filter out books already in this series AND books already in another series
|
|
const filteredBooks: SyncedBook[] = allBooks.filter(
|
|
(book: SyncedBook) => !booksInThisSeries.includes(book.id) && !booksInOtherSeries.has(book.id)
|
|
);
|
|
setAvailableBooks(filteredBooks);
|
|
}, [seriesBooks, serverSyncedBooks, localSyncedBooks, serverSyncedSeries, localSyncedSeries, isCurrentlyOffline, localSeries, seriesId]);
|
|
|
|
async function loadSeriesBooks(): Promise<void> {
|
|
setIsLoading(true);
|
|
try {
|
|
let response: SeriesBookProps[];
|
|
|
|
if (isCurrentlyOffline() || localSeries) {
|
|
response = await tauri.getSeriesBooks(seriesId);
|
|
} else {
|
|
response = await System.authGetQueryToServer<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 addData = {
|
|
seriesId: seriesId,
|
|
bookId: selectedBookToAdd
|
|
};
|
|
let response: boolean;
|
|
|
|
if (isCurrentlyOffline() || localSeries) {
|
|
response = await tauri.addBookToSeries(addData.seriesId, addData.bookId);
|
|
} else {
|
|
response = await System.authPostToServer<boolean>(
|
|
'series/book/add',
|
|
addData,
|
|
userToken,
|
|
lang
|
|
);
|
|
|
|
if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) {
|
|
addToQueue('add_book_to_series', {data: addData});
|
|
}
|
|
}
|
|
|
|
if (response) {
|
|
const allBooks: SyncedBook[] = isCurrentlyOffline() || localSeries ? 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 removeData = {
|
|
seriesId: seriesId,
|
|
bookId: bookId,
|
|
deletedAt: System.timeStampInSeconds(),
|
|
};
|
|
let response: boolean;
|
|
|
|
if (isCurrentlyOffline() || localSeries) {
|
|
response = await tauri.removeBookFromSeries(removeData.seriesId, removeData.bookId, removeData.deletedAt);
|
|
} else {
|
|
response = await System.authDeleteToServer<boolean>(
|
|
'series/book/remove',
|
|
removeData,
|
|
userToken,
|
|
lang
|
|
);
|
|
|
|
if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) {
|
|
addToQueue('remove_book_from_series', {data: removeData});
|
|
}
|
|
}
|
|
|
|
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 reorderData = {
|
|
seriesId: seriesId,
|
|
booksOrder: updatedBooks.map((book: SeriesBookProps) => ({
|
|
bookId: book.bookId,
|
|
order: book.order
|
|
}))
|
|
};
|
|
let response: boolean;
|
|
|
|
if (isCurrentlyOffline() || localSeries) {
|
|
response = await tauri.reorderSeriesBooks(reorderData.seriesId, reorderData.booksOrder);
|
|
} else {
|
|
response = await System.authPutToServer<boolean>(
|
|
'series/book/reorder',
|
|
reorderData,
|
|
userToken,
|
|
lang
|
|
);
|
|
|
|
if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) {
|
|
addToQueue('reorder_series_books', {data: reorderData});
|
|
}
|
|
}
|
|
|
|
if (response) {
|
|
setSeriesBooks(updatedBooks);
|
|
}
|
|
} catch (e: unknown) {
|
|
if (e instanceof Error) {
|
|
errorMessage(e.message);
|
|
} else {
|
|
errorMessage(t('seriesBooks.error.unknown'));
|
|
}
|
|
}
|
|
}
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-12">
|
|
<FontAwesomeIcon icon={faSpinner} className="w-8 h-8 text-primary animate-spin"/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
|
|
<h3 className="text-lg font-semibold text-text-primary mb-4">
|
|
{t('seriesBooks.addBook')}
|
|
</h3>
|
|
<div className="flex gap-3">
|
|
<select
|
|
value={selectedBookToAdd}
|
|
onChange={(e) => setSelectedBookToAdd(e.target.value)}
|
|
className="flex-1 bg-secondary/50 border border-secondary/50 rounded-lg px-4 py-2 text-text-primary focus:outline-none focus:border-primary"
|
|
>
|
|
<option value="">{t('seriesBooks.selectBookPlaceholder')}</option>
|
|
{availableBooks.map((book: SyncedBook) => (
|
|
<option key={book.id} value={book.id}>
|
|
{book.title}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<button
|
|
onClick={handleAddBook}
|
|
disabled={!selectedBookToAdd}
|
|
className="bg-primary hover:bg-primary-dark disabled:bg-secondary disabled:cursor-not-allowed text-white px-4 py-2 rounded-lg transition-colors duration-200 flex items-center gap-2"
|
|
>
|
|
<FontAwesomeIcon icon={faPlus} className="w-4 h-4"/>
|
|
{t('seriesBooks.add')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
|
|
<h3 className="text-lg font-semibold text-text-primary mb-4">
|
|
{t('seriesBooks.booksInSeries')} ({seriesBooks.length})
|
|
</h3>
|
|
|
|
{seriesBooks.length === 0 ? (
|
|
<div className="text-center py-8 text-text-secondary">
|
|
<FontAwesomeIcon icon={faBook} className="w-12 h-12 mb-4 opacity-50"/>
|
|
<p>{t('seriesBooks.noBooks')}</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{seriesBooks
|
|
.sort((a: SeriesBookProps, b: SeriesBookProps) => a.order - b.order)
|
|
.map((book: SeriesBookProps, index: number) => (
|
|
<div
|
|
key={book.bookId}
|
|
className="flex items-center justify-between bg-secondary/30 rounded-lg p-3 border border-secondary/30 hover:border-primary/30 transition-colors duration-200"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<span className="bg-primary/20 text-primary font-bold w-8 h-8 rounded-full flex items-center justify-center text-sm">
|
|
{book.order}
|
|
</span>
|
|
<span className="text-text-primary font-medium">{book.title}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => handleMoveBook(book.bookId, 'up')}
|
|
disabled={index === 0}
|
|
className="p-2 rounded-lg hover:bg-secondary/50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors duration-200"
|
|
title={t('seriesBooks.moveUp')}
|
|
>
|
|
<FontAwesomeIcon icon={faArrowUp} className="w-4 h-4 text-text-secondary"/>
|
|
</button>
|
|
<button
|
|
onClick={() => handleMoveBook(book.bookId, 'down')}
|
|
disabled={index === seriesBooks.length - 1}
|
|
className="p-2 rounded-lg hover:bg-secondary/50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors duration-200"
|
|
title={t('seriesBooks.moveDown')}
|
|
>
|
|
<FontAwesomeIcon icon={faArrowDown} className="w-4 h-4 text-text-secondary"/>
|
|
</button>
|
|
<button
|
|
onClick={() => handleRemoveBook(book.bookId)}
|
|
className="p-2 rounded-lg hover:bg-error/20 text-error transition-colors duration-200"
|
|
title={t('seriesBooks.removeBook')}
|
|
>
|
|
<FontAwesomeIcon icon={faTrash} className="w-4 h-4"/>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default forwardRef(SeriesBooksManager);
|