Integrate offline logic for book creation and enhance synchronization

- Add offline handling to `AddNewBookForm` by updating `BooksSyncContext` with server-only and local-only book management.
- Refactor `guideTourDone` to check offline completion states via `localStorage`.
- Update and lock dependencies, including `@esbuild` and `@next`, to latest versions.
- Clean up unused session state updates in book creation logic.
This commit is contained in:
natreex
2025-12-15 23:03:32 -05:00
parent 64c7cb6243
commit f5e66f8983
5 changed files with 1327 additions and 823 deletions

View File

@@ -45,7 +45,7 @@ function ScribeContent() {
const t = useTranslations(); const t = useTranslations();
const {lang: locale} = useContext(LangContext); const {lang: locale} = useContext(LangContext);
const {errorMessage} = useContext(AlertContext); const {errorMessage} = useContext(AlertContext);
const {initializeDatabase, setOfflineMode, isCurrentlyOffline} = useContext(OfflineContext); const {initializeDatabase, setOfflineMode, isCurrentlyOffline, offlineMode} = useContext(OfflineContext);
const editor: Editor | null = useEditor({ const editor: Editor | null = useEditor({
extensions: [ extensions: [
StarterKit, StarterKit,
@@ -201,19 +201,24 @@ function ScribeContent() {
async function getBooks(): Promise<void> { async function getBooks(): Promise<void> {
try { try {
let localBooksResponse: SyncedBook[] let localBooksResponse: SyncedBook[] = [];
let serverBooksResponse: SyncedBook[] = [];
if (!isCurrentlyOffline()){ if (!isCurrentlyOffline()){
// Mode online: récupérer les livres du serveur ET de la DB locale
if (offlineMode.isDatabaseInitialized) {
localBooksResponse = await window.electron.invoke<SyncedBook[]>('db:books:synced'); localBooksResponse = await window.electron.invoke<SyncedBook[]>('db:books:synced');
}
serverBooksResponse = await System.authGetQueryToServer<SyncedBook[]>('books/synced', session.accessToken, locale);
} else { } else {
localBooksResponse = []; // Mode offline: récupérer uniquement depuis la DB locale
if (offlineMode.isDatabaseInitialized) {
localBooksResponse = await window.electron.invoke<SyncedBook[]>('db:books:synced');
} }
const serverBooksResponse: SyncedBook[] = await System.authGetQueryToServer<SyncedBook[]>('books/synced', session.accessToken, locale); }
if (serverBooksResponse) {
setServerSyncedBooks(serverBooksResponse); setServerSyncedBooks(serverBooksResponse);
}
if (localBooksResponse) {
setLocalSyncedBooks(localBooksResponse); setLocalSyncedBooks(localBooksResponse);
}
} catch (e: unknown) { } catch (e: unknown) {
if (e instanceof Error) { if (e instanceof Error) {
errorMessage(e.message); errorMessage(e.message);
@@ -250,15 +255,14 @@ function ScribeContent() {
}, [session.isConnected]); }, [session.isConnected]);
async function handlePinVerifySuccess(userId: string): Promise<void> { async function handlePinVerifySuccess(userId: string): Promise<void> {
console.log('[OfflinePin] PIN verified successfully for user:', userId);
try { try {
if (window.electron) { if (window.electron) {
const storedToken: string | null = await window.electron.getToken(); const storedToken: string | null = await window.electron.getToken();
const encryptionKey:string|null = await window.electron.getUserEncryptionKey(userId); const encryptionKey:string|null = await window.electron.getUserEncryptionKey(userId);
if (encryptionKey) { if (encryptionKey) {
await window.electron.dbInitialize(userId, encryptionKey); await window.electron.dbInitialize(userId, encryptionKey);
setOfflineMode(prev => ({...prev, isDatabaseInitialized: true}));
const localUser:UserProps = await window.electron.invoke('db:user:info'); const localUser:UserProps = await window.electron.invoke('db:user:info');
if (localUser && localUser.id) { if (localUser && localUser.id) {
@@ -272,28 +276,20 @@ function ScribeContent() {
setAmountSpent(localUser.aiUsage || 0); setAmountSpent(localUser.aiUsage || 0);
} else { } else {
errorMessage(t("homePage.errors.localDataError")); errorMessage(t("homePage.errors.localDataError"));
if (window.electron) {
//window.electron.logout();
}
} }
} else { } else {
errorMessage(t("homePage.errors.encryptionKeyError")); errorMessage(t("homePage.errors.encryptionKeyError"));
if (window.electron) {
//window.electron.logout();
}
} }
} }
} catch (error) { } catch (error) {
console.error('[OfflinePin] Error initializing offline mode:', error); console.error('[OfflinePin] Error initializing offline mode:', error);
errorMessage(t("homePage.errors.offlineModeError")); errorMessage(t("homePage.errors.offlineModeError"));
if (window.electron) {
//window.electron.logout();
}
} }
} }
async function handleHomeTour(): Promise<void> { async function handleHomeTour(): Promise<void> {
try { try {
if (!isCurrentlyOffline()) {
const response: boolean = await System.authPostToServer<boolean>('logs/tour', { const response: boolean = await System.authPostToServer<boolean>('logs/tour', {
plateforme: 'desktop', plateforme: 'desktop',
tour: 'home-basic' tour: 'home-basic'
@@ -305,6 +301,16 @@ function ScribeContent() {
setSession(User.setNewGuideTour(session, 'home-basic')); setSession(User.setNewGuideTour(session, 'home-basic'));
setHomeStepsGuide(false); setHomeStepsGuide(false);
} }
} else {
// Mode offline: stocker dans localStorage
const completedGuides = JSON.parse(localStorage.getItem('completedGuides') || '[]');
if (!completedGuides.includes('home-basic')) {
completedGuides.push('home-basic');
localStorage.setItem('completedGuides', JSON.stringify(completedGuides));
}
setSession(User.setNewGuideTour(session, 'home-basic'));
setHomeStepsGuide(false);
}
} catch (e: unknown) { } catch (e: unknown) {
if (e instanceof Error) { if (e instanceof Error) {
errorMessage(e.message); errorMessage(e.message);
@@ -465,6 +471,10 @@ function ScribeContent() {
try { try {
let response: ChapterProps | null let response: ChapterProps | null
if (isCurrentlyOffline()){ if (isCurrentlyOffline()){
if (!offlineMode.isDatabaseInitialized) {
setCurrentChapter(undefined);
return;
}
response = await window.electron.invoke('db:chapter:last', currentBook?.bookId) response = await window.electron.invoke('db:chapter:last', currentBook?.bookId)
} else { } else {
response = await System.authGetQueryToServer<ChapterProps | null>(`chapter/last-chapter`, session.accessToken, locale, {bookid: currentBook?.bookId}); response = await System.authGetQueryToServer<ChapterProps | null>(`chapter/last-chapter`, session.accessToken, locale, {bookid: currentBook?.bookId});

View File

@@ -1,5 +1,5 @@
'use client' 'use client'
import {ChangeEvent, Dispatch, SetStateAction, useContext, useEffect, useRef, useState} from "react"; import {ChangeEvent, Dispatch, RefObject, SetStateAction, useContext, useEffect, useRef, useState} from "react";
import {AlertContext} from "@/context/AlertContext"; import {AlertContext} from "@/context/AlertContext";
import System from "@/lib/models/System"; import System from "@/lib/models/System";
import {SessionContext} from "@/context/SessionContext"; import {SessionContext} from "@/context/SessionContext";
@@ -28,6 +28,8 @@ import {UserProps} from "@/lib/models/User";
import {useTranslations} from "next-intl"; import {useTranslations} from "next-intl";
import {LangContext, LangContextProps} from "@/context/LangContext"; import {LangContext, LangContextProps} from "@/context/LangContext";
import OfflineContext, {OfflineContextType} from "@/context/OfflineContext"; import OfflineContext, {OfflineContextType} from "@/context/OfflineContext";
import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext";
import {SyncedBook} from "@/lib/models/SyncedBook";
interface MinMax { interface MinMax {
min: number; min: number;
@@ -37,10 +39,11 @@ interface MinMax {
export default function AddNewBookForm({setCloseForm}: { setCloseForm: Dispatch<SetStateAction<boolean>> }) { export default function AddNewBookForm({setCloseForm}: { setCloseForm: Dispatch<SetStateAction<boolean>> }) {
const t = useTranslations(); const t = useTranslations();
const {lang} = useContext<LangContextProps>(LangContext); const {lang} = useContext<LangContextProps>(LangContext);
const {session, setSession} = useContext(SessionContext); const {session} = useContext(SessionContext);
const {errorMessage} = useContext(AlertContext); const {errorMessage} = useContext(AlertContext);
const {setServerOnlyBooks, setLocalOnlyBooks} = useContext<BooksSyncContextProps>(BooksSyncContext)
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext); const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
const modalRef: React.RefObject<HTMLDivElement | null> = useRef<HTMLDivElement>(null); const modalRef: RefObject<HTMLDivElement | null> = useRef<HTMLDivElement>(null);
const [title, setTitle] = useState<string>(''); const [title, setTitle] = useState<string>('');
const [subtitle, setSubtitle] = useState<string>(''); const [subtitle, setSubtitle] = useState<string>('');
@@ -159,14 +162,45 @@ export default function AddNewBookForm({setCloseForm}: { setCloseForm: Dispatch<
bookId: bookId, bookId: bookId,
...bookData ...bookData
}; };
if (isCurrentlyOffline()){
setSession({ setLocalOnlyBooks((prevBooks: SyncedBook[]): SyncedBook[] => [...prevBooks, {
...session, id: book.bookId,
user: { type: selectedBookType,
...session.user as UserProps, title: title,
books: [...((session.user as UserProps)?.books ?? []), book] subTitle: subtitle,
lastUpdate: new Date().getTime()/1000,
chapters: [],
characters: [],
locations: [],
worlds: [],
incidents: [],
plotPoints: [],
issues: [],
actSummaries: [],
guideLine: null,
aiGuideLine: null
}]);
} }
}); else {
setServerOnlyBooks((prevBooks: SyncedBook[]): SyncedBook[] => [...prevBooks, {
id: book.bookId,
type: selectedBookType,
title: title,
subTitle: subtitle,
lastUpdate: new Date().getTime()/1000,
chapters: [],
characters: [],
locations: [],
worlds: [],
incidents: [],
plotPoints: [],
issues: [],
actSummaries: [],
guideLine: null,
aiGuideLine: null
}]);
}
setIsAddingBook(false); setIsAddingBook(false);
setCloseForm(false) setCloseForm(false)
} catch (e: unknown) { } catch (e: unknown) {

View File

@@ -24,7 +24,7 @@ export default function BookList() {
const {setBook} = useContext(BookContext); const {setBook} = useContext(BookContext);
const t = useTranslations(); const t = useTranslations();
const {lang} = useContext<LangContextProps>(LangContext) const {lang} = useContext<LangContextProps>(LangContext)
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext) const {isCurrentlyOffline, offlineMode} = useContext<OfflineContextType>(OfflineContext)
const {booksToSyncFromServer, booksToSyncToServer, serverOnlyBooks, localOnlyBooks} = useContext<BooksSyncContextProps>(BooksSyncContext) const {booksToSyncFromServer, booksToSyncToServer, serverOnlyBooks, localOnlyBooks} = useContext<BooksSyncContextProps>(BooksSyncContext)
const [searchQuery, setSearchQuery] = useState<string>(''); const [searchQuery, setSearchQuery] = useState<string>('');
@@ -87,16 +87,28 @@ export default function BookList() {
} }
}, [groupedBooks]); }, [groupedBooks]);
// Charger les livres quand les conditions sont remplies
useEffect((): void => { useEffect((): void => {
getBooks().then() const shouldFetchBooks =
}, [booksToSyncFromServer, booksToSyncToServer, serverOnlyBooks, localOnlyBooks]); (session.isConnected || accessToken) &&
(!isCurrentlyOffline() || offlineMode.isDatabaseInitialized);
useEffect((): void => { if (shouldFetchBooks) {
if (accessToken) getBooks().then(); getBooks().then();
}, [accessToken]); }
}, [
session.isConnected,
accessToken,
offlineMode.isDatabaseInitialized,
booksToSyncFromServer,
booksToSyncToServer,
serverOnlyBooks,
localOnlyBooks
]);
async function handleFirstBookGuide(): Promise<void> { async function handleFirstBookGuide(): Promise<void> {
try { try {
if (!isCurrentlyOffline()) {
const response: boolean = await System.authPostToServer<boolean>( const response: boolean = await System.authPostToServer<boolean>(
'logs/tour', 'logs/tour',
{plateforme: 'web', tour: 'new-first-book'}, {plateforme: 'web', tour: 'new-first-book'},
@@ -106,6 +118,16 @@ export default function BookList() {
setSession(User.setNewGuideTour(session, 'new-first-book')); setSession(User.setNewGuideTour(session, 'new-first-book'));
setBookGuide(false); setBookGuide(false);
} }
} else {
// Mode offline: stocker dans localStorage
const completedGuides = JSON.parse(localStorage.getItem('completedGuides') || '[]');
if (!completedGuides.includes('new-first-book')) {
completedGuides.push('new-first-book');
localStorage.setItem('completedGuides', JSON.stringify(completedGuides));
}
setSession(User.setNewGuideTour(session, 'new-first-book'));
setBookGuide(false);
}
} catch (e: unknown) { } catch (e: unknown) {
if (e instanceof Error) { if (e instanceof Error) {
errorMessage(e.message); errorMessage(e.message);
@@ -122,7 +144,9 @@ export default function BookList() {
if (!isCurrentlyOffline()) { if (!isCurrentlyOffline()) {
const [onlineBooks, localBooks]: [BookListProps[], BookListProps[]] = await Promise.all([ const [onlineBooks, localBooks]: [BookListProps[], BookListProps[]] = await Promise.all([
System.authGetQueryToServer<BookListProps[]>('books', accessToken, lang), System.authGetQueryToServer<BookListProps[]>('books', accessToken, lang),
window.electron.invoke<BookListProps[]>('db:book:books') offlineMode.isDatabaseInitialized
? window.electron.invoke<BookListProps[]>('db:book:books')
: Promise.resolve([])
]); ]);
const onlineBookIds: Set<string> = new Set(onlineBooks.map((book: BookListProps): string => book.id)); 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)); const uniqueLocalBooks: BookListProps[] = localBooks.filter((book: BookListProps): boolean => !onlineBookIds.has(book.id));
@@ -131,6 +155,10 @@ export default function BookList() {
...uniqueLocalBooks.map((book: BookListProps): BookListProps & { itIsLocal: boolean } => ({ ...book, itIsLocal: true })) ...uniqueLocalBooks.map((book: BookListProps): BookListProps & { itIsLocal: boolean } => ({ ...book, itIsLocal: true }))
]; ];
} else { } else {
if (!offlineMode.isDatabaseInitialized) {
setIsLoadingBooks(false);
return;
}
const localBooks: BookListProps[] = 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 })); bookResponse = localBooks.map((book: BookListProps): BookListProps & { itIsLocal: boolean } => ({ ...book, itIsLocal: true }));
} }
@@ -203,6 +231,10 @@ export default function BookList() {
try { try {
let bookResponse: BookListProps|null = null; let bookResponse: BookListProps|null = null;
if (isCurrentlyOffline()){ if (isCurrentlyOffline()){
if (!offlineMode.isDatabaseInitialized) {
errorMessage(t("bookList.errorBookDetails"));
return;
}
bookResponse = await window.electron.invoke('db:book:bookBasicInformation', bookId) bookResponse = await window.electron.invoke('db:book:bookBasicInformation', bookId)
} else { } else {
bookResponse = await System.authGetQueryToServer<BookListProps>(`book/basic-information`, accessToken, lang, { bookResponse = await System.authGetQueryToServer<BookListProps>(`book/basic-information`, accessToken, lang, {

View File

@@ -71,8 +71,22 @@ export default class User {
} }
static guideTourDone(guide: GuideTour[], tour: string): boolean { static guideTourDone(guide: GuideTour[], tour: string): boolean {
if (!guide || !tour) return false; if (!tour) return false;
return guide.find((guide: GuideTour): boolean => guide[tour]) === undefined;
// Vérifier d'abord dans le guide du serveur
if (guide && guide.find((guide: GuideTour): boolean => guide[tour]) !== undefined) {
return false;
}
// Vérifier ensuite dans localStorage pour le mode offline
if (typeof window !== 'undefined' && window.localStorage) {
const completedGuides = JSON.parse(localStorage.getItem('completedGuides') || '[]');
if (completedGuides.includes(tour)) {
return false;
}
}
return true;
} }
static setNewGuideTour(session: SessionProps, tour: string): SessionProps { static setNewGuideTour(session: SessionProps, tour: string): SessionProps {

1940
package-lock.json generated

File diff suppressed because it is too large Load Diff