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.
This commit is contained in:
160
components/layout/ScribeControllerBar.tsx
Normal file
160
components/layout/ScribeControllerBar.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import React, {useContext, useState} from "react";
|
||||
const logo = "/eritors-favicon-white.png";
|
||||
import {ChapterProps} from "@/lib/types/chapter";
|
||||
import {chapterVersions} from "@/lib/constants/chapter";
|
||||
import {ChapterContext, ChapterContextProps} from "@/context/ChapterContext";
|
||||
import {BookContext, BookContextProps} from "@/context/BookContext";
|
||||
import {apiGet} from '@/lib/api/client';
|
||||
import {isDesktop} from '@/lib/configs';
|
||||
import * as tauri from '@/lib/tauri';
|
||||
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
|
||||
import {getCookie, setCookie} from '@/lib/utils/cookies';
|
||||
import {useRouter} from "@/lib/navigation";
|
||||
import UserMenu from "@/components/layout/UserMenu";
|
||||
import {Globe, Home, Settings} from "lucide-react";
|
||||
import IconButton from "@/components/ui/IconButton";
|
||||
import ToggleGroup from "@/components/ui/ToggleGroup";
|
||||
import {SelectBoxProps} from "@/components/form/SelectBox";
|
||||
import ToolbarSelect from "@/components/form/ToolbarSelect";
|
||||
import {AlertContext, AlertContextProps} from "@/context/AlertContext";
|
||||
import {SessionContext, SessionContextProps} from "@/context/SessionContext";
|
||||
import {booksToSelectBox} from "@/lib/utils/book";
|
||||
import BookSetting from "@/components/book/settings/BookSetting";
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
import {isSupportedLocale, LangContext, LangContextProps, SupportedLocale} from "@/context/LangContext";
|
||||
import CreditCounter from "@/components/ui/CreditMeters";
|
||||
import {getSubLevel, isAnthropicEnabled} from "@/lib/utils/quillsense";
|
||||
import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext";
|
||||
import OfflineToggle from "@/components/offline/OfflineToggle";
|
||||
|
||||
export default function ScribeControllerBar() {
|
||||
const {chapter, setChapter}: ChapterContextProps = useContext<ChapterContextProps>(ChapterContext);
|
||||
const {book}: BookContextProps = useContext<BookContextProps>(BookContext);
|
||||
const router = useRouter();
|
||||
const {errorMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
|
||||
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
|
||||
const t = useTranslations();
|
||||
const {lang, setLang}: LangContextProps = useContext<LangContextProps>(LangContext)
|
||||
const {serverSyncedBooks, serverOnlyBooks, localOnlyBooks}: BooksSyncContextProps = useContext<BooksSyncContextProps>(BooksSyncContext);
|
||||
const {isCurrentlyOffline}: OfflineContextType = useContext<OfflineContextType>(OfflineContext);
|
||||
|
||||
const anthropicEnabled: boolean = !isCurrentlyOffline() && isAnthropicEnabled(session);
|
||||
const isSubTierTwo: boolean = !isCurrentlyOffline() && getSubLevel(session) >= 2;
|
||||
const hasAccess: boolean = anthropicEnabled || isSubTierTwo;
|
||||
|
||||
const [showSettingPanel, setShowSettingPanel] = useState<boolean>(false);
|
||||
|
||||
async function handleChapterVersionChanged(version: number) {
|
||||
try {
|
||||
let response: ChapterProps;
|
||||
if (isDesktop && (isCurrentlyOffline() || book?.localBook)) {
|
||||
response = await tauri.getWholeChapter(chapter?.chapterId ?? '', version, book?.bookId ?? '');
|
||||
} else {
|
||||
response = await apiGet<ChapterProps>(`chapter/whole`, session.accessToken, lang, {
|
||||
bookid: book?.bookId,
|
||||
id: chapter?.chapterId,
|
||||
version: version,
|
||||
});
|
||||
}
|
||||
if (!response) {
|
||||
errorMessage(t("controllerBar.chapterNotFound"));
|
||||
return;
|
||||
}
|
||||
setChapter(response);
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
} else {
|
||||
errorMessage(t("controllerBar.unknownChapterError"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleBookNavigation(bookId: string): void {
|
||||
router.push(`/book/${bookId}`);
|
||||
}
|
||||
|
||||
function handleLanguageChange(language: SupportedLocale): void {
|
||||
setCookie('lang', language, 365);
|
||||
const newLang: string | null = getCookie('lang');
|
||||
if (newLang && isSupportedLocale(newLang)) {
|
||||
setLang(language);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex items-center justify-between px-4 py-2 bg-tertiary">
|
||||
{/* Gauche : Logo + contrôles */}
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex items-center space-x-2 pr-3 border-r border-secondary">
|
||||
<img src={logo} alt={t("scribeTopBar.logoAlt")} width={24} height={24}/>
|
||||
<span className="font-['ADLaM_Display'] text-sm tracking-wide text-text-primary">Scribe</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{book && (
|
||||
<IconButton icon={Settings} variant="ghost" shape="square"
|
||||
onClick={(): void => setShowSettingPanel(true)}/>
|
||||
)}
|
||||
{book && (
|
||||
<IconButton icon={Home} variant="ghost" shape="square"
|
||||
onClick={(): void => router.push('/')}/>
|
||||
)}
|
||||
</div>
|
||||
<ToolbarSelect onChangeCallBack={(e) => handleBookNavigation(e.target.value)}
|
||||
data={booksToSelectBox([...(serverSyncedBooks ?? []), ...(serverOnlyBooks ?? []), ...(localOnlyBooks ?? [])])} defaultValue={book?.bookId}
|
||||
placeholder={t("controllerBar.selectBook")}/>
|
||||
{chapter && (
|
||||
<ToolbarSelect onChangeCallBack={(e) => handleChapterVersionChanged(parseInt(e.target.value))}
|
||||
data={chapterVersions.filter((version: SelectBoxProps): boolean => {
|
||||
return !(version.value === '1' && (!hasAccess || book?.quillsenseEnabled === false));
|
||||
}).map((version: SelectBoxProps) => {
|
||||
return {
|
||||
value: version.value.toString(),
|
||||
label: t(version.label)
|
||||
}
|
||||
})} defaultValue={chapter?.chapterContent.version.toString()}/>
|
||||
)}
|
||||
</div>
|
||||
{/* Centre : Titre du livre */}
|
||||
{book && (
|
||||
<div
|
||||
className="absolute left-1/2 -translate-x-1/2 flex items-center bg-secondary px-4 py-1.5 rounded-lg border border-secondary">
|
||||
<div className="h-4 w-0.5 bg-primary rounded-full mr-3"></div>
|
||||
<div className="text-center">
|
||||
<span className="text-text-primary font-semibold text-sm tracking-wide">
|
||||
{book.title}
|
||||
</span>
|
||||
{book.subTitle && (
|
||||
<span className="text-text-secondary text-xs italic ml-2">
|
||||
{book.subTitle}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-4 w-0.5 bg-primary rounded-full ml-3"></div>
|
||||
</div>
|
||||
)}
|
||||
{/* Droite : Crédits, offline, langue, user */}
|
||||
<div className="flex items-center space-x-3">
|
||||
{
|
||||
hasAccess && book?.quillsenseEnabled !== false &&
|
||||
<CreditCounter isCredit={isSubTierTwo}/>
|
||||
}
|
||||
{isDesktop && <OfflineToggle/>}
|
||||
<ToggleGroup
|
||||
options={[{value: 'fr', label: 'FR'}, {value: 'en', label: 'EN'}]}
|
||||
value={lang}
|
||||
onChange={function (value: string): void {
|
||||
if (isSupportedLocale(value)) {
|
||||
handleLanguageChange(value);
|
||||
}
|
||||
}}
|
||||
icon={Globe}
|
||||
size="md"
|
||||
/>
|
||||
<UserMenu/>
|
||||
</div>
|
||||
{showSettingPanel && <BookSetting onClose={() => setShowSettingPanel(false)}/>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
96
components/layout/ScribeFooterBar.tsx
Normal file
96
components/layout/ScribeFooterBar.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import {ChapterContext, ChapterContextProps} from "@/context/ChapterContext";
|
||||
import {EditorContext, EditorContextProps} from "@/context/EditorContext";
|
||||
import React, {useContext, useEffect, useState} from "react";
|
||||
import {BookOpen, ChevronRight, FileText, Pilcrow, Type} from "lucide-react";
|
||||
import IconLabel from "@/components/ui/IconLabel";
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
import {AlertContext, AlertContextProps} from "@/context/AlertContext";
|
||||
import {BookContext, BookContextProps} from "@/context/BookContext";
|
||||
import {chapterVersions} from "@/lib/constants/chapter";
|
||||
|
||||
export default function ScribeFooterBar() {
|
||||
const t = useTranslations();
|
||||
const {chapter}: ChapterContextProps = useContext<ChapterContextProps>(ChapterContext);
|
||||
const {editor}: EditorContextProps = useContext<EditorContextProps>(EditorContext);
|
||||
const {errorMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
|
||||
const {book}: BookContextProps = useContext<BookContextProps>(BookContext);
|
||||
|
||||
const [wordsCount, setWordsCount] = useState<number>(0);
|
||||
const [paragraphCount, setParagraphCount] = useState<number>(0);
|
||||
|
||||
useEffect((): void => {
|
||||
getWordCount();
|
||||
}, [editor?.state.doc.textContent]);
|
||||
|
||||
function getWordCount(): void {
|
||||
if (editor) {
|
||||
try {
|
||||
const content: string = editor.state.doc.textContent;
|
||||
const texteNormalise: string = content
|
||||
.replace(/'/g, ' ')
|
||||
.replace(/-/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
const mots: string[] = texteNormalise.split(' ');
|
||||
const wordCount: number = mots.filter(
|
||||
(mot: string): boolean => mot.length > 0,
|
||||
).length;
|
||||
setWordsCount(wordCount);
|
||||
|
||||
let paragraphs: number = 0;
|
||||
editor.state.doc.descendants(function (node): void {
|
||||
if (node.type.name === 'paragraph' && node.textContent.trim().length > 0) {
|
||||
paragraphs++;
|
||||
}
|
||||
});
|
||||
setParagraphCount(paragraphs);
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(t('errors.wordCountError') + ` (${e.message})`);
|
||||
} else {
|
||||
errorMessage(t('errors.wordCountError'));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getVersionLabel(): string {
|
||||
if (!chapter) return '';
|
||||
const version = chapterVersions.find(function (v) {
|
||||
return v.value === chapter.chapterContent.version.toString();
|
||||
});
|
||||
return version ? t(version.label) : '';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-5 py-2 bg-tertiary text-text-secondary flex justify-between items-center text-xs">
|
||||
{/* Gauche : Breadcrumb */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{book ? (
|
||||
<>
|
||||
<BookOpen className="w-3 h-3 text-muted" strokeWidth={1.75}/>
|
||||
<span className="text-text-primary font-medium">{book.title}</span>
|
||||
{chapter && (
|
||||
<>
|
||||
<ChevronRight className="w-3 h-3 text-muted" strokeWidth={1.75}/>
|
||||
<span>{chapter.title}</span>
|
||||
<ChevronRight className="w-3 h-3 text-muted" strokeWidth={1.75}/>
|
||||
<span className="text-primary">{getVersionLabel()}</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-text-dimmed">{t('scribeFooterBar.madeWith')} ERitors</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Droite : Stats */}
|
||||
{(chapter || book) && (
|
||||
<div className="flex items-center gap-4">
|
||||
<IconLabel icon={Type}>{wordsCount} {t('scribeFooterBar.words')}</IconLabel>
|
||||
<IconLabel icon={FileText}>{Math.ceil(wordsCount / 300)} {t('scribeFooterBar.pages')}</IconLabel>
|
||||
<IconLabel icon={Pilcrow}>{paragraphCount} {t('scribeFooterBar.paragraphs')}</IconLabel>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -38,6 +38,7 @@ 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';
|
||||
@@ -136,6 +137,16 @@ function ScribeContent({children}: { children: ReactNode }) {
|
||||
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[]>([]);
|
||||
@@ -426,6 +437,12 @@ function ScribeContent({children}: { children: ReactNode }) {
|
||||
onCancel={(): void => {}}
|
||||
/>
|
||||
)}
|
||||
{showMigrationPopup && (
|
||||
<MigrationModal
|
||||
onClose={function (): void { setShowMigrationPopup(false); }}
|
||||
onSuccess={function (): void { setShowMigrationPopup(false); window.location.reload(); }}
|
||||
/>
|
||||
)}
|
||||
</SettingBookContext.Provider>
|
||||
</AIUsageContext.Provider>
|
||||
</ChapterContext.Provider>
|
||||
|
||||
39
components/layout/ScribeTopBar.tsx
Normal file
39
components/layout/ScribeTopBar.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
const logo = "/eritors-favicon-white.png";
|
||||
import React, {useContext} from "react";
|
||||
import {BookContext, BookContextProps} from "@/context/BookContext";
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
|
||||
export default function ScribeTopBar() {
|
||||
const {book}: BookContextProps = useContext<BookContextProps>(BookContext);
|
||||
const t = useTranslations();
|
||||
return (
|
||||
<div className="flex items-center justify-between px-6 py-3 bg-tertiary border-b border-secondary">
|
||||
<div className="flex items-center space-x-4 group">
|
||||
<div className="transition-transform duration-300">
|
||||
<img src={logo} alt={t("scribeTopBar.logoAlt")} width={24} height={24}/>
|
||||
</div>
|
||||
<span
|
||||
className="font-['ADLaM_Display'] text-xl tracking-wide text-text-primary">{t("scribeTopBar.scribe")}</span>
|
||||
</div>
|
||||
{book && (
|
||||
<div
|
||||
className="flex items-center space-x-3 bg-text-primary/10 backdrop-blur-sm px-4 py-2 rounded-lg border border-text-primary/20">
|
||||
<div className="h-8 w-1 bg-text-primary/40 rounded-full"></div>
|
||||
<div className="text-center">
|
||||
<p className="text-text-primary font-semibold text-base tracking-wide">
|
||||
{book.title}
|
||||
</p>
|
||||
{book.subTitle && (
|
||||
<p className="text-text-secondary text-xs italic mt-0.5">
|
||||
{book.subTitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-8 w-1 bg-text-primary/40 rounded-full"></div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center space-x-2 min-w-[120px] justify-end">
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
84
components/layout/UserMenu.tsx
Normal file
84
components/layout/UserMenu.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import React, {useContext, useEffect, useRef, useState} from "react";
|
||||
import {SessionContext, SessionContextProps} from "@/context/SessionContext";
|
||||
import AvatarIcon from "@/components/ui/AvatarIcon";
|
||||
import {removeCookie} from '@/lib/utils/cookies';
|
||||
import {isDesktop} from '@/lib/configs';
|
||||
import * as tauri from '@/lib/tauri';
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
|
||||
export default function UserMenu(): React.JSX.Element {
|
||||
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
|
||||
const t = useTranslations();
|
||||
|
||||
const profileMenuRef: React.RefObject<HTMLDivElement | null> = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState<boolean>(false);
|
||||
|
||||
function handleProfileClick(): void {
|
||||
setIsProfileMenuOpen(!isProfileMenuOpen);
|
||||
}
|
||||
|
||||
useEffect((): () => void => {
|
||||
function handleClickOutside(event: MouseEvent): void {
|
||||
if (profileMenuRef.current && event.target instanceof Node && !profileMenuRef.current.contains(event.target)) {
|
||||
setIsProfileMenuOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (isProfileMenuOpen) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
|
||||
return (): void => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [isProfileMenuOpen]);
|
||||
|
||||
function handleLogout(): void {
|
||||
removeCookie("token");
|
||||
if (isDesktop) {
|
||||
tauri.logout();
|
||||
} else {
|
||||
document.location.href = "https://eritors.com/login";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" data-guide="user-dropdown" ref={profileMenuRef}>
|
||||
<button
|
||||
className="bg-secondary hover:bg-gray-dark p-1.5 rounded-full transition-colors duration-150 flex items-center border border-secondary hover:border-primary"
|
||||
onClick={session.user ? handleProfileClick : (): void => {
|
||||
document.location.href = "/login";
|
||||
}}
|
||||
>
|
||||
{
|
||||
session.user && (
|
||||
<AvatarIcon
|
||||
size="xs"
|
||||
initial={session.user.username?.charAt(0).toUpperCase() ?? ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</button>
|
||||
{isProfileMenuOpen && (
|
||||
<div
|
||||
className="absolute right-0 mt-3 w-56 bg-tertiary rounded-xl py-2 z-[100] border border-secondary backdrop-blur-sm animate-fadeIn">
|
||||
<div
|
||||
className="px-4 py-3 border-b border-secondary bg-gradient-to-r from-primary/10 to-transparent">
|
||||
<p className="text-text-primary font-bold text-sm tracking-wide">{session.user?.username}</p>
|
||||
<p className="text-text-secondary text-xs mt-0.5">{session.user?.email}</p>
|
||||
</div>
|
||||
<a href="https://eritors.com/settings"
|
||||
className="group flex items-center gap-3 px-4 py-2.5 text-text-primary hover:bg-secondary transition-all hover:pl-5">
|
||||
<span
|
||||
className="text-sm font-medium group-hover:text-primary transition-colors">{t("userMenu.settings")}</span>
|
||||
</a>
|
||||
<a onClick={handleLogout} href="#"
|
||||
className="group flex items-center gap-3 px-4 py-2.5 text-error hover:bg-error/10 transition-all hover:pl-5 rounded-b-xl">
|
||||
<span className="text-sm font-medium">{t("userMenu.logout")}</span>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user