'use client'; import {useCallback, useContext, useEffect, useRef, useState} from 'react'; import {BookContext} from "@/context/BookContext"; import {ChapterProps} from "@/lib/models/Chapter"; import {ChapterContext} from '@/context/ChapterContext'; import {EditorContext} from '@/context/EditorContext' import {Editor, useEditor} from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import Underline from "@tiptap/extension-underline"; import TextAlign from "@tiptap/extension-text-align"; import {AlertContext, AlertProvider} from "@/context/AlertContext"; import System from "@/lib/models/System"; import {SessionContext} from '@/context/SessionContext'; import {SessionProps} from "@/lib/models/Session"; import User, {UserProps} from "@/lib/models/User"; import {BookProps} from "@/lib/models/Book"; import ScribeTopBar from "@/components/ScribeTopBar"; import ScribeControllerBar from "@/components/ScribeControllerBar"; import ScribeLeftBar from "@/components/leftbar/ScribeLeftBar"; import ScribeEditor from "@/components/editor/ScribeEditor"; import ComposerRightBar from "@/components/rightbar/ComposerRightBar"; import ScribeFooterBar from "@/components/ScribeFooterBar"; import GuideTour, {GuideStep} from "@/components/GuideTour"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; import {faBookMedical, faFeather} from "@fortawesome/free-solid-svg-icons"; import TermsOfUse from "@/components/TermsOfUse"; import frMessages from '@/lib/locales/fr.json'; import enMessages from '@/lib/locales/en.json'; import {NextIntlClientProvider, useTranslations} from "next-intl"; import {LangContext} from "@/context/LangContext"; import {AIUsageContext} from "@/context/AIUsageContext"; import OfflineProvider from "@/context/OfflineProvider"; import OfflineContext, {OfflineMode} from "@/context/OfflineContext"; import OfflinePinSetup from "@/components/offline/OfflinePinSetup"; import OfflinePinVerify from "@/components/offline/OfflinePinVerify"; import {SyncedBook, BookSyncCompare, compareBookSyncs} from "@/lib/models/SyncedBook"; import {SyncedSeries, SeriesSyncCompare, compareSeriesSyncs} from "@/lib/models/SyncedSeries"; import {BooksSyncContext} from "@/context/BooksSyncContext"; import {SeriesSyncContext} from "@/context/SeriesSyncContext"; import useSyncBooks from "@/hooks/useSyncBooks"; import useSyncSeries from "@/hooks/useSyncSeries"; import {LocalSyncQueueContext, LocalSyncOperation} from "@/context/SyncQueueContext"; import * as tauri from '@/lib/tauri'; interface RemovedItemRecord { removal_id: string; table_name: string; entity_id: string; book_id: string | null; user_id: string; deleted_at: number; } interface SyncedBooksResponse { books: SyncedBook[]; tombstones: RemovedItemRecord[]; } interface SyncedSeriesResponse { series: SyncedSeries[]; tombstones: RemovedItemRecord[]; } const messagesMap = { fr: frMessages, en: enMessages }; function AutoSyncOnReconnect() { const {offlineMode} = useContext(OfflineContext); const {session} = useContext(SessionContext); const {syncAllToServer: syncAllBooksToServer, syncAllFromServer: syncAllBooksFromServer, refreshBooks, booksToSyncToServer, booksToSyncFromServer} = useSyncBooks(); const {syncAllToServer: syncAllSeriesToServer, syncAllFromServer: syncAllSeriesFromServer, refreshSeries, seriesToSyncToServer, seriesToSyncFromServer} = useSyncSeries(); const isSyncingRef = useRef(false); const hasRefreshedRef = useRef(false); const saveLastOnlineTimestamp = useCallback((): void => { const timestamp: number = Math.floor(Date.now() / 1000); localStorage.setItem('lastOnlineTimestamp', timestamp.toString()); }, []); // Refresh sync data when online + authenticated + DB ready useEffect((): void => { if (!offlineMode.isOffline && session.isConnected && offlineMode.isDatabaseInitialized) { hasRefreshedRef.current = true; Promise.all([refreshBooks(), refreshSeries()]); } }, [offlineMode.isOffline, session.isConnected, offlineMode.isDatabaseInitialized]); // Auto-sync when diffs become available (reactive, no flags) useEffect((): void => { if (offlineMode.isOffline || !session.isConnected || isSyncingRef.current || !hasRefreshedRef.current) return; const syncPromises: Promise[] = []; if (booksToSyncToServer.length > 0) syncPromises.push(syncAllBooksToServer()); if (booksToSyncFromServer.length > 0) syncPromises.push(syncAllBooksFromServer()); if (seriesToSyncToServer.length > 0) syncPromises.push(syncAllSeriesToServer()); if (seriesToSyncFromServer.length > 0) syncPromises.push(syncAllSeriesFromServer()); if (syncPromises.length > 0) { isSyncingRef.current = true; Promise.all(syncPromises).then((): void => { saveLastOnlineTimestamp(); isSyncingRef.current = false; }).catch((): void => { isSyncingRef.current = false; }); } }, [booksToSyncToServer, booksToSyncFromServer, seriesToSyncToServer, seriesToSyncFromServer]); // Update lastOnlineTimestamp every 5 minutes while online useEffect((): (() => void) | void => { if (!offlineMode.isOffline && session.isConnected) { const intervalId: NodeJS.Timeout = setInterval((): void => { saveLastOnlineTimestamp(); }, 5 * 60 * 1000); return (): void => clearInterval(intervalId); } }, [offlineMode.isOffline, session.isConnected, saveLastOnlineTimestamp]); return null; } function ScribeContent() { const t = useTranslations(); const {lang: locale} = useContext(LangContext); const {errorMessage} = useContext(AlertContext); const {initializeDatabase, setOfflineMode, isCurrentlyOffline, offlineMode} = useContext(OfflineContext); const editor: Editor | null = useEditor({ extensions: [ StarterKit, Underline, TextAlign.configure({ types: ['heading', 'paragraph'], }), ], injectCSS: false, immediatelyRender: false, shouldRerenderOnTransaction: true, }); const [session, setSession] = useState({user: null, accessToken: '', isConnected: false}); const [currentChapter, setCurrentChapter] = useState(undefined); const [currentBook, setCurrentBook] = useState(null); const [serverSyncedBooks, setServerSyncedBooks] = useState([]); const [localSyncedBooks, setLocalSyncedBooks] = useState([]); const [bookSyncDiffsFromServer, setBookSyncDiffsFromServer] = useState([]); const [bookSyncDiffsToServer, setBookSyncDiffsToServer] = useState([]); const [serverOnlyBooks, setServerOnlyBooks] = useState([]); const [localOnlyBooks, setLocalOnlyBooks] = useState([]); const [serverSyncedSeries, setServerSyncedSeries] = useState([]); const [localSyncedSeries, setLocalSyncedSeries] = useState([]); const [seriesSyncDiffsFromServer, setSeriesSyncDiffsFromServer] = useState([]); const [seriesSyncDiffsToServer, setSeriesSyncDiffsToServer] = useState([]); const [serverOnlySeries, setServerOnlySeries] = useState([]); const [localOnlySeries, setLocalOnlySeries] = useState([]); const [currentCredits, setCurrentCredits] = useState(160); const [amountSpent, setAmountSpent] = useState(session.user?.aiUsage || 0); const [isLoading, setIsLoading] = useState(true); const [isTermsAccepted, setIsTermsAccepted] = useState(false); const [homeStepsGuide, setHomeStepsGuide] = useState(false); const [showPinSetup, setShowPinSetup] = useState(false); const [showPinVerify, setShowPinVerify] = useState(false); const [localSyncQueue, setLocalSyncQueue] = useState([]); const [isQueueProcessing, setIsQueueProcessing] = useState(false); function addToLocalSyncQueue(channel: string, data: Record): void { const operation: LocalSyncOperation = { id: `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`, channel, data, timestamp: Date.now(), }; setLocalSyncQueue((prev: LocalSyncOperation[]): LocalSyncOperation[] => [...prev, operation]); } useEffect((): void => { if (localSyncQueue.length === 0 || isQueueProcessing) { return; } async function processQueue(): Promise { setIsQueueProcessing(true); const queueCopy: LocalSyncOperation[] = [...localSyncQueue]; for (const operation of queueCopy) { try { await tauri.invoke(operation.channel, operation.data); setLocalSyncQueue((prev: LocalSyncOperation[]): LocalSyncOperation[] => prev.filter((op: LocalSyncOperation): boolean => op.id !== operation.id) ); } catch (error) { console.error(`[LocalSyncQueue] Failed to process operation ${operation.channel}:`, error); } } setIsQueueProcessing(false); } processQueue().then(); }, [localSyncQueue, isQueueProcessing]); const homeSteps: GuideStep[] = [ { id: 0, x: 50, y: 50, title: t("homePage.guide.welcome", {name: session.user?.name || ''}), content: (

{t("homePage.guide.step0.description1")}


{t("homePage.guide.step0.description2")}

), }, { id: 1, position: 'right', targetSelector: `[data-guide="left-panel-container"]`, title: t("homePage.guide.step1.title"), content: (

: {t("homePage.guide.step1.addBook")}


: {t("homePage.guide.step1.generateStory")}

), }, { id: 2, title: t("homePage.guide.step2.title"), position: 'bottom', targetSelector: `[data-guide="search-bar"]`, content: (

{t("homePage.guide.step2.description")}

), }, { id: 3, title: t("homePage.guide.step3.title"), targetSelector: `[data-guide="user-dropdown"]`, position: 'auto', content: (

{t("homePage.guide.step3.description")}

), }, { id: 4, title: t("homePage.guide.step4.title"), content: (

{t("homePage.guide.step4.description1")}


{t("homePage.guide.step4.description2")}

), }, ]; useEffect((): void => { checkAuthentification().then(); let unlisten: (() => void) | undefined; import('@tauri-apps/api/event').then(function ({listen}) { listen('auth-success', function () { checkAuthentification().then(); }).then(function (fn) { unlisten = fn; }); }); return (): void => { if (unlisten) unlisten(); }; }, []); useEffect((): void => { if (session.isConnected) { setIsTermsAccepted(session.user?.termsAccepted ?? false); setHomeStepsGuide(User.guideTourDone(session.user?.guideTour ?? [], 'home-basic')); setIsLoading(false); } }, [session]); useEffect((): void => { if (session.isConnected) { if (currentBook) { getLastChapter().then(); } else { refreshBooks().then(); } } }, [currentBook]); useEffect((): void => { const diffsFromServer: BookSyncCompare[] = []; const diffsToServer: BookSyncCompare[] = []; serverSyncedBooks.forEach((serverBook: SyncedBook): void => { const localBook: SyncedBook | undefined = localSyncedBooks.find((book: SyncedBook): boolean => book.id === serverBook.id); if (!localBook) { return; } const diff: BookSyncCompare | null = compareBookSyncs(serverBook, localBook); if (diff) { diffsFromServer.push(diff); } }); localSyncedBooks.forEach((localBook: SyncedBook): void => { const serverBook: SyncedBook | undefined = serverSyncedBooks.find((book: SyncedBook): boolean => book.id === localBook.id); if (!serverBook) { return; } const diff: BookSyncCompare | null = compareBookSyncs(localBook, serverBook); if (diff) { diffsToServer.push(diff); } }); setBookSyncDiffsFromServer(diffsFromServer); setBookSyncDiffsToServer(diffsToServer); setServerOnlyBooks(serverSyncedBooks.filter((serverBook: SyncedBook):boolean => !localSyncedBooks.find((localBook: SyncedBook):boolean => localBook.id === serverBook.id))) setLocalOnlyBooks(localSyncedBooks.filter((localBook: SyncedBook):boolean => !serverSyncedBooks.find((serverBook: SyncedBook):boolean => serverBook.id === localBook.id))) }, [localSyncedBooks, serverSyncedBooks]); useEffect((): void => { const diffsFromServer: SeriesSyncCompare[] = []; const diffsToServer: SeriesSyncCompare[] = []; serverSyncedSeries.forEach((serverSeries: SyncedSeries): void => { const localSeries: SyncedSeries | undefined = localSyncedSeries.find((series: SyncedSeries): boolean => series.id === serverSeries.id); if (!localSeries) { return; } const diff: SeriesSyncCompare | null = compareSeriesSyncs(serverSeries, localSeries); if (diff) { diffsFromServer.push(diff); } }); localSyncedSeries.forEach((localSeries: SyncedSeries): void => { const serverSeries: SyncedSeries | undefined = serverSyncedSeries.find((series: SyncedSeries): boolean => series.id === localSeries.id); if (!serverSeries) { return; } const diff: SeriesSyncCompare | null = compareSeriesSyncs(localSeries, serverSeries); if (diff) { diffsToServer.push(diff); } }); setSeriesSyncDiffsFromServer(diffsFromServer); setSeriesSyncDiffsToServer(diffsToServer); setServerOnlySeries(serverSyncedSeries.filter((serverSeries: SyncedSeries): boolean => !localSyncedSeries.find((localSeries: SyncedSeries): boolean => localSeries.id === serverSeries.id))); setLocalOnlySeries(localSyncedSeries.filter((localSeries: SyncedSeries): boolean => !serverSyncedSeries.find((serverSeries: SyncedSeries): boolean => serverSeries.id === localSeries.id))); }, [localSyncedSeries, serverSyncedSeries]); async function refreshBooks(): Promise { try { let localBooksResponse: SyncedBook[] = []; let serverBooksResponse: SyncedBook[] = []; if (!isCurrentlyOffline()) { if (offlineMode.isDatabaseInitialized) { localBooksResponse = await tauri.getSyncedBooks() as SyncedBook[]; const lastOnlineStr: string | null = localStorage.getItem('lastOnlineTimestamp'); const lastOnlineTimestamp: number = lastOnlineStr ? parseInt(lastOnlineStr, 10) : 0; const localTombstones: RemovedItemRecord[] = await tauri.getTombstonesSince(lastOnlineTimestamp) as RemovedItemRecord[]; const serverResponse: SyncedBooksResponse = await System.authPostToServer('books/synced', { lastOnlineTimestamp, tombstones: localTombstones }, session.accessToken, locale); serverBooksResponse = serverResponse.books; await tauri.applyBookTombstones(serverResponse.tombstones); } else { const serverResponse: SyncedBooksResponse = await System.authPostToServer('books/synced', { lastOnlineTimestamp: 0, tombstones: [] }, session.accessToken, locale); serverBooksResponse = serverResponse.books; } } else { if (offlineMode.isDatabaseInitialized) { localBooksResponse = await tauri.getSyncedBooks() as SyncedBook[]; } } setServerSyncedBooks(serverBooksResponse); setLocalSyncedBooks(localBooksResponse); } catch (e: unknown) { if (e instanceof Error) { errorMessage(e.message); } else { errorMessage(t("homePage.errors.fetchBooksError")); } } } async function refreshSeries(): Promise { try { let localSeriesResponse: SyncedSeries[] = []; let serverSeriesResponse: SyncedSeries[] = []; if (!isCurrentlyOffline()) { if (offlineMode.isDatabaseInitialized) { localSeriesResponse = await tauri.getSyncedSeries() as SyncedSeries[]; const lastOnlineStr: string | null = localStorage.getItem('lastOnlineTimestamp'); const lastOnlineTimestamp: number = lastOnlineStr ? parseInt(lastOnlineStr, 10) : 0; const localTombstones: RemovedItemRecord[] = await tauri.getTombstonesSince(lastOnlineTimestamp) as RemovedItemRecord[]; const serverResponse: SyncedSeriesResponse = await System.authPostToServer('series/synced', { lastOnlineTimestamp, tombstones: localTombstones }, session.accessToken, locale); serverSeriesResponse = serverResponse.series; await tauri.applySeriesTombstones(serverResponse.tombstones); } else { const serverResponse: SyncedSeriesResponse = await System.authPostToServer('series/synced', { lastOnlineTimestamp: 0, tombstones: [] }, session.accessToken, locale); serverSeriesResponse = serverResponse.series; } } else { if (offlineMode.isDatabaseInitialized) { localSeriesResponse = await tauri.getSyncedSeries() as SyncedSeries[]; } } setServerSyncedSeries(serverSeriesResponse); setLocalSyncedSeries(localSeriesResponse); } catch (e: unknown) { if (e instanceof Error) { errorMessage(e.message); } else { errorMessage(t("homePage.errors.fetchSeriesError")); } } } async function handlePinVerifySuccess(userId: string): Promise { try { const storedToken: string | null = await tauri.getToken(); const encryptionKey: string | null = await tauri.getUserEncryptionKey(userId); if (encryptionKey) { await tauri.dbInitialize(userId, encryptionKey); setOfflineMode(prev => ({...prev, isDatabaseInitialized: true})); const localUser: UserProps = await tauri.getUserInfo(); if (localUser && localUser.id) { setSession({ isConnected: true, user: localUser, accessToken: storedToken || '', }); setShowPinVerify(false); setCurrentCredits(localUser.creditsBalance || 0); setAmountSpent(localUser.aiUsage || 0); } else { errorMessage(t("homePage.errors.localDataError")); } } else { errorMessage(t("homePage.errors.encryptionKeyError")); } } catch (error) { console.error('[OfflinePin] Error initializing offline mode:', error); errorMessage(t("homePage.errors.offlineModeError")); } } async function handleHomeTour(): Promise { try { if (!isCurrentlyOffline()) { const response: boolean = await System.authPostToServer('logs/tour', { plateforme: 'desktop', tour: 'home-basic' }, session.accessToken, locale ); if (response) { setSession(User.setNewGuideTour(session, 'home-basic')); setHomeStepsGuide(false); } } else { 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) { if (e instanceof Error) { errorMessage(e.message); } else { errorMessage(t("homePage.errors.termsError")); } } } async function checkAuthentification(): Promise { let token: string | null = null; try { token = await tauri.getToken(); } catch (e) { console.error('Error getting token:', e); } if (token) { try { const user: UserProps = await System.authGetQueryToServer('user/infos', token, locale); if (!user) { errorMessage(t("homePage.errors.userNotFound")); await tauri.removeToken(); tauri.logout(); return; } if (user.id) { try { const initResult = await tauri.initUser(user.id); if (!initResult.success) { errorMessage(initResult.error || t("homePage.errors.offlineInitError")); return; } try { const offlineStatus = await tauri.offlineModeGet(); if (!offlineStatus.hasPin) { setTimeout(():void => { setShowPinSetup(true); }, 2000); } } catch (error) { console.error('[Page] Error checking offline mode:', error); } } catch (error) { console.error('[Page] Error initializing user:', error); } } if (user.id) { try { const dbInitialized: boolean = await initializeDatabase(user.id); if (dbInitialized) { try { await tauri.syncUser({ userId: user.id, username: user.username, email: user.email }); } catch (syncError) { console.error('[Page] syncUser failed:', syncError); errorMessage(t("homePage.errors.syncError")); } } else { errorMessage(t("homePage.errors.dbInitError")); } } catch (error) { console.error('[Page] DB init or sync failed:', error); errorMessage(t("homePage.errors.syncError")); } } setSession({ isConnected: true, user: user, accessToken: token, }); setCurrentCredits(user.creditsBalance) setAmountSpent(user.aiUsage) } catch (e: unknown) { try { const offlineStatus = await tauri.offlineModeGet(); if (offlineStatus.hasPin && offlineStatus.lastUserId) { setOfflineMode((prev:OfflineMode):OfflineMode => ({...prev, isOffline: true, isNetworkOnline: false})); setShowPinVerify(true); setIsLoading(false); return; } else { await tauri.removeToken(); tauri.logout(); } } catch (offlineError) { errorMessage(t("homePage.errors.offlineError")); } if (e instanceof Error) { errorMessage(e.message); } else { errorMessage(t("homePage.errors.authenticationError")); } } } else { try { const offlineStatus = await tauri.offlineModeGet(); if (offlineStatus.hasPin && offlineStatus.lastUserId) { setOfflineMode(prev => ({...prev, isOffline: true, isNetworkOnline: false})); setShowPinVerify(true); setIsLoading(false); return; } } catch (error) { errorMessage(t("homePage.errors.authenticationError")); } tauri.logout(); } } async function handleTermsAcceptance(): Promise { try { const response: boolean = await System.authPostToServer(`user/terms/accept`, { version: '2025-07-1' }, session.accessToken, locale); if (response) { setIsTermsAccepted(true); setHomeStepsGuide(true); const newSession: SessionProps = { ...session, user: { ...session?.user as UserProps, termsAccepted: true } } setSession(newSession); } else { errorMessage(t("homePage.errors.termsAcceptError")); } } catch (e: unknown) { if (e instanceof Error) { errorMessage(e.message); } else { errorMessage(t("homePage.errors.termsAcceptError")); } } } async function getLastChapter(): Promise { if (session?.accessToken) { try { let response: ChapterProps | null if (isCurrentlyOffline()){ if (!offlineMode.isDatabaseInitialized) { setCurrentChapter(undefined); return; } response = await tauri.getLastChapter(currentBook?.bookId ?? '') } else { if (currentBook?.localBook) { if (!offlineMode.isDatabaseInitialized) { setCurrentChapter(undefined); return; } response = await tauri.getLastChapter(currentBook?.bookId ?? '') } else { response = await System.authGetQueryToServer(`chapter/last-chapter`, session.accessToken, locale, {bookid: currentBook?.bookId}); } } if (response) { setCurrentChapter(response) } else { setCurrentChapter(undefined); } } catch (e: unknown) { if (e instanceof Error) { errorMessage(e.message); } else { errorMessage(t("homePage.errors.lastChapterError")); } } } } if (isLoading) { return (
ERitors Logo

{t("homePage.loading")}

) } return (
{ homeStepsGuide && !isCurrentlyOffline() && setHomeStepsGuide(false)}/> } { !isTermsAccepted && !isCurrentlyOffline() && } { showPinSetup && ( setShowPinSetup(false)} onSuccess={():void => { setShowPinSetup(false); }} /> ) } { showPinVerify && ( {}} /> ) }
); } export default function Scribe() { const [locale, setLocale] = useState<'fr' | 'en'>('fr'); useEffect((): void => { const lang: "fr" | "en" | null = System.getCookie('lang') as "fr" | "en" | null; if (lang) { setLocale(lang); } }, []); const messages = messagesMap[locale]; return ( ); }