Bump app version to 0.5.1 and add auto-update support

- Implemented auto-update logic in `ScribeTopBar` with update notification and user interaction.
- Integrated `@tauri-apps/plugin-updater` and `@tauri-apps/plugin-process` for updater functionality.
- Added automatic migration feature with `autoMigrateElectron` support and UI feedback.
- Refactored app architecture with new routing, components, and layout for better modularity.
- Enhanced JSON response handling in API client for robust data parsing.
- Updated locales to include new translations for update and migration-related UI.
This commit is contained in:
natreex
2026-04-07 16:09:35 -04:00
parent 687c1d582c
commit 5c7e71ce9e
19 changed files with 461 additions and 46 deletions

5
app/HomePage.tsx Normal file
View File

@@ -0,0 +1,5 @@
import BookList from '@/components/book/BookList';
export default function HomePage() {
return <BookList/>;
}

1
app/book/BookLayout.tsx Normal file
View File

@@ -0,0 +1 @@
export {default} from '@/app/book/[bookId]/layout';

1
app/book/BookPage.tsx Normal file
View File

@@ -0,0 +1 @@
export {default} from '@/app/book/[bookId]/page';

1
app/book/ChapterPage.tsx Normal file
View File

@@ -0,0 +1 @@
export {default} from '@/app/book/[bookId]/chapter/[chapterId]/page';

View File

@@ -0,0 +1,77 @@
'use client';
import React, {useContext, useEffect, useRef} from 'react';
import {useParams} from '@/lib/navigation';
import {ChapterContext, ChapterContextProps} from '@/context/ChapterContext';
import {SessionContext, SessionContextProps} from '@/context/SessionContext';
import {LangContext, LangContextProps} from '@/context/LangContext';
import {AlertContext, AlertContextProps} from '@/context/AlertContext';
import {apiGet} from '@/lib/api/client';
import {isDesktop} from '@/lib/configs';
import * as tauri from '@/lib/tauri';
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
import {BookContext, BookContextProps} from '@/context/BookContext';
import {ChapterProps} from '@/lib/types/chapter';
import {useTranslations} from '@/lib/i18n';
import TextEditor from '@/components/editor/TextEditor';
export default function ChapterPage() {
const params: { bookId: string; chapterId: string } = useParams<{ bookId: string; chapterId: string }>();
const {chapter, setChapter}: ChapterContextProps = useContext<ChapterContextProps>(ChapterContext);
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext);
const {errorMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
const {book}: BookContextProps = useContext<BookContextProps>(BookContext);
const {isCurrentlyOffline}: OfflineContextType = useContext<OfflineContextType>(OfflineContext);
const t = useTranslations();
const hasFetched = useRef<string>('');
useEffect((): void => {
if (!session.accessToken || !params.chapterId) return;
if (chapter && chapter.chapterId === params.chapterId) {
hasFetched.current = params.chapterId;
return;
}
if (hasFetched.current === params.chapterId) return;
hasFetched.current = params.chapterId;
fetchChapter().then();
}, [params.chapterId, session.accessToken, chapter]);
async function fetchChapter(): Promise<void> {
try {
const isFirstLoad: boolean = !chapter;
let response: ChapterProps | null;
if (isDesktop && (isCurrentlyOffline() || book?.localBook)) {
if (isFirstLoad) {
response = await tauri.getLastChapter(params.bookId);
} else {
response = await tauri.getWholeChapter(params.chapterId, chapter!.chapterContent.version, params.bookId);
}
} else {
const endpoint: string = isFirstLoad ? 'chapter/last-chapter' : 'chapter/whole';
const queryParams: Record<string, string | number> = isFirstLoad
? {bookid: params.bookId}
: {bookid: params.bookId, id: params.chapterId, version: chapter!.chapterContent.version};
response = await apiGet<ChapterProps | null>(
endpoint, session.accessToken, lang, queryParams
);
}
if (!response) {
errorMessage(t('scribeChapterComponent.errorFetchChapter'));
return;
}
setChapter(response);
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t('scribeChapterComponent.errorFetchChapter'));
}
}
}
if (!chapter || chapter.chapterId !== params.chapterId) {
return null;
}
return <TextEditor/>;
}

View File

@@ -0,0 +1,86 @@
'use client';
import React, {ReactNode, useContext, useEffect} from 'react';
import {useParams} from '@/lib/navigation';
import {BookContext, BookContextProps} from '@/context/BookContext';
import {SessionContext, SessionContextProps} from '@/context/SessionContext';
import {LangContext, LangContextProps} from '@/context/LangContext';
import {AlertContext, AlertContextProps} from '@/context/AlertContext';
import {apiGet} from '@/lib/api/client';
import {isDesktop} from '@/lib/configs';
import * as tauri from '@/lib/tauri';
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
import {BookProps} from '@/lib/types/book';
import {useTranslations} from '@/lib/i18n';
import {BooksSyncContext, BooksSyncContextProps} from '@/context/BooksSyncContext';
import {SyncedBook} from '@/lib/types/synced-book';
export default function BookLayout({children}: { children: ReactNode }) {
const params: { bookId: string } = useParams<{ bookId: string }>();
const {book, setBook}: BookContextProps = useContext<BookContextProps>(BookContext);
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext);
const {errorMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
const {isCurrentlyOffline}: OfflineContextType = useContext<OfflineContextType>(OfflineContext);
const {localOnlyBooks}: BooksSyncContextProps = useContext<BooksSyncContextProps>(BooksSyncContext);
const t = useTranslations();
useEffect((): void => {
if (session.accessToken && params.bookId) {
if (!book || book.bookId !== params.bookId) {
fetchBook().then();
}
}
}, [params.bookId, session.accessToken]);
async function fetchBook(): Promise<void> {
try {
let localBookOnly: boolean = false;
let bookResponse: BookProps | null = null;
if (isCurrentlyOffline()) {
bookResponse = await tauri.getBookBasicInformation(params.bookId);
if (bookResponse) localBookOnly = true;
} else {
const isOfflineBook = localOnlyBooks.find((b: SyncedBook): boolean => b.id === params.bookId);
if (isOfflineBook) {
bookResponse = await tauri.getBookBasicInformation(params.bookId);
localBookOnly = true;
}
if (!bookResponse) {
bookResponse = await apiGet<BookProps>(
'book/basic-information', session.accessToken, lang, {id: params.bookId}
);
}
}
if (!bookResponse) {
errorMessage(t('controllerBar.bookNotFound'));
return;
}
setBook({
bookId: bookResponse.bookId,
type: bookResponse.type,
title: bookResponse.title,
subTitle: bookResponse.subTitle,
summary: bookResponse.summary,
publicationDate: bookResponse.publicationDate,
desiredWordCount: bookResponse.desiredWordCount,
totalWordCount: bookResponse.totalWordCount ?? 0,
quillsenseEnabled: bookResponse.quillsenseEnabled,
tools: bookResponse.tools,
seriesId: bookResponse.seriesId,
serie: bookResponse.serie,
coverImage: bookResponse.coverImage ? 'data:image/jpeg;base64,' + bookResponse.coverImage : '',
localBook: localBookOnly,
});
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t('controllerBar.unknownBookError'));
}
}
}
return <>{children}</>;
}

View File

@@ -0,0 +1,67 @@
'use client';
import React, {useContext, useEffect, useState} from 'react';
import {useParams, useRouter} from '@/lib/navigation';
import {SessionContext, SessionContextProps} from '@/context/SessionContext';
import {LangContext, LangContextProps} from '@/context/LangContext';
import {AlertContext, AlertContextProps} from '@/context/AlertContext';
import {ChapterContext, ChapterContextProps} from '@/context/ChapterContext';
import {apiGet} from '@/lib/api/client';
import {isDesktop} from '@/lib/configs';
import * as tauri from '@/lib/tauri';
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
import {BookContext, BookContextProps} from '@/context/BookContext';
import {ChapterProps} from '@/lib/types/chapter';
import {useTranslations} from '@/lib/i18n';
import NoBookHome from '@/components/editor/NoBookHome';
export default function BookPage() {
const params: { bookId: string } = useParams<{ bookId: string }>();
const router = useRouter();
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext);
const {errorMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
const {setChapter}: ChapterContextProps = useContext<ChapterContextProps>(ChapterContext);
const {book}: BookContextProps = useContext<BookContextProps>(BookContext);
const {isCurrentlyOffline}: OfflineContextType = useContext<OfflineContextType>(OfflineContext);
const t = useTranslations();
const [isRedirecting, setIsRedirecting] = useState<boolean>(true);
useEffect((): void => {
if (session.accessToken && params.bookId) {
redirectToLastChapter().then();
}
}, [session.accessToken, params.bookId]);
async function redirectToLastChapter(): Promise<void> {
try {
let response: ChapterProps | null;
if (isDesktop && (isCurrentlyOffline() || book?.localBook)) {
response = await tauri.getLastChapter(params.bookId);
} else {
response = await apiGet<ChapterProps | null>(
'chapter/last-chapter', session.accessToken, lang, {bookid: params.bookId}
);
}
if (response) {
setChapter(response);
router.replace(`/book/${params.bookId}/chapter/${response.chapterId}`);
return;
}
setIsRedirecting(false);
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t('homePage.errors.lastChapterError'));
}
setIsRedirecting(false);
}
}
if (isRedirecting) {
return null;
}
return <NoBookHome/>;
}

1
app/login/page.tsx Normal file
View File

@@ -0,0 +1 @@
export {default} from './login/page';

137
app/main.tsx Normal file
View File

@@ -0,0 +1,137 @@
import React, {useEffect, useState} from 'react';
import ReactDOM from 'react-dom/client';
import {BrowserRouter, Routes, Route, Outlet} from 'react-router-dom';
import '@/app/globals.css';
import '@/lib/i18n';
import {listen} from '@tauri-apps/api/event';
import * as tauri from '@/lib/tauri';
import PulseLoader from '@/components/ui/PulseLoader';
import {useTranslations} from '@/lib/i18n';
listen('auth-success', () => window.location.reload());
type MigrationState = 'pending' | 'error' | 'done';
function AppInitializer({children}: { children: React.ReactNode }) {
const t = useTranslations();
const [state, setState] = useState<MigrationState>('pending');
const [retryCount, setRetryCount] = useState<number>(0);
useEffect(function (): void {
setState('pending');
tauri.autoMigrateElectron().then(function (result): void {
if (result.migrated) {
window.location.reload();
} else if (result.error) {
setState('error');
} else {
setState('done');
}
}).catch(function (): void {
setState('error');
});
}, [retryCount]);
if (state === 'pending') {
return (
<div className="bg-background text-text-primary h-screen flex flex-col items-center justify-center gap-4 font-['Lora']">
<PulseLoader text={t('migration.autoMigrating')}/>
</div>
);
}
if (state === 'error') {
return (
<div className="bg-background text-text-primary h-screen flex flex-col items-center justify-center gap-6 font-['Lora'] p-8">
<div className="flex flex-col items-center gap-3 max-w-md text-center">
<p className="text-lg font-semibold">{t('migration.autoErrorTitle')}</p>
<p className="text-sm text-text-secondary">{t('migration.autoErrorText')}</p>
</div>
<div className="flex flex-col items-center gap-3">
<button
className="px-6 py-2 bg-primary text-white rounded-lg hover:opacity-90 transition-opacity"
onClick={function (): void { setRetryCount(function (n): number { return n + 1; }); }}
>
{t('migration.autoErrorRetry')}
</button>
<div className="flex flex-col items-center gap-1">
<button
className="px-6 py-2 text-sm text-text-secondary hover:text-text-primary underline transition-colors"
onClick={function (): void { setState('done'); }}
>
{t('migration.autoErrorContinue')}
</button>
<p className="text-xs text-text-secondary opacity-70">{t('migration.autoErrorContinueWarning')}</p>
</div>
</div>
</div>
);
}
return <>{children}</>;
}
import ScribeShell from '@/components/layout/ScribeShell';
import LoginWrapper from '@/app/login/LoginWrapper';
import HomePage from '@/app/HomePage';
import BookPage from '@/app/book/BookPage';
import ChapterPage from '@/app/book/ChapterPage';
import BookLayout from '@/app/book/BookLayout';
import LoginPage from '@/app/login/login/page';
import RegisterPage from '@/app/login/register/page';
import ResetPasswordPage from '@/app/login/reset-password/page';
import OfflineLoginPage from '@/app/login/offline/page';
function LoginShell() {
return (
<LoginWrapper>
<Outlet/>
</LoginWrapper>
);
}
function MainShell() {
return (
<ScribeShell>
<Outlet/>
</ScribeShell>
);
}
function BookShell() {
return (
<BookLayout>
<Outlet/>
</BookLayout>
);
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<AppInitializer>
<BrowserRouter>
<Routes>
{/* Login routes — dedicated window, no ScribeShell */}
<Route element={<LoginShell/>}>
<Route path="/login" element={<LoginPage/>}/>
<Route path="/login/login" element={<LoginPage/>}/>
<Route path="/login/register" element={<RegisterPage/>}/>
<Route path="/login/reset-password" element={<ResetPasswordPage/>}/>
<Route path="/login/offline" element={<OfflineLoginPage/>}/>
</Route>
{/* Main app routes — with ScribeShell */}
<Route element={<MainShell/>}>
<Route path="/" element={<HomePage/>}/>
<Route element={<BookShell/>}>
<Route path="/book/:bookId" element={<BookPage/>}/>
<Route path="/book/:bookId/chapter/:chapterId" element={<ChapterPage/>}/>
</Route>
</Route>
</Routes>
</BrowserRouter>
</AppInitializer>
</React.StrictMode>
);