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:
natreex
2026-04-05 12:52:54 -04:00
parent 2b6d4cc48b
commit d4765e6576
49 changed files with 3115 additions and 2 deletions

View File

@@ -0,0 +1,207 @@
'use client'
import React, {ChangeEvent, forwardRef, useContext, useEffect, useImperativeHandle, useState} from 'react';
import {apiGet, apiPost} from '@/lib/api/client';
import {isDesktop} from '@/lib/configs';
import * as tauri from '@/lib/tauri';
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
import {AlertContext, AlertContextProps} from "@/context/AlertContext";
import {BookContext, BookContextProps} from '@/context/BookContext';
import {SessionContext, SessionContextProps} from "@/context/SessionContext";
import {GuideLine} from "@/lib/types/book";
import TextAreaInput from "@/components/form/TextAreaInput";
import InputField from "@/components/form/InputField";
import {useTranslations} from '@/lib/i18n';
import {LangContext, LangContextProps} from "@/context/LangContext";
import {SettingRef} from "@/lib/types/settings";
function GuideLineSetting(_props: object, ref: React.ForwardedRef<SettingRef>): React.JSX.Element {
const t = useTranslations();
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext);
const {book}: BookContextProps = useContext<BookContextProps>(BookContext);
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
const userToken: string = session?.accessToken ? session?.accessToken : '';
const {errorMessage, successMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
const {isCurrentlyOffline}: OfflineContextType = useContext<OfflineContextType>(OfflineContext);
const bookId: string = book?.bookId ?? '';
const [tone, setTone] = useState<string>('');
const [atmosphere, setAtmosphere] = useState<string>('');
const [writingStyle, setWritingStyle] = useState<string>('');
const [themes, setThemes] = useState<string>('');
const [symbolism, setSymbolism] = useState<string>('');
const [motifs, setMotifs] = useState<string>('');
const [narrativeVoice, setNarrativeVoice] = useState<string>('');
const [pacing, setPacing] = useState<string>('');
const [intendedAudience, setIntendedAudience] = useState<string>('');
const [keyMessages, setKeyMessages] = useState<string>('');
useEffect((): void => {
getGuideLine().then();
}, []);
useImperativeHandle(ref, (): SettingRef => ({
handleSave: savePersonal
}));
async function getGuideLine(): Promise<void> {
try {
let response: GuideLine;
if (isDesktop && (isCurrentlyOffline() || book?.localBook)) {
response = await tauri.getGuideLine(bookId);
} else {
response = await apiGet<GuideLine>(
`book/guide-line`,
userToken,
lang,
{id: bookId},
);
}
if (response) {
setTone(response.tone);
setAtmosphere(response.atmosphere);
setWritingStyle(response.writingStyle);
setThemes(response.themes);
setSymbolism(response.symbolism);
setMotifs(response.motifs);
setNarrativeVoice(response.narrativeVoice);
setPacing(response.pacing);
setIntendedAudience(response.intendedAudience);
setKeyMessages(response.keyMessages);
}
} catch (error: unknown) {
if (error instanceof Error) {
errorMessage(error.message);
} else {
errorMessage(t("guideLineSetting.errorUnknown"));
}
}
}
async function savePersonal(): Promise<void> {
try {
let response: boolean;
if (isDesktop && (isCurrentlyOffline() || book?.localBook)) {
response = await tauri.updateGuideLine({
bookId: bookId,
tone: tone,
atmosphere: atmosphere,
writingStyle: writingStyle,
themes: themes,
symbolism: symbolism,
motifs: motifs,
narrativeVoice: narrativeVoice,
pacing: pacing,
intendedAudience: intendedAudience,
keyMessages: keyMessages,
});
} else {
response = await apiPost<boolean>(
'book/guide-line',
{
bookId: bookId,
tone: tone,
atmosphere: atmosphere,
writingStyle: writingStyle,
themes: themes,
symbolism: symbolism,
motifs: motifs,
narrativeVoice: narrativeVoice,
pacing: pacing,
intendedAudience: intendedAudience,
keyMessages: keyMessages,
},
userToken,
lang,
);
}
if (!response) {
errorMessage(t("guideLineSetting.saveError"));
return;
}
successMessage(t("guideLineSetting.saveSuccess"));
} catch (error: unknown) {
if (error instanceof Error) {
errorMessage(error.message);
} else {
errorMessage(t("guideLineSetting.errorUnknown"));
}
}
}
return (
<div className="space-y-4">
<InputField fieldName={t("guideLineSetting.tone")} input={
<TextAreaInput
value={tone}
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setTone(e.target.value)}
placeholder={t("guideLineSetting.tonePlaceholder")}
/>
}/>
<InputField fieldName={t("guideLineSetting.atmosphere")} input={
<TextAreaInput
value={atmosphere}
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setAtmosphere(e.target.value)}
placeholder={t("guideLineSetting.atmospherePlaceholder")}
/>
}/>
<InputField fieldName={t("guideLineSetting.writingStyle")} input={
<TextAreaInput
value={writingStyle}
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setWritingStyle(e.target.value)}
placeholder={t("guideLineSetting.writingStylePlaceholder")}
/>
}/>
<InputField fieldName={t("guideLineSetting.themes")} input={
<TextAreaInput
value={themes}
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setThemes(e.target.value)}
placeholder={t("guideLineSetting.themesPlaceholder")}
/>
}/>
<InputField fieldName={t("guideLineSetting.symbolism")} input={
<TextAreaInput
value={symbolism}
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setSymbolism(e.target.value)}
placeholder={t("guideLineSetting.symbolismPlaceholder")}
/>
}/>
<InputField fieldName={t("guideLineSetting.motifs")} input={
<TextAreaInput
value={motifs}
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setMotifs(e.target.value)}
placeholder={t("guideLineSetting.motifsPlaceholder")}
/>
}/>
<InputField fieldName={t("guideLineSetting.narrativeVoice")} input={
<TextAreaInput
value={narrativeVoice}
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setNarrativeVoice(e.target.value)}
placeholder={t("guideLineSetting.narrativeVoicePlaceholder")}
/>
}/>
<InputField fieldName={t("guideLineSetting.pacing")} input={
<TextAreaInput
value={pacing}
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setPacing(e.target.value)}
placeholder={t("guideLineSetting.pacingPlaceholder")}
/>
}/>
<InputField fieldName={t("guideLineSetting.intendedAudience")} input={
<TextAreaInput
value={intendedAudience}
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setIntendedAudience(e.target.value)}
placeholder={t("guideLineSetting.intendedAudiencePlaceholder")}
/>
}/>
<InputField fieldName={t("guideLineSetting.keyMessages")} input={
<TextAreaInput
value={keyMessages}
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setKeyMessages(e.target.value)}
placeholder={t("guideLineSetting.keyMessagesPlaceholder")}
/>
}/>
</div>
);
}
export default forwardRef<SettingRef, object>(GuideLineSetting);

View File

@@ -0,0 +1,72 @@
'use client'
import {DragEvent, ReactNode, useRef, useState} from "react";
import {ImagePlus} from "lucide-react";
interface ImageDropZoneProps {
onFileSelect: (file: File) => void;
accept?: string;
label?: ReactNode;
}
const acceptedTypes: string[] = ['image/png', 'image/jpeg', 'image/webp'];
function ImageDropZone({onFileSelect, accept = "image/png, image/jpeg, image/webp", label}: ImageDropZoneProps) {
const inputRef = useRef<HTMLInputElement | null>(null);
const [isDragging, setIsDragging] = useState<boolean>(false);
function handleDragOver(e: DragEvent<HTMLDivElement>): void {
e.preventDefault();
setIsDragging(true);
}
function handleDragLeave(e: DragEvent<HTMLDivElement>): void {
e.preventDefault();
setIsDragging(false);
}
function handleDrop(e: DragEvent<HTMLDivElement>): void {
e.preventDefault();
setIsDragging(false);
const file: File | undefined = e.dataTransfer.files?.[0];
if (file && acceptedTypes.includes(file.type)) {
onFileSelect(file);
}
}
function handleClick(): void {
inputRef.current?.click();
}
function handleInputChange(e: React.ChangeEvent<HTMLInputElement>): void {
const file: File | undefined = e.target.files?.[0];
if (file) {
onFileSelect(file);
}
}
return (
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={handleClick}
className={`flex flex-col items-center justify-center gap-2 border-2 border-dashed rounded-lg p-6 cursor-pointer transition-colors
${isDragging
? 'border-primary bg-primary/10'
: 'border-secondary bg-dark-background hover:bg-tertiary'
}`}
>
<input
ref={inputRef}
type="file"
accept={accept}
onChange={handleInputChange}
className="hidden"
/>
<ImagePlus className="w-8 h-8 text-muted"/>
{label && <span className="text-text-primary text-sm">{label}</span>}
</div>
);
}
export default ImageDropZone;

View File

@@ -0,0 +1,72 @@
import React, {ChangeEvent} from "react";
import {Minus, Plus} from "lucide-react";
import IconButton from "@/components/ui/IconButton";
import TextInput from "@/components/form/TextInput";
interface OrderInputProps {
value: string;
setValue: (e: ChangeEvent<HTMLInputElement>) => void;
placeholder: string;
order: number;
setOrder: (order: number) => void;
onAdd: () => Promise<void>;
isAddDisabled?: boolean;
}
export default function OrderInput(
{
value,
setValue,
placeholder,
order,
setOrder,
onAdd,
isAddDisabled = false
}: OrderInputProps) {
function decrementOrder(): void {
if (order > 0) {
setOrder(order - 1);
}
}
function incrementOrder(): void {
setOrder(order + 1);
}
return (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 shrink-0">
<IconButton
icon={Minus}
variant="muted"
size="sm"
onClick={decrementOrder}
disabled={order <= 0}
/>
<span className="text-muted text-xs w-5 h-5 rounded-md bg-tertiary text-center leading-5">
{order}
</span>
<IconButton
icon={Plus}
variant="muted"
size="sm"
onClick={incrementOrder}
/>
</div>
<div className="flex-1">
<TextInput
value={value}
setValue={setValue}
placeholder={placeholder}
/>
</div>
<IconButton
icon={Plus}
variant="primary"
onClick={onAdd}
disabled={isAddDisabled}
/>
</div>
);
}

View File

@@ -0,0 +1,84 @@
import React, {ChangeEvent, useEffect, useRef} from "react";
interface TextAreaInputProps {
value: string;
setValue: (e: ChangeEvent<HTMLTextAreaElement>) => void;
placeholder: string;
maxLength?: number;
}
export default function TextAreaInput(
{
value,
setValue,
placeholder,
maxLength
}: TextAreaInputProps) {
const progressRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (progressRef.current && maxLength) {
progressRef.current.style.width = `${getProgressPercentage()}%`;
}
});
function getProgressPercentage(): number {
if (!maxLength) return 0;
return Math.min((value.length / maxLength) * 100, 100);
}
function getStatusStyles(): { textColor: string; progressColor: string } | Record<string, never> {
if (!maxLength) return {};
const percentage = getProgressPercentage();
if (percentage >= 100) return {
textColor: 'text-error',
progressColor: 'bg-error'
};
if (percentage >= 75) return {
textColor: 'text-warning',
progressColor: 'bg-warning'
};
return {
textColor: 'text-muted',
progressColor: 'bg-primary'
};
}
const styles = getStatusStyles();
return (
<div className="flex-grow flex-col flex h-full">
<textarea
value={value}
onChange={setValue}
placeholder={placeholder}
rows={3}
className={`input-base w-full flex-grow p-3 lg:p-4 resize-none ${
maxLength && value.length >= maxLength
? 'border-error'
: ''
} h-full min-h-[200px]`}
/>
{maxLength && (
<div className="flex items-center justify-end gap-3 mt-3">
{/* Compteur avec effet de croissance */}
<div className="flex items-center gap-3">
<div className="w-24 h-1.5 bg-secondary rounded-full overflow-hidden">
<div
ref={progressRef}
className={`h-full rounded-full transition-all duration-500 ease-out ${styles.progressColor}`}
></div>
</div>
<span className={`text-xs font-medium ${styles.textColor}`}>
{value.length}/{maxLength}
</span>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,49 @@
import React from "react";
import {LucideIcon} from "lucide-react";
import InputField from "@/components/form/InputField";
import ToggleWithConfirmation from "@/components/form/ToggleWithConfirmation";
import {AlertType} from "@/components/ui/AlertBox";
interface ToggleFieldProps {
icon: LucideIcon;
label: string;
checked: boolean;
onChange: (value: boolean) => void;
alertTitle: string;
alertMessage: string;
alertType: AlertType;
confirmText?: string;
cancelText?: string;
}
export default function ToggleField(
{
icon,
label,
checked,
onChange,
alertTitle,
alertMessage,
alertType,
confirmText,
cancelText,
}: ToggleFieldProps) {
return (
<InputField
icon={icon}
fieldName={label}
centered
input={
<ToggleWithConfirmation
checked={checked}
onChange={onChange}
alertTitle={alertTitle}
alertMessage={alertMessage}
alertType={alertType}
confirmText={confirmText}
cancelText={cancelText}
/>
}
/>
);
}

View File

@@ -0,0 +1,43 @@
import React, {ChangeEvent} from "react";
import {SelectBoxProps} from "@/components/form/SelectBox";
interface ToolbarSelectProps {
onChangeCallBack: (event: ChangeEvent<HTMLSelectElement>) => void;
data: SelectBoxProps[];
defaultValue: string | null | undefined;
placeholder?: string;
disabled?: boolean;
}
export default function ToolbarSelect(
{
onChangeCallBack,
data,
defaultValue,
placeholder,
disabled
}: ToolbarSelectProps): React.JSX.Element {
return (
<select
onChange={onChangeCallBack}
disabled={disabled}
key={defaultValue || 'placeholder'}
defaultValue={defaultValue || '0'}
className="bg-transparent text-text-primary text-xs px-2 py-1.5 rounded-lg
border border-transparent
hover:bg-secondary hover:border-secondary
focus:bg-secondary focus:border-primary focus:outline-none
transition-all duration-200 cursor-pointer
disabled:opacity-50 disabled:cursor-not-allowed"
>
{placeholder && <option value={'0'}>{placeholder}</option>}
{data.map(function (item: SelectBoxProps): React.JSX.Element {
return (
<option key={item.value} value={item.value} className="bg-tertiary text-text-primary">
{item.label}
</option>
);
})}
</select>
);
}

View 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>
)
}

View 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>
);
}

View File

@@ -38,6 +38,7 @@ import OfflineProvider from '@/context/OfflineProvider';
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext'; import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
import OfflinePinSetup from '@/components/offline/OfflinePinSetup'; import OfflinePinSetup from '@/components/offline/OfflinePinSetup';
import OfflinePinVerify from '@/components/offline/OfflinePinVerify'; import OfflinePinVerify from '@/components/offline/OfflinePinVerify';
import MigrationModal from '@/components/migration/MigrationModal';
import {isDesktop} from '@/lib/configs'; import {isDesktop} from '@/lib/configs';
import * as tauri from '@/lib/tauri'; import * as tauri from '@/lib/tauri';
import useSyncBooks from '@/hooks/useSyncBooks'; import useSyncBooks from '@/hooks/useSyncBooks';
@@ -136,6 +137,16 @@ function ScribeContent({children}: { children: ReactNode }) {
const [currentChapter, setCurrentChapter] = useState<ChapterProps | undefined>(undefined); const [currentChapter, setCurrentChapter] = useState<ChapterProps | undefined>(undefined);
const [currentBook, setCurrentBook] = useState<BookProps | null>(null); const [currentBook, setCurrentBook] = useState<BookProps | null>(null);
const [bookSettingId, setBookSettingId] = useState<string>(''); 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 [serverSyncedBooks, setServerSyncedBooks] = useState<SyncedBook[]>([]);
const [localSyncedBooks, setLocalSyncedBooks] = useState<SyncedBook[]>([]); const [localSyncedBooks, setLocalSyncedBooks] = useState<SyncedBook[]>([]);
@@ -426,6 +437,12 @@ function ScribeContent({children}: { children: ReactNode }) {
onCancel={(): void => {}} onCancel={(): void => {}}
/> />
)} )}
{showMigrationPopup && (
<MigrationModal
onClose={function (): void { setShowMigrationPopup(false); }}
onSuccess={function (): void { setShowMigrationPopup(false); window.location.reload(); }}
/>
)}
</SettingBookContext.Provider> </SettingBookContext.Provider>
</AIUsageContext.Provider> </AIUsageContext.Provider>
</ChapterContext.Provider> </ChapterContext.Provider>

View 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>
)
}

View 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>
)
}

View File

@@ -0,0 +1,176 @@
import React, {useState} from 'react';
import {ArrowRightLeft, CheckCircle, AlertTriangle, FolderOpen, Loader2} from 'lucide-react';
import Modal from '@/components/ui/Modal';
import Button from '@/components/ui/Button';
import {useTranslations} from '@/lib/i18n';
import * as tauri from '@/lib/tauri';
interface MigrationModalProps {
onClose: () => void;
onSuccess: () => void;
}
type MigrationStep = 'intro' | 'select' | 'importing' | 'success' | 'error';
export default function MigrationModal({onClose, onSuccess}: MigrationModalProps) {
const t = useTranslations();
const [step, setStep] = useState<MigrationStep>('intro');
const [filePath, setFilePath] = useState<string>('');
const [errorMsg, setErrorMsg] = useState<string>('');
const [migratedUserId, setMigratedUserId] = useState<string>('');
async function handleCheck(): Promise<void> {
if (!filePath.trim()) return;
try {
const result: tauri.MigrationCheckResult = await tauri.checkElectronMigration(filePath.trim());
if (!result.found) {
setErrorMsg(t('migration.fileNotFound'));
setStep('error');
return;
}
if (!result.hasDb) {
setErrorMsg(t('migration.dbNotFound'));
setStep('error');
return;
}
await handleImport();
} catch (e: unknown) {
setErrorMsg(e instanceof Error ? e.message : String(e));
setStep('error');
}
}
async function handleImport(): Promise<void> {
setStep('importing');
try {
const result: tauri.MigrationResult = await tauri.importFromElectron(filePath.trim());
if (result.success) {
setMigratedUserId(result.userId || '');
setStep('success');
} else {
setErrorMsg(result.error || t('migration.importFailed'));
setStep('error');
}
} catch (e: unknown) {
setErrorMsg(e instanceof Error ? e.message : String(e));
setStep('error');
}
}
function handleSuccessClose(): void {
localStorage.setItem('electron_migration_done', 'true');
onSuccess();
}
function handleDismiss(): void {
localStorage.setItem('electron_migration_dismissed', 'true');
onClose();
}
return (
<Modal
title={t('migration.title')}
icon={ArrowRightLeft}
size="md"
onClose={onClose}
>
{step === 'intro' && (
<div className="space-y-4">
<p className="text-muted leading-relaxed">{t('migration.introText')}</p>
<div className="bg-tertiary rounded-xl p-4 space-y-2">
<p className="text-sm font-semibold text-text-primary">{t('migration.steps')}</p>
<ol className="list-decimal list-inside text-sm text-muted space-y-1">
<li>{t('migration.step1')}</li>
<li>{t('migration.step2')}</li>
<li>{t('migration.step3')}</li>
</ol>
</div>
<div className="flex justify-end gap-3 pt-2">
<Button variant="secondary" onClick={handleDismiss}>
{t('migration.later')}
</Button>
<Button variant="primary" icon={FolderOpen} onClick={function (): void {
setStep('select');
}}>
{t('migration.haveFile')}
</Button>
</div>
</div>
)}
{step === 'select' && (
<div className="space-y-4">
<p className="text-muted text-sm">{t('migration.selectText')}</p>
<div className="space-y-2">
<label className="text-sm font-medium text-text-primary">
{t('migration.filePath')}
</label>
<input
type="text"
value={filePath}
onChange={function (e: React.ChangeEvent<HTMLInputElement>): void {
setFilePath(e.target.value);
}}
placeholder="/Users/.../Desktop/eritors-migration.json"
className="w-full px-4 py-2.5 rounded-xl bg-tertiary border border-secondary text-text-primary text-sm placeholder:text-muted/50 focus:outline-none focus:border-primary"
/>
</div>
<div className="flex justify-end gap-3 pt-2">
<Button variant="secondary" onClick={function (): void {
setStep('intro');
}}>
{t('migration.back')}
</Button>
<Button variant="primary" onClick={handleCheck} disabled={!filePath.trim()}>
{t('migration.import')}
</Button>
</div>
</div>
)}
{step === 'importing' && (
<div className="flex flex-col items-center py-8 space-y-4">
<Loader2 className="w-10 h-10 text-primary animate-spin" strokeWidth={1.75}/>
<p className="text-muted">{t('migration.importing')}</p>
</div>
)}
{step === 'success' && (
<div className="space-y-4">
<div className="flex flex-col items-center py-6 space-y-3">
<CheckCircle className="w-12 h-12 text-success" strokeWidth={1.75}/>
<p className="text-text-primary font-semibold text-lg">{t('migration.successTitle')}</p>
<p className="text-muted text-sm text-center">{t('migration.successText')}</p>
</div>
<div className="flex justify-end pt-2">
<Button variant="primary" onClick={handleSuccessClose}>
{t('migration.done')}
</Button>
</div>
</div>
)}
{step === 'error' && (
<div className="space-y-4">
<div className="flex flex-col items-center py-6 space-y-3">
<AlertTriangle className="w-12 h-12 text-error" strokeWidth={1.75}/>
<p className="text-text-primary font-semibold">{t('migration.errorTitle')}</p>
<p className="text-muted text-sm text-center">{errorMsg}</p>
</div>
<div className="flex justify-end gap-3 pt-2">
<Button variant="secondary" onClick={onClose}>
{t('migration.close')}
</Button>
<Button variant="primary" onClick={function (): void {
setStep('select');
setErrorMsg('');
}}>
{t('migration.retry')}
</Button>
</div>
</div>
)}
</Modal>
);
}

View File

@@ -1,5 +1,5 @@
'use client' 'use client'
import {ExternalLink, Feather, Globe, Info, MapPin, MessageCircle, Users, Wand2, X} from 'lucide-react'; import {ArrowRightLeft, ExternalLink, Feather, Globe, Info, MapPin, MessageCircle, Users, Wand2, X} from 'lucide-react';
import React, {lazy, Suspense, useContext, useEffect, useState} from "react"; import React, {lazy, Suspense, useContext, useEffect, useState} from "react";
import {BookContext, BookContextProps} from "@/context/BookContext"; import {BookContext, BookContextProps} from "@/context/BookContext";
import {PanelComponent} from "@/lib/types/editor"; import {PanelComponent} from "@/lib/types/editor";
@@ -11,6 +11,8 @@ import PulseLoader from '@/components/ui/PulseLoader';
import InsetPanel from "@/components/ui/InsetPanel"; import InsetPanel from "@/components/ui/InsetPanel";
import IconButton from "@/components/ui/IconButton"; import IconButton from "@/components/ui/IconButton";
import OfflineContext, {OfflineContextType} from "@/context/OfflineContext"; import OfflineContext, {OfflineContextType} from "@/context/OfflineContext";
import {isDesktop} from '@/lib/configs';
import MigrationModal from '@/components/migration/MigrationModal';
// Lazy loaded Editor components // Lazy loaded Editor components
const WorldEditor = lazy(function () { const WorldEditor = lazy(function () {
@@ -52,6 +54,11 @@ export default function ComposerRightBar(): React.JSX.Element {
const [panelHidden, setPanelHidden] = useState<boolean>(false); const [panelHidden, setPanelHidden] = useState<boolean>(false);
const [currentPanel, setCurrentPanel] = useState<PanelComponent | undefined>(); const [currentPanel, setCurrentPanel] = useState<PanelComponent | undefined>();
const [showAbout, setShowAbout] = useState<boolean>(false); const [showAbout, setShowAbout] = useState<boolean>(false);
const [showMigration, setShowMigration] = useState<boolean>(false);
const migrationDone: boolean = localStorage.getItem('electron_migration_done') === 'true';
const migrationDismissed: boolean = localStorage.getItem('electron_migration_dismissed') === 'true';
const showMigrationButton: boolean = isDesktop && !migrationDone;
function togglePanel(component: PanelComponent): void { function togglePanel(component: PanelComponent): void {
if (panelHidden) { if (panelHidden) {
@@ -103,6 +110,16 @@ export default function ComposerRightBar(): React.JSX.Element {
]; ];
const homeComponents: PanelComponent[] = [ const homeComponents: PanelComponent[] = [
...(showMigrationButton ? [{
id: 0,
title: t("composerRightBar.homeComponents.migration.title"),
description: t("composerRightBar.homeComponents.migration.description"),
badge: 'IMPORT',
icon: ArrowRightLeft,
action: function (): void {
setShowMigration(true);
}
}] : []),
{ {
id: 1, id: 1,
title: t("composerRightBar.homeComponents.about.title"), title: t("composerRightBar.homeComponents.about.title"),
@@ -224,6 +241,10 @@ export default function ComposerRightBar(): React.JSX.Element {
{showAbout && <AboutEditors onClose={function (): void { {showAbout && <AboutEditors onClose={function (): void {
setShowAbout(false); setShowAbout(false);
}}/>} }}/>}
{showMigration && <MigrationModal
onClose={function (): void { setShowMigration(false); }}
onSuccess={function (): void { setShowMigration(false); window.location.reload(); }}
/>}
</div> </div>
); );
} }

123
components/ui/AlertBox.tsx Normal file
View File

@@ -0,0 +1,123 @@
import React, {useEffect, useState} from 'react';
import {createPortal} from 'react-dom';
import {AlertTriangle, Check, Info, LucideIcon, X} from 'lucide-react';
import Button from "@/components/ui/Button";
export type AlertType = 'alert' | 'danger' | 'informatif' | 'success';
interface AlertBoxProps {
title: string;
message: string;
type: AlertType;
confirmText?: string;
cancelText?: string;
onConfirm: () => Promise<void>;
onCancel: () => void;
}
interface AlertConfig {
background: string;
borderColor: string;
icon: LucideIcon;
iconBg: string;
}
export default function AlertBox(
{
title,
message,
type,
confirmText = 'Confirmer',
cancelText = 'Annuler',
onConfirm,
onCancel
}: AlertBoxProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
return () => setMounted(false);
}, []);
function getButtonVariant(alertType: AlertType): 'warning' | 'danger' | 'info' | 'success' {
switch (alertType) {
case 'alert':
return 'warning';
case 'danger':
return 'danger';
case 'informatif':
return 'info';
case 'success':
default:
return 'success';
}
}
function getAlertConfig(alertType: AlertType): AlertConfig {
switch (alertType) {
case 'alert':
return {
background: 'bg-warning',
borderColor: 'border-warning/30',
icon: AlertTriangle,
iconBg: 'bg-warning/10'
};
case 'danger':
return {
background: 'bg-error',
borderColor: 'border-error/30',
icon: X,
iconBg: 'bg-error/10'
};
case 'informatif':
return {
background: 'bg-info',
borderColor: 'border-info/30',
icon: Info,
iconBg: 'bg-info/10'
};
case 'success':
default:
return {
background: 'bg-success',
borderColor: 'border-success/30',
icon: Check,
iconBg: 'bg-success/10'
};
}
}
const alertSettings = getAlertConfig(type);
const AlertIcon = alertSettings.icon;
const alertContent = (
<div
className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-darkest-background/60 backdrop-blur-md animate-fadeIn">
<div
className="relative w-full max-w-md rounded-2xl bg-tertiary overflow-hidden">
<div className={`${alertSettings.background} px-6 py-4`}>
<div className="flex items-center gap-4">
<div
className={`w-12 h-12 rounded-xl ${alertSettings.iconBg} flex items-center justify-center`}>
<AlertIcon className="w-6 h-6 text-text-primary" strokeWidth={1.75}/>
</div>
<h3 className="text-xl font-bold text-text-primary tracking-wide">{title}</h3>
</div>
</div>
<div className="p-6 bg-darkest-background">
<p className="mb-6 text-text-primary whitespace-pre-line leading-relaxed">{message}</p>
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={onCancel}>{cancelText}</Button>
<Button variant={getButtonVariant(type)} onClick={onConfirm}>{confirmText}</Button>
</div>
</div>
</div>
</div>
);
if (!mounted) return null;
return createPortal(alertContent, document.body);
}

View File

@@ -0,0 +1,44 @@
'use client';
import React from 'react';
import {createPortal} from 'react-dom';
import StaticAlert from '@/components/ui/StaticAlert';
import {Alert} from '@/context/AlertProvider';
interface AlertStackProps {
alerts: Alert[];
onClose: (id: string) => void;
}
export default function AlertStack({alerts, onClose}: AlertStackProps) {
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
return () => setMounted(false);
}, []);
if (!mounted) return null;
const alertContent = (
<div className="fixed top-4 right-4 z-50 flex flex-col gap-3 pointer-events-none">
{alerts.map((alert, index) => (
<div
key={alert.id}
className="pointer-events-auto alert-slide-in"
ref={(el: HTMLDivElement | null): void => {
if (el) el.style.animationDelay = `${index * 50}ms`;
}}
>
<StaticAlert
type={alert.type}
message={alert.message}
onClose={() => onClose(alert.id)}
/>
</div>
))}
</div>
);
return createPortal(alertContent, document.body);
}

View File

@@ -0,0 +1,65 @@
import React from 'react';
import {LucideIcon} from 'lucide-react';
type AvatarSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
interface AvatarIconProps {
size?: AvatarSize;
image?: string | null;
icon?: LucideIcon;
initial?: string;
alt?: string;
}
const sizeClasses: Record<AvatarSize, string> = {
xs: 'w-7 h-7',
sm: 'w-10 h-10',
md: 'w-12 h-12',
lg: 'w-14 h-14',
xl: 'w-16 h-16',
};
const iconSizeClasses: Record<AvatarSize, string> = {
xs: 'w-3.5 h-3.5',
sm: 'w-5 h-5',
md: 'w-6 h-6',
lg: 'w-7 h-7',
xl: 'w-8 h-8',
};
const initialSizeClasses: Record<AvatarSize, string> = {
xs: 'text-xs',
sm: 'text-sm',
md: 'text-base',
lg: 'text-lg',
xl: 'text-xl',
};
export default function AvatarIcon({
size = 'md',
image,
icon: Icon,
initial,
alt = '',
}: AvatarIconProps): React.JSX.Element {
return (
<div
className={`${sizeClasses[size]} rounded-full border-2 border-primary overflow-hidden bg-secondary transition-colors flex items-center justify-center`}
>
{image ? (
<img
src={image}
alt={alt}
className="w-full h-full object-cover"
/>
) : Icon ? (
<Icon className={`text-primary ${iconSizeClasses[size]}`} strokeWidth={1.75}/>
) : initial ? (
<div
className={`w-full h-full flex items-center justify-center bg-primary/10 text-primary font-bold ${initialSizeClasses[size]}`}>
{initial}
</div>
) : null}
</div>
);
}

61
components/ui/Badge.tsx Normal file
View File

@@ -0,0 +1,61 @@
import React, {ReactNode} from "react";
import {LucideIcon} from 'lucide-react';
type BadgeVariant = 'primary' | 'success' | 'warning' | 'error' | 'muted';
type BadgeSize = 'sm' | 'md';
type BadgeShape = 'pill' | 'rounded';
interface BadgeProps {
variant?: BadgeVariant;
size?: BadgeSize;
shape?: BadgeShape;
icon?: LucideIcon;
children?: ReactNode;
interactive?: boolean;
floating?: boolean;
}
const variantClasses: Record<BadgeVariant, string> = {
primary: 'bg-primary/20 text-primary border border-primary/30',
success: 'bg-success/20 text-success border border-success/30',
warning: 'bg-warning/20 text-warning border border-warning/30',
error: 'bg-error/20 text-error border border-error/30',
muted: 'bg-secondary text-muted border border-secondary',
};
const sizeClasses: Record<BadgeSize, string> = {
sm: 'text-[10px] px-2 py-0.5',
md: 'text-xs px-3 py-1',
};
const shapeClasses: Record<BadgeShape, string> = {
pill: 'rounded-full',
rounded: 'rounded-lg',
};
export default function Badge(
{
variant = 'primary',
size = 'md',
shape = 'pill',
icon: Icon,
children,
interactive = false,
floating = false,
}: BadgeProps) {
return (
<span
className={`
font-medium inline-flex items-center gap-1.5
${shapeClasses[shape]}
${variantClasses[variant]}
${sizeClasses[size]}
${interactive ? 'cursor-pointer group transition-colors' : ''}
${floating ? 'absolute top-3 right-3' : ''}
`}
>
{Icon && <Icon className="w-3 h-3" strokeWidth={1.75}/>}
{children}
</span>
);
}

84
components/ui/Button.tsx Normal file
View File

@@ -0,0 +1,84 @@
import React, {ReactNode} from "react";
import {Loader2, LucideIcon} from "lucide-react";
type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost' | 'warning' | 'info' | 'success' | 'dashed';
type ButtonSize = 'sm' | 'md' | 'lg';
interface ButtonProps {
variant?: ButtonVariant;
size?: ButtonSize;
icon?: LucideIcon;
isLoading?: boolean;
loadingText?: string;
disabled?: boolean;
children: ReactNode;
onClick?: () => void | Promise<void>;
type?: 'button' | 'submit';
fullWidth?: boolean;
}
const variantClasses: Record<ButtonVariant, string> = {
primary: 'bg-primary-dark text-text-primary hover:bg-primary',
secondary: 'bg-secondary text-text-primary border border-secondary hover:bg-gray-dark hover:border-gray-dark',
danger: 'bg-error text-text-primary hover:bg-error/80',
warning: 'bg-warning text-text-primary hover:bg-warning/80',
info: 'bg-info text-text-primary hover:bg-info/80',
success: 'bg-success text-text-primary hover:bg-success/80',
ghost: 'text-muted hover:text-primary hover:bg-primary/10',
dashed: 'border-2 border-dashed border-secondary bg-dark-background hover:bg-tertiary text-text-primary',
};
const sizeClasses: Record<ButtonSize, string> = {
sm: 'px-3 py-1.5 text-xs rounded-lg gap-1.5',
md: 'px-5 py-2.5 text-sm rounded-xl gap-2',
lg: 'px-6 py-3 text-base rounded-xl gap-2',
};
export default function Button(
{
variant = 'primary',
size = 'md',
icon: Icon,
isLoading = false,
loadingText,
disabled = false,
children,
onClick,
type = 'button',
fullWidth = false,
}: ButtonProps) {
const isDisabled: boolean = disabled || isLoading;
return (
<button
type={type}
onClick={onClick}
disabled={isDisabled}
className={`
font-semibold transition-all duration-200
flex items-center justify-center relative overflow-hidden
${variantClasses[variant]}
${sizeClasses[size]}
${isDisabled ? 'opacity-50 cursor-not-allowed' : ''}
${fullWidth ? 'w-full' : ''}
`}
>
<span
className={`flex items-center gap-2 transition-opacity duration-200 ${isLoading ? 'opacity-0' : 'opacity-100'}`}>
{Icon && (
<Icon className="w-4 h-4" strokeWidth={1.75}/>
)}
{children}
</span>
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center">
<Loader2 className="w-4 h-4 animate-spin" strokeWidth={1.75}/>
{loadingText && (
<span className="ml-2 text-sm font-medium">{loadingText}</span>
)}
</div>
)}
</button>
);
}

View File

@@ -0,0 +1,65 @@
import React, {ReactNode, useState} from "react";
import {ChevronDown, ChevronUp, LucideIcon} from 'lucide-react';
import IconContainer from "@/components/ui/IconContainer";
type CollapseVariant = 'default' | 'card';
export interface CollapseProps {
title: string;
icon?: LucideIcon;
variant?: CollapseVariant;
content?: ReactNode;
children?: ReactNode;
defaultOpen?: boolean;
}
export default function Collapse(
{
title,
icon,
variant = 'default',
content,
children,
defaultOpen = false,
}: CollapseProps) {
const [isOpen, setIsOpen] = useState<boolean>(defaultOpen);
const renderContent: ReactNode = children || content;
const isCard: boolean = variant === 'card';
const ChevronIcon = isOpen ? ChevronUp : ChevronDown;
return (
<div
className={`mb-4 transition-colors duration-150 ${isCard ? 'bg-tertiary rounded-xl' : ''}`}>
<button
onClick={() => setIsOpen(!isOpen)}
className={`
w-full text-left transition-all duration-200 p-4 flex items-center justify-between group
${isCard
? ''
: `bg-secondary hover:bg-gray-dark ${isOpen ? 'rounded-t-xl' : 'rounded-xl'}`
}
`}
>
<div className="flex items-center gap-2">
{icon && (
<div className="transition-colors duration-150">
<IconContainer icon={icon} size="sm" shape="circle" filled/>
</div>
)}
<span className="text-text-primary font-bold">{title}</span>
</div>
<ChevronIcon className="text-primary w-4 h-4 transition-transform"
strokeWidth={1.75}/>
</button>
{isOpen && (
<div className={`animate-fadeIn ${isCard
? 'p-3 m-4 mt-0 bg-darkest-background rounded-lg'
: 'bg-darkest-background border-l-4 border-primary p-4 rounded-b-xl'
}`}>
{renderContent}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,29 @@
import React, {useContext} from "react";
import {Coins, DollarSign} from "lucide-react";
import {AIUsageContext, AIUsageContextProps} from "@/context/AIUsageContext";
export default function CreditCounter({isCredit}: { isCredit: boolean }) {
const {totalCredits, totalPrice}: AIUsageContextProps = useContext<AIUsageContextProps>(AIUsageContext)
if (isCredit) {
return (
<div
className="flex items-center space-x-2 bg-darkest-background rounded-lg px-3 py-1.5 border border-secondary">
<Coins className="w-4 h-4 text-warning" strokeWidth={1.75}/>
<span className="text-sm text-text-primary font-medium">
{Math.round(totalCredits)} crédits
</span>
</div>
);
}
return (
<div
className="flex items-center space-x-2 bg-darkest-background rounded-lg px-3 py-1.5 border border-secondary">
<DollarSign className="w-4 h-4 text-primary" strokeWidth={1.75}/>
<span className="text-sm text-text-primary font-medium">
{totalPrice ? totalPrice.toFixed(2) : '0.00'}
</span>
</div>
);
}

View File

@@ -0,0 +1,51 @@
import React from 'react';
import {LucideIcon} from 'lucide-react';
type DetailFieldVariant = 'default' | 'compact';
interface DetailFieldProps {
label: string;
value: string | number | null | undefined;
icon?: LucideIcon;
variant?: DetailFieldVariant;
preserveWhitespace?: boolean;
}
export default function DetailField({
label,
value,
icon,
variant = 'default',
preserveWhitespace = true,
}: DetailFieldProps): React.JSX.Element | null {
if (variant === 'compact' && !value) return null;
if (variant === 'compact') {
return (
<div className="mb-3">
<span className="text-text-secondary text-xs block mb-1">{label}</span>
<p className={`text-text-primary text-sm ${preserveWhitespace ? 'whitespace-pre-wrap' : ''}`}>
{value}
</p>
</div>
);
}
function renderIcon(): React.JSX.Element | null {
if (!icon) return null;
const Icon: LucideIcon = icon;
return <Icon className="w-4 h-4 text-primary" strokeWidth={1.75}/>;
}
return (
<div className="p-5 bg-secondary rounded-xl">
<div className="flex items-center gap-2 mb-3">
{renderIcon()}
<h3 className="text-text-primary font-semibold">{label}</h3>
</div>
<p className={`${preserveWhitespace ? 'whitespace-pre-wrap' : ''} ${value ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
{value || '—'}
</p>
</div>
);
}

View File

@@ -0,0 +1,26 @@
import React, {ReactNode} from 'react';
import {LucideIcon} from 'lucide-react';
import IconContainer from "@/components/ui/IconContainer";
interface DetailHeroSectionProps {
icon: LucideIcon;
title: string;
children?: ReactNode;
}
export default function DetailHeroSection({icon, title, children}: DetailHeroSectionProps): React.JSX.Element {
return (
<div
className="p-6 bg-gradient-to-r from-primary/10 via-darkest-background to-transparent rounded-2xl border border-secondary">
<div className="flex items-start gap-4">
<div className="shrink-0">
<IconContainer icon={icon} size="lg" shape="square"/>
</div>
<div className="flex-1 min-w-0">
<h2 className="text-2xl font-bold text-text-primary">{title}</h2>
{children}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,89 @@
import React, {ReactNode, useEffect, useRef, useState} from "react";
import {LucideIcon} from "lucide-react";
interface DropdownItem {
label: string;
icon?: LucideIcon;
onClick: () => void;
variant?: 'default' | 'danger';
}
interface DropdownProps {
trigger: ReactNode;
items: DropdownItem[];
align?: 'left' | 'right';
}
export default function Dropdown(
{
trigger,
items,
align = 'left',
}: DropdownProps): React.JSX.Element {
const [isOpen, setIsOpen] = useState<boolean>(false);
const dropdownRef: React.RefObject<HTMLDivElement | null> = useRef<HTMLDivElement>(null);
useEffect(function handleClickOutside() {
function onClickOutside(event: MouseEvent): void {
if (dropdownRef.current && event.target instanceof Node && !dropdownRef.current.contains(event.target)) {
setIsOpen(false);
}
}
document.addEventListener('mousedown', onClickOutside);
return (): void => document.removeEventListener('mousedown', onClickOutside);
}, []);
function handleItemClick(item: DropdownItem): void {
item.onClick();
setIsOpen(false);
}
return (
<div ref={dropdownRef} className="relative">
<div onClick={(): void => setIsOpen(!isOpen)}>
{trigger}
</div>
{isOpen && (
<div
className={`
absolute top-full mt-2 z-50 min-w-48
bg-tertiary rounded-xl py-2
border border-secondary backdrop-blur-sm animate-fadeIn
${align === 'right' ? 'right-0' : 'left-0'}
`}
>
{items.map(function renderDropdownItem(item: DropdownItem, index: number) {
const isDanger: boolean = item.variant === 'danger';
const ItemIcon: LucideIcon | undefined = item.icon;
return (
<button
key={index}
onClick={(): void => handleItemClick(item)}
className={`
group w-full flex items-center gap-3 px-4 py-2.5
transition-all hover:pl-5
${isDanger
? 'text-error hover:bg-error/10'
: 'text-text-primary hover:bg-secondary'
}
${index === 0 ? 'rounded-t-xl' : ''}
${index === items.length - 1 ? 'rounded-b-xl' : ''}
`}
>
{ItemIcon && (
<ItemIcon
className={`w-4 h-4 ${isDanger ? 'text-error' : 'text-muted group-hover:text-primary'}`}
strokeWidth={1.75}
/>
)}
<span className="font-medium text-sm">{item.label}</span>
</button>
);
})}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,33 @@
import React, {ReactNode} from "react";
import {LucideIcon} from "lucide-react";
import IconContainer from "@/components/ui/IconContainer";
interface EmptyStateProps {
icon: LucideIcon;
title: string;
description?: string;
action?: ReactNode;
}
export default function EmptyState(
{
icon,
title,
description,
action,
}: EmptyStateProps) {
return (
<div className="h-full flex flex-col items-center justify-center py-12 text-center p-8">
<div className="mb-6">
<IconContainer icon={icon} size="xl" shape="rounded"/>
</div>
<h3 className="text-xl font-['ADLaM_Display'] text-text-primary mb-3">{title}</h3>
{description && (
<p className="text-muted max-w-md text-lg leading-relaxed">{description}</p>
)}
{action && (
<div className="mt-6">{action}</div>
)}
</div>
);
}

View File

@@ -0,0 +1,86 @@
import React, {ReactNode} from 'react';
import {ChevronRight} from 'lucide-react';
type EntityListItemSize = 'sm' | 'md';
type EntityListItemVariant = 'default' | 'transparent';
interface EntityListItemProps {
onClick: () => void;
avatar: ReactNode;
title: string;
subtitle?: string | null;
extra?: ReactNode;
size?: EntityListItemSize;
variant?: EntityListItemVariant;
}
const rowClasses: Record<EntityListItemSize, Record<EntityListItemVariant, string>> = {
sm: {
default: 'group flex items-center p-3 bg-tertiary rounded-lg cursor-pointer hover:bg-secondary transition-colors duration-150',
transparent: 'group flex items-center p-3 rounded-lg cursor-pointer hover:bg-dark-background transition-colors duration-150',
},
md: {
default: 'group flex items-center p-4 bg-tertiary rounded-xl cursor-pointer hover:bg-secondary transition-colors duration-150',
transparent: 'group flex items-center p-4 rounded-xl cursor-pointer hover:bg-dark-background transition-colors duration-150',
},
};
const contentClasses: Record<EntityListItemSize, { margin: string; title: string; subtitle: string }> = {
sm: {
margin: 'ml-3',
title: 'text-text-primary font-semibold text-sm group-hover:text-primary transition-colors truncate',
subtitle: 'text-muted text-xs truncate',
},
md: {
margin: 'ml-4',
title: 'text-text-primary font-bold text-base group-hover:text-primary transition-colors',
subtitle: 'text-text-secondary text-sm mt-0.5 truncate',
},
};
const chevronClasses: Record<EntityListItemSize, { container: string; icon: string }> = {
sm: {
container: 'w-6 flex justify-center',
icon: 'text-muted group-hover:text-primary group-hover:translate-x-1 transition-all w-3 h-3',
},
md: {
container: 'w-8 flex justify-center',
icon: 'text-muted group-hover:text-primary group-hover:translate-x-1 transition-all w-4 h-4',
},
};
export default function EntityListItem({
onClick,
avatar,
title,
subtitle,
extra,
size = 'md',
variant = 'default',
}: EntityListItemProps): React.JSX.Element {
const content: { margin: string; title: string; subtitle: string } = contentClasses[size];
const chevron: { container: string; icon: string } = chevronClasses[size];
return (
<div onClick={onClick} className={rowClasses[size][variant]}>
{avatar}
<div className={`${content.margin} flex-1 min-w-0`}>
<div className={content.title}>{title}</div>
{subtitle && (
<div className={content.subtitle}>{subtitle}</div>
)}
</div>
{extra && (
<div className="px-3">
{extra}
</div>
)}
<div className={chevron.container}>
<ChevronRight className={chevron.icon} strokeWidth={1.75}/>
</div>
</div>
);
}

View File

@@ -0,0 +1,69 @@
import React from "react";
import {LucideIcon} from "lucide-react";
type IconButtonVariant = 'primary' | 'danger' | 'ghost' | 'muted' | 'light';
type IconButtonSize = 'sm' | 'md' | 'lg';
type IconButtonShape = 'circle' | 'square';
interface IconButtonProps {
icon: LucideIcon;
variant?: IconButtonVariant;
size?: IconButtonSize;
shape?: IconButtonShape;
onClick?: () => void | Promise<void>;
disabled?: boolean;
selected?: boolean;
tooltip?: string;
}
const variantClasses: Record<IconButtonVariant, string> = {
primary: 'text-muted hover:text-primary hover:bg-primary/10',
danger: 'text-muted hover:text-error hover:bg-error/10',
ghost: 'text-text-secondary hover:text-text-primary hover:bg-secondary',
muted: 'bg-secondary hover:bg-gray-dark text-text-secondary hover:text-text-primary',
light: 'text-text-primary/80 hover:text-text-primary hover:bg-text-primary/10',
};
const sizeClasses: Record<IconButtonSize, { button: string; icon: string }> = {
sm: {button: 'p-1.5', icon: 'w-4 h-4'},
md: {button: 'p-2', icon: 'w-5 h-5'},
lg: {button: 'p-2', icon: 'w-6 h-6'},
};
const shapeClasses: Record<IconButtonShape, string> = {
circle: 'rounded-full',
square: 'rounded-lg',
};
export default function IconButton(
{
icon: Icon,
variant = 'ghost',
size = 'md',
shape = 'square',
onClick,
disabled = false,
selected = false,
tooltip,
}: IconButtonProps) {
const sizeConfig = sizeClasses[size];
const selectedClass: string = selected ? 'bg-secondary text-primary' : '';
return (
<button
onClick={onClick}
disabled={disabled}
title={tooltip}
className={`
flex items-center justify-center
transition-all duration-200
${shapeClasses[shape]}
${selected ? selectedClass : variantClasses[variant]}
${sizeConfig.button}
${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
`}
>
<Icon className={sizeConfig.icon} strokeWidth={2.25}/>
</button>
);
}

View File

@@ -0,0 +1,47 @@
import React from "react";
import {LucideIcon} from "lucide-react";
type IconContainerSize = 'sm' | 'md' | 'lg' | 'xl';
type IconContainerShape = 'square' | 'rounded' | 'circle';
interface IconContainerProps {
icon: LucideIcon;
size?: IconContainerSize;
shape?: IconContainerShape;
filled?: boolean;
}
const sizeClasses: Record<IconContainerSize, { container: string; icon: string }> = {
sm: {container: 'w-10 h-10', icon: 'w-4 h-4'},
md: {container: 'w-12 h-12', icon: 'w-5 h-5'},
lg: {container: 'w-16 h-16', icon: 'w-8 h-8'},
xl: {container: 'w-20 h-20', icon: 'w-10 h-10'},
};
const shapeClasses: Record<IconContainerShape, string> = {
square: 'rounded-lg',
rounded: 'rounded-2xl',
circle: 'rounded-full',
};
export default function IconContainer(
{
icon: Icon,
size = 'sm',
shape = 'square',
filled = false,
}: IconContainerProps) {
const sizeConfig = sizeClasses[size];
return (
<div
className={`
${filled ? 'bg-primary' : 'bg-primary/10'} flex items-center justify-center
${sizeConfig.container}
${shapeClasses[shape]}
`}
>
<Icon className={`${filled ? 'text-text-primary' : 'text-primary'} ${sizeConfig.icon}`} strokeWidth={1.75}/>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import React from "react";
import {LucideIcon} from "lucide-react";
interface IconLabelProps {
icon: LucideIcon;
children: React.ReactNode;
}
export default function IconLabel({icon: Icon, children}: IconLabelProps): React.JSX.Element {
return (
<div className="flex items-center gap-1.5">
<Icon className="w-3 h-3 text-muted" strokeWidth={1.75}/>
<span>{children}</span>
</div>
);
}

View File

@@ -0,0 +1,14 @@
import React, {ReactNode} from "react";
interface InsetPanelProps {
children: ReactNode;
className?: string;
}
export default function InsetPanel({children, className = ''}: InsetPanelProps): React.JSX.Element {
return (
<div className={`flex-1 bg-darkest-background rounded-xl m-1 overflow-auto flex flex-col ${className}`}>
{children}
</div>
);
}

121
components/ui/ListItem.tsx Normal file
View File

@@ -0,0 +1,121 @@
import {ArrowDown, ArrowUp, Check, LucideIcon, Pen, Trash2, X} from "lucide-react";
import React, {ChangeEvent, useState} from "react";
import IconContainer from "@/components/ui/IconContainer";
interface ListItemProps {
onClick: () => void;
selectedId: number | string;
id: number | string;
icon?: LucideIcon;
numericalIdentifier?: number;
isEditable?: boolean;
text: string;
handleDelete?: (itemId: string) => void;
handleUpdate?: (itemId: string, newValue: string, subNewValue: number) => void;
onReorder?: (itemId: string, newOrder: number) => void;
}
export default function ListItem(
{
text,
selectedId,
id,
icon,
onClick,
isEditable = false,
handleDelete,
numericalIdentifier,
handleUpdate,
onReorder
}: ListItemProps): React.JSX.Element {
const [editMode, setEditMode] = useState<boolean>(false);
const [newName, setNewName] = useState<string>('');
const [newChapterOrder, setNewChapterOrder] = useState<number>(numericalIdentifier ?? 0);
function handleEdit(itemName: string): void {
setNewName(itemName)
setEditMode(true)
}
function handleSave(): void {
if (!handleUpdate) return;
handleUpdate(String(id), newName, newChapterOrder)
setEditMode(false);
}
function moveItem(direction: "up" | "down"): void {
const nextOrder: number = direction === "up" ? newChapterOrder - 1 : newChapterOrder + 1;
if (nextOrder < 0) return;
setNewChapterOrder(nextOrder);
if (onReorder) {
onReorder(String(id), nextOrder);
}
}
return (
<li className={`group relative flex items-center p-3 rounded-xl transition-colors duration-200 ${
selectedId === id
? 'bg-tertiary text-text-primary'
: 'hover:bg-tertiary'
}`}>
{(numericalIdentifier != null && newChapterOrder >= 0) && (
<span className="text-muted text-xs w-5 h-5 rounded-md bg-tertiary text-center leading-5 shrink-0 mr-2">
{newChapterOrder}
</span>
)}
{icon && (
<IconContainer icon={icon} size="sm" shape="square"/>
)}
<div className="flex items-center w-full gap-2">
{editMode ? (
<>
<input
type="text"
value={newName || text}
onChange={(e: ChangeEvent<HTMLInputElement>): void => setNewName(e.target.value)}
autoFocus
className="flex-1 bg-transparent text-sm font-medium text-text-primary outline-none min-w-0"
/>
<div className="flex shrink-0">
<button onClick={(): void => moveItem('up')} disabled={newChapterOrder <= 0}
className="text-muted hover:text-text-primary disabled:opacity-30 p-0.5">
<ArrowUp className="w-3.5 h-3.5" strokeWidth={1.75}/>
</button>
<button onClick={(): void => moveItem('down')}
className="text-muted hover:text-text-primary p-0.5">
<ArrowDown className="w-3.5 h-3.5" strokeWidth={1.75}/>
</button>
<button onClick={handleSave}
className="text-primary hover:text-primary/80 p-0.5">
<Check className="w-3.5 h-3.5" strokeWidth={1.75}/>
</button>
<button onClick={(): void => setEditMode(false)}
className="text-muted hover:text-text-primary p-0.5">
<X className="w-3.5 h-3.5" strokeWidth={1.75}/>
</button>
</div>
</>
) : (
<span
className="cursor-pointer text-sm font-medium flex-1 group-hover:text-text-primary transition-colors"
onClick={onClick}>{text}</span>
)}
{!editMode && isEditable && (
<div className="absolute right-1 flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={(): void => handleEdit(text)}
className="text-muted hover:text-text-primary p-1.5 rounded-lg hover:bg-secondary">
<Pen className="w-4 h-4" strokeWidth={1.75}/>
</button>
<button onClick={(): void => {
if (handleDelete) handleDelete(id.toString());
}}
className="text-muted hover:text-error p-1.5 rounded-lg hover:bg-error/10">
<Trash2 className="w-4 h-4" strokeWidth={1.75}/>
</button>
</div>
)}
</div>
</li>
)
}

View File

@@ -0,0 +1,30 @@
import React from 'react';
import {Lock} from 'lucide-react';
import IconContainer from "@/components/ui/IconContainer";
interface LockCardProps {
title: string;
description: string;
}
export default function LockCard({title, description}: LockCardProps): React.JSX.Element {
return (
<div className="flex flex-col h-full bg-tertiary">
<div className="flex-1 p-6 overflow-y-auto flex items-center justify-center">
<div className="max-w-md mx-auto">
<div className="bg-tertiary rounded-2xl p-8 text-center">
<div className="mx-auto mb-6">
<IconContainer icon={Lock} size="xl" shape="rounded" filled/>
</div>
<h3 className="text-xl font-['ADLaM_Display'] text-text-primary mb-4">
{title}
</h3>
<p className="text-muted leading-relaxed text-lg">
{description}
</p>
</div>
</div>
</div>
</div>
);
}

102
components/ui/Modal.tsx Normal file
View File

@@ -0,0 +1,102 @@
'use client';
import React, {ReactNode, useEffect, useState} from 'react';
import {createPortal} from 'react-dom';
import {LucideIcon, X} from "lucide-react";
import Button from "@/components/ui/Button";
import IconButton from "@/components/ui/IconButton";
type ModalSize = 'sm' | 'md' | 'lg';
interface ModalProps {
title: string;
icon?: LucideIcon;
children: ReactNode;
size?: ModalSize;
onClose: () => void;
onConfirm?: () => void;
confirmText?: string;
cancelText?: string;
footer?: ReactNode;
actions?: ReactNode;
enableOverflow?: boolean;
}
const sizeClasses: Record<ModalSize, string> = {
sm: 'md:w-3/4 xl:w-1/4 lg:w-2/4 sm:w-11/12',
md: 'md:w-3/4 xl:w-2/5 lg:w-2/4 sm:w-11/12',
lg: 'md:w-3/4 xl:w-3/5 lg:w-3/4 sm:w-11/12',
};
export default function Modal(
{
title,
icon: Icon,
children,
size = 'md',
onClose,
onConfirm,
confirmText = 'Confirmer',
cancelText = 'Annuler',
footer,
actions,
enableOverflow = true,
}: ModalProps) {
const [mounted, setMounted] = useState<boolean>(false);
useEffect((): (() => void) => {
setMounted(true);
document.body.style.overflow = 'hidden';
return (): void => {
setMounted(false);
document.body.style.overflow = 'auto';
};
}, []);
const hasFooter: boolean = !!footer || !!onConfirm;
const modalContent: React.JSX.Element = (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-darkest-background/60 backdrop-blur-md animate-fadeIn">
<div
className={`relative bg-tertiary text-text-primary rounded-xl max-h-[90vh] overflow-hidden flex flex-col ${sizeClasses[size]}`}>
<div className="flex justify-between items-center px-6 py-4">
<h2 className="flex items-center gap-3 font-['ADLaM_Display'] text-xl tracking-wide">
{Icon && <Icon className="w-6 h-6" strokeWidth={1.75}/>}
{title}
</h2>
<div className="flex items-center gap-2">
{actions}
<IconButton icon={X} variant="light" onClick={onClose}/>
</div>
</div>
<div
className={`flex-1 min-h-0 bg-darkest-background rounded-xl mx-2 flex flex-col overflow-hidden ${!hasFooter ? 'mb-2' : ''}`}>
<div
className={`flex-1 min-h-0 ${enableOverflow ? 'overflow-auto custom-scrollbar' : 'overflow-hidden'}`}>
<div className="p-5 space-y-6">
{children}
</div>
</div>
</div>
{hasFooter && (
<div className="flex justify-end gap-3 px-6 py-4">
{footer ? footer : (
<>
<Button variant="secondary" onClick={onClose}>
{cancelText}
</Button>
<Button variant="primary" onClick={onConfirm}>
{confirmText}
</Button>
</>
)}
</div>
)}
</div>
</div>
);
if (!mounted) return null;
return createPortal(modalContent, document.body);
}

View File

@@ -0,0 +1,30 @@
import React from 'react';
import {Loader2} from 'lucide-react';
type PulseLoaderSize = 'sm' | 'md' | 'lg';
interface PulseLoaderProps {
text?: string;
size?: PulseLoaderSize;
}
const sizeClasses: Record<PulseLoaderSize, { container: string; icon: string }> = {
sm: {container: 'py-8', icon: 'w-6 h-6'},
md: {container: 'h-32', icon: 'w-8 h-8'},
lg: {container: 'h-64', icon: 'w-10 h-10'},
};
export default function PulseLoader({text, size = 'md'}: PulseLoaderProps): React.JSX.Element {
const sizeConfig: { container: string; icon: string } = sizeClasses[size];
return (
<div className={`flex items-center justify-center ${sizeConfig.container}`}>
<div className="flex flex-col items-center">
<Loader2 className={`${sizeConfig.icon} text-primary animate-spin mb-3`} strokeWidth={1.75}/>
{text && (
<p className="text-text-secondary">{text}</p>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,98 @@
import React from 'react';
import {RefreshCw, Send, Square} from 'lucide-react';
import {useTranslations} from '@/lib/i18n';
import IconButton from '@/components/ui/IconButton';
import Button from '@/components/ui/Button';
import Modal from '@/components/ui/Modal';
interface QSTextGeneratedPreviewProps {
onClose: () => void;
onRefresh: () => void;
value: string;
onInsert: () => void;
isGenerating?: boolean;
onStop?: () => void;
}
export default function QSTextGeneratedPreview(
{
onClose,
onRefresh,
value,
onInsert,
isGenerating = false,
onStop,
}: QSTextGeneratedPreviewProps) {
const t = useTranslations();
const filteredValue: string = value.replace(/^starting\.{0,3}\s*/i, '').trim();
const hasRealContent: boolean = filteredValue.length > 0;
const headerActions: React.ReactNode = isGenerating && onStop ? (
<IconButton icon={Square} variant="danger" onClick={onStop}/>
) : (
<IconButton icon={RefreshCw} variant="ghost" onClick={onRefresh}/>
);
const footerContent: React.ReactNode = (
<Button variant="primary" onClick={onInsert} icon={Send}>
{t("qsTextPreview.insert")}
</Button>
);
return (
<Modal
title={t("qsTextPreview.title")}
onClose={onClose}
size="md"
actions={headerActions}
footer={footerContent}
enableOverflow={true}
>
<div className="font-['Lora']">
{isGenerating && !hasRealContent ? (
<div className="space-y-3 animate-pulse">
<div className="flex flex-wrap gap-2">
<span className="h-5 bg-primary/20 rounded px-4"></span>
<span className="h-5 bg-primary/15 rounded px-6"></span>
<span className="h-5 bg-primary/20 rounded px-3"></span>
<span className="h-5 bg-primary/10 rounded px-8"></span>
<span className="h-5 bg-primary/20 rounded px-5"></span>
<span className="h-5 bg-primary/15 rounded px-4"></span>
<span className="h-5 bg-primary/20 rounded px-7"></span>
</div>
<div className="flex flex-wrap gap-2">
<span className="h-5 bg-primary/15 rounded px-5"></span>
<span className="h-5 bg-primary/20 rounded px-3"></span>
<span className="h-5 bg-primary/10 rounded px-6"></span>
<span className="h-5 bg-primary/20 rounded px-4"></span>
<span className="h-5 bg-primary/15 rounded px-8"></span>
<span className="h-5 bg-primary/20 rounded px-3"></span>
</div>
<div className="flex flex-wrap gap-2">
<span className="h-5 bg-primary/20 rounded px-6"></span>
<span className="h-5 bg-primary/10 rounded px-4"></span>
<span className="h-5 bg-primary/20 rounded px-5"></span>
<span className="h-5 bg-primary/15 rounded px-7"></span>
<span className="h-5 bg-primary/20 rounded px-3"></span>
<span className="h-5 bg-primary/10 rounded px-5"></span>
<span className="h-5 bg-primary/20 rounded px-4"></span>
</div>
<div className="flex flex-wrap gap-2">
<span className="h-5 bg-primary/15 rounded px-4"></span>
<span className="h-5 bg-primary/20 rounded px-6"></span>
<span className="h-5 bg-primary/10 rounded px-3"></span>
<span className="h-5 bg-primary/20 rounded px-5"></span>
<span className="h-5 bg-primary/15 rounded px-7"></span>
</div>
</div>
) : (
<div className="text-text-primary text-justify leading-relaxed whitespace-pre-wrap fade-in-text">
{filteredValue}
</div>
)}
</div>
</Modal>
);
}

View File

@@ -0,0 +1,43 @@
import React, {ReactNode} from "react";
import {LucideIcon} from "lucide-react";
import Badge from "@/components/ui/Badge";
import IconContainer from "@/components/ui/IconContainer";
interface SectionHeaderProps {
icon?: LucideIcon;
title: string;
badge?: string;
description?: string;
actions?: ReactNode;
}
export default function SectionHeader(
{
icon,
title,
badge,
description,
actions,
}: SectionHeaderProps) {
return (
<div className="shrink-0">
<div className="flex justify-between items-center">
<div className="flex-1">
<h2 className="text-xl text-text-primary font-bold flex items-center gap-3 flex-wrap">
{icon && <IconContainer icon={icon} size="sm"/>}
<span className="tracking-wide">{title}</span>
{badge && <Badge>{badge}</Badge>}
</h2>
{description && (
<p className="text-text-secondary text-xs mt-2 ml-13">{description}</p>
)}
</div>
{actions && (
<div className="flex items-center gap-2">
{actions}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,53 @@
'use client'
import {ReactNode, useEffect, useState} from "react";
import {X} from "lucide-react";
import {createPortal} from "react-dom";
interface SettingsPanelProps {
title: string;
sidebar: ReactNode;
children: ReactNode;
onClose: () => void;
}
export default function SettingsPanel({title, sidebar, children, onClose}: SettingsPanelProps) {
const [mounted, setMounted] = useState<boolean>(false);
useEffect((): void => {
setMounted(true);
}, []);
if (!mounted) return null;
return createPortal(
<div className="fixed inset-0 z-40 bg-darkest-background/60 backdrop-blur-md flex items-center justify-center">
<div className="w-3/4 h-[85vh] bg-tertiary rounded-2xl border border-secondary flex flex-col">
<div className="px-6 py-4 rounded-t-2xl flex justify-between items-center">
<h2 className="font-['ADLaM_Display'] text-xl text-text-primary">{title}</h2>
<button
onClick={onClose}
className="text-text-primary/80 hover:text-text-primary p-2 rounded-lg hover:bg-text-primary/10 transition-all"
>
<X className="w-5 h-5" strokeWidth={1.75}/>
</button>
</div>
<div className="flex flex-1 min-h-0">
<div className="w-56 flex-shrink-0 overflow-y-auto">
{sidebar}
</div>
<div className="flex-1 min-h-0 p-2 pl-0">
<div className="h-full bg-darkest-background rounded-xl overflow-hidden">
<div className="h-full overflow-y-auto">
{children}
</div>
</div>
</div>
</div>
</div>
</div>,
document.body
);
}

View File

@@ -0,0 +1,73 @@
'use client'
import React, {useEffect, useState} from 'react';
import {CheckCircle, AlertCircle, Info, AlertTriangle, X, LucideIcon} from 'lucide-react';
interface StaticAlertProps {
type: 'success' | 'error' | 'info' | 'warning';
message: string;
onClose: () => void;
}
const iconMap: Record<StaticAlertProps['type'], LucideIcon> = {
success: CheckCircle,
error: AlertCircle,
info: Info,
warning: AlertTriangle,
};
const accentColorMap: Record<StaticAlertProps['type'], string> = {
success: 'text-success border-l-success',
error: 'text-error border-l-error',
info: 'text-info border-l-info',
warning: 'text-warning border-l-warning',
};
export default function StaticAlert({type, message, onClose}: StaticAlertProps) {
const [visible, setVisible] = useState<boolean>(false);
const onCloseRef = React.useRef(onClose);
useEffect((): void => {
onCloseRef.current = onClose;
}, [onClose]);
useEffect((): (() => void) => {
setVisible(true);
const timer: ReturnType<typeof setTimeout> = setTimeout((): void => {
setVisible(false);
setTimeout((): void => onCloseRef.current(), 300);
}, 4800);
return (): void => {
clearTimeout(timer);
};
}, []);
function handleClose(): void {
setVisible(false);
setTimeout((): void => onCloseRef.current(), 300);
}
const AlertIcon: LucideIcon = iconMap[type];
const accentClasses: string = accentColorMap[type];
return (
<div
className={`max-w-sm rounded-xl transition-all duration-300 ease-in-out transform ${
visible ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0'
} bg-darkest-background border-l-4 ${accentClasses}`}
>
<div className="px-4 py-3 flex items-center gap-3">
<AlertIcon className="w-5 h-5 flex-shrink-0" strokeWidth={2}/>
<span className="flex-grow text-text-primary text-sm font-medium">
{typeof message === 'string' ? message : String(message ?? 'Une erreur est survenue')}
</span>
<button
onClick={handleClose}
className="text-muted hover:text-text-primary p-1 rounded-lg hover:bg-secondary transition-all duration-200 flex-shrink-0"
>
<X className="w-4 h-4" strokeWidth={2}/>
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,59 @@
import React from "react";
import {LucideIcon} from "lucide-react";
interface ToggleOption {
value: string;
label: string;
}
type ToggleGroupSize = 'sm' | 'md';
interface ToggleGroupProps {
options: ToggleOption[];
value: string;
onChange: (value: string) => void;
icon?: LucideIcon;
size?: ToggleGroupSize;
}
const sizeClasses: Record<ToggleGroupSize, { wrapper: string; icon: string; button: string }> = {
sm: {wrapper: 'rounded-lg', icon: 'w-3.5 h-3.5 mx-2', button: 'px-2.5 py-1 text-xs'},
md: {wrapper: 'rounded-xl', icon: 'w-4 h-4 mx-2.5', button: 'px-3 py-1.5 text-sm'},
};
export default function ToggleGroup(
{
options,
value,
onChange,
icon: Icon,
size = 'sm'
}: ToggleGroupProps): React.JSX.Element {
const config = sizeClasses[size];
return (
<div className={`flex items-center bg-darkest-background ${config.wrapper} border border-secondary`}>
{Icon && (
<Icon className={`${config.icon} text-primary flex-shrink-0`} strokeWidth={1.75}/>
)}
{options.map(function (option: ToggleOption): React.JSX.Element {
const isActive: boolean = value === option.value;
return (
<button
key={option.value}
onClick={function (): void {
onChange(option.value);
}}
className={`${config.button} font-semibold transition-all duration-200 ${
isActive
? 'bg-primary text-text-primary rounded-lg'
: 'text-text-secondary hover:text-text-primary'
}`}
>
{option.label}
</button>
);
})}
</div>
);
}

14
context/ThemeContext.ts Normal file
View File

@@ -0,0 +1,14 @@
import {Context, createContext} from "react";
export type SupportedTheme = 'dark' | 'light';
export interface ThemeContextProps {
theme: SupportedTheme;
setTheme: (theme: SupportedTheme) => void;
}
export const ThemeContext: Context<ThemeContextProps> = createContext<ThemeContextProps>({
theme: 'dark',
setTheme: (): void => {
}
});

View File

@@ -450,6 +450,79 @@ ipcMain.on('logout', ():void => {
createLoginWindow(); createLoginWindow();
}); });
// ========== MIGRATION EXPORT (Electron → Tauri) ==========
ipcMain.handle('export-migration', async ():Promise<{success: boolean; path?: string; error?: string}> => {
const storage:SecureStorage = getSecureStorage();
const userId:string | null = storage.get<string>('userId', null);
const lastUserId:string | null = storage.get<string>('lastUserId', null);
const targetUserId:string | null = userId || lastUserId;
if (!targetUserId) {
return { success: false, error: 'No user found in storage' };
}
let encryptionKey:string | null = null;
try {
encryptionKey = getUserEncryptionKey(targetUserId);
} catch {
return { success: false, error: 'Encryption key not found for this user' };
}
const pinHash:string | null = storage.get<string>(`pin-${targetUserId}`, null);
const userDataPath:string = app.getPath('userData');
const dbPath:string = path.join(userDataPath, 'eritors-local.db');
if (!fs.existsSync(dbPath)) {
return { success: false, error: 'No local database found' };
}
const { filePath } = await dialog.showSaveDialog({
title: 'Export migration data',
defaultPath: path.join(app.getPath('desktop'), 'eritors-migration.json'),
filters: [{ name: 'Migration', extensions: ['json'] }],
});
if (!filePath) {
return { success: false, error: 'Export cancelled' };
}
const migrationData = {
version: 1,
exported_at: Date.now(),
user_id: targetUserId,
encryption_key: encryptionKey,
pin_hash: pinHash,
db_source: dbPath,
};
try {
fs.writeFileSync(filePath, JSON.stringify(migrationData, null, 2), 'utf-8');
// Copier aussi la DB à côté du fichier de migration
const dbDestination:string = path.join(path.dirname(filePath), `eritors-local-${targetUserId}.db`);
fs.copyFileSync(dbPath, dbDestination);
// Copier WAL et SHM si existants
const walPath:string = dbPath + '-wal';
const shmPath:string = dbPath + '-shm';
if (fs.existsSync(walPath)) {
fs.copyFileSync(walPath, dbDestination + '-wal');
}
if (fs.existsSync(shmPath)) {
fs.copyFileSync(shmPath, dbDestination + '-shm');
}
return { success: true, path: filePath };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Export failed',
};
}
});
// ========== USER SYNC (PRE-AUTHENTICATION) ========== // ========== USER SYNC (PRE-AUTHENTICATION) ==========
interface SyncUserData { interface SyncUserData {

175
hooks/useOnboarding.tsx Normal file
View File

@@ -0,0 +1,175 @@
'use client';
import {Dispatch, SetStateAction, useContext, useEffect, useState} from 'react';
import {useTranslations} from '@/lib/i18n';
import {AlertContext, AlertContextProps} from '@/context/AlertContext';
import {LangContext, LangContextProps} from '@/context/LangContext';
import {SessionProps} from '@/lib/types/session';
import {guideTourDone, setNewGuideTour} from '@/lib/utils/user';
import {apiPost} from '@/lib/api/client';
import {GuideStep} from '@/components/GuideTour';
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
interface UseOnboardingReturn {
isTermsAccepted: boolean;
homeStepsGuide: boolean;
setHomeStepsGuide: Dispatch<SetStateAction<boolean>>;
handleTermsAcceptance: () => Promise<void>;
handleHomeTour: () => Promise<void>;
homeSteps: GuideStep[];
}
interface UseOnboardingParams {
session: SessionProps;
setSession: Dispatch<SetStateAction<SessionProps>>;
}
export default function useOnboarding({session, setSession}: UseOnboardingParams): UseOnboardingReturn {
const t = useTranslations();
const {lang: locale}: LangContextProps = useContext<LangContextProps>(LangContext);
const {errorMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
const {isCurrentlyOffline}: OfflineContextType = useContext<OfflineContextType>(OfflineContext);
const [isTermsAccepted, setIsTermsAccepted] = useState<boolean>(false);
const [homeStepsGuide, setHomeStepsGuide] = useState<boolean>(false);
useEffect((): void => {
if (isCurrentlyOffline()) {
setIsTermsAccepted(true);
const localGuideDone: boolean = localStorage.getItem('guide-tour-home-basic') === 'true';
setHomeStepsGuide(!localGuideDone);
return;
}
if (session.isConnected) {
setIsTermsAccepted(session.user?.termsAccepted ?? false);
const guides: string[] = ['home-basic', 'new-first-book'];
for (const guide of guides) {
const done: boolean = !guideTourDone(session.user?.guideTour ?? [], guide);
localStorage.setItem(`guide-tour-${guide}`, done ? 'true' : 'false');
}
setHomeStepsGuide(guideTourDone(session.user?.guideTour ?? [], 'home-basic'));
}
}, [session]);
const homeSteps: GuideStep[] = [
{
id: 0,
x: 50,
y: 50,
title: t('homePage.guide.welcome', {name: session.user?.name || ''}),
content: (
<>
<p>{t('homePage.guide.step0.description1')}</p>
<br/>
<p>{t('homePage.guide.step0.description2')}</p>
</>
),
},
{
id: 1, position: 'right',
targetSelector: `[data-guide="left-panel-container"]`,
title: t('homePage.guide.step1.title'),
content: (
<>
<p className={'flex items-center space-x-2'}>
<strong>{t('homePage.guide.step1.addBook')}</strong>
</p>
<br/>
<p><strong>{t('homePage.guide.step1.generateStory')}</strong></p>
</>
),
},
{
id: 2,
title: t('homePage.guide.step2.title'), position: 'bottom',
targetSelector: `[data-guide="search-bar"]`,
content: (
<p>{t('homePage.guide.step2.description')}</p>
),
},
{
id: 3,
title: t('homePage.guide.step3.title'),
targetSelector: `[data-guide="user-dropdown"]`,
position: 'auto',
content: (
<p>{t('homePage.guide.step3.description')}</p>
),
},
{
id: 4,
title: t('homePage.guide.step4.title'),
content: (
<>
<p>{t('homePage.guide.step4.description1')}</p>
<br/>
<p>{t('homePage.guide.step4.description2')}</p>
</>
),
},
];
async function handleTermsAcceptance(): Promise<void> {
try {
const response: boolean = await apiPost<boolean>(`user/terms/accept`, {
version: '2025-07-1'
}, session.accessToken, locale);
if (response && session.user) {
setIsTermsAccepted(true);
setHomeStepsGuide(true);
const newSession: SessionProps = {
...session,
user: {
...session.user,
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 handleHomeTour(): Promise<void> {
if (isCurrentlyOffline()) {
localStorage.setItem('guide-tour-home-basic', 'true');
setHomeStepsGuide(false);
return;
}
try {
const response: boolean = await apiPost<boolean>('logs/tour', {
plateforme: 'desktop',
tour: 'home-basic'
},
session.accessToken,
locale
);
if (response) {
localStorage.setItem('guide-tour-home-basic', 'true');
setSession(setNewGuideTour(session, 'home-basic'));
setHomeStepsGuide(false);
}
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t('homePage.errors.termsError'));
}
}
}
return {
isTermsAccepted,
homeStepsGuide,
setHomeStepsGuide,
handleTermsAcceptance,
handleHomeTour,
homeSteps,
};
}

30
hooks/useTheme.ts Normal file
View File

@@ -0,0 +1,30 @@
import {useCallback, useEffect, useState} from "react";
import {SupportedTheme} from "@/context/ThemeContext";
import {getCookie, setCookie} from "@/lib/utils/cookies";
interface UseThemeReturn {
theme: SupportedTheme;
setTheme: (theme: SupportedTheme) => void;
}
export default function useTheme(): UseThemeReturn {
const [theme, setThemeState] = useState<SupportedTheme>('dark');
useEffect(function loadSavedTheme(): void {
const savedTheme: string | null = getCookie('theme');
if (savedTheme === 'light' || savedTheme === 'dark') {
setThemeState(savedTheme);
document.documentElement.setAttribute('data-theme', savedTheme);
} else {
document.documentElement.setAttribute('data-theme', 'dark');
}
}, []);
const setTheme = useCallback(function applyTheme(newTheme: SupportedTheme): void {
setThemeState(newTheme);
setCookie('theme', newTheme, 365);
document.documentElement.setAttribute('data-theme', newTheme);
}, []);
return {theme, setTheme};
}

101
lib/crashReporter.ts Normal file
View File

@@ -0,0 +1,101 @@
import axios from 'axios';
import {configs, isDesktop} from '@/lib/configs';
interface CrashReportPayload {
appName: string;
appVersion: string;
platform: string;
osVersion?: string;
errorType: string;
errorMessage: string;
stackTrace?: string;
screenName?: string;
userId?: string;
breadcrumbs?: Breadcrumb[];
extraData?: Record<string, unknown>;
}
interface Breadcrumb {
timestamp: number;
action: string;
detail?: string;
}
const MAX_BREADCRUMBS = 30;
const breadcrumbs: Breadcrumb[] = [];
export function addBreadcrumb(action: string, detail?: string): void {
breadcrumbs.push({timestamp: Date.now(), action, detail});
if (breadcrumbs.length > MAX_BREADCRUMBS) breadcrumbs.shift();
}
function getPlatform(): string {
if (!isDesktop) return 'web';
const ua: string = navigator.userAgent.toLowerCase();
if (ua.includes('mac')) return 'desktop-macos';
if (ua.includes('win')) return 'desktop-windows';
if (ua.includes('linux')) return 'desktop-linux';
return 'desktop';
}
function getUserId(): string | undefined {
try {
const raw: string | null = localStorage.getItem('userId');
return raw ?? undefined;
} catch {
return undefined;
}
}
async function sendCrashReport(payload: CrashReportPayload): Promise<void> {
try {
await axios.post(configs.apiUrl + 'crash-report', payload, {
headers: {'Content-Type': 'application/json'},
timeout: 5000,
});
} catch {
// Silently fail — we can't crash while reporting a crash
}
}
function buildReport(errorType: string, errorMessage: string, stackTrace?: string): CrashReportPayload {
return {
appName: configs.appName,
appVersion: configs.appVersion,
platform: getPlatform(),
osVersion: navigator.userAgent,
errorType,
errorMessage,
stackTrace,
screenName: window.location.pathname,
userId: getUserId(),
breadcrumbs: [...breadcrumbs],
};
}
export function reportError(error: Error, extraData?: Record<string, unknown>): void {
const report: CrashReportPayload = buildReport(
error.name || 'Error',
error.message,
error.stack,
);
if (extraData) report.extraData = extraData;
sendCrashReport(report);
}
export function initCrashReporter(): void {
window.onerror = (_message, _source, _lineno, _colno, error: Error | undefined): void => {
if (error) {
sendCrashReport(buildReport('UncaughtError', error.message, error.stack));
}
};
window.onunhandledrejection = (event: PromiseRejectionEvent): void => {
const reason: unknown = event.reason;
if (reason instanceof Error) {
sendCrashReport(buildReport('UnhandledRejection', reason.message, reason.stack));
} else {
sendCrashReport(buildReport('UnhandledRejection', String(reason)));
}
};
}

View File

@@ -187,9 +187,38 @@
"title": "Discord", "title": "Discord",
"description": "Join our community on Discord.", "description": "Join our community on Discord.",
"badge": "DISCORD" "badge": "DISCORD"
},
"migration": {
"title": "Import from Electron",
"description": "Migrate your data from the old version."
} }
} }
}, },
"migration": {
"title": "Migration from Electron",
"introText": "We detected this is your first launch. If you were using the old version of ERitors Scribe (Electron), you can import your local data (books, characters, chapters, etc.).",
"steps": "How to proceed:",
"step1": "In the old Electron app, go to the menu and click \"Export for migration\".",
"step2": "A .json file and a copy of your database will be created on your Desktop.",
"step3": "Come back here and enter the path to the exported .json file.",
"later": "Later",
"haveFile": "I have the file",
"selectText": "Paste the full path to the migration file exported from Electron.",
"filePath": "Migration file path",
"back": "Back",
"import": "Import",
"importing": "Migrating...",
"successTitle": "Migration successful!",
"successText": "Your data has been imported successfully. Log in again to access it.",
"deleteReminder": "Remember to delete the migration file and the database copy from your Desktop.",
"done": "Done",
"errorTitle": "Migration error",
"close": "Close",
"retry": "Retry",
"fileNotFound": "The migration file was not found at this path.",
"dbNotFound": "The database was not found next to the migration file.",
"importFailed": "Import failed. Please check that the files are valid."
},
"quillSense": { "quillSense": {
"needSubscription": "Please subscribe to QuillSense or bring your keys to access this feature.", "needSubscription": "Please subscribe to QuillSense or bring your keys to access this feature.",
"subscriptionDescription": "Unlock powerful writing tools to enrich your prose.", "subscriptionDescription": "Unlock powerful writing tools to enrich your prose.",

View File

@@ -187,9 +187,38 @@
"title": "Discord", "title": "Discord",
"description": "Rejoignez notre communauté sur Discord.", "description": "Rejoignez notre communauté sur Discord.",
"badge": "DISCORD" "badge": "DISCORD"
},
"migration": {
"title": "Importer depuis Electron",
"description": "Migrer vos données depuis l'ancienne version."
} }
} }
}, },
"migration": {
"title": "Migration depuis Electron",
"introText": "Nous avons détecté que c'est votre premier lancement. Si vous utilisiez l'ancienne version d'ERitors Scribe (Electron), vous pouvez importer vos données locales (livres, personnages, chapitres, etc.).",
"steps": "Comment procéder :",
"step1": "Dans l'ancienne app Electron, allez dans le menu et cliquez sur « Exporter pour migration ».",
"step2": "Un fichier .json et une copie de votre base de données seront créés sur votre Bureau.",
"step3": "Revenez ici et indiquez le chemin du fichier .json exporté.",
"later": "Plus tard",
"haveFile": "J'ai le fichier",
"selectText": "Collez le chemin complet du fichier de migration exporté depuis Electron.",
"filePath": "Chemin du fichier de migration",
"back": "Retour",
"import": "Importer",
"importing": "Migration en cours...",
"successTitle": "Migration réussie !",
"successText": "Vos données ont été importées avec succès. Reconnectez-vous pour y accéder.",
"deleteReminder": "Pensez à supprimer le fichier de migration et la copie de la base de données de votre Bureau.",
"done": "Terminé",
"errorTitle": "Erreur de migration",
"close": "Fermer",
"retry": "Réessayer",
"fileNotFound": "Le fichier de migration est introuvable à ce chemin.",
"dbNotFound": "La base de données n'a pas été trouvée à côté du fichier de migration.",
"importFailed": "L'importation a échoué. Vérifiez que les fichiers sont valides."
},
"quillSense": { "quillSense": {
"needSubscription": "Veuillez vous abonner à QuillSense ou Amenez vos clés pour accéder à cette fonctionnalité.", "needSubscription": "Veuillez vous abonner à QuillSense ou Amenez vos clés pour accéder à cette fonctionnalité.",
"subscriptionDescription": "Débloquez des outils d'aide à l'écriture puissants pour enrichir votre prose.", "subscriptionDescription": "Débloquez des outils d'aide à l'écriture puissants pour enrichir votre prose.",

View File

@@ -677,6 +677,29 @@ export async function applySeriesTombstones(tombstones: TombstoneRecord[]): Prom
return invoke<void>('apply_series_tombstones', {tombstones}); return invoke<void>('apply_series_tombstones', {tombstones});
} }
// ─── Migration ────────────────────────────────────────────────
export interface MigrationCheckResult {
found: boolean;
userId: string | null;
hasDb: boolean;
migrationPath: string | null;
}
export interface MigrationResult {
success: boolean;
userId: string | null;
error: string | null;
}
export async function checkElectronMigration(migrationFilePath: string): Promise<MigrationCheckResult> {
return invoke<MigrationCheckResult>('check_electron_migration', {migrationFilePath});
}
export async function importFromElectron(migrationFilePath: string): Promise<MigrationResult> {
return invoke<MigrationResult>('import_from_electron', {data: {migrationFilePath}});
}
// ─── Window Management ────────────────────────────────────── // ─── Window Management ──────────────────────────────────────
let loginWindowOpening = false; let loginWindowOpening = false;

View File

@@ -0,0 +1,4 @@
export function isWebKitWithoutIndentFix(): boolean {
const ua: string = navigator.userAgent;
return /AppleWebKit/.test(ua) && !/Chrome|Chromium|Edg/.test(ua);
}

View File

@@ -11,6 +11,7 @@ pub mod incident;
pub mod offline; pub mod offline;
pub mod issue; pub mod issue;
pub mod location; pub mod location;
pub mod migration;
pub mod plotpoint; pub mod plotpoint;
pub mod series; pub mod series;
pub mod series_character; pub mod series_character;

View File

@@ -167,10 +167,13 @@ pub fn run() {
domains::series_sync::commands::series_sync_upload, domains::series_sync::commands::series_sync_upload,
// ─── Sync ────────────────────────────────────── // ─── Sync ──────────────────────────────────────
domains::sync::commands::get_synced_series, domains::sync::commands::get_synced_series,
// ─── Tombstone ──────────────────────────────── // ─── Tombstone ─────────────<EFBFBD><EFBFBD><EFBFBD>───────────────────
domains::tombstone::commands::get_tombstones_since, domains::tombstone::commands::get_tombstones_since,
domains::tombstone::commands::apply_book_tombstones, domains::tombstone::commands::apply_book_tombstones,
domains::tombstone::commands::apply_series_tombstones, domains::tombstone::commands::apply_series_tombstones,
// ─── Migration ────────────<E29480><E29480>───────────────────
domains::migration::commands::check_electron_migration,
domains::migration::commands::import_from_electron,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");