Add components for Act management and integrate Electron setup

This commit is contained in:
natreex
2025-11-16 11:00:04 -05:00
parent e192b6dcc2
commit 8167948881
97 changed files with 25378 additions and 3 deletions

View File

@@ -0,0 +1,574 @@
import React, {ChangeEvent, useContext, useEffect, useState} from "react";
import {Editor, EditorContent, useEditor} from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Underline from "@tiptap/extension-underline";
import TextAlign from "@tiptap/extension-text-align";
import System from "@/lib/models/System";
import {ChapterContext} from "@/context/ChapterContext";
import {BookContext} from "@/context/BookContext";
import {SelectBoxProps} from "@/shared/interface";
import {AlertContext} from "@/context/AlertContext";
import {SessionContext} from "@/context/SessionContext";
import {
faCubes,
faFeather,
faGlobe,
faMagicWandSparkles,
faMapPin,
faPalette,
faUser
} from "@fortawesome/free-solid-svg-icons";
import SubmitButtonWLoading from "@/components/form/SubmitButtonWLoading";
import QSTextGeneratedPreview from "@/components/QSTextGeneratedPreview";
import {EditorContext} from "@/context/EditorContext";
import {useTranslations} from "next-intl";
import QuillSense from "@/lib/models/QuillSense";
import TextInput from "@/components/form/TextInput";
import InputField from "@/components/form/InputField";
import TexteAreaInput from "@/components/form/TexteAreaInput";
import SuggestFieldInput from "@/components/form/SuggestFieldInput";
import Collapse from "@/components/Collapse";
import {LangContext, LangContextProps} from "@/context/LangContext";
import {BookTags} from "@/lib/models/Book";
import {AIUsageContext, AIUsageContextProps} from "@/context/AIUsageContext";
import {configs} from "@/lib/configs";
interface CompanionContent {
version: number;
content: string;
wordsCount: number;
}
export default function DraftCompanion() {
const t = useTranslations();
const {setTotalPrice, setTotalCredits} = useContext<AIUsageContextProps>(AIUsageContext)
const {lang} = useContext<LangContextProps>(LangContext)
const mainEditor: Editor | null = useEditor({
extensions: [
StarterKit,
Underline,
TextAlign.configure({
types: ['heading', 'paragraph'],
}),
],
injectCSS: false,
editable: false,
immediatelyRender: false,
});
const {editor} = useContext(EditorContext);
const {chapter} = useContext(ChapterContext);
const {book} = useContext(BookContext);
const {session} = useContext(SessionContext);
const {errorMessage, infoMessage} = useContext(AlertContext);
const [draftVersion, setDraftVersion] = useState<number>(0);
const [draftWordCount, setDraftWordCount] = useState<number>(0);
const [refinedText, setRefinedText] = useState<string>('');
const [isRefining, setIsRefining] = useState<boolean>(false);
const [showRefinedText, setShowRefinedText] = useState<boolean>(false);
const [showEnhancer, setShowEnhancer] = useState<boolean>(false);
const [abortController, setAbortController] = useState<ReadableStreamDefaultReader<Uint8Array> | null>(null);
const [toneAtmosphere, setToneAtmosphere] = useState<string>('');
const [specifications, setSpecifications] = useState<string>('');
const [characters, setCharacters] = useState<SelectBoxProps[]>([]);
const [locations, setLocations] = useState<SelectBoxProps[]>([]);
const [objects, setObjects] = useState<SelectBoxProps[]>([]);
const [worldElements, setWorldElements] = useState<SelectBoxProps[]>([]);
const [taguedCharacters, setTaguedCharacters] = useState<string[]>([]);
const [taguedLocations, setTaguedLocations] = useState<string[]>([]);
const [taguedObjects, setTaguedObjects] = useState<string[]>([]);
const [taguedWorldElements, setTaguedWorldElements] = useState<string[]>([]);
const [searchCharacters, setSearchCharacters] = useState<string>('');
const [searchLocations, setSearchLocations] = useState<string>('');
const [searchObjects, setSearchObjects] = useState<string>('');
const [searchWorldElements, setSearchWorldElements] = useState<string>('');
const [showCharacterSuggestions, setShowCharacterSuggestions] = useState<boolean>(false);
const [showLocationSuggestions, setShowLocationSuggestions] = useState<boolean>(false);
const [showObjectSuggestions, setShowObjectSuggestions] = useState<boolean>(false);
const [showWorldElementSuggestions, setShowWorldElementSuggestions] = useState<boolean>(false);
const isGPTEnabled: boolean = QuillSense.isOpenAIEnabled(session);
const isSubTierTree: boolean = QuillSense.getSubLevel(session) === 3;
const hasAccess: boolean = isGPTEnabled || isSubTierTree;
useEffect((): void => {
getDraftContent().then();
if (showEnhancer) {
fetchTags().then();
}
}, [mainEditor, chapter, showEnhancer]);
async function getDraftContent(): Promise<void> {
try {
const response: CompanionContent = await System.authGetQueryToServer<CompanionContent>(`chapter/content/companion`, session.accessToken, lang, {
bookid: book?.bookId,
chapterid: chapter?.chapterId,
version: chapter?.chapterContent.version,
});
if (response && mainEditor) {
mainEditor.commands.setContent(JSON.parse(response.content));
setDraftVersion(response.version);
setDraftWordCount(response.wordsCount);
} else if (response && response.content.length === 0 && mainEditor) {
mainEditor.commands.setContent({
"type": "doc",
"content": [
{
"type": "heading",
"attrs": {
"level": 1
},
"content": [
{
"type": "text",
"text": t("draftCompanion.noPreviousVersion")
}
]
}
]
});
}
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t("draftCompanion.unknownError"));
}
}
}
async function fetchTags(): Promise<void> {
try {
const responseTags: BookTags = await System.authGetQueryToServer<BookTags>(`book/tags`, session.accessToken, lang, {
bookId: book?.bookId
});
if (responseTags) {
setCharacters(responseTags.characters);
setLocations(responseTags.locations);
setObjects(responseTags.objects);
setWorldElements(responseTags.worldElements);
}
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t("draftCompanion.unknownError"));
}
}
}
async function handleStopRefining(): Promise<void> {
if (abortController) {
await abortController.cancel();
setAbortController(null);
infoMessage(t("draftCompanion.abortSuccess"));
}
}
async function handleQuillSenseRefined(): Promise<void> {
if (chapter && session?.accessToken) {
setIsRefining(true);
setShowRefinedText(false);
setRefinedText('');
try {
const response: Response = await fetch(`${configs.apiUrl}quillsense/refine`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${session.accessToken}`,
},
body: JSON.stringify({
chapterId: chapter?.chapterId,
bookId: book?.bookId,
toneAndAtmosphere: toneAtmosphere,
advancedPrompt: specifications,
tags: {
characters: taguedCharacters,
locations: taguedLocations,
objects: taguedObjects,
worldElements: taguedWorldElements,
}
}),
});
if (!response.ok) {
const error: { message?: string } = await response.json();
errorMessage(error.message || t('draftCompanion.errorRefineDraft'));
setIsRefining(false);
return;
}
const reader: ReadableStreamDefaultReader<Uint8Array> | undefined = response.body?.getReader();
const decoder: TextDecoder = new TextDecoder();
let accumulatedText: string = '';
if (!reader) {
errorMessage(t('draftCompanion.errorRefineDraft'));
setIsRefining(false);
return;
}
setAbortController(reader);
while (true) {
try {
const {done, value}: ReadableStreamReadResult<Uint8Array> = await reader.read();
if (done) break;
const chunk: string = decoder.decode(value, {stream: true});
const lines: string[] = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const dataStr: string = line.slice(6);
const data: {
content?: string;
totalCost?: number;
totalPrice?: number;
useYourKey?: boolean;
aborted?: boolean;
} = JSON.parse(dataStr);
// Si c'est le message final avec les totaux
if ('totalCost' in data && 'useYourKey' in data && 'totalPrice' in data) {
console.log(data);
if (data.useYourKey) {
setTotalPrice((prev: number): number => prev + data.totalPrice!);
} else {
setTotalCredits(data.totalPrice!);
}
} else if ('content' in data && data.content && data.content !== 'starting') {
accumulatedText += data.content;
setRefinedText(accumulatedText);
}
} catch (e: unknown) {
console.error('Error parsing SSE data:', e);
}
}
}
} catch (e: unknown) {
break;
}
}
setIsRefining(false);
setShowRefinedText(true);
setAbortController(null);
} catch (e: unknown) {
setIsRefining(false);
setAbortController(null);
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t('draftCompanion.unknownErrorRefineDraft'));
}
}
}
}
function insertText(): void {
if (editor && refinedText) {
editor.commands.focus('end');
if (editor.getText().length > 0) {
editor.commands.insertContent('\n\n');
}
editor.commands.insertContent(System.textContentToHtml(refinedText));
setShowRefinedText(false);
}
}
function filteredCharacters(): SelectBoxProps[] {
if (searchCharacters.trim().length === 0) return [];
return characters
.filter((item: SelectBoxProps): boolean => item.label.toLowerCase().includes(searchCharacters.toLowerCase()) && !taguedCharacters.includes(item.value))
.slice(0, 3);
}
function filteredLocations(): SelectBoxProps[] {
if (searchLocations.trim().length === 0) return [];
return locations
.filter((item: SelectBoxProps): boolean => item.label.toLowerCase().includes(searchLocations.toLowerCase()) && !taguedLocations.includes(item.value))
.slice(0, 3);
}
function filteredObjects(): SelectBoxProps[] {
if (searchObjects.trim().length === 0) return [];
return objects
.filter((item: SelectBoxProps): boolean => item.label.toLowerCase().includes(searchObjects.toLowerCase()) && !taguedObjects.includes(item.value))
.slice(0, 3);
}
function filteredWorldElements(): SelectBoxProps[] {
if (searchWorldElements.trim().length === 0) return [];
return worldElements
.filter((item: SelectBoxProps): boolean => item.label.toLowerCase().includes(searchWorldElements.toLowerCase()) && !taguedWorldElements.includes(item.value))
.slice(0, 3);
}
function handleAddCharacter(value: string): void {
if (!taguedCharacters.includes(value)) {
const newCharacters: string[] = [...taguedCharacters, value];
setTaguedCharacters(newCharacters);
}
setSearchCharacters('');
setShowCharacterSuggestions(false);
}
function handleAddLocation(value: string): void {
if (!taguedLocations.includes(value)) {
const newLocations: string[] = [...taguedLocations, value];
setTaguedLocations(newLocations);
}
setSearchLocations('');
setShowLocationSuggestions(false);
}
function handleAddObject(value: string): void {
if (!taguedObjects.includes(value)) {
const newObjects: string[] = [...taguedObjects, value];
setTaguedObjects(newObjects);
}
setSearchObjects('');
setShowObjectSuggestions(false);
}
function handleAddWorldElement(value: string): void {
if (!taguedWorldElements.includes(value)) {
const newWorldElements: string[] = [...taguedWorldElements, value];
setTaguedWorldElements(newWorldElements);
}
setSearchWorldElements('');
setShowWorldElementSuggestions(false);
}
function handleRemoveCharacter(value: string): void {
setTaguedCharacters(taguedCharacters.filter((tag: string): boolean => tag !== value));
}
function handleRemoveLocation(value: string): void {
setTaguedLocations(taguedLocations.filter((tag: string): boolean => tag !== value));
}
function handleRemoveObject(value: string): void {
setTaguedObjects(taguedObjects.filter((tag: string): boolean => tag !== value));
}
function handleRemoveWorldElement(value: string): void {
setTaguedWorldElements(taguedWorldElements.filter((tag: string): boolean => tag !== value));
}
function handleCharacterSearch(text: string): void {
setSearchCharacters(text);
setShowCharacterSuggestions(text.trim().length > 0);
}
function handleLocationSearch(text: string): void {
setSearchLocations(text);
setShowLocationSuggestions(text.trim().length > 0);
}
function handleObjectSearch(text: string): void {
setSearchObjects(text);
setShowObjectSuggestions(text.trim().length > 0);
}
function handleWorldElementSearch(text: string): void {
setSearchWorldElements(text);
setShowWorldElementSuggestions(text.trim().length > 0);
}
function getCharacterLabel(value: string): string {
const character: SelectBoxProps | undefined = characters.find((item: SelectBoxProps): boolean => item.value === value);
return character ? character.label : value;
}
function getLocationLabel(value: string): string {
const location: SelectBoxProps | undefined = locations.find((item: SelectBoxProps): boolean => item.value === value);
return location ? location.label : value;
}
function getObjectLabel(value: string): string {
const object: SelectBoxProps | undefined = objects.find((item: SelectBoxProps): boolean => item.value === value);
return object ? object.label : value;
}
function getWorldElementLabel(value: string): string {
const element: SelectBoxProps | undefined = worldElements.find((item: SelectBoxProps): boolean => item.value === value);
return element ? element.label : value;
}
if (showEnhancer && hasAccess) {
return (
<div className="flex flex-col h-full min-h-0 overflow-hidden">
<div
className="flex items-center justify-between p-4 bg-secondary/30 backdrop-blur-sm border-b border-secondary/50 flex-shrink-0 shadow-sm">
<h2 className="text-text-primary font-['ADLaM_Display'] text-xl">Amélioration de texte</h2>
<button
onClick={(): void => setShowEnhancer(false)}
className="px-5 py-2.5 bg-secondary/50 hover:bg-secondary text-text-primary rounded-xl transition-all duration-200 hover:scale-105 shadow-md border border-secondary/50 font-medium"
>
Retour
</button>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-4 custom-scrollbar">
<Collapse
title="Style d'écriture"
content={
<div className="space-y-4">
<InputField
icon={faPalette}
fieldName={t("ghostWriter.toneAtmosphere")}
input={
<TextInput
value={toneAtmosphere}
setValue={(e: ChangeEvent<HTMLInputElement>) => setToneAtmosphere(e.target.value)}
placeholder={t("ghostWriter.tonePlaceholder")}
/>
}
/>
</div>
}
/>
<Collapse
title="Tags contextuels"
content={
<div className="space-y-4">
<SuggestFieldInput inputFieldName={`Personnages`}
inputFieldIcon={faUser}
searchTags={searchCharacters}
tagued={taguedCharacters}
handleTagSearch={(e) => handleCharacterSearch(e.target.value)}
handleAddTag={handleAddCharacter}
handleRemoveTag={handleRemoveCharacter}
filteredTags={filteredCharacters}
showTagSuggestions={showCharacterSuggestions}
setShowTagSuggestions={setShowCharacterSuggestions}
getTagLabel={getCharacterLabel}
/>
<SuggestFieldInput inputFieldName={`Lieux`}
inputFieldIcon={faMapPin}
searchTags={searchLocations}
tagued={taguedLocations}
handleTagSearch={(e) => handleLocationSearch(e.target.value)}
handleAddTag={handleAddLocation}
handleRemoveTag={handleRemoveLocation}
filteredTags={filteredLocations}
showTagSuggestions={showLocationSuggestions}
setShowTagSuggestions={setShowLocationSuggestions}
getTagLabel={getLocationLabel}
/>
<SuggestFieldInput inputFieldName={`Objets`}
inputFieldIcon={faCubes}
searchTags={searchObjects}
tagued={taguedObjects}
handleTagSearch={(e) => handleObjectSearch(e.target.value)}
handleAddTag={handleAddObject}
handleRemoveTag={handleRemoveObject}
filteredTags={filteredObjects}
showTagSuggestions={showObjectSuggestions}
setShowTagSuggestions={setShowObjectSuggestions}
getTagLabel={getObjectLabel}
/>
<SuggestFieldInput inputFieldName={`Éléments mondiaux`}
inputFieldIcon={faGlobe}
searchTags={searchWorldElements}
tagued={taguedWorldElements}
handleTagSearch={(e) => handleWorldElementSearch(e.target.value)}
handleAddTag={handleAddWorldElement}
handleRemoveTag={handleRemoveWorldElement}
filteredTags={filteredWorldElements}
showTagSuggestions={showWorldElementSuggestions}
setShowTagSuggestions={setShowWorldElementSuggestions}
getTagLabel={getWorldElementLabel}
/>
</div>
}
/>
<div className="bg-secondary/20 rounded-xl p-5 shadow-inner border border-secondary/30">
<InputField
icon={faMagicWandSparkles}
fieldName="Spécifications"
input={
<TexteAreaInput
value={specifications}
setValue={(e: ChangeEvent<HTMLTextAreaElement>) => setSpecifications(e.target.value)}
placeholder="Spécifications particulières pour l'amélioration..."
maxLength={600}
/>
}
/>
</div>
</div>
<div
className="p-5 border-t border-secondary/50 bg-secondary/30 backdrop-blur-sm shrink-0 shadow-inner">
<div className="flex justify-center">
<SubmitButtonWLoading
callBackAction={handleQuillSenseRefined}
isLoading={isRefining}
text={t("draftCompanion.refine")}
loadingText={t("draftCompanion.refining")}
icon={faMagicWandSparkles}
/>
</div>
</div>
{(showRefinedText || isRefining) && (
<QSTextGeneratedPreview
onClose={(): void => setShowRefinedText(false)}
onRefresh={handleQuillSenseRefined}
value={refinedText}
onInsert={insertText}
isGenerating={isRefining}
onStop={handleStopRefining}
/>
)}
</div>
);
}
return (
<div className="flex flex-col h-full min-h-0 overflow-hidden font-['Lora']">
<div
className="flex items-center justify-between p-4 bg-secondary/30 backdrop-blur-sm border-b border-secondary/50 flex-shrink-0 font-['ADLaM_Display'] shadow-sm">
<div className="mr-4 text-primary-light">
<span>{t("draftCompanion.words")}: </span>
<span className="text-text-primary">{draftWordCount}</span>
</div>
{
hasAccess && chapter?.chapterContent.version === 3 && (
<div className="flex gap-2">
<SubmitButtonWLoading
callBackAction={(): void => setShowEnhancer(true)}
isLoading={isRefining}
text={t("draftCompanion.refine")}
loadingText={t("draftCompanion.refining")}
icon={faFeather}
/>
</div>
)
}
</div>
<div className="flex-1 min-h-0 overflow-auto">
<EditorContent
className="w-full h-full tiptap-draft"
editor={mainEditor}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,26 @@
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {faBookOpen} from "@fortawesome/free-solid-svg-icons";
import React from "react";
import {useTranslations} from "next-intl";
export default function NoBookHome() {
const t = useTranslations();
return (
<div className="flex items-center justify-center h-full p-8 text-center">
<div
className="max-w-md bg-tertiary/90 backdrop-blur-sm p-10 rounded-2xl shadow-2xl border border-secondary/50">
<FontAwesomeIcon icon={faBookOpen} className={"text-primary w-20 h-20 mb-6 animate-pulse"}/>
<h3 className="text-2xl font-['ADLaM_Display'] text-text-primary mb-4">{t("noBookHome.title")}</h3>
<p className="text-muted mb-6 text-lg leading-relaxed">
{t("noBookHome.description")}
</p>
<div
className="flex items-center justify-center gap-3 text-sm text-muted bg-secondary/30 p-4 rounded-xl border border-secondary/40">
<FontAwesomeIcon icon={faBookOpen} className="text-primary w-5 h-5"/>
<span>{t("noBookHome.hint")}</span>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,33 @@
import React, {useContext, useState} from "react";
import {ChapterContext} from "@/context/ChapterContext";
import {BookContext} from "@/context/BookContext";
import {SettingBookContext} from "@/context/SettingBookContext";
import TextEditor from "./TextEditor";
import BookList from "@/components/book/BookList";
import BookSettingOption from "@/components/book/settings/BookSettingOption";
import NoBookHome from "@/components/editor/NoBookHome";
export default function ScribeEditor() {
const {chapter} = useContext(ChapterContext);
const {book} = useContext(BookContext);
const [bookSettingId, setBookSettingId] = useState<string>('');
return (
<SettingBookContext.Provider value={{bookSettingId, setBookSettingId}}>
<div className="flex-1 bg-darkest-background">
{
chapter ? (
<TextEditor/>
) : book ? (
<NoBookHome/>
) : book === null ? (
<BookList/>
) : bookSettingId && (
<BookSettingOption setting={bookSettingId}/>
)
}
</div>
</SettingBookContext.Provider>
);
}

View File

@@ -0,0 +1,516 @@
'use client'
import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react';
import {EditorContent} from '@tiptap/react';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {
faAlignCenter,
faAlignLeft,
faAlignRight,
faBold,
faCog,
faFloppyDisk,
faGhost,
faHeading,
faLayerGroup,
faListOl,
faListUl,
faParagraph,
faUnderline
} from '@fortawesome/free-solid-svg-icons';
import {EditorContext} from "@/context/EditorContext";
import {ChapterContext} from '@/context/ChapterContext';
import System from '@/lib/models/System';
import {AlertContext} from '@/context/AlertContext';
import {SessionContext} from "@/context/SessionContext";
import DraftCompanion from "@/components/editor/DraftCompanion";
import GhostWriter from "@/components/ghostwriter/GhostWriter";
import SubmitButtonWLoading from "@/components/form/SubmitButtonWLoading";
import CollapsableButton from "@/components/CollapsableButton";
import {IconDefinition} from "@fortawesome/fontawesome-svg-core";
import UserEditorSettings, {EditorDisplaySettings} from "@/components/editor/UserEditorSetting";
import {useTranslations} from "next-intl";
import {LangContext, LangContextProps} from "@/context/LangContext";
interface ToolbarButton {
action: () => void;
icon: IconDefinition;
isActive: boolean;
label?: string;
}
interface EditorClasses {
base: string;
h1: string;
h2: string;
h3: string;
container: string;
theme: string;
paragraph: string;
lists: string;
listItems: string;
}
const DEFAULT_EDITOR_SETTINGS: EditorDisplaySettings = {
zoomLevel: 3,
indent: 30,
lineHeight: 1.5,
theme: 'sombre',
fontFamily: 'lora',
maxWidth: 768,
focusMode: false
};
const FONT_SIZE_CLASSES = {
1: 'text-sm',
2: 'text-base',
3: 'text-lg',
4: 'text-xl',
5: 'text-2xl'
} as const;
const H1_SIZE_CLASSES = {
1: 'text-xl',
2: 'text-2xl',
3: 'text-3xl',
4: 'text-4xl',
5: 'text-5xl'
} as const;
const H2_SIZE_CLASSES = {
1: 'text-lg',
2: 'text-xl',
3: 'text-2xl',
4: 'text-3xl',
5: 'text-4xl'
} as const;
const H3_SIZE_CLASSES = {
1: 'text-base',
2: 'text-lg',
3: 'text-xl',
4: 'text-2xl',
5: 'text-3xl'
} as const;
const FONT_FAMILY_CLASSES = {
'lora': 'Lora',
'serif': 'font-serif',
'sans-serif': 'font-sans',
'monospace': 'font-mono'
} as const;
const LINE_HEIGHT_CLASSES = {
1.2: 'leading-tight',
1.5: 'leading-normal',
1.75: 'leading-relaxed',
2: 'leading-loose'
} as const;
const MAX_WIDTH_CLASSES = {
600: 'max-w-xl',
650: 'max-w-2xl',
700: 'max-w-3xl',
750: 'max-w-4xl',
800: 'max-w-5xl',
850: 'max-w-6xl',
900: 'max-w-7xl',
950: 'max-w-full',
1000: 'max-w-full',
1050: 'max-w-full',
1100: 'max-w-full',
1150: 'max-w-full',
1200: 'max-w-full'
} as const;
function getClosestKey<T extends Record<number, any>>(value: number, obj: T): keyof T {
const keys: number[] = Object.keys(obj).map(Number).sort((a: number, b: number): number => a - b);
return keys.reduce((prev: number, curr: number): number =>
Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev
);
}
export default function TextEditor() {
const t = useTranslations();
const {lang} = useContext<LangContextProps>(LangContext)
const {editor} = useContext(EditorContext);
const {chapter} = useContext(ChapterContext);
const {errorMessage, successMessage} = useContext(AlertContext);
const {session} = useContext(SessionContext);
const [mainTimer, setMainTimer] = useState<number>(0);
const [showDraftCompanion, setShowDraftCompanion] = useState<boolean>(false);
const [showGhostWriter, setShowGhostWriter] = useState<boolean>(false);
const [showUserSettings, setShowUserSettings] = useState<boolean>(false);
const [isSaving, setIsSaving] = useState<boolean>(false);
const [editorSettings, setEditorSettings] = useState<EditorDisplaySettings>(DEFAULT_EDITOR_SETTINGS);
const [editorClasses, setEditorClasses] = useState<EditorClasses>({
base: 'text-lg font-serif leading-normal',
h1: 'text-3xl font-bold',
h2: 'text-2xl font-bold',
h3: 'text-xl font-bold',
container: 'max-w-3xl',
theme: 'bg-tertiary/90 backdrop-blur-sm bg-opacity-25 text-text-primary',
paragraph: 'indent-6',
lists: 'pl-10',
listItems: 'text-lg'
});
const timerRef: React.RefObject<number | null> = useRef<number | null>(null);
const timeoutRef: React.RefObject<number | null> = useRef<number | null>(null);
const updateEditorClasses: (settings: EditorDisplaySettings) => void = useCallback((settings: EditorDisplaySettings): void => {
const fontSizeKey = settings.zoomLevel as keyof typeof FONT_SIZE_CLASSES;
const h1SizeKey = settings.zoomLevel as keyof typeof H1_SIZE_CLASSES;
const h2SizeKey = settings.zoomLevel as keyof typeof H2_SIZE_CLASSES;
const h3SizeKey = settings.zoomLevel as keyof typeof H3_SIZE_CLASSES;
const fontFamilyKey = settings.fontFamily as keyof typeof FONT_FAMILY_CLASSES;
const lineHeightKey = settings.lineHeight as keyof typeof LINE_HEIGHT_CLASSES;
const maxWidthKey: number = getClosestKey(settings.maxWidth, MAX_WIDTH_CLASSES);
const indentClass = `indent-${Math.round(settings.indent / 4)}`;
const baseClass = `${FONT_SIZE_CLASSES[fontSizeKey]} ${FONT_FAMILY_CLASSES[fontFamilyKey]} ${LINE_HEIGHT_CLASSES[lineHeightKey]}`;
const h1Class = `${H1_SIZE_CLASSES[h1SizeKey]} font-bold ${FONT_FAMILY_CLASSES[fontFamilyKey]} ${LINE_HEIGHT_CLASSES[lineHeightKey]}`;
const h2Class = `${H2_SIZE_CLASSES[h2SizeKey]} font-bold ${FONT_FAMILY_CLASSES[fontFamilyKey]} ${LINE_HEIGHT_CLASSES[lineHeightKey]}`;
const h3Class = `${H3_SIZE_CLASSES[h3SizeKey]} font-bold ${FONT_FAMILY_CLASSES[fontFamilyKey]} ${LINE_HEIGHT_CLASSES[lineHeightKey]}`;
const containerClass = MAX_WIDTH_CLASSES[maxWidthKey as keyof typeof MAX_WIDTH_CLASSES];
const listsClass = `pl-${Math.round((settings.indent + 20) / 4)}`;
let themeClass: string = '';
switch (settings.theme) {
case 'clair':
themeClass = 'bg-white text-black';
break;
case 'sépia':
themeClass = 'text-amber-900';
break;
default:
themeClass = 'bg-tertiary/90 backdrop-blur-sm bg-opacity-25 text-text-primary';
}
setEditorClasses({
base: baseClass,
h1: h1Class,
h2: h2Class,
h3: h3Class,
container: containerClass,
theme: themeClass,
paragraph: indentClass,
lists: listsClass,
listItems: baseClass
});
}, []);
const containerStyle = useMemo(() => {
if (editorSettings.theme === 'sépia') {
return {backgroundColor: '#f4f1e8'};
}
return {};
}, [editorSettings.theme]);
const toolbarButtons: ToolbarButton[] = (() => {
if (!editor) return [];
return [
{
action: (): boolean => editor.chain().focus().setParagraph().run(),
icon: faParagraph,
isActive: editor.isActive('paragraph')
},
{
action: (): boolean => editor.chain().focus().toggleBold().run(),
icon: faBold,
isActive: editor.isActive('bold')
},
{
action: (): boolean => editor.chain().focus().toggleUnderline().run(),
icon: faUnderline,
isActive: editor.isActive('underline')
},
{
action: (): boolean => editor.chain().focus().setTextAlign('left').run(),
icon: faAlignLeft,
isActive: editor.isActive({textAlign: 'left'})
},
{
action: (): boolean => editor.chain().focus().setTextAlign('center').run(),
icon: faAlignCenter,
isActive: editor.isActive({textAlign: 'center'})
},
{
action: (): boolean => editor.chain().focus().setTextAlign('right').run(),
icon: faAlignRight,
isActive: editor.isActive({textAlign: 'right'})
},
{
action: (): boolean => editor.chain().focus().toggleBulletList().run(),
icon: faListUl,
isActive: editor.isActive('bulletList')
},
{
action: (): boolean => editor.chain().focus().toggleOrderedList().run(),
icon: faListOl,
isActive: editor.isActive('orderedList')
},
{
action: (): boolean => editor.chain().focus().toggleHeading({level: 1}).run(),
icon: faHeading,
isActive: editor.isActive('heading', {level: 1}),
label: '1'
},
{
action: (): boolean => editor.chain().focus().toggleHeading({level: 2}).run(),
icon: faHeading,
isActive: editor.isActive('heading', {level: 2}),
label: '2'
},
{
action: (): boolean => editor.chain().focus().toggleHeading({level: 3}).run(),
icon: faHeading,
isActive: editor.isActive('heading', {level: 3}),
label: '3'
},
];
})();
const saveContent: () => Promise<void> = useCallback(async (): Promise<void> => {
if (!editor || !chapter) return;
setIsSaving(true);
const content = editor.state.doc.toJSON();
const chapterId: string = chapter.chapterId || '';
const version: number = chapter.chapterContent.version || 0;
try {
const response: boolean = await System.authPostToServer<boolean>(`chapter/content`, {
chapterId,
version,
content,
totalWordCount: editor.getText().length,
currentTime: mainTimer
}, session?.accessToken ?? '');
if (!response) {
errorMessage(t('editor.error.savedFailed'));
setIsSaving(false);
return;
}
setMainTimer(0);
successMessage(t('editor.success.saved'));
setIsSaving(false);
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t('editor.error.unknownError'));
}
setIsSaving(false);
}
}, [editor, chapter, mainTimer, session?.accessToken, successMessage, errorMessage]);
const handleShowDraftCompanion: () => void = useCallback((): void => {
setShowDraftCompanion((prev: boolean): boolean => !prev);
setShowGhostWriter(false);
setShowUserSettings(false);
}, []);
const handleShowGhostWriter: () => void = useCallback((): void => {
if (chapter?.chapterContent.version === 2) {
setShowGhostWriter((prev: boolean): boolean => !prev);
setShowDraftCompanion(false);
setShowUserSettings(false);
}
}, [chapter?.chapterContent.version]);
const handleShowUserSettings: () => void = useCallback((): void => {
setShowUserSettings((prev: boolean): boolean => !prev);
setShowDraftCompanion(false);
setShowGhostWriter(false);
}, []);
useEffect((): void => {
if (!editor) return;
const editorElement: HTMLElement = editor.view.dom;
if (editorElement) {
const indentClasses: string[] = Array.from({length: 21}, (_, i) => `indent-${i}`);
editorElement.classList.remove(...indentClasses);
if (editorClasses.paragraph) {
editorElement.classList.add(editorClasses.paragraph);
}
}
}, [editor, editorClasses.paragraph]);
useEffect((): void => {
updateEditorClasses(editorSettings);
}, [editorSettings, updateEditorClasses]);
useEffect((): () => void => {
function startTimer(): void {
if (timerRef.current === null) {
timerRef.current = window.setInterval(() => {
setMainTimer(prevTimer => prevTimer + 1);
}, 1000);
}
}
function stopTimer(): void {
if (timerRef.current !== null) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}
function resetTimeout(): void {
if (timeoutRef.current !== null) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = window.setTimeout(stopTimer, 5000);
}
function handleKeyDown(): void {
startTimer();
resetTimeout();
}
window.addEventListener('keydown', handleKeyDown, {passive: true});
return (): void => {
window.removeEventListener('keydown', handleKeyDown);
if (timerRef.current !== null) {
clearInterval(timerRef.current);
}
if (timeoutRef.current !== null) {
clearTimeout(timeoutRef.current);
}
};
}, []);
useEffect((): () => void => {
document.addEventListener('keydown', handleKeyDown, {passive: false});
return (): void => document.removeEventListener('keydown', handleKeyDown);
}, [saveContent]);
useEffect((): void => {
if (!editor) return;
if (chapter?.chapterContent.content) {
try {
const parsedContent = JSON.parse(chapter.chapterContent.content);
editor.commands.setContent(parsedContent);
} catch (error) {
console.error('Erreur lors du parsing du contenu:', error);
editor.commands.setContent({
type: "doc",
content: [{type: "paragraph", content: []}]
});
}
} else {
editor.commands.setContent({
type: "doc",
content: [{type: "paragraph", content: []}]
});
}
if (chapter?.chapterContent.version !== 2) {
setShowGhostWriter(false);
}
}, [editor, chapter?.chapterContent.content, chapter?.chapterContent.version]);
async function handleKeyDown(event: KeyboardEvent): Promise<void> {
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
event.preventDefault();
await saveContent();
}
}
if (!editor) {
return null;
}
return (
<div className="flex flex-col flex-1 w-full h-full">
<div
className={`flex justify-between gap-3 border-b border-secondary/30 px-4 py-3 bg-gradient-to-b from-dark-background/80 to-dark-background/50 backdrop-blur-sm transition-opacity duration-300 shadow-md ${editorSettings.focusMode ? 'opacity-70 hover:opacity-100' : ''}`}>
<div className="flex flex-wrap gap-1">
{toolbarButtons.map((button: ToolbarButton, index: number) => (
<button
key={index}
onClick={button.action}
className={`group flex items-center px-3 py-2 rounded-lg transition-all duration-200 ${button.isActive ? 'bg-primary text-text-primary shadow-md shadow-primary/30 scale-105' : 'text-muted hover:text-text-primary hover:bg-secondary/50 hover:shadow-sm hover:scale-105'}`}
>
<FontAwesomeIcon icon={button.icon}
className={'w-4 h-4 transition-transform duration-200 group-hover:scale-110'}/>
{
button.label &&
<span className="ml-2 text-sm font-medium">
{t(`textEditor.toolbar.${button.label}`)}
</span>
}
</button>
))}
</div>
<div className="flex items-center gap-2">
<CollapsableButton
showCollapsable={showUserSettings}
text={t("textEditor.preferences")}
onClick={handleShowUserSettings}
icon={faCog}
/>
{chapter?.chapterContent.version === 2 && (
<CollapsableButton
showCollapsable={showGhostWriter}
text={t("textEditor.ghostWriter")}
onClick={handleShowGhostWriter}
icon={faGhost}
/>
)}
{chapter?.chapterContent.version && chapter.chapterContent.version > 2 && (
<CollapsableButton
showCollapsable={showDraftCompanion}
text={t("textEditor.draftCompanion")}
onClick={handleShowDraftCompanion}
icon={faLayerGroup}
/>
)}
<SubmitButtonWLoading
callBackAction={saveContent}
isLoading={isSaving}
text={t("textEditor.save")}
loadingText={t("textEditor.saving")}
icon={faFloppyDisk}
/>
</div>
</div>
<div className="flex justify-between w-full h-full overflow-auto">
<div
className={`flex-1 p-8 overflow-auto transition-all duration-300 ${editorSettings.focusMode ? 'bg-black/20' : ''}`}>
<div
className={`editor-container mx-auto p-6 rounded-2xl shadow-2xl min-h-[80%] border border-secondary/50 ${editorClasses.container} ${editorClasses.theme} relative`}
style={containerStyle}>
<div
className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-primary/30 to-transparent"></div>
<div
className="absolute bottom-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-primary/30 to-transparent"></div>
<EditorContent className={`w-full h-full ${editorClasses.base} editor-content`}
editor={editor}/>
</div>
</div>
{(showDraftCompanion || showGhostWriter || showUserSettings) && (
<div
className={`w-4/12 transition-opacity duration-300 ${editorSettings.focusMode ? 'opacity-50 hover:opacity-100' : ''}`}>
{showDraftCompanion && <DraftCompanion/>}
{showGhostWriter && <GhostWriter/>}
{showUserSettings && (
<UserEditorSettings
settings={editorSettings}
onSettingsChange={setEditorSettings}
/>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,240 @@
'use client'
import React, {ChangeEvent, useCallback, useEffect, useMemo} from 'react';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faEye, faFont, faIndent, faPalette, faTextHeight, faTextWidth} from '@fortawesome/free-solid-svg-icons';
import {useTranslations} from "next-intl";
import SelectBox from "@/components/form/SelectBox";
interface UserEditorSettingsProps {
settings: EditorDisplaySettings;
onSettingsChange: (settings: EditorDisplaySettings) => void;
}
export interface EditorDisplaySettings {
zoomLevel: number;
indent: number;
lineHeight: number;
theme: 'clair' | 'sombre' | 'sépia';
fontFamily: 'lora' | 'serif' | 'sans-serif' | 'monospace';
maxWidth: number;
focusMode: boolean;
}
const ZOOM_LABELS = ['Très petit', 'Petit', 'Normal', 'Grand', 'Très grand'] as const;
const FONT_SIZES = [14, 16, 18, 20, 22] as const;
const THEMES = ['clair', 'sombre', 'sépia'] as const;
const DEFAULT_SETTINGS: EditorDisplaySettings = {
zoomLevel: 3,
indent: 30,
lineHeight: 1.5,
theme: 'sombre',
fontFamily: 'lora',
maxWidth: 768,
focusMode: false
};
export default function UserEditorSettings({settings, onSettingsChange}: UserEditorSettingsProps) {
const t = useTranslations();
const handleSettingChange = useCallback(<K extends keyof EditorDisplaySettings>(
key: K,
value: EditorDisplaySettings[K]
) => {
onSettingsChange({...settings, [key]: value});
}, [settings, onSettingsChange]);
const resetToDefaults = useCallback(() => {
onSettingsChange(DEFAULT_SETTINGS);
}, [onSettingsChange]);
const zoomOptions = useMemo(() =>
ZOOM_LABELS.map((label, index) => ({
value: (index + 1).toString(),
label: `${t(`userEditorSettings.zoom.${label}`)} (${FONT_SIZES[index]}px)`
}))
, [t]);
const themeButtons = useMemo(() =>
THEMES.map(theme => ({
key: theme,
isActive: settings.theme === theme,
className: `p-2.5 rounded-xl border capitalize transition-all duration-200 font-medium ${
settings.theme === theme
? 'bg-primary text-text-primary border-primary shadow-md scale-105'
: 'bg-secondary/50 border-secondary/50 text-muted hover:text-text-primary hover:border-secondary hover:bg-secondary hover:scale-102'
}`
}))
, [settings.theme]);
useEffect((): void => {
try {
const savedSettings: string | null = localStorage.getItem('userEditorSettings');
if (savedSettings) {
const parsed = JSON.parse(savedSettings);
if (parsed && typeof parsed === 'object') {
onSettingsChange({...DEFAULT_SETTINGS, ...parsed});
}
}
} catch (e: unknown) {
onSettingsChange(DEFAULT_SETTINGS);
}
}, [onSettingsChange]);
useEffect((): () => void => {
const timeoutId = setTimeout((): void => {
try {
localStorage.setItem('userEditorSettings', JSON.stringify(settings));
} catch (error) {
console.error('Erreur lors de la sauvegarde des settings:', error);
}
}, 100);
return (): void => clearTimeout(timeoutId);
}, [settings]);
return (
<div
className="p-5 bg-secondary/30 backdrop-blur-sm border-l border-secondary/50 h-full overflow-y-auto shadow-inner">
<div className="flex items-center gap-3 mb-8 pb-4 border-b border-secondary/50">
<FontAwesomeIcon icon={faEye} className="text-primary w-6 h-6"/>
<h3 className="text-xl font-['ADLaM_Display'] text-text-primary">{t("userEditorSettings.displayPreferences")}</h3>
</div>
<div className="space-y-6">
<div>
<label className="flex items-center gap-2 mb-2 text-text-primary">
<FontAwesomeIcon icon={faTextHeight} className="text-muted w-5 h-5"/>
{t("userEditorSettings.textSize")}
</label>
<SelectBox
defaultValue={settings.zoomLevel.toString()}
onChangeCallBack={(e: ChangeEvent<HTMLSelectElement>) => {
handleSettingChange('zoomLevel', Number(e.target.value))
}}
data={zoomOptions}
/>
</div>
<div>
<label className="flex items-center gap-2 mb-2 text-text-primary">
<FontAwesomeIcon icon={faIndent} className="text-muted w-5 h-5"/>
{t("userEditorSettings.indent")}
</label>
<div className="space-y-2">
<input
type="range"
min={0}
max={50}
step={5}
value={settings.indent}
onChange={(e) => handleSettingChange('indent', Number(e.target.value))}
className="w-full accent-primary"
/>
<div className="flex justify-between text-sm text-muted">
<span>{t("userEditorSettings.indentNone")}</span>
<span className="text-text-primary font-medium">{settings.indent}px</span>
<span>{t("userEditorSettings.indentMax")}</span>
</div>
</div>
</div>
<div>
<label className="flex items-center gap-2 mb-2 text-text-primary">
<FontAwesomeIcon icon={faTextWidth} className="text-muted w-5 h-5"/>
{t("userEditorSettings.lineHeight")}
</label>
<SelectBox
defaultValue={settings.lineHeight.toString()}
onChangeCallBack={(e: ChangeEvent<HTMLSelectElement>) => handleSettingChange('lineHeight', Number(e.target.value))}
data={[
{value: "1.2", label: t("userEditorSettings.lineHeightCompact")},
{value: "1.5", label: t("userEditorSettings.lineHeightNormal")},
{value: "1.75", label: t("userEditorSettings.lineHeightSpaced")},
{value: "2", label: t("userEditorSettings.lineHeightDouble")}
]}
/>
</div>
<div>
<label className="flex items-center gap-2 mb-2 text-text-primary">
<FontAwesomeIcon icon={faFont} className="text-muted w-5 h-5"/>
{t("userEditorSettings.fontFamily")}
</label>
<SelectBox
defaultValue={settings.fontFamily}
onChangeCallBack={(e: ChangeEvent<HTMLSelectElement>) => handleSettingChange('fontFamily', e.target.value as EditorDisplaySettings['fontFamily'])}
data={[
{value: "lora", label: t("userEditorSettings.fontLora")},
{value: "serif", label: t("userEditorSettings.fontSerif")},
{value: "sans-serif", label: t("userEditorSettings.fontSansSerif")},
{value: "monospace", label: t("userEditorSettings.fontMonospace")}
]}
/>
</div>
<div>
<label className="flex items-center gap-2 mb-2 text-text-primary">
<FontAwesomeIcon icon={faTextWidth} className="text-muted w-5 h-5"/>
{t("userEditorSettings.maxWidth")}
</label>
<div className="space-y-2">
<input
type="range"
min={600}
max={1200}
step={50}
value={settings.maxWidth}
onChange={(e) => handleSettingChange('maxWidth', Number(e.target.value))}
className="w-full accent-primary"
/>
<div className="flex justify-between text-sm text-muted">
<span>{t("userEditorSettings.maxWidthNarrow")}</span>
<span className="text-text-primary font-medium">{settings.maxWidth}px</span>
<span>{t("userEditorSettings.maxWidthWide")}</span>
</div>
</div>
</div>
<div>
<label className="flex items-center gap-2 mb-2 text-text-primary">
<FontAwesomeIcon icon={faPalette} className="text-muted w-5 h-5"/>
{t("userEditorSettings.theme")}
</label>
<div className="grid grid-cols-3 gap-2">
{themeButtons.map((themeBtn) => (
<button
key={themeBtn.key}
onClick={() => handleSettingChange('theme', themeBtn.key)}
className={themeBtn.className}
>
{t(`userEditorSettings.themeOption.${themeBtn.key}`)}
</button>
))}
</div>
</div>
<div>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={settings.focusMode}
onChange={(e) => handleSettingChange('focusMode', e.target.checked)}
className="w-4 h-4 accent-primary"
/>
<span className="text-text-primary">{t("userEditorSettings.focusMode")}</span>
</label>
</div>
<div className="pt-6 border-t border-secondary/50">
<button
onClick={resetToDefaults}
className="w-full py-2.5 bg-secondary/50 border border-secondary/50 rounded-xl text-muted hover:text-text-primary hover:border-secondary hover:bg-secondary transition-all duration-200 hover:scale-105 shadow-sm hover:shadow-md font-medium"
>
{t("userEditorSettings.reset")}
</button>
</div>
</div>
</div>
);
}