Add BooksSyncContext, refine database schema, and enhance synchronization support
- Introduce `BooksSyncContext` for managing book synchronization states (server-only, local-only, to-sync, etc.). - Remove `UserContext` and related dependencies. - Refine localization strings (`en.json`) with sync-related updates (e.g., "toSyncFromServer", "toSyncToServer"). - Extend database schema with additional tables and fields for syncing books, chapters, and related entities. - Add `last_update` fields and update corresponding repository methods to support synchronization logic. - Enhance IPC handlers with stricter typing, data validation, and sync-aware operations.
This commit is contained in:
138
components/SyncBook.tsx
Normal file
138
components/SyncBook.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {faCloud, faCloudArrowDown, faCloudArrowUp, faSpinner} from "@fortawesome/free-solid-svg-icons";
|
||||
import {useTranslations} from "next-intl";
|
||||
import {useState, useContext} from "react";
|
||||
import OfflineContext, {OfflineContextType} from "@/context/OfflineContext";
|
||||
import System from "@/lib/models/System";
|
||||
import {SessionContext, SessionContextProps} from "@/context/SessionContext";
|
||||
import {LangContext} from "@/context/LangContext";
|
||||
import {CompleteBook} from "@/lib/models/Book";
|
||||
import {SyncType} from "@/context/BooksSyncContext";
|
||||
import {AlertContext, AlertContextProps} from "@/context/AlertContext";
|
||||
|
||||
interface SyncBookProps {
|
||||
bookId: string;
|
||||
status: SyncType;
|
||||
}
|
||||
|
||||
export default function SyncBook({bookId, status}: SyncBookProps) {
|
||||
const t = useTranslations();
|
||||
const {session} = useContext<SessionContextProps>(SessionContext);
|
||||
const {lang} = useContext(LangContext);
|
||||
const {errorMessage} = useContext<AlertContextProps>(AlertContext);
|
||||
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [currentStatus, setCurrentStatus] = useState<SyncType>(status);
|
||||
|
||||
const isOffline: boolean = isCurrentlyOffline();
|
||||
|
||||
async function upload(): Promise<void> {
|
||||
// TODO: Implement upload local-only book to server
|
||||
}
|
||||
|
||||
async function download(): Promise<void> {
|
||||
try {
|
||||
const response: CompleteBook = await System.authGetQueryToServer('book/sync/download', session.accessToken, lang, {bookId});
|
||||
if (!response) {
|
||||
errorMessage(t("bookCard.downloadError"));
|
||||
return;
|
||||
}
|
||||
const syncStatus:boolean = await window.electron.invoke<boolean>('db:book:syncSave', response);
|
||||
if (!syncStatus) {
|
||||
errorMessage(t("bookCard.downloadError"));
|
||||
return;
|
||||
}
|
||||
setCurrentStatus('synced');
|
||||
} catch (e:unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
} else {
|
||||
errorMessage(t("bookCard.downloadError"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function syncFromServer(): Promise<void> {
|
||||
// TODO: Implement sync from server (server has newer version)
|
||||
}
|
||||
|
||||
async function syncToServer(): Promise<void> {
|
||||
// TODO: Implement sync to server (local has newer version)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-primary">
|
||||
<FontAwesomeIcon icon={faSpinner} className="w-4 h-4 animate-spin"/>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Fully synced - no action needed */}
|
||||
{currentStatus === 'synced' && (
|
||||
<span
|
||||
className="text-gray-light"
|
||||
title={t("bookCard.synced")}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCloud} className="w-4 h-4"/>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Local only - can upload to server */}
|
||||
{currentStatus === 'local-only' && (
|
||||
<button
|
||||
onClick={upload}
|
||||
className={`transition-colors ${isOffline ? 'text-gray-dark cursor-not-allowed' : 'text-gray hover:text-primary cursor-pointer'}`}
|
||||
title={t("bookCard.localOnly")}
|
||||
type="button"
|
||||
disabled={isOffline}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCloudArrowUp} className="w-4 h-4"/>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Server only - can download to local */}
|
||||
{currentStatus === 'server-only' && (
|
||||
<button
|
||||
onClick={download}
|
||||
className={`transition-colors ${isOffline ? 'text-gray-dark cursor-not-allowed' : 'text-gray hover:text-primary cursor-pointer'}`}
|
||||
title={t("bookCard.serverOnly")}
|
||||
type="button"
|
||||
disabled={isOffline}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCloudArrowDown} className="w-4 h-4"/>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Needs to sync from server (server has newer version) */}
|
||||
{currentStatus === 'to-sync-from-server' && (
|
||||
<button
|
||||
onClick={syncFromServer}
|
||||
className={`transition-colors ${isOffline ? 'text-gray-dark cursor-not-allowed' : 'text-warning hover:text-primary cursor-pointer'}`}
|
||||
title={t("bookCard.toSyncFromServer")}
|
||||
type="button"
|
||||
disabled={isOffline}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCloudArrowDown} className="w-4 h-4"/>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Needs to sync to server (local has newer version) */}
|
||||
{currentStatus === 'to-sync-to-server' && (
|
||||
<button
|
||||
onClick={syncToServer}
|
||||
className={`transition-colors ${isOffline ? 'text-gray-dark cursor-not-allowed' : 'text-warning hover:text-primary cursor-pointer'}`}
|
||||
title={t("bookCard.toSyncToServer")}
|
||||
type="button"
|
||||
disabled={isOffline}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCloudArrowUp} className="w-4 h-4"/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,19 +3,22 @@ import {BookProps} from "@/lib/models/Book";
|
||||
import DeleteBook from "@/components/book/settings/DeleteBook";
|
||||
import ExportBook from "@/components/ExportBook";
|
||||
import {useTranslations} from "next-intl";
|
||||
import SyncBook from "@/components/SyncBook";
|
||||
import {SyncType} from "@/context/BooksSyncContext";
|
||||
import {useEffect} from "react";
|
||||
|
||||
export default function BookCard(
|
||||
{
|
||||
book,
|
||||
onClickCallback,
|
||||
index
|
||||
}: {
|
||||
book: BookProps,
|
||||
onClickCallback: Function;
|
||||
index: number;
|
||||
}) {
|
||||
interface BookCardProps {
|
||||
book: BookProps;
|
||||
onClickCallback: (bookId: string) => void;
|
||||
index: number;
|
||||
syncStatus: SyncType;
|
||||
}
|
||||
|
||||
export default function BookCard({book, onClickCallback, index, syncStatus}: BookCardProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
useEffect(() => {
|
||||
console.log(syncStatus)
|
||||
}, [syncStatus]);
|
||||
return (
|
||||
<div
|
||||
className="group bg-tertiary/90 backdrop-blur-sm rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-300 h-full border border-secondary/50 hover:border-primary/50 flex flex-col hover:scale-105">
|
||||
@@ -66,8 +69,7 @@ export default function BookCard(
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center pt-3 border-t border-secondary/30">
|
||||
<span
|
||||
className="bg-primary/10 text-primary text-xs px-3 py-1 rounded-full font-medium border border-primary/30"></span>
|
||||
<SyncBook status={syncStatus} bookId={book.bookId}/>
|
||||
<div className="flex items-center gap-1" {...index === 0 && {'data-guide': 'bottom-book-card'}}>
|
||||
<ExportBook bookTitle={book.title} bookId={book.bookId}/>
|
||||
<DeleteBook bookId={book.bookId}/>
|
||||
|
||||
@@ -14,6 +14,8 @@ import User from "@/lib/models/User";
|
||||
import {useTranslations} from "next-intl";
|
||||
import {LangContext, LangContextProps} from "@/context/LangContext";
|
||||
import OfflineContext, {OfflineContextType} from "@/context/OfflineContext";
|
||||
import {BooksSyncContext, BooksSyncContextProps, SyncType} from "@/context/BooksSyncContext";
|
||||
import {SyncedBook} from "@/lib/models/SyncedBook";
|
||||
|
||||
export default function BookList() {
|
||||
const {session, setSession} = useContext(SessionContext);
|
||||
@@ -23,6 +25,7 @@ export default function BookList() {
|
||||
const t = useTranslations();
|
||||
const {lang} = useContext<LangContextProps>(LangContext)
|
||||
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext)
|
||||
const {booksToSyncFromServer, booksToSyncToServer, serverOnlyBooks, localOnlyBooks} = useContext<BooksSyncContextProps>(BooksSyncContext)
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
const [groupedBooks, setGroupedBooks] = useState<Record<string, BookProps[]>>({});
|
||||
@@ -86,7 +89,7 @@ export default function BookList() {
|
||||
|
||||
useEffect((): void => {
|
||||
getBooks().then()
|
||||
}, [session.user?.books]);
|
||||
}, [booksToSyncFromServer, booksToSyncToServer, serverOnlyBooks, localOnlyBooks]);
|
||||
|
||||
useEffect((): void => {
|
||||
if (accessToken) getBooks().then();
|
||||
@@ -115,11 +118,21 @@ export default function BookList() {
|
||||
async function getBooks(): Promise<void> {
|
||||
setIsLoadingBooks(true);
|
||||
try {
|
||||
let bookResponse: BookListProps[] = [];
|
||||
let bookResponse: (BookListProps & { itIsLocal: boolean })[] = [];
|
||||
if (!isCurrentlyOffline()) {
|
||||
bookResponse = await System.authGetQueryToServer<BookListProps[]>('books', accessToken, lang);
|
||||
const [onlineBooks, localBooks]: [BookListProps[], BookListProps[]] = await Promise.all([
|
||||
System.authGetQueryToServer<BookListProps[]>('books', accessToken, lang),
|
||||
window.electron.invoke<BookListProps[]>('db:book:books')
|
||||
]);
|
||||
const onlineBookIds: Set<string> = new Set(onlineBooks.map((book: BookListProps): string => book.id));
|
||||
const uniqueLocalBooks: BookListProps[] = localBooks.filter((book: BookListProps): boolean => !onlineBookIds.has(book.id));
|
||||
bookResponse = [
|
||||
...onlineBooks.map((book: BookListProps): BookListProps & { itIsLocal: boolean } => ({ ...book, itIsLocal: false })),
|
||||
...uniqueLocalBooks.map((book: BookListProps): BookListProps & { itIsLocal: boolean } => ({ ...book, itIsLocal: true }))
|
||||
];
|
||||
} else {
|
||||
bookResponse = await window.electron.invoke<BookListProps[]>('db:book:books');
|
||||
const localBooks: BookListProps[] = await window.electron.invoke<BookListProps[]>('db:book:books');
|
||||
bookResponse = localBooks.map((book: BookListProps): BookListProps & { itIsLocal: boolean } => ({ ...book, itIsLocal: true }));
|
||||
}
|
||||
if (bookResponse) {
|
||||
const booksByType: Record<string, BookProps[]> = bookResponse.reduce((groups: Record<string, BookProps[]>, book: BookListProps): Record<string, BookProps[]> => {
|
||||
@@ -170,6 +183,22 @@ export default function BookList() {
|
||||
{}
|
||||
);
|
||||
|
||||
function detectBookSyncStatus(bookId: string):SyncType {
|
||||
if (serverOnlyBooks.find((book: SyncedBook):boolean => book.id === bookId)) {
|
||||
return 'server-only';
|
||||
}
|
||||
if (localOnlyBooks.find((book: SyncedBook):boolean => book.id === bookId)) {
|
||||
return 'local-only';
|
||||
}
|
||||
if (booksToSyncFromServer.find((book: SyncedBook):boolean => book.id === bookId)) {
|
||||
return 'to-sync-from-server';
|
||||
}
|
||||
if (booksToSyncToServer.find((book: SyncedBook):boolean => book.id === bookId)) {
|
||||
return 'to-sync-to-server';
|
||||
}
|
||||
return 'synced';
|
||||
}
|
||||
|
||||
async function getBook(bookId: string): Promise<void> {
|
||||
try {
|
||||
let bookResponse: BookListProps|null = null;
|
||||
@@ -267,8 +296,10 @@ export default function BookList() {
|
||||
{...(idx === 0 && {'data-guide': 'book-card'})}
|
||||
className={`w-full sm:w-1/3 md:w-1/4 lg:w-1/5 xl:w-1/6 p-2 box-border ${User.guideTourDone(session.user?.guideTour || [], 'new-first-book') && 'mb-[200px]'}`}>
|
||||
<BookCard book={book}
|
||||
syncStatus={detectBookSyncStatus(book.bookId)}
|
||||
onClickCallback={getBook}
|
||||
index={idx}/>
|
||||
index={idx}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
|
||||
@@ -300,7 +300,7 @@ export default function TextEditor() {
|
||||
content,
|
||||
totalWordCount: editor.getText().length,
|
||||
currentTime: mainTimer
|
||||
}, session?.accessToken ?? '');
|
||||
}, session?.accessToken, lang);
|
||||
}
|
||||
if (!response) {
|
||||
errorMessage(t('editor.error.savedFailed'));
|
||||
|
||||
Reference in New Issue
Block a user