Files
ERitors-Scribe-Desktop/components/layout/ScribeShell.tsx
natreex d4765e6576 Add foundational components and logic for migration, UI design, and input handling
- Introduced foundational UI components (`Badge`, `LockCard`, `SectionHeader`, `AvatarIcon`, etc.) for flexible layouts and consistent design.
- Added migration support with the `MigrationModal` component and backend integration for exporting/importing data between Electron and Tauri.
- Extended form components with `TextAreaInput`, `OrderInput`, `ToggleField`, and `ToolbarSelect` for improved input handling.
- Updated `ScribeShell` with migration popup logic to prompt users for data migration.
- Integrated `AlertStack` for better alert handling and notification management.
- Enhanced Rust/Tauri services with migration command implementations.
- Added translations and styles for new components.
2026-04-05 12:52:54 -04:00

488 lines
24 KiB
TypeScript

import React, {ReactNode, useCallback, useContext, useEffect, useRef, useState} from 'react';
import {BookContext} from '@/context/BookContext';
import {ChapterProps} from '@/lib/types/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, AlertContextProps, AlertProvider} from '@/context/AlertContext';
import {usePathname} from '@/lib/navigation';
import {apiPost} from '@/lib/api/client';
import {getCookie} from '@/lib/utils/cookies';
import {SessionContext, SessionContextProps} from '@/context/SessionContext';
import {BookProps} from '@/lib/types/book';
import ScribeControllerBar from '@/components/layout/ScribeControllerBar';
import ScribeLeftBar from '@/components/leftbar/ScribeLeftBar';
import ComposerRightBar from '@/components/rightbar/ComposerRightBar';
import ScribeFooterBar from '@/components/layout/ScribeFooterBar';
import GuideTour from '@/components/GuideTour';
import TermsOfUse from '@/components/TermsOfUse';
import {useTranslations, changeLanguage} from '@/lib/i18n';
import {isSupportedLocale, LangContext, LangContextProps, SupportedLocale} from '@/context/LangContext';
import {ThemeContext} from '@/context/ThemeContext';
import useTheme from '@/hooks/useTheme';
import {AIUsageContext} from '@/context/AIUsageContext';
import {BookSyncCompare, compareBookSyncs, SyncedBook} from '@/lib/types/synced-book';
import {SeriesSyncCompare, compareSeriesSyncs, SyncedSeries} from '@/lib/types/synced-series';
import {BooksSyncContext} from '@/context/BooksSyncContext';
import {SeriesSyncContext} from '@/context/SeriesSyncContext';
import {LocalSyncQueueContext, LocalSyncOperation} from '@/context/SyncQueueContext';
import useAuthentication from '@/hooks/useAuthentication';
import useOnboarding from '@/hooks/useOnboarding';
import {SettingBookContext} from '@/context/SettingBookContext';
import PulseLoader from '@/components/ui/PulseLoader';
import {initCrashReporter} from '@/lib/crashReporter';
import OfflineProvider from '@/context/OfflineProvider';
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
import OfflinePinSetup from '@/components/offline/OfflinePinSetup';
import OfflinePinVerify from '@/components/offline/OfflinePinVerify';
import MigrationModal from '@/components/migration/MigrationModal';
import {isDesktop} from '@/lib/configs';
import * as tauri from '@/lib/tauri';
import useSyncBooks from '@/hooks/useSyncBooks';
import useSyncSeries from '@/hooks/useSyncSeries';
function AutoSyncOnReconnect() {
const {offlineMode}: OfflineContextType = useContext<OfflineContextType>(OfflineContext);
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
const {syncAllToServer: syncAllBooksToServer, syncAllFromServer: syncAllBooksFromServer, refreshBooks, booksToSyncToServer, booksToSyncFromServer} = useSyncBooks();
const {syncAllToServer: syncAllSeriesToServer, syncAllFromServer: syncAllSeriesFromServer, refreshSeries, seriesToSyncToServer, seriesToSyncFromServer} = useSyncSeries();
const isSyncingRef = useRef<boolean>(false);
const hasRefreshedRef = useRef<boolean>(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
useEffect((): void => {
if (offlineMode.isOffline || !session.isConnected || isSyncingRef.current || !hasRefreshedRef.current) return;
const syncPromises: Promise<void>[] = [];
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: ReturnType<typeof setInterval> = setInterval((): void => {
saveLastOnlineTimestamp();
}, 5 * 60 * 1000);
return (): void => clearInterval(intervalId);
}
}, [offlineMode.isOffline, session.isConnected, saveLastOnlineTimestamp]);
return null;
}
function ScribeContent({children}: { children: ReactNode }) {
const t = useTranslations();
const {lang: locale}: LangContextProps = useContext<LangContextProps>(LangContext);
const {errorMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
const {isCurrentlyOffline, offlineMode}: OfflineContextType = useContext<OfflineContextType>(OfflineContext);
const {
session, setSession, isLoading,
currentCredits, setCurrentCredits,
amountSpent, setAmountSpent,
showPinSetup, setShowPinSetup,
showPinVerify, setShowPinVerify,
handlePinVerifySuccess
} = useAuthentication();
const {
isTermsAccepted, homeStepsGuide, setHomeStepsGuide,
handleTermsAcceptance, handleHomeTour, homeSteps
} = useOnboarding({session, setSession});
const editor: Editor | null = useEditor({
extensions: [
StarterKit,
Underline,
TextAlign.configure({
types: ['heading', 'paragraph'],
}),
],
injectCSS: false,
immediatelyRender: false,
shouldRerenderOnTransaction: true,
});
const pathname: string = usePathname();
const [currentChapter, setCurrentChapter] = useState<ChapterProps | undefined>(undefined);
const [currentBook, setCurrentBook] = useState<BookProps | null>(null);
const [bookSettingId, setBookSettingId] = useState<string>('');
const [showMigrationPopup, setShowMigrationPopup] = useState<boolean>(false);
useEffect(function (): void {
if (!isDesktop) return;
const done: boolean = localStorage.getItem('electron_migration_done') === 'true';
const dismissed: boolean = localStorage.getItem('electron_migration_dismissed') === 'true';
if (!done && !dismissed) {
setShowMigrationPopup(true);
}
}, []);
const [serverSyncedBooks, setServerSyncedBooks] = useState<SyncedBook[]>([]);
const [localSyncedBooks, setLocalSyncedBooks] = useState<SyncedBook[]>([]);
const [bookSyncDiffsFromServer, setBookSyncDiffsFromServer] = useState<BookSyncCompare[]>([]);
const [bookSyncDiffsToServer, setBookSyncDiffsToServer] = useState<BookSyncCompare[]>([]);
const [serverOnlyBooks, setServerOnlyBooks] = useState<SyncedBook[]>([]);
const [localOnlyBooks, setLocalOnlyBooks] = useState<SyncedBook[]>([]);
const [serverSyncedSeries, setServerSyncedSeries] = useState<SyncedSeries[]>([]);
const [localSyncedSeries, setLocalSyncedSeries] = useState<SyncedSeries[]>([]);
const [seriesSyncDiffsFromServer, setSeriesSyncDiffsFromServer] = useState<SeriesSyncCompare[]>([]);
const [seriesSyncDiffsToServer, setSeriesSyncDiffsToServer] = useState<SeriesSyncCompare[]>([]);
const [serverOnlySeries, setServerOnlySeries] = useState<SyncedSeries[]>([]);
const [localOnlySeries, setLocalOnlySeries] = useState<SyncedSeries[]>([]);
const [localSyncQueue, setLocalSyncQueue] = useState<LocalSyncOperation[]>([]);
const [isQueueProcessing, setIsQueueProcessing] = useState<boolean>(false);
function addToLocalSyncQueue(channel: string, data: Record<string, unknown>): 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<void> {
setIsQueueProcessing(true);
const queueCopy: LocalSyncOperation[] = [...localSyncQueue];
for (const operation of queueCopy) {
try {
await tauri.invoke(operation.channel, {data: operation.data});
setLocalSyncQueue((prev: LocalSyncOperation[]): LocalSyncOperation[] =>
prev.filter((op: LocalSyncOperation): boolean => op.id !== operation.id)
);
} catch (_e) { /* queue retry on next cycle */ }
}
setIsQueueProcessing(false);
}
processQueue().then();
}, [localSyncQueue, isQueueProcessing]);
useEffect((): void => {
if (pathname === '/') {
setCurrentBook(null);
setCurrentChapter(undefined);
}
}, [pathname]);
useEffect((): void => {
if (!session.isConnected) return;
// On desktop with DB initialized, AutoSyncOnReconnect handles fetching + tombstone exchange
if (isDesktop && offlineMode.isDatabaseInitialized) return;
getBooks().then();
getSeries().then();
}, [session, offlineMode.isDatabaseInitialized]);
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((s: SyncedSeries): boolean => !localSyncedSeries.find((l: SyncedSeries): boolean => l.id === s.id)));
setLocalOnlySeries(localSyncedSeries.filter((l: SyncedSeries): boolean => !serverSyncedSeries.find((s: SyncedSeries): boolean => s.id === l.id)));
}, [localSyncedSeries, serverSyncedSeries]);
async function getBooks(): Promise<void> {
try {
let localBooksResponse: SyncedBook[] = [];
let serverBooksResponse: SyncedBook[] = [];
if (!isCurrentlyOffline()) {
if (isDesktop && 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: tauri.TombstoneRecord[] = await tauri.getTombstonesSince(lastOnlineTimestamp) as tauri.TombstoneRecord[];
const serverResponse: { books: SyncedBook[]; tombstones: tauri.TombstoneRecord[] } = await apiPost<{
books: SyncedBook[]; tombstones: tauri.TombstoneRecord[];
}>('books/synced', { lastOnlineTimestamp, tombstones: localTombstones }, session.accessToken, locale);
serverBooksResponse = serverResponse.books;
await tauri.applyBookTombstones(serverResponse.tombstones ?? []);
} else {
const serverResponse: { books: SyncedBook[] } = await apiPost<{
books: SyncedBook[]
}>('books/synced', { lastOnlineTimestamp: 0, tombstones: [] }, session.accessToken, locale);
if (serverResponse) serverBooksResponse = serverResponse.books;
}
} else {
if (isDesktop && 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 getSeries(): Promise<void> {
try {
let localSeriesResponse: SyncedSeries[] = [];
let serverSeriesResponse: SyncedSeries[] = [];
if (!isCurrentlyOffline()) {
if (isDesktop && 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: tauri.TombstoneRecord[] = await tauri.getTombstonesSince(lastOnlineTimestamp) as tauri.TombstoneRecord[];
const serverResponse: { series: SyncedSeries[]; tombstones: tauri.TombstoneRecord[] } = await apiPost<{
series: SyncedSeries[]; tombstones: tauri.TombstoneRecord[];
}>('series/synced', { lastOnlineTimestamp, tombstones: localTombstones }, session.accessToken, locale);
serverSeriesResponse = serverResponse.series;
await tauri.applySeriesTombstones(serverResponse.tombstones ?? []);
} else {
const serverResponse: { series: SyncedSeries[] } = await apiPost<{
series: SyncedSeries[]
}>('series/synced', { lastOnlineTimestamp: 0, tombstones: [] }, session.accessToken, locale);
if (serverResponse) serverSeriesResponse = serverResponse.series;
}
} else {
if (isDesktop && 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.fetchBooksError'));
}
}
}
if (isLoading) {
return (
<div
className="bg-background text-text-primary h-screen flex flex-col items-center justify-center font-['Lora']">
<div className="flex flex-col items-center space-y-6">
<div className="animate-pulse">
<img src="/logo.png" alt="ERitors Logo" width={400} height={400}/>
</div>
<PulseLoader text={t('homePage.loading')}/>
</div>
</div>
);
}
return (
<SessionContext.Provider value={{session: session, setSession: setSession}}>
<LocalSyncQueueContext.Provider value={{
queue: localSyncQueue,
setQueue: setLocalSyncQueue,
addToQueue: addToLocalSyncQueue,
isProcessing: isQueueProcessing,
}}>
<BooksSyncContext.Provider value={{
serverSyncedBooks,
setServerSyncedBooks,
localSyncedBooks,
setLocalSyncedBooks,
booksToSyncFromServer: bookSyncDiffsFromServer,
setBooksToSyncFromServer: setBookSyncDiffsFromServer,
booksToSyncToServer: bookSyncDiffsToServer,
setBooksToSyncToServer: setBookSyncDiffsToServer,
setServerOnlyBooks,
setLocalOnlyBooks,
serverOnlyBooks,
localOnlyBooks
}}>
<SeriesSyncContext.Provider value={{
serverSyncedSeries,
localSyncedSeries,
seriesToSyncFromServer: seriesSyncDiffsFromServer,
seriesToSyncToServer: seriesSyncDiffsToServer,
setServerSyncedSeries,
setLocalSyncedSeries,
setServerOnlySeries,
setLocalOnlySeries,
setSeriesToSyncFromServer: setSeriesSyncDiffsFromServer,
setSeriesToSyncToServer: setSeriesSyncDiffsToServer,
serverOnlySeries,
localOnlySeries
}}>
{isDesktop && <AutoSyncOnReconnect/>}
<BookContext.Provider value={{book: currentBook, setBook: setCurrentBook}}>
<ChapterContext.Provider value={{chapter: currentChapter, setChapter: setCurrentChapter}}>
<AIUsageContext.Provider value={{
totalCredits: currentCredits,
setTotalCredits: setCurrentCredits,
totalPrice: amountSpent,
setTotalPrice: setAmountSpent
}}>
<SettingBookContext.Provider value={{bookSettingId, setBookSettingId}}>
<div
className="bg-tertiary text-text-primary h-screen flex flex-col font-['Lora']">
<EditorContext.Provider value={{editor: editor}}>
<ScribeControllerBar/>
<div className="flex-1 flex overflow-hidden">
<ScribeLeftBar/>
<div
className="flex-1 min-w-0 bg-darkest-background rounded-xl m-1 overflow-hidden">
{children}
</div>
<ComposerRightBar/>
</div>
<ScribeFooterBar/>
</EditorContext.Provider>
</div>
{homeStepsGuide &&
<GuideTour stepId={0} steps={homeSteps} onComplete={handleHomeTour}
onClose={(): void => setHomeStepsGuide(false)}/>
}
{!isCurrentlyOffline() && !isTermsAccepted && <TermsOfUse onAccept={handleTermsAcceptance}/>}
{isDesktop && showPinSetup && (
<OfflinePinSetup
showOnFirstLogin={true}
onClose={(): void => setShowPinSetup(false)}
onSuccess={(): void => setShowPinSetup(false)}
/>
)}
{isDesktop && showPinVerify && (
<OfflinePinVerify
onSuccess={handlePinVerifySuccess}
onCancel={(): void => {}}
/>
)}
{showMigrationPopup && (
<MigrationModal
onClose={function (): void { setShowMigrationPopup(false); }}
onSuccess={function (): void { setShowMigrationPopup(false); window.location.reload(); }}
/>
)}
</SettingBookContext.Provider>
</AIUsageContext.Provider>
</ChapterContext.Provider>
</BookContext.Provider>
</SeriesSyncContext.Provider>
</BooksSyncContext.Provider>
</LocalSyncQueueContext.Provider>
</SessionContext.Provider>
);
}
export default function ScribeShell({children}: { children: ReactNode }) {
const [locale, setLocale] = useState<SupportedLocale>('fr');
const {theme, setTheme} = useTheme();
useEffect((): void => {
initCrashReporter();
}, []);
useEffect((): void => {
const lang: string | null = getCookie('lang');
if (lang && isSupportedLocale(lang)) {
setLocale(lang);
}
}, []);
useEffect((): void => {
changeLanguage(locale);
}, [locale]);
return (
<ThemeContext.Provider value={{theme, setTheme}}>
<LangContext.Provider value={{lang: locale, setLang: setLocale}}>
<OfflineProvider>
<AlertProvider>
<ScribeContent>{children}</ScribeContent>
</AlertProvider>
</OfflineProvider>
</LangContext.Provider>
</ThemeContext.Provider>
);
}