diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f84e181
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,38 @@
+# Dependencies
+node_modules
+/.pnp
+.pnp.js
+
+# Testing
+/coverage
+
+# Next.js
+/.next/
+/out/
+
+# Production
+/dist
+/build
+
+# Misc
+.DS_Store
+*.pem
+
+# Debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# Local env files
+.env*.local
+.env
+
+# Vercel
+.vercel
+
+# TypeScript
+*.tsbuildinfo
+next-env.d.ts
+
+# Electron
+/release
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..b7e625e
--- /dev/null
+++ b/README.md
@@ -0,0 +1,116 @@
+# EritorsScribe - Electron + Next.js
+
+Application Electron avec Next.js et TypeScript.
+
+## Structure du projet
+
+```
+eritorsscribe/
+├── electron/ # Code Electron (main process)
+│ ├── main.ts # Point d'entrée principal
+│ └── preload.ts # Script preload (bridge sécurisé)
+├── src/ # Mettez vos fichiers Next.js ici (app/, pages/, components/, etc.)
+├── dist/ # Fichiers compilés Electron
+├── out/ # Export statique Next.js
+├── build/ # Configuration electron-builder
+└── release/ # Binaires packagés
+```
+
+## Installation
+
+Les dépendances sont déjà installées. Si besoin:
+
+```bash
+npm install
+```
+
+## Développement
+
+1. Mettez vos fichiers Next.js dans le dossier `src/` (créez `src/app/` ou `src/pages/` selon votre structure Next.js)
+
+2. Lancez le mode développement:
+
+```bash
+npm run dev
+```
+
+Cela va:
+- Démarrer Next.js sur http://localhost:3000
+- Lancer Electron qui charge cette URL
+- Recharger automatiquement au changement
+
+## Scripts disponibles
+
+- `npm run dev` - Développement (Next.js + Electron)
+- `npm run dev:next` - Next.js uniquement
+- `npm run dev:electron` - Electron uniquement
+- `npm run build` - Build complet (Next.js + Electron)
+- `npm run start` - Lancer l'app compilée
+- `npm run package:mac` - Packager pour macOS
+- `npm run package:win` - Packager pour Windows
+- `npm run package:linux` - Packager pour Linux
+- `npm run package` - Packager pour toutes les plateformes
+
+## Build de production
+
+1. Compilez tout:
+
+```bash
+npm run build
+```
+
+2. Packagez pour votre plateforme:
+
+```bash
+# macOS
+npm run package:mac
+
+# Windows
+npm run package:win
+
+# Linux
+npm run package:linux
+
+# Toutes les plateformes
+npm run package
+```
+
+Les binaires seront dans le dossier `release/`.
+
+## Versions installées
+
+- Electron: 39.x (dernière version stable)
+- Next.js: 16.x
+- React: 19.x
+- TypeScript: 5.9.x
+- electron-builder: 26.x
+
+## Configuration Next.js
+
+Le fichier `next.config.ts` est configuré avec:
+- `output: 'export'` - Export statique pour Electron
+- `images.unoptimized: true` - Images non optimisées
+- `trailingSlash: true` - Compatibilité Electron
+
+## Sécurité
+
+Le preload script utilise:
+- `contextIsolation: true`
+- `nodeIntegration: false`
+- `sandbox: true`
+
+Pour exposer des APIs au renderer, modifiez `electron/preload.ts`.
+
+## Multi-plateforme
+
+- macOS: Build sur Mac (requis pour signing)
+- Windows: Build sur n'importe quelle plateforme
+- Linux: Build sur n'importe quelle plateforme
+
+## Prochaines étapes
+
+1. Créez votre structure Next.js dans `src/`
+2. Ajoutez vos pages dans `src/app/` ou `src/pages/`
+3. Testez avec `npm run dev`
+4. Personnalisez `electron/main.ts` selon vos besoins
+5. Ajoutez des APIs dans `electron/preload.ts` si nécessaire
diff --git a/app/globals.css b/app/globals.css
new file mode 100644
index 0000000..096d817
--- /dev/null
+++ b/app/globals.css
@@ -0,0 +1,551 @@
+@import "tailwindcss";
+
+@theme {
+ /* Colors */
+ --color-primary: #51AE84;
+ --color-primary-dark: #3A8B69;
+ --color-primary-light: #74C9A0;
+ --color-secondary: #3E3E3E;
+ --color-tertiary: #2C2C2C;
+ --color-background: #2B2D30;
+ --color-dark-background: #2C2C2C;
+ --color-darkest-background: #1A1A1A;
+ --color-text-primary: #FFFFFF;
+ --color-text-secondary: #B0B0B0;
+ --color-muted: #B0B0B0;
+ --color-success: #28A745;
+ --color-error: #DC3545;
+ --color-warning: #FFC107;
+ --color-info: #17A2B8;
+
+ --color-gray: #808080;
+ --color-gray-light: #A0A0A0;
+ --color-gray-dark: #404040;
+
+ /* Font Family */
+ --font-family-lora: 'Lora', Georgia, serif;
+ --font-family-lora-italic: 'Lora Italic', serif;
+ --font-family-adlam: 'ADLaM Display', serif;
+}
+
+@font-face {
+ font-family: 'Lora';
+ src: url('../fonts/lora-variable.ttf') format('truetype');
+ font-weight: normal;
+ font-style: normal;
+}
+
+@font-face {
+ font-family: 'Lora Italic';
+ src: url('../fonts/lora-Italic.ttf') format('truetype');
+ font-weight: normal;
+ font-style: italic;
+}
+
+@font-face {
+ font-family: 'ADLaM Display';
+ src: url('../fonts/adlam-display.ttf') format('truetype');
+ font-weight: normal;
+ font-style: normal;
+}
+
+:root {
+ --foreground-rgb: 0, 0, 0;
+ --background-start-rgb: 214, 219, 220;
+ --background-end-rgb: 255, 255, 255;
+}
+
+body {
+ --tw-bg-opacity: 1;
+ background-color: rgb(17 24 39 / var(--tw-bg-opacity))
+}
+
+::-webkit-scrollbar {
+ width: 12px;
+ height: 12px;
+}
+
+::-webkit-scrollbar-track {
+ background: #2d2d2d;
+ border-radius: 10px;
+}
+
+::-webkit-scrollbar-thumb {
+ background: #51AE84;
+ border-radius: 10px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: #3a8b69;
+}
+
+/* Scrollbar Styles for Firefox */
+* {
+ scrollbar-width: thin;
+ scrollbar-color: #51AE84 #2d2d2d;
+}
+
+.fade-in {
+ opacity: 0;
+ animation: fadeIn 0.8s ease-out forwards;
+ animation-delay: 0.3s;
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes slideInFromLeft {
+ from {
+ opacity: 0;
+ transform: translateX(-20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(0);
+ }
+}
+
+@keyframes slideInFromRight {
+ from {
+ opacity: 0;
+ transform: translateX(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(0);
+ }
+}
+
+@keyframes smoothBounce {
+ 0%, 100% {
+ transform: translateY(0);
+ }
+ 50% {
+ transform: translateY(-5px);
+ }
+}
+
+.tiptap {
+ min-height: calc(100vh - 15rem);
+ font-family: 'Lora', sans-serif;
+ padding-bottom: 10px;
+}
+
+.indent-0 {
+ text-indent: 0px !important;
+}
+
+.indent-1 {
+ text-indent: 4px !important;
+}
+
+.indent-2 {
+ text-indent: 8px !important;
+}
+
+.indent-3 {
+ text-indent: 12px !important;
+}
+
+.indent-4 {
+ text-indent: 16px !important;
+}
+
+.indent-5 {
+ text-indent: 20px !important;
+}
+
+.indent-6 {
+ text-indent: 24px !important;
+}
+
+.indent-7 {
+ text-indent: 28px !important;
+}
+
+.indent-8 {
+ text-indent: 32px !important;
+}
+
+.indent-9 {
+ text-indent: 36px !important;
+}
+
+.indent-10 {
+ text-indent: 40px !important;
+}
+
+.indent-11 {
+ text-indent: 44px !important;
+}
+
+.indent-12 {
+ text-indent: 48px !important;
+}
+
+.indent-13 {
+ text-indent: 52px !important;
+}
+
+/* Styles pour l'éditeur principal avec classes dynamiques */
+.editor-content .tiptap p {
+ color: #dedede;
+ margin-top: 0.7em;
+ margin-bottom: 0.7em;
+}
+
+.editor-content .tiptap p {
+ text-indent: inherit;
+}
+
+.editor-content .tiptap p strong {
+ font-weight: 900;
+ color: #f9f9f9;
+}
+
+.editor-content .tiptap h1, .editor-content .tiptap h2, .editor-content .tiptap h3 {
+ color: #34acd0;
+ margin-top: 1em;
+ margin-bottom: 0.5em;
+}
+
+.editor-content .tiptap h1 {
+ text-indent: 5px;
+ font-size: 1.7em;
+}
+
+.editor-content .tiptap h2 {
+ text-indent: 3px;
+ font-size: 1.4em;
+}
+
+.editor-content .tiptap h3 {
+ text-indent: 1px;
+ font-size: 1.2em;
+}
+
+.editor-content .tiptap ul[data-type="bulletList"],
+.editor-content .tiptap ol[data-type="orderedList"] {
+ text-indent: 0px !important;
+}
+
+.editor-content .tiptap li p {
+ text-indent: 0px !important;
+ margin: 0.2em 0;
+}
+
+.ProseMirror-focused {
+ outline: none !important;
+ border: none !important;
+}
+
+.setting-container {
+ height: calc(100vh - 10rem);
+}
+
+.composer-panel-h {
+ height: calc(100vh - 8rem);
+}
+
+.composer-panel-component-h {
+ height: calc(100vh - 14rem);
+}
+
+.embla__viewport {
+ overflow: hidden;
+ width: 100%;
+}
+
+.embla__container {
+ display: flex;
+ gap: 16px;
+}
+
+.embla__slide {
+ flex: 0 0 18%; /* Changez à 10% pour afficher plus de livres */
+ max-width: 200px;
+}
+
+.embla__slide img {
+ width: 100%;
+ height: auto;
+ object-fit: cover;
+ border-radius: 8px;
+}
+
+/* Nouvelles classes pour les cartes de fonctionnalités */
+.feature-card {
+ position: relative;
+ transition: all 0.3s ease;
+ overflow: hidden;
+ background-color: #3E3E3E;
+ border-radius: 0.75rem;
+}
+
+.feature-card:hover {
+ transform: translateY(-5px);
+ box-shadow: 0 10px 25px -5px rgba(81, 174, 132, 0.2);
+}
+
+.feature-card-bg {
+ position: absolute;
+ inset: 0;
+ opacity: 0;
+ transition: opacity 0.5s ease;
+}
+
+.feature-card:hover .feature-card-bg {
+ opacity: 0.2;
+}
+
+.feature-icon-container {
+ width: 4rem;
+ height: 4rem;
+ border-radius: 50%;
+ background: linear-gradient(135deg, #313131, #4A4A4A);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-bottom: 1.5rem;
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
+ transition: box-shadow 0.3s ease, transform 0.3s ease;
+}
+
+.feature-card:hover .feature-icon-container {
+ box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.2);
+ transform: scale(1.05);
+}
+
+.feature-icon {
+ width: 3rem;
+ height: 3rem;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: white;
+}
+
+.feature-title-container {
+ position: relative;
+ display: inline-block;
+ margin-bottom: 1rem;
+}
+
+.feature-title-underline {
+ position: absolute;
+ bottom: -2px;
+ left: 0;
+ height: 2px;
+ width: 0;
+ transition: width 0.3s ease;
+}
+
+.feature-card:hover .feature-title-underline {
+ width: 100%;
+}
+
+.feature-shine-line {
+ width: 100%;
+ height: 1px;
+ background-color: #4A4A4A;
+ position: relative;
+ overflow: hidden;
+}
+
+.feature-shine {
+ position: absolute;
+ top: 0;
+ left: 0;
+ height: 1px;
+ width: 0;
+ transition: width 0.7s ease-out;
+}
+
+.feature-card:hover .feature-shine {
+ width: 100%;
+}
+
+.feature-button {
+ margin-top: 1.5rem;
+ opacity: 0;
+ transform: translateY(10px);
+ transition: all 0.3s ease;
+}
+
+.feature-card:hover .feature-button {
+ opacity: 1;
+ transform: translateY(0);
+}
+
+/* Classes pour les cartes de communauté */
+.community-card {
+ position: relative;
+ overflow: hidden;
+ border-radius: 0.75rem;
+ transition: transform 0.3s ease;
+}
+
+.community-card:hover {
+ transform: translateY(-5px);
+}
+
+.community-glow {
+ position: absolute;
+ inset: -2px;
+ opacity: 0.75;
+ filter: blur(15px);
+ transition: opacity 1s ease, inset 1s ease;
+}
+
+.community-card:hover .community-glow {
+ opacity: 1;
+ inset: -4px;
+}
+
+.community-content {
+ position: relative;
+ padding: 2rem;
+ border-radius: 0.75rem;
+}
+
+.community-icon {
+ width: 4rem;
+ height: 4rem;
+ border-radius: 50%;
+ background-color: rgba(255, 255, 255, 0.1);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin: 0 auto 1rem;
+}
+
+/* Animation pour le statut "En développement" */
+@keyframes pulse {
+ 0% {
+ opacity: 0.6;
+ }
+ 50% {
+ opacity: 1;
+ }
+ 100% {
+ opacity: 0.6;
+ }
+}
+
+.dev-status {
+ animation: pulse 2s infinite;
+}
+
+.last-updated {
+ color: #777777;
+ font-size: 0.75rem;
+ text-align: center;
+ margin-top: 1rem;
+}
+
+.tiptap-draft {
+ min-height: auto;
+ height: 100%;
+ font-family: 'Lora', sans-serif;
+}
+
+.tiptap-draft .ProseMirror {
+ min-height: auto !important;
+ height: auto !important;
+ overflow: visible !important;
+ padding: 1rem;
+}
+
+.tiptap-draft .ProseMirror em {
+ font-family: 'Lora Italic', serif;
+ font-style: italic;
+}
+
+.tiptap-draft .ProseMirror h1,
+.tiptap-draft .ProseMirror h2,
+.tiptap-draft .ProseMirror h3 {
+ font-family: 'Lora', sans-serif;
+ text-indent: 30px;
+}
+
+.tiptap-draft p {
+ font-family: 'Lora', sans-serif;
+ text-indent: 30px;
+ margin-top: 0.7em;
+ margin-bottom: 0.7em;
+}
+
+/* Smooth transitions for all interactive elements */
+button, a, input, textarea, select {
+ transition: all 0.2s ease-in-out;
+}
+
+/* Enhanced focus states */
+button:focus-visible,
+input:focus-visible,
+textarea:focus-visible,
+select:focus-visible {
+ outline: 2px solid #51AE84;
+ outline-offset: 2px;
+ box-shadow: 0 0 0 4px rgba(81, 174, 132, 0.1);
+}
+
+/* Smooth hover scale for interactive elements */
+.hover-lift:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+}
+
+/* Literary decorative elements */
+.literary-ornament::before,
+.literary-ornament::after {
+ content: "❖";
+ color: #51AE84;
+ opacity: 0.3;
+ font-size: 0.8em;
+ margin: 0 0.5em;
+}
+
+/* Subtle pulse for active states */
+@keyframes subtlePulse {
+ 0%, 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.8;
+ }
+}
+
+.pulse-subtle {
+ animation: subtlePulse 2s ease-in-out infinite;
+}
+
+/* Glass morphism effect */
+.glass-effect {
+ background: rgba(62, 62, 62, 0.7);
+ backdrop-filter: blur(10px);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+}
+
+/* Fade in pour le texte qui stream */
+.fade-in-text {
+ animation: textFadeIn 100ms ease-out;
+}
+
+@keyframes textFadeIn {
+ from {
+ opacity: 0.7;
+ }
+ to {
+ opacity: 1;
+ }
+}
diff --git a/app/layout.tsx b/app/layout.tsx
new file mode 100644
index 0000000..7828cef
--- /dev/null
+++ b/app/layout.tsx
@@ -0,0 +1,26 @@
+import type {Metadata} from "next";
+import "./globals.css";
+import {ReactNode} from "react";
+
+export const metadata: Metadata = {
+ title: "ERitors Scribe",
+ description: "Les meilleurs livres sont ceux qui ont le meilleur plan.",
+ icons: {
+ icon: "/eritors-favicon-white.png"
+ }
+};
+
+export default function RootLayout(
+ {
+ children,
+ }: Readonly<{
+ children: ReactNode;
+}>) {
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/app/page.tsx b/app/page.tsx
new file mode 100644
index 0000000..748cffb
--- /dev/null
+++ b/app/page.tsx
@@ -0,0 +1,339 @@
+'use client';
+import React, {useContext, useEffect, useState} from 'react';
+import {BookContext} from "@/context/BookContext";
+import {ChapterProps} from "@/lib/models/Chapter";
+import {ChapterContext} from '@/context/ChapterContext';
+import {EditorContext} from '@/context/EditorContext'
+import {Editor, useEditor} from "@tiptap/react";
+import StarterKit from "@tiptap/starter-kit";
+import Underline from "@tiptap/extension-underline";
+import TextAlign from "@tiptap/extension-text-align";
+import {AlertContext, AlertProvider} from "@/context/AlertContext";
+import System from "@/lib/models/System";
+import {SessionContext} from '@/context/SessionContext';
+import {SessionProps} from "@/lib/models/Session";
+import User, {UserProps} from "@/lib/models/User";
+import {BookProps} from "@/lib/models/Book";
+import {AppRouterInstance} from "next/dist/shared/lib/app-router-context.shared-runtime";
+import {useRouter} from "next/navigation";
+import ScribeTopBar from "@/components/ScribeTopBar";
+import ScribeControllerBar from "@/components/ScribeControllerBar";
+import ScribeLeftBar from "@/components/leftbar/ScribeLeftBar";
+import ScribeEditor from "@/components/editor/ScribeEditor";
+import ComposerRightBar from "@/components/rightbar/ComposerRightBar";
+import ScribeFooterBar from "@/components/ScribeFooterBar";
+import GuideTour, {GuideStep} from "@/components/GuideTour";
+import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
+import {faBookMedical, faFeather} from "@fortawesome/free-solid-svg-icons";
+import TermsOfUse from "@/components/TermsOfUse";
+import frMessages from '@/lib/locales/fr.json';
+import enMessages from '@/lib/locales/en.json';
+import Image from "next/image";
+import {NextIntlClientProvider, useTranslations} from "next-intl";
+import {LangContext} from "@/context/LangContext";
+import {AIUsageContext} from "@/context/AIUsageContext";
+
+const messagesMap = {
+ fr: frMessages,
+ en: enMessages
+};
+
+function ScribeContent() {
+ const t = useTranslations();
+ const {lang: locale} = useContext(LangContext);
+ const {errorMessage, successMessage} = useContext(AlertContext);
+ const editor: Editor | null = useEditor({
+ extensions: [
+ StarterKit,
+ Underline,
+ TextAlign.configure({
+ types: ['heading', 'paragraph'],
+ }),
+ ],
+ injectCSS: false,
+ immediatelyRender: false,
+ });
+
+ const router: AppRouterInstance = useRouter();
+ const [session, setSession] = useState({user: null, accessToken: '', isConnected: false});
+ const [currentChapter, setCurrentChapter] = useState(undefined);
+ const [currentBook, setCurrentBook] = useState(null);
+
+ const [currentCredits, setCurrentCredits] = useState(160);
+ const [amountSpent, setAmountSpent] = useState(session.user?.aiUsage || 0);
+
+ const [isLoading, setIsLoading] = useState(true);
+
+ const [sessionAttempts, setSessionAttempts] = useState(0)
+
+ const [isTermsAccepted, setIsTermsAccepted] = useState(false);
+ const [homeStepsGuide, setHomeStepsGuide] = useState(false);
+
+ const homeSteps: GuideStep[] = [
+ {
+ id: 0,
+ x: 50,
+ y: 50,
+ title: t("homePage.guide.welcome", {name: session.user?.name || ''}),
+ content: (
+
+
{t("homePage.guide.step0.description1")}
+
+
{t("homePage.guide.step0.description2")}
+
+ ),
+ },
+ {
+ id: 1, position: 'right',
+ targetSelector: `[data-guide="left-panel-container"]`,
+ title: t("homePage.guide.step1.title"),
+ content: (
+
+
+
+ :
+
+ {t("homePage.guide.step1.addBook")}
+
+
+
: {t("homePage.guide.step1.generateStory")}
+
+
+ ),
+ },
+ {
+ id: 2,
+ title: t("homePage.guide.step2.title"), position: 'bottom',
+ targetSelector: `[data-guide="search-bar"]`,
+ content: (
+
+
{t("homePage.guide.step2.description")}
+
+ ),
+ },
+ {
+ id: 3,
+ title: t("homePage.guide.step3.title"),
+ targetSelector: `[data-guide="user-dropdown"]`,
+ position: 'auto',
+ content: (
+
+
{t("homePage.guide.step3.description")}
+
+ ),
+ },
+ {
+ id: 4,
+ title: t("homePage.guide.step4.title"),
+ content: (
+
+
{t("homePage.guide.step4.description1")}
+
+
{t("homePage.guide.step4.description2")}
+
+ ),
+ },
+ ];
+
+ useEffect((): void => {
+ checkAuthentification().then()
+ }, []);
+
+ useEffect((): void => {
+ if (session.isConnected) {
+ setIsTermsAccepted(session.user?.termsAccepted ?? false);
+ setHomeStepsGuide(User.guideTourDone(session.user?.guideTour ?? [], 'home-basic'));
+ setIsLoading(false);
+ } else {
+ if (sessionAttempts > 2) {
+ router.push('/');
+ }
+ }
+ setSessionAttempts(sessionAttempts + 1);
+ }, [session]);
+
+ useEffect((): void => {
+ if (currentBook) {
+ getLastChapter().then();
+ }
+ }, [currentBook]);
+
+ async function handleHomeTour(): Promise {
+ try {
+ const response: boolean = await System.authPostToServer('logs/tour', {
+ plateforme: 'web',
+ tour: 'home-basic'
+ },
+ session.accessToken,
+ locale
+ );
+ if (response) {
+ setSession(User.setNewGuideTour(session, 'home-basic'));
+ setHomeStepsGuide(false);
+ }
+ } catch (e: unknown) {
+ if (e instanceof Error) {
+ errorMessage(e.message);
+ } else {
+ errorMessage(t("homePage.errors.termsError"));
+ }
+ }
+ }
+
+ async function checkAuthentification(): Promise {
+ const token: string | null = System.getCookie('token');
+ if (token) {
+ try {
+ const user: UserProps = await System.authGetQueryToServer('user/infos', token, locale);
+ if (!user) {
+ errorMessage(t("homePage.errors.userNotFound"));
+ }
+ setSession({
+ isConnected: true,
+ user: user,
+ accessToken: token,
+ });
+ setCurrentCredits(user.creditsBalance)
+ setAmountSpent(user.aiUsage)
+ } catch (e: unknown) {
+ if (e instanceof Error) {
+ errorMessage(e.message);
+ } else {
+ errorMessage(t("homePage.errors.authenticationError"));
+ }
+ window.location.href = 'https://eritors.com/login';
+ }
+ } else {
+ window.location.href = 'https://eritors.com/login';
+ }
+ }
+
+ async function handleTermsAcceptance(): Promise {
+ try {
+ const response: boolean = await System.authPostToServer(`user/terms/accept`, {
+ version: '2025-07-1'
+ }, session.accessToken, locale);
+ if (response) {
+ setIsTermsAccepted(true);
+ setHomeStepsGuide(true);
+ const newSession: SessionProps = {
+ ...session,
+ user: {
+ ...session?.user as UserProps,
+ 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 getLastChapter(): Promise {
+ if (session?.accessToken) {
+ try {
+ const response: ChapterProps | null = await System.authGetQueryToServer(`chapter/last-chapter`, session.accessToken, locale, {bookid: currentBook?.bookId});
+ if (response) {
+ setCurrentChapter(response)
+ } else {
+ setCurrentChapter(undefined);
+ }
+ } catch (e: unknown) {
+ if (e instanceof Error) {
+ errorMessage(e.message);
+ } else {
+ errorMessage(t("homePage.errors.lastChapterError"));
+ }
+ }
+ }
+ }
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+
+
+ {t("homePage.loading")}
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+ {
+ homeStepsGuide &&
+ setHomeStepsGuide(false)}/>
+ }
+ {
+ !isTermsAccepted &&
+ }
+
+
+
+
+ );
+}
+
+export default function Scribe() {
+ const [locale, setLocale] = useState<'fr' | 'en'>('fr');
+
+ useEffect((): void => {
+ const lang: "fr" | "en" | null = System.getCookie('lang') as "fr" | "en" | null;
+ if (lang) {
+ setLocale(lang);
+ }
+ }, []);
+
+ const messages = messagesMap[locale];
+
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/components/AlertBox.tsx b/components/AlertBox.tsx
new file mode 100644
index 0000000..3b38292
--- /dev/null
+++ b/components/AlertBox.tsx
@@ -0,0 +1,103 @@
+import React, {useEffect, useState} from 'react';
+import {createPortal} from 'react-dom';
+import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
+import {faCheck, faExclamationTriangle, faInfoCircle, faTimes} from '@fortawesome/free-solid-svg-icons';
+import ConfirmButton from "@/components/form/ConfirmButton";
+import CancelButton from "@/components/form/CancelButton";
+
+export type AlertType = 'alert' | 'danger' | 'informatif' | 'success';
+
+interface AlertBoxProps {
+ title: string;
+ message: string;
+ type: AlertType;
+ confirmText?: string;
+ cancelText?: string;
+ onConfirm: () => Promise;
+ onCancel: () => void;
+}
+
+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 getAlertConfig(alertType: AlertType) {
+ switch (alertType) {
+ case 'alert':
+ return {
+ background: 'bg-warning',
+ borderColor: 'border-warning/30',
+ icon: faExclamationTriangle,
+ iconBg: 'bg-warning/10'
+ };
+ case 'danger':
+ return {
+ background: 'bg-error',
+ borderColor: 'border-error/30',
+ icon: faTimes,
+ iconBg: 'bg-error/10'
+ };
+ case 'informatif':
+ return {
+ background: 'bg-info',
+ borderColor: 'border-info/30',
+ icon: faInfoCircle,
+ iconBg: 'bg-info/10'
+ };
+ case 'success':
+ default:
+ return {
+ background: 'bg-success',
+ borderColor: 'border-success/30',
+ icon: faCheck,
+ iconBg: 'bg-success/10'
+ };
+ }
+ }
+
+ const alertSettings = getAlertConfig(type);
+
+ const alertContent = (
+
+ );
+
+ if (!mounted) return null;
+
+ return createPortal(alertContent, document.body);
+}
diff --git a/components/AlertStack.tsx b/components/AlertStack.tsx
new file mode 100644
index 0000000..9a5f6a9
--- /dev/null
+++ b/components/AlertStack.tsx
@@ -0,0 +1,57 @@
+'use client';
+
+import React from 'react';
+import {createPortal} from 'react-dom';
+import StaticAlert from '@/components/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 = (
+
+ {alerts.map((alert, index) => (
+
+ onClose(alert.id)}
+ />
+
+ ))}
+
+
+ );
+
+ return createPortal(alertContent, document.body);
+}
diff --git a/components/CollapsableArea.tsx b/components/CollapsableArea.tsx
new file mode 100644
index 0000000..ed79ab5
--- /dev/null
+++ b/components/CollapsableArea.tsx
@@ -0,0 +1,52 @@
+import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
+import {faChevronDown, faChevronUp, IconDefinition} from "@fortawesome/free-solid-svg-icons";
+import React from "react";
+
+interface CollapsableAreaProps {
+ title: string;
+ children: React.ReactNode;
+ icon?: IconDefinition;
+}
+
+export default function CollapsableArea(
+ {
+ title,
+ children,
+ icon,
+ }: CollapsableAreaProps) {
+ const [isExpanded, setIsExpanded] = React.useState(false);
+
+ return (
+
+
setIsExpanded(!isExpanded)}>
+
+ {
+ icon && (
+
+
+
+ )
+ }
+
{title}
+
+
+
+
+ {isExpanded && (
+
+ {children}
+
+ )}
+
+ );
+}
diff --git a/components/CollapsableButton.tsx b/components/CollapsableButton.tsx
new file mode 100644
index 0000000..9954a6a
--- /dev/null
+++ b/components/CollapsableButton.tsx
@@ -0,0 +1,33 @@
+import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
+import React from "react";
+import {IconDefinition} from "@fortawesome/fontawesome-svg-core";
+
+interface CollapsableButtonProps {
+ showCollapsable: boolean;
+ text: string;
+ onClick: () => void;
+ icon?: IconDefinition;
+}
+
+export default function CollapsableButton(
+ {
+ showCollapsable,
+ text,
+ icon,
+ onClick
+ }: CollapsableButtonProps) {
+ return (
+
+ {icon && }
+ {text}
+
+ )
+}
diff --git a/components/Collapse.tsx b/components/Collapse.tsx
new file mode 100644
index 0000000..e2baebc
--- /dev/null
+++ b/components/Collapse.tsx
@@ -0,0 +1,39 @@
+import React, {JSX, useState} from "react";
+import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
+import {faChevronDown, faChevronRight} from '@fortawesome/free-solid-svg-icons';
+
+export interface CollapseProps {
+ title: string;
+ content: JSX.Element;
+}
+
+export default function Collapse({title, content}: CollapseProps) {
+ const [isOpen, setIsOpen] = useState(false);
+
+ function toggleCollapse(): void {
+ setIsOpen(!isOpen);
+ }
+
+ return (
+
+
+ {title}
+
+
+ {isOpen && (
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/components/CreditMeters.tsx b/components/CreditMeters.tsx
new file mode 100644
index 0000000..6f4b36f
--- /dev/null
+++ b/components/CreditMeters.tsx
@@ -0,0 +1,30 @@
+import React, {useContext} from "react";
+import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
+import {faCoins, faDollarSign} from "@fortawesome/free-solid-svg-icons";
+import {AIUsageContext, AIUsageContextProps} from "@/context/AIUsageContext";
+
+export default function CreditCounter({isCredit}: { isCredit: boolean }) {
+ const {totalCredits, totalPrice} = useContext(AIUsageContext)
+
+ if (isCredit) {
+ return (
+
+
+
+ {Math.round(totalCredits)} crédits
+
+
+ );
+ }
+
+ return (
+
+
+
+ {totalPrice ? totalPrice.toFixed(2) : '0.00'}
+
+
+ );
+}
\ No newline at end of file
diff --git a/components/ExportBook.tsx b/components/ExportBook.tsx
new file mode 100644
index 0000000..3910310
--- /dev/null
+++ b/components/ExportBook.tsx
@@ -0,0 +1,190 @@
+import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
+import {faDownload} from "@fortawesome/free-solid-svg-icons";
+import React, {useContext, useRef, useState} from "react";
+import {SessionContext} from "@/context/SessionContext";
+import {AlertContext} from "@/context/AlertContext";
+import {configs} from "@/lib/configs";
+
+interface CreateEpubProps {
+ bookId: string;
+ bookTitle: string;
+}
+
+export default function ExportBook({bookId, bookTitle}: CreateEpubProps) {
+ const {session} = useContext(SessionContext);
+ const {successMessage, errorMessage} = useContext(AlertContext);
+ const [showMenu, setShowMenu] = useState(false);
+ const menuRef = useRef(null);
+ const buttonRef = useRef(null);
+
+ function handleClickOutside(event: MouseEvent): void {
+ if (
+ menuRef.current &&
+ buttonRef.current &&
+ !menuRef.current.contains(event.target as Node) &&
+ !buttonRef.current.contains(event.target as Node)
+ ) {
+ setShowMenu(false);
+ document.removeEventListener("mousedown", handleClickOutside);
+ }
+ }
+
+ function toggleMenu(): void {
+ if (!showMenu) {
+ setTimeout((): void => {
+ document.addEventListener("mousedown", handleClickOutside);
+ }, 0);
+ } else {
+ document.removeEventListener("mousedown", handleClickOutside);
+ }
+ setShowMenu(!showMenu);
+ }
+
+ async function handleDownloadEpub() {
+ try {
+ const response = await fetch(
+ `${configs.apiUrl}book/transform/epub?id=${bookId}`,
+ {
+ method: "GET",
+ headers: {
+ Authorization: `Bearer ${session.accessToken}`,
+ },
+ }
+ );
+
+ if (!response.ok) {
+ errorMessage(`Échec du téléchargement du EPUB.`);
+ return;
+ }
+
+ const blob = await response.blob();
+ const virtualUrl = window.URL.createObjectURL(blob);
+ const aLink = document.createElement("a");
+ aLink.href = virtualUrl;
+ aLink.download = `${bookTitle}.epub`;
+ document.body.appendChild(aLink);
+ aLink.click();
+ aLink.remove();
+ window.URL.revokeObjectURL(virtualUrl);
+ setShowMenu(false);
+ successMessage(`Votre fichier EPUB a été téléchargé.`);
+ } catch (error) {
+ console.error(`Error downloading EPUB:`, error);
+ errorMessage(`Une erreur est survenue lors du téléchargement.`);
+ }
+ }
+
+ async function handleDownloadPdf() {
+ try {
+ const response = await fetch(
+ `${configs.apiUrl}book/transform/pdf?id=${bookId}`,
+ {
+ method: "GET",
+ headers: {
+ Authorization: `Bearer ${session.accessToken}`,
+ },
+ }
+ );
+
+ if (!response.ok) {
+ errorMessage(`Échec du téléchargement du PDF.`);
+ return;
+ }
+
+ const blob = await response.blob();
+ const virtualUrl = window.URL.createObjectURL(blob);
+ const aLink = document.createElement("a");
+ aLink.href = virtualUrl;
+ aLink.download = `${bookTitle}.pdf`;
+ document.body.appendChild(aLink);
+ aLink.click();
+ aLink.remove();
+ window.URL.revokeObjectURL(virtualUrl);
+ setShowMenu(false);
+ successMessage(`Votre fichier PDF a été téléchargé.`);
+ } catch (error) {
+ console.error(`Error downloading PDF:`, error);
+ errorMessage(`Une erreur est survenue lors du téléchargement.`);
+ }
+ }
+
+ async function handleDownloadDocx() {
+ try {
+ const response = await fetch(
+ `${configs.apiUrl}book/transform/docx?id=${bookId}`,
+ {
+ method: "GET",
+ headers: {
+ Authorization: `Bearer ${session.accessToken}`,
+ },
+ }
+ );
+
+ if (!response.ok) {
+ errorMessage(`Échec du téléchargement du DOCX.`);
+ return;
+ }
+
+ const blob = await response.blob();
+ const virtualUrl = window.URL.createObjectURL(blob);
+ const aLink = document.createElement("a");
+ aLink.href = virtualUrl;
+ aLink.download = `${bookTitle}.docx`;
+ document.body.appendChild(aLink);
+ aLink.click();
+ aLink.remove();
+ window.URL.revokeObjectURL(virtualUrl);
+ setShowMenu(false);
+ successMessage(`Votre fichier DOCX a été téléchargé.`);
+ } catch (error) {
+ console.error(`Error downloading DOCX:`, error);
+ errorMessage(`Une erreur est survenue lors du téléchargement.`);
+ }
+ }
+
+ return (
+
+
+
+
+
+ {showMenu && (
+
+
+
+ EPUB
+
+
+ PDF
+
+
+ DOCX
+
+
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/components/GuideTour.tsx b/components/GuideTour.tsx
new file mode 100644
index 0000000..817c475
--- /dev/null
+++ b/components/GuideTour.tsx
@@ -0,0 +1,383 @@
+import React, {JSX, useEffect, useRef, useState} from 'react';
+import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
+import {faXmark} from '@fortawesome/free-solid-svg-icons';
+
+export type GuidePosition =
+ 'top'
+ | 'bottom'
+ | 'left'
+ | 'right'
+ | 'auto'
+ | 'top-left'
+ | 'top-right'
+ | 'bottom-left'
+ | 'bottom-right';
+
+export interface GuideStep {
+ id: number;
+ x?: number;
+ y?: number;
+ title: string;
+ content: React.ReactNode;
+ targetSelector?: string;
+ highlightRadius?: number;
+ position?: GuidePosition;
+}
+
+interface GuideTourProps {
+ stepId: number;
+ steps: GuideStep[];
+ onClose: () => void;
+ onComplete: () => void;
+}
+
+/**
+ * Generates the spotlight background style for a given guide step.
+ *
+ * @param {GuideStep} step - The guide step containing information about the target element,
+ * position, and properties for spotlight rendering.
+ * @return {string} The CSS background string representing the spotlight effect.
+ */
+function getSpotlightBackground(step: GuideStep): string {
+ if (step.x !== undefined && step.y !== undefined) {
+ return 'rgba(0, 0, 0, 0.5)';
+ }
+ if (!step.targetSelector) {
+ return 'rgba(0, 0, 0, 0.5)';
+ }
+ const element = document.querySelector(step.targetSelector) as HTMLElement | null;
+ if (!element) {
+ return 'rgba(0, 0, 0, 0.5)';
+ }
+ const rect: DOMRect = element.getBoundingClientRect();
+ const centerX: number = rect.left + rect.width / 2;
+ const centerY: number = rect.top + rect.height / 2;
+ const radius: number = Math.max(rect.width, rect.height) / 2 + (step.highlightRadius || 10);
+
+ return `radial-gradient(circle at ${centerX}px ${centerY}px, transparent ${radius}px, rgba(0, 0, 0, 0.65) ${radius + 20}px)`;
+}
+
+/**
+ * Determines the position of a popover element based on the provided guide step properties.
+ *
+ * @param {GuideStep} step - An object containing the configuration for positioning the popover, including its x and y coordinates, target selector, and preferred position.
+ * @return {React.CSSProperties} An object representing the CSS properties to position the popover, including `left`, `top`, and optionally `transform` values.
+ */
+function getPopoverPosition(step: GuideStep): React.CSSProperties {
+ if (step.x !== undefined && step.y !== undefined) {
+ return {
+ left: `${step.x}%`,
+ top: `${step.y}%`,
+ transform: 'translate(-50%, -50%)'
+ };
+ }
+
+ if (!step.targetSelector) {
+ return {
+ left: '50%',
+ top: '50%',
+ transform: 'translate(-50%, -50%)'
+ };
+ }
+
+ const element = document.querySelector(step.targetSelector) as HTMLElement | null;
+ if (!element) {
+ return {
+ left: '50%',
+ top: '50%',
+ transform: 'translate(-50%, -50%)'
+ };
+ }
+
+ const rect: DOMRect = element.getBoundingClientRect();
+ const {left, top, width, height} = rect;
+ const popoverWidth = 420;
+ const popoverHeight = 300;
+ const margin = 20;
+ const position: GuidePosition = step.position || 'auto';
+
+ switch (position) {
+ case 'top':
+ return {
+ left: `${Math.max(margin, Math.min(left + width / 2 - popoverWidth / 2, window.innerWidth - popoverWidth - margin))}px`,
+ top: `${Math.max(margin, top - popoverHeight - margin)}px`,
+ };
+
+ case 'bottom':
+ return {
+ left: `${Math.max(margin, Math.min(left + width / 2 - popoverWidth / 2, window.innerWidth - popoverWidth - margin))}px`,
+ top: `${Math.min(top + height + margin, window.innerHeight - popoverHeight - margin)}px`,
+ };
+
+ case 'left':
+ return {
+ left: `${Math.max(margin, left - popoverWidth - margin)}px`,
+ top: `${Math.max(margin, Math.min(top + height / 2 - popoverHeight / 2, window.innerHeight - popoverHeight - margin))}px`,
+ };
+
+ case 'right':
+ return {
+ left: `${Math.min(left + width + margin, window.innerWidth - popoverWidth - margin)}px`,
+ top: `${Math.max(margin, Math.min(top + height / 2 - popoverHeight / 2, window.innerHeight - popoverHeight - margin))}px`,
+ };
+
+ case 'top-left':
+ return {
+ left: `${Math.max(margin, left)}px`,
+ top: `${Math.max(margin, top - popoverHeight - margin)}px`,
+ };
+
+ case 'top-right':
+ return {
+ left: `${Math.max(margin, Math.min(left + width - popoverWidth, window.innerWidth - popoverWidth - margin))}px`,
+ top: `${Math.max(margin, top - popoverHeight - margin)}px`,
+ };
+
+ case 'bottom-left':
+ return {
+ left: `${Math.max(margin, left)}px`,
+ top: `${Math.min(top + height + margin, window.innerHeight - popoverHeight - margin)}px`,
+ };
+
+ case 'bottom-right':
+ return {
+ left: `${Math.max(margin, Math.min(left + width - popoverWidth, window.innerWidth - popoverWidth - margin))}px`,
+ top: `${Math.min(top + height + margin, window.innerHeight - popoverHeight - margin)}px`,
+ };
+
+ case 'auto':
+ default:
+ let x: number = left + width + margin;
+ let y: number = top + height / 2 - popoverHeight / 2;
+
+ if (x + popoverWidth > window.innerWidth - margin) {
+ x = left - popoverWidth - margin;
+ }
+
+ if (x < margin) {
+ x = left + width / 2 - popoverWidth / 2;
+ y = top + height + margin;
+ }
+
+ x = Math.max(margin, Math.min(x, window.innerWidth - popoverWidth - margin));
+ y = Math.max(margin, Math.min(y, window.innerHeight - popoverHeight - margin));
+
+ return {
+ left: `${x}px`,
+ top: `${y}px`,
+ };
+ }
+}
+
+/**
+ * A component that guides the user through a series of steps.
+ * Displays a sequence of instructional overlay elements based on the provided steps.
+ * Handles navigation between steps and supports custom actions upon completion or closure.
+ *
+ * @param {Object} props - The properties object.
+ * @param {number} props.stepId - The initial step ID to start the guide.
+ * @param {Array} props.steps - An array of objects representing each step of the guide.
+ * Each step should include necessary details such as its ID and other metadata.
+ * @param {Function} props.onClose - Callback function executed when the guide is closed manually.
+ * @param {Function} props.onComplete - Callback function executed when the guide is completed after the last step.
+ *
+ * @return {JSX.Element|null} The guide tour component that renders the step-by-step instructions,
+ * or null if no steps are available or the initial conditions aren't met.
+ */
+export default function GuideTour({stepId, steps, onClose, onComplete}: GuideTourProps): JSX.Element | null {
+ const [currentStep, setCurrentStep] = useState(0);
+ const [isVisible, setIsVisible] = useState(false);
+ const [rendered, setRendered] = useState(false);
+
+ const filteredSteps: GuideStep[] = React.useMemo((): GuideStep[] => {
+ return steps.filter((step: GuideStep): boolean => step.id >= stepId);
+ }, [steps, stepId]);
+
+ const currentStepData: GuideStep = filteredSteps[currentStep];
+
+ const timeoutRef = useRef(null);
+
+ const showStep = (index: number) => {
+ setIsVisible(false);
+
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ }
+
+ timeoutRef.current = setTimeout((): void => {
+ setCurrentStep(index);
+ setRendered(false);
+
+ const step: GuideStep = filteredSteps[index];
+ if (step?.targetSelector) {
+ const element = document.querySelector(step.targetSelector) as HTMLElement;
+ if (element) {
+ element.scrollIntoView({behavior: 'smooth', block: 'center'});
+ }
+ }
+
+ timeoutRef.current = setTimeout((): void => {
+ setRendered(true);
+
+ timeoutRef.current = setTimeout((): void => {
+ setIsVisible(true);
+ }, 50);
+ }, 600);
+ }, 200);
+ };
+
+ useEffect((): () => void => {
+ showStep(0);
+
+ return (): void => {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ }
+ };
+ }, []);
+
+ const handleNext: () => void = (): void => {
+ if (currentStep < filteredSteps.length - 1) {
+ showStep(currentStep + 1);
+ } else {
+ onComplete();
+ }
+ };
+
+ const handlePrevious: () => void = (): void => {
+ if (currentStep > 0) {
+ showStep(currentStep - 1);
+ }
+ };
+ if (!filteredSteps.length || !currentStepData) {
+ return null;
+ }
+ return (
+
+
+ {rendered && (
+
+ )}
+
+ );
+}
+
+/**
+ * Functional component that displays a guide popup. This popup includes step-based navigation,
+ * title, content, and control buttons for navigating between steps or closing the popup.
+ *
+ * @param {object} params - The parameters for the GuidePopup component.
+ * @param {GuideStep} params.step - The current guide step data, containing title and content.
+ * @param {boolean} params.isVisible - Determines whether the popup is visible.
+ * @param {number} params.currentStep - The index of the current step in the guide.
+ * @param {number} params.totalSteps - Total number of steps in the guide.
+ * @param {function} params.onPrevious - Callback invoked when navigating to the previous step.
+ * @param {function} params.onNext - Callback invoked when navigating to the next step.
+ * @param {function} params.onClose - Callback invoked when closing the popup.
+ * @return {JSX.Element} The rendered GuidePopup component.
+ */
+function GuidePopup(
+ {
+ step,
+ isVisible,
+ currentStep,
+ totalSteps,
+ onPrevious,
+ onNext,
+ onClose
+ }: {
+ step: GuideStep;
+ isVisible: boolean;
+ currentStep: number;
+ totalSteps: number;
+ onPrevious: () => void;
+ onNext: () => void;
+ onClose: () => void;
+ }): JSX.Element {
+ const positionStyle = React.useMemo(() => {
+ return getPopoverPosition(step);
+ }, [step]);
+
+ return (
+
+
+
+
+
+ {step.title}
+
+
+
+ Étape {currentStep + 1} sur {totalSteps}
+
+
+ {Array.from({length: totalSteps}).map((_, index) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+ {currentStep > 0 ? (
+
+ ← Précédent
+
+ ) : (
+
+ )}
+
+ {currentStep === totalSteps - 1 ? '🎉 Terminer' : 'Continuer →'}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/components/ListItem.tsx b/components/ListItem.tsx
new file mode 100644
index 0000000..d12a05c
--- /dev/null
+++ b/components/ListItem.tsx
@@ -0,0 +1,154 @@
+import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
+import {faArrowDown, faArrowUp, faCheck, faPen, faTrash, faX, IconDefinition} from "@fortawesome/free-solid-svg-icons";
+import React, {ChangeEvent, useState} from "react";
+import TextInput from "@/components/form/TextInput";
+
+interface ListItemProps {
+ onClick: () => void;
+ selectedId: number | string;
+ id: number | string;
+ icon?: IconDefinition;
+ numericalIdentifier?: number;
+ isEditable?: boolean;
+ text: string;
+ handleDelete?: (itemId: string) => void;
+ handleUpdate?: (itemId: string, newValue: string, subNewValue: number) => void;
+}
+
+export default function ListItem(
+ {
+ text,
+ selectedId,
+ id,
+ icon,
+ onClick,
+ isEditable = false,
+ handleDelete,
+ numericalIdentifier,
+ handleUpdate
+ }: ListItemProps) {
+
+ const [itemHover, setItemHover] = useState(false);
+ const [editMode, setEditMode] = useState(false);
+
+ const [newName, setNewName] = useState('');
+ const [newChapterOrder, setNewChapterOrder] = useState(numericalIdentifier ?? 0);
+
+ function handleEdit(itemName: string): void {
+ setNewName(itemName)
+ setEditMode(true)
+ }
+
+ function handleSave(): void {
+ if (!handleUpdate) return;
+ handleUpdate(id as string, newName, newChapterOrder)
+ setEditMode(false);
+ }
+
+
+ function moveItem(direction: "up" | "down"): void {
+ switch (direction) {
+ case "up":
+ if (newChapterOrder > 0) {
+ setNewChapterOrder(newChapterOrder - 1)
+ }
+ break;
+ case "down":
+ if (newChapterOrder < 100) {
+ setNewChapterOrder(newChapterOrder + 1)
+ }
+ break;
+ default:
+ break;
+ }
+ }
+
+ return (
+ setItemHover(true)} onMouseLeave={(): void => setItemHover(false)}
+ className={`group relative flex items-center p-3 rounded-xl transition-colors duration-200 border-l-4 ${
+ selectedId === id
+ ? 'bg-secondary border-primary'
+ : 'bg-secondary/50 hover:bg-secondary border-transparent'
+ }`}>
+ {
+ (numericalIdentifier != null && newChapterOrder >= 0) && (
+
+ {newChapterOrder >= 0 ? newChapterOrder : numericalIdentifier}.
+
+ )
+ }
+ {
+ icon && (
+
+
+
+ )
+ }
+
+ {
+ editMode ? (
+
+
+ ): void => setNewName(e.target.value)}
+ placeholder=""
+ />
+
+
moveItem('up')}
+ disabled={numericalIdentifier === 0}
+ >
+
+
+
moveItem("down")}
+ >
+
+
+
+ ) : (
+
{text}
+ )
+ }
+ {
+ !editMode && isEditable && (
+
+ handleEdit(text)}
+ className="p-1 rounded-lg bg-secondary hover:bg-primary/10 transition-colors">
+
+
+ handleDelete && handleDelete(id.toString())}
+ className="p-1 rounded-lg bg-secondary hover:bg-error/10 transition-colors">
+
+
+
+ )
+ }
+ {
+ editMode && isEditable && (
+
+
+
+
+ setEditMode(false)}
+ className="p-2 rounded-lg hover:bg-error/10 transition-all">
+
+
+
+ )
+ }
+
+
+ )
+}
diff --git a/components/Modal.tsx b/components/Modal.tsx
new file mode 100644
index 0000000..e516d5b
--- /dev/null
+++ b/components/Modal.tsx
@@ -0,0 +1,91 @@
+import React, {ReactNode, useEffect, useState} from 'react';
+import {createPortal} from 'react-dom';
+import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
+import {faX} from "@fortawesome/free-solid-svg-icons";
+
+interface ModalProps {
+ title: string;
+ children: ReactNode;
+ size: 'small' | 'medium' | 'large';
+ onClose: () => void;
+ onConfirm: () => void;
+ confirmText?: string;
+ cancelText?: string;
+ enableFooter?: boolean;
+}
+
+export default function Modal(
+ {
+ title,
+ children,
+ size,
+ onClose,
+ onConfirm,
+ confirmText = 'Confirm',
+ cancelText = 'Cancel',
+ enableFooter = true,
+ }: ModalProps) {
+ const [mounted, setMounted] = useState(false);
+
+ useEffect(() => {
+ setMounted(true);
+ return () => setMounted(false);
+ }, []);
+
+ function getSizeClasses(size: 'small' | 'medium' | 'large'): string {
+ switch (size) {
+ case 'small':
+ return 'w-1/4';
+ case 'medium':
+ return 'w-1/2';
+ case 'large':
+ return 'w-3/4';
+ default:
+ return 'w-1/2';
+ }
+ }
+
+ const modalContent = (
+
+
+
+
{title}
+
+
+
+
+
+ {children}
+
+ {
+ enableFooter && (
+
+
+ {cancelText || 'Annuler'}
+
+
+ {confirmText || 'Confirmer'}
+
+
+ )
+ }
+
+
+ );
+
+ if (!mounted) return null;
+
+ return createPortal(modalContent, document.body);
+}
diff --git a/components/NoPicture.tsx b/components/NoPicture.tsx
new file mode 100644
index 0000000..f87eb90
--- /dev/null
+++ b/components/NoPicture.tsx
@@ -0,0 +1,13 @@
+import React, {useContext} from "react";
+import {SessionContext} from "@/context/SessionContext";
+
+export default function NoPicture() {
+ const {session} = useContext(SessionContext);
+ return (
+
+ {session.user?.name && session.user.name.charAt(0).toUpperCase()}
+ {session.user?.lastName && session.user.lastName.charAt(0).toUpperCase()}
+
+ )
+}
diff --git a/components/PanelHeader.tsx b/components/PanelHeader.tsx
new file mode 100644
index 0000000..fc8728c
--- /dev/null
+++ b/components/PanelHeader.tsx
@@ -0,0 +1,88 @@
+import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
+import {faSave, faX} from "@fortawesome/free-solid-svg-icons";
+import React from "react";
+import {IconDefinition} from "@fortawesome/fontawesome-svg-core";
+
+interface PanelHeaderProps {
+ title: string;
+ badge?: string;
+ description: string;
+ icon?: IconDefinition;
+ callBackAction?: () => Promise;
+ secondActionIcon?: IconDefinition;
+ secondActionCallback?: () => Promise;
+ actionIcon?: IconDefinition;
+ actionText?: string;
+}
+
+export default function PanelHeader(
+ {
+ title,
+ badge,
+ description,
+ icon,
+ callBackAction,
+ secondActionCallback,
+ secondActionIcon = faSave,
+ actionIcon = faX,
+ actionText
+ }: PanelHeaderProps) {
+ return (
+
+
+
+
+ {
+ icon && (
+
+
+
+ )
+ }
+ {title}
+ {
+ badge &&
+ {badge}
+ }
+
+ {description &&
{description}
}
+
+
+ {
+ actionText && (
+
+
+
+
+ {
+ actionText && {actionText}
+ }
+
+ )
+ }
+ {
+ secondActionCallback && (
+
+
+
+ )
+ }
+ {
+ callBackAction && actionIcon && !actionText && (
+
+
+
+ )
+ }
+
+
+
+ );
+}
diff --git a/components/QSTextGeneratedPreview.tsx b/components/QSTextGeneratedPreview.tsx
new file mode 100644
index 0000000..3b83a8a
--- /dev/null
+++ b/components/QSTextGeneratedPreview.tsx
@@ -0,0 +1,121 @@
+import React, {ReactPortal, useEffect, useState} from 'react';
+import {createPortal} from 'react-dom';
+import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
+import {faPaperPlane, faStop, faSync, faX} from '@fortawesome/free-solid-svg-icons';
+import {useTranslations} from "next-intl";
+
+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): ReactPortal | null {
+
+ const [mounted, setMounted] = useState(false);
+ const [isVisible, setIsVisible] = useState(false);
+ const t = useTranslations();
+
+ useEffect((): () => void => {
+ setMounted(true);
+ const timer = setTimeout(() => setIsVisible(true), 10);
+ return (): void => {
+ setMounted(false);
+ setIsVisible(false);
+ clearTimeout(timer);
+ };
+ }, []);
+
+ const handleClose = (): void => {
+ setIsVisible(false);
+ setTimeout(onClose, 300); // Attend la fin de l'animation avant de fermer
+ };
+
+ if (!mounted) return null;
+
+ const modalContent = (
+
+
+
+
+
+
{t("qsTextPreview.title")}
+
+
+
+ {isGenerating && onStop ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+
+
+
+ {isGenerating && !value ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {t("qsTextPreview.insert")}
+
+
+
+
+ );
+
+ return createPortal(modalContent, document.body);
+}
diff --git a/components/ScribeControllerBar.tsx b/components/ScribeControllerBar.tsx
new file mode 100644
index 0000000..e7bdaed
--- /dev/null
+++ b/components/ScribeControllerBar.tsx
@@ -0,0 +1,184 @@
+import React, {useContext, useState} from "react";
+import {ChapterProps, chapterVersions} from "@/lib/models/Chapter";
+import {ChapterContext} from "@/context/ChapterContext";
+import {BookContext} from "@/context/BookContext";
+import System from "@/lib/models/System";
+import UserMenu from "@/components/UserMenu";
+import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
+import {faGear, faGlobe, faHome} from "@fortawesome/free-solid-svg-icons";
+import {SelectBoxProps} from "@/shared/interface";
+import {AlertContext} from "@/context/AlertContext";
+import {SessionContext} from "@/context/SessionContext";
+import Book, {BookListProps} from "@/lib/models/Book";
+import Modal from "@/components/Modal";
+import BookSetting from "@/components/book/settings/BookSetting";
+import SelectBox from "@/components/form/SelectBox";
+import {useTranslations} from "next-intl";
+import {LangContext, LangContextProps} from "@/context/LangContext";
+import CreditCounter from "@/components/CreditMeters";
+import QuillSense from "@/lib/models/QuillSense";
+
+export default function ScribeControllerBar() {
+ const {chapter, setChapter} = useContext(ChapterContext);
+ const {book, setBook} = useContext(BookContext);
+ const {errorMessage} = useContext(AlertContext)
+ const {session} = useContext(SessionContext);
+ const t = useTranslations();
+ const {lang, setLang} = useContext(LangContext)
+
+ const isGPTEnabled: boolean = QuillSense.isOpenAIEnabled(session);
+ const isGemini: boolean = QuillSense.isOpenAIEnabled(session);
+ const isAnthropic: boolean = QuillSense.isOpenAIEnabled(session);
+ const isSubTierTwo: boolean = QuillSense.getSubLevel(session) >= 2;
+ const hasAccess: boolean = (isGPTEnabled || isAnthropic || isGemini) || isSubTierTwo;
+
+ const [showSettingPanel, setShowSettingPanel] = useState(false);
+
+ async function handleChapterVersionChanged(version: number) {
+ try {
+ const response: ChapterProps = await System.authGetQueryToServer(`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"));
+ }
+ }
+ }
+
+ async function getBook(bookId: string): Promise {
+ try {
+ const response: BookListProps = await System.authGetQueryToServer(`book/basic-information`, session.accessToken, lang, {
+ id: bookId,
+ });
+ if (!response) {
+ errorMessage(t("controllerBar.bookNotFound"));
+ return;
+ }
+ setBook!!({
+ bookId: response.id,
+ type: response.type,
+ title: response.title,
+ subTitle: response.subTitle,
+ summary: response.summary,
+ publicationDate: response.desiredReleaseDate,
+ desiredWordCount: response.desiredWordCount,
+ totalWordCount: response.desiredWordCount,
+ });
+ } catch (e: unknown) {
+ if (e instanceof Error) {
+ errorMessage(e.message);
+ } else {
+ errorMessage(t("controllerBar.unknownBookError"));
+ }
+ }
+ }
+
+ function handleLanguageChange(language: "fr" | "en"): void {
+ System.setCookie('lang', language, 365);
+ const newLang: "en" | "fr" | null = System.getCookie('lang') as "en" | "fr" | null;
+ if (newLang) {
+ setLang(language);
+ }
+ }
+
+ return (
+
+
+
+ {book && (
+ setShowSettingPanel(true)}
+ className="group p-2 rounded-lg text-muted hover:text-text-primary hover:bg-secondary/50 transition-all hover:scale-110">
+
+
+ )}
+ {
+ book && (
+ {
+ setBook && setBook(null)
+ setChapter && setChapter(undefined)
+ }}
+ className="group p-2 rounded-lg text-muted hover:text-primary hover:bg-secondary/50 transition-all hover:scale-110">
+
+
+ )
+ }
+
+
+ getBook(e.target.value)}
+ data={Book.booksToSelectBox(session.user?.books ?? [])} defaultValue={book?.bookId}
+ placeholder={t("controllerBar.selectBook")}/>
+
+ {chapter && (
+
+ handleChapterVersionChanged(parseInt(e.target.value))}
+ data={chapterVersions.filter((version: SelectBoxProps): boolean => {
+ return !(version.value === '1' && !hasAccess);
+ }).map((version: SelectBoxProps) => {
+ return {
+ value: version.value.toString(),
+ label: t(version.label)
+ }
+ })} defaultValue={chapter?.chapterContent.version.toString()}/>
+
+ )}
+
+
+ {
+ hasAccess &&
+
+ }
+
+
+
+
+
handleLanguageChange('fr')}
+ className={`px-4 py-2 text-sm font-semibold transition-all ${
+ lang === 'fr'
+ ? 'bg-primary text-text-primary shadow-md'
+ : 'bg-transparent text-text-secondary hover:bg-secondary/50 hover:text-text-primary'
+ }`}
+ >
+ FR
+
+
handleLanguageChange('en')}
+ className={`px-4 py-2 text-sm font-semibold transition-all ${
+ lang === 'en'
+ ? 'bg-primary text-text-primary shadow-md'
+ : 'bg-transparent text-text-secondary hover:bg-secondary/50 hover:text-text-primary'
+ }`}
+ >
+ EN
+
+
+
+
+ {
+ showSettingPanel &&
+
setShowSettingPanel(false)}
+ onConfirm={() => {
+ }}
+ children={ }
+ enableFooter={false}
+ />
+ }
+
+ )
+}
\ No newline at end of file
diff --git a/components/ScribeFooterBar.tsx b/components/ScribeFooterBar.tsx
new file mode 100644
index 0000000..8ec1327
--- /dev/null
+++ b/components/ScribeFooterBar.tsx
@@ -0,0 +1,99 @@
+import {ChapterContext} from "@/context/ChapterContext";
+import {EditorContext} from "@/context/EditorContext";
+import React, {useContext, useEffect, useState} from "react";
+import {Editor} from "@tiptap/react";
+import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
+import {faBook, faChartSimple, faHeart, faSheetPlastic} from "@fortawesome/free-solid-svg-icons";
+import {SessionContext} from "@/context/SessionContext";
+import {useTranslations} from "next-intl";
+import {AlertContext} from "@/context/AlertContext";
+
+export default function ScribeFooterBar() {
+ const t = useTranslations();
+ const {chapter} = useContext(ChapterContext);
+ const editor: Editor | null = useContext(EditorContext).editor;
+ const {session} = useContext(SessionContext);
+ const {errorMessage} = useContext(AlertContext)
+
+ const [wordsCount, setWordsCount] = useState(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);
+ } catch (e: unknown) {
+ if (e instanceof Error) {
+ errorMessage(t('errors.wordCountError') + ` (${e.message})`);
+ } else {
+ errorMessage(t('errors.wordCountError'));
+ }
+ }
+ }
+ }
+
+ return (
+
+
+
+ {chapter && (
+
+
+ {chapter.chapterOrder < 0 ? t('scribeFooterBar.sheet') : `${chapter.chapterOrder}.`}
+
+
+ )}
+
+ {chapter?.title || (
+ <>
+ {t('scribeFooterBar.madeWith')}
+
+ >
+ )}
+
+
+
+ {
+ chapter ? (
+
+
+
+ {t('scribeFooterBar.words')}:
+ {wordsCount}
+
+
+
+ {Math.ceil(wordsCount / 300)}
+
+
+ ) : (
+
+
+
+ {t('scribeFooterBar.books')}:
+ {session.user?.books?.length}
+
+
+ )
+ }
+
+ )
+}
\ No newline at end of file
diff --git a/components/ScribeTopBar.tsx b/components/ScribeTopBar.tsx
new file mode 100644
index 0000000..8dc75cd
--- /dev/null
+++ b/components/ScribeTopBar.tsx
@@ -0,0 +1,40 @@
+import Image from "next/image";
+import logo from "@/public/eritors-favicon-white.png";
+import React, {useContext} from "react";
+import {BookContext, BookContextProps} from "@/context/BookContext";
+import {useTranslations} from "next-intl";
+
+export default function ScribeTopBar() {
+ const book: BookContextProps = useContext(BookContext);
+ const t = useTranslations();
+ return (
+
+
+
+
+
+
{t("scribeTopBar.scribe")}
+
+ {book.book && (
+
+
+
+
+ {book.book.title}
+
+ {book.book.subTitle && (
+
+ {book.book.subTitle}
+
+ )}
+
+
+
+ )}
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/components/ShortStoryGenerator.tsx b/components/ShortStoryGenerator.tsx
new file mode 100644
index 0000000..4f44203
--- /dev/null
+++ b/components/ShortStoryGenerator.tsx
@@ -0,0 +1,692 @@
+import React, {ChangeEvent, RefObject, useContext, useEffect, useRef, useState} from 'react';
+import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
+import {
+ faBookBookmark,
+ faBookOpen,
+ faChartSimple,
+ faChevronRight,
+ faClock,
+ faCloudSun,
+ faComments,
+ faFileLines,
+ faGraduationCap,
+ faLanguage,
+ faMagicWandSparkles,
+ faMusic,
+ faPencilAlt,
+ faRotateRight,
+ faSpinner,
+ faStop,
+ faUserAstronaut,
+ faUserEdit,
+ faX
+} from "@fortawesome/free-solid-svg-icons";
+import {writingLevel} from "@/lib/models/User";
+import Story, {
+ advancedDialogueTypes,
+ advancedNarrativePersons,
+ advancedPredefinedType,
+ beginnerDialogueTypes,
+ beginnerNarrativePersons,
+ beginnerPredefinedType,
+ intermediateDialogueTypes,
+ intermediateNarrativePersons,
+ intermediatePredefinedType,
+ langues,
+ verbalTime
+} from '@/lib/models/Story';
+import SelectBox from "@/components/form/SelectBox";
+import TextInput from "@/components/form/TextInput";
+import TexteAreaInput from "@/components/form/TexteAreaInput";
+import {SessionContext} from "@/context/SessionContext";
+import System from "@/lib/models/System";
+import {AlertContext} from "@/context/AlertContext";
+import {configs} from "@/lib/configs";
+import InputField from "@/components/form/InputField";
+import NumberInput from "@/components/form/NumberInput";
+import {Editor as TipEditor, EditorContent, useEditor} from "@tiptap/react";
+import Editor from "@/lib/models/Editor";
+import StarterKit from "@tiptap/starter-kit";
+import Underline from "@tiptap/extension-underline";
+import TextAlign from "@tiptap/extension-text-align";
+import QuillSense from "@/lib/models/QuillSense";
+import {useTranslations} from "next-intl";
+import {LangContext, LangContextProps} from "@/context/LangContext";
+import {AIUsageContext, AIUsageContextProps} from "@/context/AIUsageContext";
+
+interface ShortStoryGeneratorProps {
+ onClose: () => void;
+}
+
+export default function ShortStoryGenerator({onClose}: ShortStoryGeneratorProps) {
+ const {session} = useContext(SessionContext);
+ const {errorMessage, infoMessage} = useContext(AlertContext);
+ const {lang} = useContext(LangContext)
+ const t = useTranslations();
+ const {setTotalPrice, setTotalCredits} = useContext(AIUsageContext)
+
+ const [tone, setTone] = useState('');
+ const [atmosphere, setAtmosphere] = useState('');
+ const [verbTense, setVerbTense] = useState('0');
+ const [person, setPerson] = useState('0');
+ const [characters, setCharacters] = useState('');
+ const [language, setLanguage] = useState(
+ session.user?.writingLang.toString() ?? '0',
+ );
+ const [dialogueType, setDialogueType] = useState('0');
+ const [wordsCount, setWordsCount] = useState(500)
+ const [directives, setDirectives] = useState('');
+ const [authorLevel, setAuthorLevel] = useState(
+ session.user?.writingLevel.toString() ?? '0',
+ );
+ const [presetType, setPresetType] = useState('0');
+
+ const [activeTab, setActiveTab] = useState(1);
+ const [progress, setProgress] = useState(25);
+ const modalRef: RefObject = useRef(null);
+ const [isGenerating, setIsGenerating] = useState(false);
+
+ const [generatedText, setGeneratedText] = useState('');
+ const [generatedStoryTitle, setGeneratedStoryTitle] = useState('');
+ const [resume, setResume] = useState('');
+ const [totalWordsCount, setTotalWordsCount] = useState(0);
+
+ const [hasGenerated, setHasGenerated] = useState(false);
+ const [abortController, setAbortController] = useState | null>(null);
+
+ const isAnthropicEnabled: boolean = QuillSense.isAnthropicEnabled(session);
+ const isSubTierTwo: boolean = QuillSense.getSubLevel(session) >= 2;
+ const hasAccess: boolean = isAnthropicEnabled || isSubTierTwo;
+
+ const editor: TipEditor | null = useEditor({
+ extensions: [
+ StarterKit,
+ Underline,
+ TextAlign.configure({
+ types: ['heading', 'paragraph'],
+ }),
+ ],
+ injectCSS: false,
+ immediatelyRender: false,
+ });
+
+ useEffect((): () => void => {
+ document.body.style.overflow = 'hidden';
+ return (): void => {
+ document.body.style.overflow = 'auto';
+ };
+ }, []);
+
+ useEffect((): void => {
+ Story.presetStoryType(
+ presetType,
+ setTone,
+ setAtmosphere,
+ setVerbTense,
+ setPerson,
+ setDialogueType,
+ (): void => {
+ },
+ );
+ }, [presetType]);
+
+ useEffect((): void => {
+ setProgress(activeTab * 25);
+ }, [activeTab]);
+
+ useEffect((): void => {
+ if (editor)
+ editor.commands.setContent(Editor.convertToHtml(generatedText))
+ getWordCount();
+ }, [editor, generatedText]);
+
+ async function handleStopGeneration(): Promise {
+ if (abortController) {
+ await abortController.cancel();
+ setAbortController(null);
+ infoMessage(t("shortStoryGenerator.result.abortSuccess"));
+ }
+ }
+
+ async function handleGeneration(): Promise {
+ setIsGenerating(true);
+ setGeneratedText('');
+
+ try {
+ const response: Response = await fetch(`${configs.apiUrl}quillsense/generate/short`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${session.accessToken}`,
+ },
+ body: JSON.stringify({
+ authorLevel: authorLevel,
+ tone: tone,
+ atmosphere: atmosphere,
+ verbTense: verbTense,
+ person: person,
+ characters: characters,
+ language: language,
+ dialogueType: dialogueType,
+ directives: directives,
+ wordsCount: wordsCount
+ }),
+ });
+
+ if (!response.ok) {
+ const error: { message?: string } = await response.json();
+ errorMessage(error.message || t("shortStoryGenerator.result.unknownError"));
+ setIsGenerating(false);
+ return;
+ }
+ setActiveTab(4);
+ setProgress(100);
+
+ const reader: ReadableStreamDefaultReader | undefined = response.body?.getReader();
+ const decoder: TextDecoder = new TextDecoder();
+ let accumulatedText: string = '';
+
+ if (!reader) {
+ errorMessage(t("shortStoryGenerator.result.noResponse"));
+ setIsGenerating(false);
+ return;
+ }
+
+ setAbortController(reader);
+
+ while (true) {
+ try {
+ const {done, value}: ReadableStreamReadResult = 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 data: {
+ content?: string;
+ title?: string;
+ useYourKey?: boolean;
+ totalPrice?: number;
+ totalCost?: number;
+ } = JSON.parse(line.slice(6));
+
+ if (data.content && data.content !== 'starting') {
+ accumulatedText += data.content;
+ setGeneratedText(accumulatedText);
+ }
+
+ if (data.title) {
+ setGeneratedStoryTitle(data.title);
+ }
+
+ // Le message final du endpoint avec title, totalPrice, useYourKey, totalCost
+ if (data.useYourKey !== undefined && data.totalPrice !== undefined) {
+ console.log(data);
+ if (data.useYourKey) {
+ setTotalPrice((prev: number): number => prev + data.totalPrice!);
+ } else {
+ setTotalCredits(data.totalPrice);
+ }
+ }
+ } catch (e: unknown) {
+ console.error('Error parsing SSE data:', e);
+ }
+ }
+ }
+ } catch (e: unknown) {
+ // Si le reader est annulé ou une erreur survient, sortir
+ break;
+ }
+ }
+
+ setIsGenerating(false);
+ setHasGenerated(true);
+ setAbortController(null);
+ } catch (e: unknown) {
+ if (e instanceof Error) {
+ if (e.name !== 'AbortError') {
+ errorMessage(e.message);
+ }
+ } else {
+ errorMessage(t("shortStoryGenerator.result.unknownError"));
+ }
+ setIsGenerating(false);
+ setAbortController(null);
+ }
+ }
+
+ 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;
+ setTotalWordsCount(wordCount);
+ } catch (e: unknown) {
+ if (e instanceof Error) {
+ errorMessage(e.message);
+ } else {
+ errorMessage(t("shortStoryGenerator.result.unknownError"));
+ }
+ }
+ }
+ }
+
+ async function handleSave(): Promise {
+ let content: string = '';
+ if (editor) content = editor?.state?.doc.toJSON();
+ try {
+ const bookId: string = await System.authPostToServer(
+ `quillsense/generate/add`,
+ {
+ title: generatedStoryTitle,
+ resume: resume,
+ content: content,
+ wordCount: totalWordsCount,
+ tone: tone,
+ atmosphere: atmosphere,
+ verbTense: verbTense,
+ language: language,
+ dialogueType: dialogueType,
+ person: person,
+ authorLevel: authorLevel
+ ? authorLevel
+ : session.user?.writingLevel,
+ },
+ session.accessToken,
+ lang
+ );
+ if (!bookId) {
+ errorMessage(t("shortStoryGenerator.result.saveError"));
+ return;
+ }
+ onClose();
+ } catch (e: unknown) {
+ if (e instanceof Error) {
+ errorMessage(e.message);
+ } else {
+ errorMessage(t("shortStoryGenerator.result.unknownError"));
+ }
+ }
+ }
+
+ if (!hasAccess) {
+ return (
+
+
+
+
+ {t("shortStoryGenerator.accessDenied.title")}
+
+
+ {t("shortStoryGenerator.accessDenied.message")}
+
+
+ {t("shortStoryGenerator.accessDenied.close")}
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ {t("shortStoryGenerator.title")}
+
+
+
+
+
+
+
+
+
+ {[
+ {id: 1, label: t("shortStoryGenerator.tabs.basics"), icon: faBookOpen},
+ {id: 2, label: t("shortStoryGenerator.tabs.structure"), icon: faUserEdit},
+ {id: 3, label: t("shortStoryGenerator.tabs.atmosphere"), icon: faCloudSun},
+ ...(hasGenerated || isGenerating ? [{
+ id: 4,
+ label: t("shortStoryGenerator.tabs.result"),
+ icon: faFileLines
+ }] : [])
+ ].map(tab => (
+ setActiveTab(tab.id)}
+ disabled={isGenerating}
+ className={`flex items-center px-6 py-3 font-medium transition-colors ${
+ activeTab === tab.id
+ ? 'text-primary border-b-2 border-primary bg-primary/5'
+ : 'text-text-secondary hover:text-text-primary'
+ }`}
+ >
+
+ {tab.label}
+ {tab.id === 4 && isGenerating && !generatedText && (
+
+ )}
+
+ ))}
+
+
+
+ {activeTab === 1 && (
+
+
+ setAuthorLevel(e.target.value)}
+ data={writingLevel}
+ defaultValue={authorLevel}
+ />
+ }
+ />
+ setPresetType(e.target.value)}
+ data={
+ authorLevel === '1'
+ ? beginnerPredefinedType
+ : authorLevel === '2'
+ ? intermediatePredefinedType
+ : advancedPredefinedType
+ }
+ defaultValue={presetType}
+ />
+ }
+ />
+ setLanguage(e.target.value)}
+ data={langues}
+ defaultValue={language}
+ />
+ }
+ />
+
+ }
+ />
+
+
+ )}
+
+ {activeTab === 2 && (
+
+
+ setVerbTense(e.target.value)}
+ data={verbalTime}
+ defaultValue={verbTense}
+ />
+ }
+ />
+ setPerson(e.target.value)}
+ data={
+ authorLevel === '1'
+ ? beginnerNarrativePersons
+ : authorLevel === '2'
+ ? intermediateNarrativePersons
+ : advancedNarrativePersons
+ }
+ defaultValue={person}
+ />
+ }
+ />
+
+
+
setDialogueType(e.target.value)}
+ data={
+ authorLevel === '1'
+ ? beginnerDialogueTypes
+ : authorLevel === '2'
+ ? intermediateDialogueTypes
+ : advancedDialogueTypes
+ }
+ defaultValue={dialogueType}
+ />
+ }
+ />
+
+ ) => setDirectives(e.target.value)}
+ placeholder={t("shortStoryGenerator.placeholders.directives")}
+ />
+ }
+ />
+
+ )}
+
+ {activeTab === 3 && (
+
+
+ ) => setTone(e.target.value)}
+ placeholder={t("shortStoryGenerator.placeholders.tone")}
+ />
+ }
+ />
+
+
+
+ ) => setAtmosphere(e.target.value)}
+ placeholder={t("shortStoryGenerator.placeholders.atmosphere")}
+ />
+ }
+ />
+
+
+
) => setCharacters(e.target.value)}
+ placeholder={t("shortStoryGenerator.placeholders.character")}
+ />
+ }
+ />
+
+ )}
+
+ {activeTab === 4 && (
+
+
+
+ {generatedStoryTitle || t("shortStoryGenerator.result.title")}
+
+
+
+ {isGenerating ? (
+
+
+
+ ) : generatedText && (
+ <>
+
+
+
+
+
+
+ >
+ )}
+
+
+
+ {isGenerating && !generatedText ? (
+
+
+
{t("shortStoryGenerator.result.generating")}
+
+ ) : (
+
+
+
+ )}
+
+ {generatedText && (
+
+
+
+ {totalWordsCount} {t("shortStoryGenerator.result.words")}
+
+
+ )}
+
+ )}
+
+
+
+
setActiveTab(Math.max(1, activeTab - 1))}
+ className={`px-4 py-2 rounded-xl transition-all duration-200 flex items-center ${
+ activeTab > 1 && !isGenerating
+ ? 'text-text-secondary hover:text-text-primary hover:bg-secondary hover:scale-105 shadow-sm'
+ : 'text-muted cursor-not-allowed'
+ }`}
+ disabled={activeTab === 1 || isGenerating}
+ >
+
+ {t("shortStoryGenerator.navigation.previous")}
+
+
+
+
+ {activeTab === 4 && hasGenerated ? t("shortStoryGenerator.navigation.close") : t("shortStoryGenerator.navigation.cancel")}
+
+
+ {activeTab < 3 ? (
+ setActiveTab(activeTab + 1)}
+ disabled={isGenerating}
+ className="px-6 py-2.5 rounded-xl bg-primary text-text-primary hover:bg-primary-dark transition-all duration-200 hover:scale-105 flex items-center disabled:opacity-50 shadow-md hover:shadow-lg font-medium"
+ >
+ {t("shortStoryGenerator.navigation.next")}
+
+
+ ) : activeTab === 3 && (
+
+ {isGenerating ? (
+ <>
+
+ {t("shortStoryGenerator.actions.generating")}
+ >
+ ) : (
+ <>
+
+ {t("shortStoryGenerator.actions.generate")}
+ >
+ )}
+
+ )}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/components/StaticAlert.tsx b/components/StaticAlert.tsx
new file mode 100644
index 0000000..265d195
--- /dev/null
+++ b/components/StaticAlert.tsx
@@ -0,0 +1,134 @@
+'use client'
+import React, {useEffect, useState} from 'react';
+import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
+import {
+ faCheckCircle,
+ faExclamationCircle,
+ faInfoCircle,
+ faTimes,
+ faTimesCircle
+} from '@fortawesome/free-solid-svg-icons';
+
+interface StaticAlertProps {
+ type: 'success' | 'error' | 'info' | 'warning';
+ message: string;
+ onClose: () => void;
+}
+
+const iconMap = {
+ success: faCheckCircle,
+ error: faExclamationCircle,
+ info: faInfoCircle,
+ warning: faTimesCircle,
+};
+
+const bgColorMap = {
+ success: 'bg-success',
+ error: 'bg-error',
+ info: 'bg-info',
+ warning: 'bg-warning',
+};
+
+export default function StaticAlert(
+ {type, message, onClose}: StaticAlertProps) {
+ const [visible, setVisible] = useState(false);
+ const onCloseRef = React.useRef(onClose);
+
+ useEffect(() => {
+ onCloseRef.current = onClose;
+ }, [onClose]);
+
+ useEffect(() => {
+ setVisible(true);
+ const timer = setTimeout(() => {
+ setVisible(false);
+ setTimeout(() => onCloseRef.current(), 500); // Wait for fade out animation to complete
+ }, 4800);
+
+ return () => {
+ clearTimeout(timer);
+ };
+ }, []);
+
+ const handleClose = () => {
+ setVisible(false);
+ setTimeout(() => onCloseRef.current(), 1000); // Wait for fade out animation to complete
+ };
+
+ return (
+
+
+
+
+
+
+
+
{
+ e.currentTarget.style.transform = 'rotate(90deg)';
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.transform = 'rotate(0deg)';
+ }}
+ >
+
+
+
+
+
+
+ );
+}
diff --git a/components/TermsOfUse.tsx b/components/TermsOfUse.tsx
new file mode 100644
index 0000000..37c5a8d
--- /dev/null
+++ b/components/TermsOfUse.tsx
@@ -0,0 +1,125 @@
+import React from 'react';
+import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
+import {faExternalLinkAlt, faFileContract} from '@fortawesome/free-solid-svg-icons';
+import {AppRouterInstance} from 'next/dist/shared/lib/app-router-context.shared-runtime';
+import {useRouter} from 'next/navigation';
+import Link from "next/link";
+
+interface TermsOfUseProps {
+ onAccept: () => void;
+}
+
+export default function TermsOfUse({onAccept}: TermsOfUseProps) {
+ const router: AppRouterInstance = useRouter();
+
+ function handleAcceptTerm(): void {
+ onAccept();
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
Termes d'utilisation
+
Acceptation requise pour accéder à ERitors
+ Scribe
+
+
+
+
+
+
+
+ Acceptation obligatoire
+
+
+
+ Pour pouvoir utiliser nos services, tel qu'ERitors
+ Scribe ,
+ vous devez accepter les termes d'utilisation en cliquant
+ sur J'accepte .
+
+
+ Veuillez lire attentivement la page détaillée des termes et conditions d'utilisation
+ avant de procéder à l'acceptation.
+
+
+ Si vous n'acceptez pas ces conditions, vous ne pourrez pas accéder à nos services
+ et serez redirigé vers la page d'accueil.
+
+
+
+
+
+
+ Documentation complète
+
+
+ Pour consulter l'intégralité de nos termes et conditions d'utilisation,
+ veuillez visiter notre page dédiée :
+
+
+ Consulter les termes complets
+
+
+
+
+
+
+
+
+
+
+
+ Importance capitale
+
+
+ Cette acceptation est obligatoire et constitue un prérequis légal
+ pour l'utilisation de nos services d'édition assistée par intelligence
+ artificielle.
+
+
+
+
+
+
+
+
+
+
+ Décision requise pour continuer
+
+
+
+ Refuser et quitter
+
+
+ J'accepte les termes
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/components/TwoFactorSetup.tsx b/components/TwoFactorSetup.tsx
new file mode 100644
index 0000000..7412656
--- /dev/null
+++ b/components/TwoFactorSetup.tsx
@@ -0,0 +1,196 @@
+'use client';
+
+import {ChangeEvent, Dispatch, SetStateAction, useContext, useState} from 'react';
+import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
+import {faApple, faGooglePlay} from '@fortawesome/free-brands-svg-icons';
+import {faCheck, faKey, faMobileAlt, faQrcode} from '@fortawesome/free-solid-svg-icons';
+import System from "@/lib/models/System";
+import {AlertContext, AlertContextProps} from "@/context/AlertContext";
+import {FormResponse} from "@/shared/interface";
+import {SessionContext} from "@/context/SessionContext";
+import TextInput from "@/components/form/TextInput";
+
+export default function TwoFactorSetup({setShowSetup}: { setShowSetup: Dispatch> }) {
+ const {session} = useContext(SessionContext);
+ const alert: AlertContextProps = useContext(AlertContext);
+
+ const [step, setStep] = useState(1);
+ const [token, setToken] = useState('')
+ const [qrCode, setQrCode] = useState(null);
+ const [loadingQRCode, setLoadingQRCode] = useState(false);
+
+ async function getQRCode() {
+ try {
+ const response: { qrCode: string } = await System.authPostToServer('twofactor/setup', {
+ email: session?.user?.email,
+ }, session?.accessToken ?? '');
+ setQrCode(response.qrCode);
+ } catch (e: any) {
+ alert.errorMessage(e.message);
+ console.error(e);
+ }
+ }
+
+ async function handleNextStep() {
+ if (step === 3) {
+ await validateToken();
+ } else if (step === 1) {
+ if (qrCode === null) {
+ getQRCode();
+ }
+ setStep((prev: number) => Math.min(prev + 1, 3));
+ } else {
+ setStep((prev: number) => Math.min(prev + 1, 3));
+ }
+ }
+
+ async function validateToken() {
+ try {
+ const response: FormResponse = await System.authPostToServer('twofactor/activate', {
+ email: session?.user?.email, token: token
+ }, session?.accessToken ?? '');
+ if (response.valid) {
+ alert.successMessage(response.message ?? '');
+ setShowSetup(false);
+ }
+ } catch (e: any) {
+ alert.errorMessage(e.message);
+ console.error(e);
+ }
+ }
+
+ function handlePrevStep() {
+ setStep((prev) => Math.max(prev - 1, 1));
+ }
+
+ function getProgressClass(currentStep: number) {
+ return `flex-grow h-2.5 rounded-full transition-all duration-300 ${
+ step >= currentStep ? 'bg-primary shadow-sm' : 'bg-secondary/50'
+ }`;
+ }
+
+ return (
+
+
+ Setup Two-Factor Authentication
+
+
+ {/* Step Indicator */}
+
+
+ {/* Step Content */}
+
+ {step === 1 && (
+
+
+ Follow these steps to enable two-factor authentication for your account:
+
+
+
+
+ Download a two-factor authentication app like Google Authenticator or Authy.
+
+
+
+ Open the app and select the option to scan a QR code.
+
+
+
+ Proceed to the next step to scan the QR code provided.
+
+
+
+
+ )}
+ {step === 2 && (
+
+
+ Scan the QR code below with your authentication app to link your account.
+
+
+
+ {loadingQRCode ? (
+
Loading QR Code...
+ ) : qrCode ? (
+
+ ) : (
+
Failed to load QR Code.
+ )}
+
+
+
+ Having trouble? Make sure your app supports QR code scanning.
+
+
+ )}
+ {step === 3 && (
+
+
+ Enter the 6-digit code generated by your authentication app to verify the setup.
+
+
+ ) => setToken(e.target.value)}
+ placeholder="Enter 6-digit code"
+ />
+
+
+
+ )}
+
+
+ {/* Navigation Buttons */}
+
+
+ Back
+
+
+ {step === 3 ? 'Finish' : 'Next'}
+
+
+
+ );
+}
diff --git a/components/UserMenu.tsx b/components/UserMenu.tsx
new file mode 100644
index 0000000..86fe16e
--- /dev/null
+++ b/components/UserMenu.tsx
@@ -0,0 +1,69 @@
+import React, {useContext, useEffect, useRef, useState} from "react";
+import {SessionContext} from "@/context/SessionContext";
+import NoPicture from "@/components/NoPicture";
+import System from "@/lib/models/System";
+
+export default function UserMenu() {
+ const {session} = useContext(SessionContext);
+
+ const profileMenuRef: React.RefObject = useRef(null);
+
+ const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
+
+ function handleProfileClick(): void {
+ setIsProfileMenuOpen(!isProfileMenuOpen);
+ }
+
+ useEffect((): () => void => {
+ function handleClickOutside(event: MouseEvent): void {
+ if (profileMenuRef.current && !profileMenuRef.current.contains(event.target as Node)) {
+ setIsProfileMenuOpen(false);
+ }
+ }
+
+ if (isProfileMenuOpen) {
+ document.addEventListener("mousedown", handleClickOutside);
+ }
+
+ return (): void => {
+ document.removeEventListener("mousedown", handleClickOutside);
+ };
+ }, [isProfileMenuOpen]);
+
+ function handleLogout(): void {
+ System.removeCookie("token");
+ document.location.href = "https://eritors.com/login";
+ }
+
+ return (
+
+
document.location.href = "/login"}
+ >
+ {
+ session.user &&
+ }
+
+ {isProfileMenuOpen && (
+
+ )}
+
+ )
+}
diff --git a/components/book/AddNewBookForm.tsx b/components/book/AddNewBookForm.tsx
new file mode 100644
index 0000000..86f8a53
--- /dev/null
+++ b/components/book/AddNewBookForm.tsx
@@ -0,0 +1,295 @@
+'use client'
+import React, {ChangeEvent, Dispatch, SetStateAction, useContext, useEffect, useRef, useState} from "react";
+import {AlertContext} from "@/context/AlertContext";
+import System from "@/lib/models/System";
+import {SessionContext} from "@/context/SessionContext";
+import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
+import {
+ faBook,
+ faBookOpen,
+ faCalendarAlt,
+ faFileWord,
+ faInfo,
+ faPencilAlt,
+ faX
+} from "@fortawesome/free-solid-svg-icons";
+import {SelectBoxProps} from "@/shared/interface";
+import {BookProps, bookTypes} from "@/lib/models/Book";
+import InputField from "@/components/form/InputField";
+import TextInput from "@/components/form/TextInput";
+import SelectBox from "@/components/form/SelectBox";
+import DatePicker from "@/components/form/DatePicker";
+import NumberInput from "@/components/form/NumberInput";
+import TexteAreaInput from "@/components/form/TexteAreaInput";
+import CancelButton from "@/components/form/CancelButton";
+import SubmitButtonWLoading from "@/components/form/SubmitButtonWLoading";
+import GuideTour, {GuideStep} from "@/components/GuideTour";
+import {UserProps} from "@/lib/models/User";
+import {useTranslations} from "next-intl";
+import {LangContext, LangContextProps} from "@/context/LangContext";
+
+interface MinMax {
+ min: number;
+ max: number;
+}
+
+export default function AddNewBookForm({setCloseForm}: { setCloseForm: Dispatch> }) {
+ const t = useTranslations();
+ const {lang} = useContext(LangContext);
+ const {session, setSession} = useContext(SessionContext);
+ const {errorMessage} = useContext(AlertContext);
+ const modalRef: React.RefObject = useRef(null);
+
+ const [title, setTitle] = useState('');
+ const [subtitle, setSubtitle] = useState('');
+ const [summary, setSummary] = useState('');
+ const [publicationDate, setPublicationDate] = useState('');
+ const [wordCount, setWordCount] = useState(0);
+ const [selectedBookType, setSelectedBookType] = useState('');
+
+ const [isAddingBook, setIsAddingBook] = useState(false);
+ const [bookTypeHint, setBookTypeHint] = useState(false);
+
+ const token: string = session?.accessToken ?? '';
+
+ const bookTypesHint: GuideStep[] = [{
+ id: 0,
+ x: 80,
+ y: 50,
+ title: t("addNewBookForm.bookTypeHint.title"),
+ content: (
+
+
+
+
{t("addNewBookForm.bookTypeHint.nouvelle.title")}
+
{t("addNewBookForm.bookTypeHint.nouvelle.range")}
+
{t("addNewBookForm.bookTypeHint.nouvelle.description")}
+
+
+
{t("addNewBookForm.bookTypeHint.novelette.title")}
+
{t("addNewBookForm.bookTypeHint.novelette.range")}
+
{t("addNewBookForm.bookTypeHint.novelette.description")}
+
+
+
{t("addNewBookForm.bookTypeHint.novella.title")}
+
{t("addNewBookForm.bookTypeHint.novella.range")}
+
{t("addNewBookForm.bookTypeHint.novella.description")}
+
+
+
{t("addNewBookForm.bookTypeHint.chapbook.title")}
+
{t("addNewBookForm.bookTypeHint.chapbook.range")}
+
{t("addNewBookForm.bookTypeHint.chapbook.description")}
+
+
+
{t("addNewBookForm.bookTypeHint.roman.title")}
+
{t("addNewBookForm.bookTypeHint.roman.range")}
+
{t("addNewBookForm.bookTypeHint.roman.description")}
+
+
+
+
+ {t("addNewBookForm.bookTypeHint.tip")}
+
+
+
+ ),
+ }]
+
+ useEffect((): () => void => {
+ document.body.style.overflow = 'hidden';
+ return (): void => {
+ document.body.style.overflow = 'auto';
+ };
+ }, []);
+
+ async function handleAddBook(): Promise {
+ if (!title) {
+ errorMessage(t('addNewBookForm.error.titleMissing'));
+ return;
+ } else {
+ if (title.length < 2) {
+ errorMessage(t('addNewBookForm.error.titleTooShort'));
+ return;
+ }
+ if (title.length > 50) {
+ errorMessage(t('addNewBookForm.error.titleTooLong'));
+ return;
+ }
+ }
+ if (selectedBookType === '') {
+ errorMessage(t('addNewBookForm.error.typeMissing'));
+ return;
+ }
+ setIsAddingBook(true);
+ try {
+ const bookId: string = await System.authPostToServer('book/add', {
+ title: title,
+ subTitle: subtitle,
+ type: selectedBookType,
+ summary: summary,
+ serie: 0,
+ publicationDate: publicationDate,
+ desiredWordCount: wordCount,
+ }, token, lang)
+ if (!bookId) {
+ errorMessage(t('addNewBookForm.error.addingBook'));
+ setIsAddingBook(false);
+ return;
+ }
+ const book: BookProps = {
+ bookId: bookId,
+ title,
+ subTitle: subtitle,
+ type: selectedBookType,
+ summary, serie: 0,
+ publicationDate,
+ desiredWordCount: wordCount
+ };
+ setSession({
+ ...session,
+ user: {
+ ...session.user as UserProps,
+ books: [...((session.user as UserProps)?.books ?? []), book]
+ }
+ });
+ setIsAddingBook(false);
+ setCloseForm(false)
+ } catch (e: unknown) {
+ if (e instanceof Error) {
+ errorMessage(e.message);
+ } else {
+ errorMessage(t('addNewBookForm.error.addingBook'));
+ }
+ setIsAddingBook(false);
+ }
+ }
+
+ function maxWordsCountHint(): MinMax {
+ switch (selectedBookType) {
+ case 'short':
+ return {
+ min: 1000,
+ max: 7500,
+ };
+ case 'chapbook':
+ return {
+ min: 1000,
+ max: 10000,
+ };
+ case 'novelette' :
+ return {
+ min: 7500,
+ max: 17500,
+ };
+ case 'long' :
+ return {
+ min: 17500,
+ max: 40000,
+ };
+ case 'novel' :
+ return {
+ min: 40000,
+ max: 0,
+ };
+ default :
+ return {
+ min: 0,
+ max: 0
+ }
+ }
+ }
+
+ return (
+
+
+
+
+
+ {t("addNewBookForm.title")}
+
+ setCloseForm(false)}
+ >
+
+
+
+
+
+
+ ): void => setSelectedBookType(e.target.value)}
+ data={bookTypes.map((types: SelectBoxProps): SelectBoxProps => {
+ return {
+ value: types.value,
+ label: t(types.label)
+ }
+ })} defaultValue={selectedBookType}
+ placeholder={t("addNewBookForm.typePlaceholder")}/>
+ } action={async (): Promise => setBookTypeHint(true)} actionIcon={faInfo}/>
+ ): void => setTitle(e.target.value)}
+ placeholder={t("addNewBookForm.bookTitlePlaceholder")}/>
+ }/>
+ {
+ selectedBookType !== 'lyric' && (
+ ): void => setSubtitle(e.target.value)}
+ placeholder={t("addNewBookForm.subtitlePlaceholder")}/>
+ }/>
+ )
+ }
+
+ ): void => setPublicationDate(e.target.value)}/>
+ }/>
+
+ {
+ selectedBookType !== 'lyric' && (
+ <>
+ 0 ? maxWordsCountHint().max.toLocaleString('fr-FR') : '∞'} ${t("addNewBookForm.words")}`}
+ input={
+
+ }/>
+
+ ): void => setSummary(e.target.value)}
+ placeholder={t("addNewBookForm.summaryPlaceholder")}
+ />
+ }
+ />
+ >
+ )
+ }
+
+
+
+
+
+
+ setCloseForm(false)}/>
+
+
+
+
+ {bookTypeHint &&
setBookTypeHint(false)}
+ onComplete={async (): Promise => setBookTypeHint(false)}/>}
+
+ );
+}
\ No newline at end of file
diff --git a/components/book/BookCard.tsx b/components/book/BookCard.tsx
new file mode 100644
index 0000000..7c8f505
--- /dev/null
+++ b/components/book/BookCard.tsx
@@ -0,0 +1,80 @@
+import Link from "next/link";
+import React from "react";
+import {BookProps} from "@/lib/models/Book";
+import DeleteBook from "@/components/book/settings/DeleteBook";
+import ExportBook from "@/components/ExportBook";
+import {useTranslations} from "next-intl";
+
+export default function BookCard(
+ {
+ book,
+ onClickCallback,
+ index
+ }: {
+ book: BookProps,
+ onClickCallback: Function;
+ index: number;
+ }) {
+ const t = useTranslations();
+
+ return (
+
+
+
onClickCallback(book.bookId)} href={``}>
+ {book.coverImage ? (
+
+ ) : (
+
+
+
+ {book.title.charAt(0).toUpperCase()}{t("bookCard.initialsSeparator")}{book.subTitle ? book.subTitle.charAt(0).toUpperCase() : ''}
+
+
+
+
+ )}
+
+
+
+
+
+
+
onClickCallback(book.bookId)} href={``}>
+
+ {book.title}
+
+
+
+ {book.subTitle ? (
+ <>
+
+
+ {book.subTitle}
+
+
+ >
+ ) : null}
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/components/book/BookCardSkeleton.tsx b/components/book/BookCardSkeleton.tsx
new file mode 100644
index 0000000..07d3c6a
--- /dev/null
+++ b/components/book/BookCardSkeleton.tsx
@@ -0,0 +1,26 @@
+export default function BookCardSkeleton() {
+ return (
+
+ );
+}
diff --git a/components/book/BookList.tsx b/components/book/BookList.tsx
new file mode 100644
index 0000000..3181883
--- /dev/null
+++ b/components/book/BookList.tsx
@@ -0,0 +1,291 @@
+import React, {useContext, useEffect, useState} from "react";
+import System from "@/lib/models/System";
+import {AlertContext} from "@/context/AlertContext";
+import {BookContext} from "@/context/BookContext";
+import SearchBook from "./SearchBook";
+import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
+import {faBook, faDownload, faGear, faTrash} from "@fortawesome/free-solid-svg-icons";
+import {SessionContext} from "@/context/SessionContext";
+import Book, {BookListProps, BookProps} from "@/lib/models/Book";
+import BookCard from "@/components/book/BookCard";
+import BookCardSkeleton from "@/components/book/BookCardSkeleton";
+import GuideTour, {GuideStep} from "@/components/GuideTour";
+import User from "@/lib/models/User";
+import {useTranslations} from "next-intl";
+import {LangContext, LangContextProps} from "@/context/LangContext";
+
+export default function BookList() {
+ const {session, setSession} = useContext(SessionContext);
+ const accessToken: string = session?.accessToken || '';
+ const {errorMessage} = useContext(AlertContext);
+ const {setBook} = useContext(BookContext);
+ const t = useTranslations();
+ const {lang} = useContext(LangContext)
+
+ const [searchQuery, setSearchQuery] = useState('');
+ const [groupedBooks, setGroupedBooks] = useState>({});
+ const [isLoadingBooks, setIsLoadingBooks] = useState(true);
+
+ const [bookGuide, setBookGuide] = useState(false);
+
+ const bookGuideSteps: GuideStep[] = [
+ {
+ id: 0,
+ targetSelector: '[data-guide="book-category"]',
+ position: 'left',
+ highlightRadius: -200,
+ title: `${t("bookList.guideStep0Title")} ${session.user?.name}`,
+ content: (
+
+
{t("bookList.guideStep0Content")}
+
+ ),
+ },
+ {
+ id: 1,
+ targetSelector: '[data-guide="book-card"]',
+ position: 'left',
+ title: t("bookList.guideStep1Title"),
+ content: (
+
+
{t("bookList.guideStep1Content")}
+
+ ),
+ },
+ {
+ id: 2,
+ targetSelector: '[data-guide="bottom-book-card"]',
+ position: 'left',
+ title: t("bookList.guideStep2Title"),
+ content: (
+
+
+
+ {t("bookList.guideStep2ContentGear")}
+
+
+
+ {t("bookList.guideStep2ContentDownload")}
+
+
+
+ {t("bookList.guideStep2ContentTrash")}
+
+
+ ),
+ },
+ ]
+
+ useEffect((): void => {
+ if (groupedBooks && Object.keys(groupedBooks).length > 0 && User.guideTourDone(session.user?.guideTour || [], 'new-first-book')) {
+ setBookGuide(true);
+ }
+ }, [groupedBooks]);
+
+ useEffect((): void => {
+ getBooks().then()
+ }, [session.user?.books]);
+
+ useEffect((): void => {
+ if (accessToken) getBooks().then();
+ }, [accessToken]);
+
+ async function handleFirstBookGuide(): Promise {
+ try {
+ const response: boolean = await System.authPostToServer(
+ 'logs/tour',
+ {plateforme: 'web', tour: 'new-first-book'},
+ session.accessToken, lang
+ );
+ if (response) {
+ setSession(User.setNewGuideTour(session, 'new-first-book'));
+ setBookGuide(false);
+ }
+ } catch (e: unknown) {
+ if (e instanceof Error) {
+ errorMessage(e.message);
+ } else {
+ errorMessage(t("bookList.errorBookCreate"));
+ }
+ }
+ }
+
+ async function getBooks(): Promise {
+ setIsLoadingBooks(true);
+ try {
+ const bookResponse: BookListProps[] = await System.authGetQueryToServer('books', accessToken, lang);
+ if (bookResponse) {
+ const booksByType: Record = bookResponse.reduce((groups: Record, book: BookListProps): Record => {
+ const imageDataUrl: string = book.coverImage ? 'data:image/jpeg;base64,' + book.coverImage : '';
+ const categoryLabel: string = Book.getBookTypeLabel(book.type);
+ const transformedBook: BookProps = {
+ bookId: book.id,
+ type: categoryLabel,
+ title: book.title,
+ subTitle: book.subTitle,
+ summary: book.summary,
+ serie: book.serieId,
+ publicationDate: book.desiredReleaseDate,
+ desiredWordCount: book.desiredWordCount,
+ totalWordCount: 0,
+ coverImage: imageDataUrl,
+ };
+ if (!groups[t(categoryLabel)]) {
+ groups[t(categoryLabel)] = [];
+ }
+ groups[t(categoryLabel)].push(transformedBook);
+ return groups;
+ }, {});
+ setGroupedBooks(booksByType);
+ }
+ } catch (e: unknown) {
+ if (e instanceof Error) {
+ errorMessage(e.message);
+ } else {
+ errorMessage(t("bookList.errorBooksFetch"));
+ }
+ } finally {
+ setIsLoadingBooks(false);
+ }
+ }
+
+ const filteredGroupedBooks: Record = Object.entries(groupedBooks).reduce(
+ (acc: Record, [category, books]: [string, BookProps[]]): Record => {
+ const filteredBooks: BookProps[] = books.filter((book: BookProps): boolean =>
+ book.title.toLowerCase().includes(searchQuery.toLowerCase())
+ );
+
+ if (filteredBooks.length > 0) {
+ acc[category] = filteredBooks;
+ }
+ return acc;
+ },
+ {}
+ );
+
+ async function getBook(bookId: string): Promise {
+ try {
+ const bookResponse: BookListProps = await System.authGetQueryToServer(
+ `book/basic-information`,
+ accessToken,
+ lang,
+ {id: bookId}
+ );
+ if (!bookResponse) {
+ errorMessage(t("bookList.errorBookDetails"));
+ return;
+ }
+ if (setBook) {
+ setBook({
+ bookId: bookId,
+ title: bookResponse?.title || '',
+ subTitle: bookResponse?.subTitle || '',
+ summary: bookResponse?.summary || '',
+ type: bookResponse?.type || '',
+ serie: bookResponse?.serieId,
+ publicationDate: bookResponse?.desiredReleaseDate || '',
+ desiredWordCount: bookResponse?.desiredWordCount || 0,
+ totalWordCount: 0,
+ coverImage: bookResponse?.coverImage ? 'data:image/jpeg;base64,' + bookResponse.coverImage : '',
+ });
+ }
+ } catch (e: unknown) {
+ if (e instanceof Error) {
+ errorMessage(e.message);
+ } else {
+ errorMessage(t("bookList.errorUnknown"));
+ }
+ }
+ }
+
+ return (
+
+ {session?.user && (
+
+
+
+ )}
+
+ {
+ isLoadingBooks ? (
+ <>
+
+
{t("bookList.library")}
+
{t("bookList.booksAreMirrors")}
+
+
+
+
+
+
+ {Array.from({length: 6}).map((_, id: number) => (
+
+
+
+ ))}
+
+
+ >
+ ) : Object.entries(filteredGroupedBooks).length > 0 ? (
+ <>
+
+
{t("bookList.library")}
+
{t("bookList.booksAreMirrors")}
+
+
+ {Object.entries(filteredGroupedBooks).map(([category, books], index) => (
+
+
+
+
+ {category}
+
+ {books.length} {t("bookList.works")}
+
+
+
+ {
+ books.map((book: BookProps, idx) => (
+
+
+
+ ))
+ }
+
+
+ ))}
+ >
+ ) : (
+
+
+
+
+
+
{t("bookList.welcomeWritingWorkshop")}
+
+ {t("bookList.whitePageText")}
+
+
+
+ )}
+
+ {
+ bookGuide &&
setBookGuide(false)}/>
+ }
+
+ );
+}
\ No newline at end of file
diff --git a/components/book/SearchBook.tsx b/components/book/SearchBook.tsx
new file mode 100644
index 0000000..a91827b
--- /dev/null
+++ b/components/book/SearchBook.tsx
@@ -0,0 +1,33 @@
+import {faSearch} from "@fortawesome/free-solid-svg-icons";
+import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
+import React, {ChangeEvent, Dispatch, SetStateAction} from "react";
+import {t} from "i18next";
+import TextInput from "@/components/form/TextInput";
+
+export default function SearchBook(
+ {
+ searchQuery,
+ setSearchQuery,
+ }: {
+ searchQuery: string;
+ setSearchQuery: Dispatch>
+ }) {
+
+ return (
+
+
+
+
+
+ ) => setSearchQuery(e.target.value)}
+ placeholder={t("searchBook.placeholder")}
+ />
+
+
+
+
+ )
+}
diff --git a/components/book/settings/BasicInformationSetting.tsx b/components/book/settings/BasicInformationSetting.tsx
new file mode 100644
index 0000000..a85dcc6
--- /dev/null
+++ b/components/book/settings/BasicInformationSetting.tsx
@@ -0,0 +1,232 @@
+'use client'
+import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
+import {faFeather, faTimes} from "@fortawesome/free-solid-svg-icons";
+import {ChangeEvent, forwardRef, useContext, useImperativeHandle, useState} from "react";
+import System from "@/lib/models/System";
+import axios, {AxiosResponse} from "axios";
+import {AlertContext} from "@/context/AlertContext";
+import {BookContext} from "@/context/BookContext";
+import {SessionContext} from "@/context/SessionContext";
+import TextInput from "@/components/form/TextInput";
+import TexteAreaInput from "@/components/form/TexteAreaInput";
+import InputField from "@/components/form/InputField";
+import NumberInput from "@/components/form/NumberInput";
+import DatePicker from "@/components/form/DatePicker";
+import {configs} from "@/lib/configs";
+import {useTranslations} from "next-intl";
+import {LangContext, LangContextProps} from "@/context/LangContext";
+import {BookProps} from "@/lib/models/Book";
+
+function BasicInformationSetting(props: any, ref: any) {
+ const t = useTranslations();
+ const {lang} = useContext(LangContext)
+
+ const {session} = useContext(SessionContext);
+ const {book, setBook} = useContext(BookContext);
+ const userToken: string = session?.accessToken ? session?.accessToken : '';
+ const {errorMessage, successMessage} = useContext(AlertContext);
+ const bookId: string = book?.bookId ? book?.bookId.toString() : '';
+
+ const [currentImage, setCurrentImage] = useState(book?.coverImage ?? '');
+ const [title, setTitle] = useState(book?.title ? book?.title : '');
+ const [subTitle, setSubTitle] = useState(book?.subTitle ? book?.subTitle : '');
+ const [summary, setSummary] = useState(book?.summary ? book?.summary : '');
+ const [publicationDate, setPublicationDate] = useState(book?.publicationDate ? book?.publicationDate : '');
+ const [wordCount, setWordCount] = useState(book?.desiredWordCount ? book?.desiredWordCount : 0);
+
+ useImperativeHandle(ref, function () {
+ return {
+ handleSave: handleSave
+ };
+ });
+
+ async function handleCoverImageChange(e: ChangeEvent): Promise {
+ const file: File | undefined = e.target.files?.[0];
+
+ if (!file) {
+ errorMessage(t('basicInformationSetting.error.noFileSelected'));
+ return;
+ }
+
+ const formData = new FormData();
+ formData.append('bookId', bookId);
+ formData.append('picture', file);
+
+ try {
+ const query: AxiosResponse = await axios({
+ method: "POST",
+ url: configs.apiUrl + `book/cover?bookid=${bookId}`,
+ headers: {
+ 'Authorization': `Bearer ${userToken}`,
+ },
+ params: {
+ lang: lang,
+ plateforme: 'web',
+ },
+ data: formData,
+ responseType: 'arraybuffer'
+ });
+
+ const contentType: string = query.headers['content-type'] || 'image/jpeg';
+ const blob = new Blob([query.data], {type: contentType});
+ const reader = new FileReader();
+
+ reader.onloadend = function (): void {
+ if (typeof reader.result === 'string') {
+ setCurrentImage(reader.result);
+ }
+ };
+
+ reader.readAsDataURL(blob);
+ } catch (e: unknown) {
+ if (axios.isAxiosError(e)) {
+ const serverMessage: string = e.response?.data?.message || e.response?.data || e.message;
+ throw new Error(serverMessage as string);
+ } else if (e instanceof Error) {
+ throw new Error(e.message);
+ } else {
+ throw new Error('An unexpected error occurred');
+ }
+ }
+ }
+
+ async function handleRemoveCurrentImage(): Promise {
+ try {
+ const response: boolean = await System.authDeleteToServer(`book/cover/delete`, {
+ bookId: bookId
+ }, userToken, lang);
+ if (!response) {
+ errorMessage(t('basicInformationSetting.error.removeCover'));
+ }
+ setCurrentImage('');
+ } catch (e: unknown) {
+ if (e instanceof Error) {
+ errorMessage(e.message);
+ } else {
+ errorMessage(t('basicInformationSetting.error.unknown'));
+ }
+ }
+ }
+
+ async function handleSave(): Promise {
+ if (!title) {
+ errorMessage(t('basicInformationSetting.error.titleRequired'));
+ return;
+ }
+ try {
+ const response: boolean = await System.authPostToServer('book/basic-information', {
+ title: title,
+ subTitle: subTitle,
+ summary: summary,
+ publicationDate: publicationDate,
+ wordCount: wordCount,
+ bookId: bookId
+ }, userToken, lang);
+ if (!response) {
+ errorMessage(t('basicInformationSetting.error.update'));
+ return;
+ }
+ if (!book) {
+ errorMessage(t('basicInformationSetting.error.unknown'));
+ return;
+ }
+ const updatedBook: BookProps = {
+ ...book,
+ title: title,
+ subTitle: subTitle,
+ summary: summary,
+ publicationDate: publicationDate,
+ desiredWordCount: wordCount,
+ };
+ setBook!!(updatedBook);
+ successMessage(t('basicInformationSetting.success.update'));
+ } catch (e: unknown) {
+ if (e instanceof Error) {
+ errorMessage(e.message);
+ } else {
+ errorMessage(t('basicInformationSetting.error.unknown'));
+ }
+ }
+ }
+
+ return (
+
+
+
+ ) => setTitle(e.target.value)}
+ placeholder={t('basicInformationSetting.fields.titlePlaceholder')}
+ />}/>
+ ) => setSubTitle(e.target.value)}
+ placeholder={t('basicInformationSetting.fields.subtitlePlaceholder')}
+ />}/>
+
+
+
+
+ ) => setSummary(e.target.value)}
+ placeholder={t('basicInformationSetting.fields.summaryPlaceholder')}
+ />}/>
+
+
+
+
+ ) => setPublicationDate(e.target.value)}
+ />
+ }/>
+
+ }/>
+
+
+
+
+ {currentImage ? (
+
+
+
+
+
+
+
+
+ ) : (
+
+
+
+ {
+ }} input={ }/>
+
+
+
+ )}
+
+
+ );
+}
+
+export default forwardRef(BasicInformationSetting);
\ No newline at end of file
diff --git a/components/book/settings/BookSetting.tsx b/components/book/settings/BookSetting.tsx
new file mode 100644
index 0000000..74db62d
--- /dev/null
+++ b/components/book/settings/BookSetting.tsx
@@ -0,0 +1,18 @@
+import {useState} from "react";
+import BookSettingSidebar from "@/components/book/settings/BookSettingSidebar";
+import BookSettingOption from "@/components/book/settings/BookSettingOption";
+
+export default function BookSetting() {
+ const [currentSetting, setCurrentSetting] = useState('basic-information')
+ return (
+
+ )
+}
diff --git a/components/book/settings/BookSettingOption.tsx b/components/book/settings/BookSettingOption.tsx
new file mode 100644
index 0000000..a089ab7
--- /dev/null
+++ b/components/book/settings/BookSettingOption.tsx
@@ -0,0 +1,118 @@
+import BasicInformationSetting from "./BasicInformationSetting";
+import GuideLineSetting from "./guide-line/GuideLineSetting";
+import StorySetting from "./story/StorySetting";
+import WorldSetting from "@/components/book/settings/world/WorldSetting";
+import {faPen, faSave} from "@fortawesome/free-solid-svg-icons";
+import {RefObject, useRef} from "react";
+import PanelHeader from "@/components/PanelHeader";
+import LocationComponent from "@/components/book/settings/locations/LocationComponent";
+import CharacterComponent from "@/components/book/settings/characters/CharacterComponent";
+import {useTranslations} from "next-intl"; // Ajouté pour la traduction
+
+export default function BookSettingOption(
+ {
+ setting,
+ }: {
+ setting: string;
+ }) {
+ const t = useTranslations(); // Ajouté pour la traduction
+
+ const basicInfoRef: RefObject<{ handleSave: () => Promise } | null> = useRef<{
+ handleSave: () => Promise
+ }>(null);
+ const guideLineRef: RefObject<{ handleSave: () => Promise } | null> = useRef<{
+ handleSave: () => Promise
+ }>(null);
+ const storyRef: RefObject<{ handleSave: () => Promise } | null> = useRef<{
+ handleSave: () => Promise
+ }>(null);
+ const worldRef: RefObject<{ handleSave: () => Promise } | null> = useRef<{
+ handleSave: () => Promise
+ }>(null);
+ const locationRef: RefObject<{ handleSave: () => Promise } | null> = useRef<{
+ handleSave: () => Promise
+ }>(null);
+ const characterRef: RefObject<{ handleSave: () => Promise } | null> = useRef<{
+ handleSave: () => Promise
+ }>(null);
+
+ function renderTitle(): string {
+ switch (setting) {
+ case 'basic-information':
+ return t("bookSettingOption.basicInformation");
+ case 'guide-line':
+ return t("bookSettingOption.guideLine");
+ case 'story':
+ return t("bookSettingOption.storyPlan");
+ case 'world':
+ return t("bookSettingOption.manageWorlds");
+ case 'locations':
+ return t("bookSettingOption.yourLocations");
+ case 'characters':
+ return t("bookSettingOption.characters");
+ case 'objects':
+ return t("bookSettingOption.objectsList");
+ case 'goals':
+ return t("bookSettingOption.bookGoals");
+ default:
+ return "";
+ }
+ }
+
+ async function handleSaveClick(): Promise {
+ switch (setting) {
+ case 'basic-information':
+ basicInfoRef.current?.handleSave();
+ break;
+ case 'guide-line':
+ guideLineRef.current?.handleSave();
+ break;
+ case 'story':
+ storyRef.current?.handleSave();
+ break;
+ case 'world':
+ worldRef.current?.handleSave();
+ break;
+ case 'locations':
+ locationRef.current?.handleSave();
+ break;
+ case 'characters':
+ characterRef.current?.handleSave();
+ break;
+ default:
+ break;
+ }
+ }
+
+ return (
+
+
+
+ {
+ setting === 'basic-information' ? (
+
+ ) : setting === 'guide-line' ? (
+
+ ) : setting === 'story' ? (
+
+ ) : setting === 'world' ? (
+
+ ) : setting === 'locations' ? (
+
+ ) : setting === 'characters' ? (
+
+ ) : {t("bookSettingOption.notAvailable")}
+ }
+
+
+ )
+}
\ No newline at end of file
diff --git a/components/book/settings/BookSettingSidebar.tsx b/components/book/settings/BookSettingSidebar.tsx
new file mode 100644
index 0000000..caa195c
--- /dev/null
+++ b/components/book/settings/BookSettingSidebar.tsx
@@ -0,0 +1,91 @@
+'use client'
+import Link from "next/link";
+import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
+import {faBook, faGlobe, faListAlt, faMapMarkedAlt, faPencilAlt, faUser} from "@fortawesome/free-solid-svg-icons";
+import React, {Dispatch, SetStateAction} from "react";
+import {IconDefinition} from "@fortawesome/fontawesome-svg-core";
+import {useTranslations} from "next-intl";
+
+interface BookSettingOption {
+ id: string;
+ name: string;
+ icon: IconDefinition;
+}
+
+export default function BookSettingSidebar(
+ {
+ selectedSetting,
+ setSelectedSetting
+ }: {
+ selectedSetting: string,
+ setSelectedSetting: Dispatch>
+ }) {
+ const t = useTranslations();
+
+ const settings: BookSettingOption[] = [
+ {
+ id: 'basic-information',
+ name: 'bookSetting.basicInformation',
+ icon: faPencilAlt
+ },
+ {
+ id: 'guide-line',
+ name: 'bookSetting.guideLine',
+ icon: faListAlt
+ },
+ {
+ id: 'story',
+ name: 'bookSetting.story',
+ icon: faBook
+ },
+ {
+ id: 'world',
+ name: 'bookSetting.world',
+ icon: faGlobe
+ },
+ {
+ id: 'locations',
+ name: 'bookSetting.locations',
+ icon: faMapMarkedAlt
+ },
+ {
+ id: 'characters',
+ name: 'bookSetting.characters',
+ icon: faUser
+ },
+ // {
+ // id: 'objects',
+ // name: t('bookSetting.objects'),
+ // icon: faLocationArrow
+ // },
+ // {
+ // id: 'goals',
+ // name: t('bookSetting.goals'),
+ // icon: faCogs
+ // },
+ ]
+
+ return (
+
+
+ {
+ settings.map((setting: BookSettingOption) => (
+ setSelectedSetting(setting.id)}
+ className={`flex items-center text-base rounded-xl transition-all duration-200 ${
+ selectedSetting === setting.id
+ ? 'bg-primary/20 text-text-primary border-l-4 border-primary font-semibold shadow-md scale-105'
+ : 'text-text-secondary hover:bg-secondary/50 hover:text-text-primary hover:scale-102'
+ } p-3 mb-1`}>
+
+ {t(setting.name)}
+
+ ))
+ }
+
+
+ )
+}
diff --git a/components/book/settings/DeleteBook.tsx b/components/book/settings/DeleteBook.tsx
new file mode 100644
index 0000000..c0c0cfc
--- /dev/null
+++ b/components/book/settings/DeleteBook.tsx
@@ -0,0 +1,90 @@
+import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
+import {faTrash} from "@fortawesome/free-solid-svg-icons";
+import React, {useContext, useState} from "react";
+import System from "@/lib/models/System";
+import {SessionContext} from "@/context/SessionContext";
+import {BookProps} from "@/lib/models/Book";
+import {LangContext, LangContextProps} from "@/context/LangContext";
+import {AlertContext, AlertContextProps} from "@/context/AlertContext";
+import AlertBox from "@/components/AlertBox";
+
+interface DeleteBookProps {
+ bookId: string;
+}
+
+export default function DeleteBook({bookId}: DeleteBookProps) {
+ const {session, setSession} = useContext(SessionContext);
+ const {lang} = useContext(LangContext)
+ const [showConfirmBox, setShowConfirmBox] = useState(false);
+ const {errorMessage} = useContext(AlertContext)
+
+ function handleConfirmation(): void {
+ setShowConfirmBox(true);
+ }
+
+ async function handleDeleteBook(): Promise {
+ try {
+ const response: boolean = await System.authDeleteToServer(
+ `book/delete`,
+ {
+ id: bookId,
+ },
+ session.accessToken,
+ lang
+ );
+ if (response) {
+ setShowConfirmBox(false);
+ const updatedBooks: BookProps[] = (session.user?.books || []).reduce((acc: BookProps[], book: BookProps): BookProps[] => {
+ if (book.bookId !== bookId) {
+ acc.push({...book});
+ }
+ return acc;
+ }, []);
+ if (!response) {
+ errorMessage("Une erreur est survenue lors de la suppression du livre.");
+ return;
+ }
+ const updatedUser = {
+ ...(JSON.parse(JSON.stringify(session.user))),
+ books: updatedBooks
+ };
+
+ const newSession = {
+ ...JSON.parse(JSON.stringify(session)),
+ user: updatedUser,
+ isConnected: true,
+ accessToken: session.accessToken
+ };
+
+ setSession(newSession);
+
+ setTimeout((): void => {
+ setSession({...newSession});
+ }, 0);
+ }
+ } catch (e: unknown) {
+ if (e instanceof Error) {
+ errorMessage(e.message)
+ } else {
+ errorMessage("Une erreur inconnue est survenue lors de la suppression du livre.");
+ }
+ }
+ }
+
+ return (
+ <>
+
+
+
+ {
+ showConfirmBox && (
+ setShowConfirmBox(false)}
+ confirmText={'Supprimer'} cancelText={'Annuler'}/>
+ )
+ }
+ >
+ )
+}
diff --git a/components/book/settings/characters/CharacterComponent.tsx b/components/book/settings/characters/CharacterComponent.tsx
new file mode 100644
index 0000000..1b03318
--- /dev/null
+++ b/components/book/settings/characters/CharacterComponent.tsx
@@ -0,0 +1,264 @@
+'use client';
+import React, {Dispatch, forwardRef, SetStateAction, useContext, useEffect, useImperativeHandle, useState} from 'react';
+import {Attribute, CharacterProps} from "@/lib/models/Character";
+import {SessionContext} from "@/context/SessionContext";
+import CharacterList from './CharacterList';
+import System from '@/lib/models/System';
+import {AlertContext} from "@/context/AlertContext";
+import {BookContext} from "@/context/BookContext";
+import CharacterDetail from "@/components/book/settings/characters/CharacterDetail";
+import {useTranslations} from "next-intl";
+import {LangContext, LangContextProps} from "@/context/LangContext";
+
+interface CharacterDetailProps {
+ selectedCharacter: CharacterProps | null;
+ setSelectedCharacter: Dispatch>;
+ handleCharacterChange: (key: keyof CharacterProps, value: string) => void;
+ handleAddElement: (section: keyof CharacterProps, element: any) => void;
+ handleRemoveElement: (
+ section: keyof CharacterProps,
+ index: number,
+ attrId: string,
+ ) => void;
+ handleSaveCharacter: () => void;
+}
+
+const initialCharacterState: CharacterProps = {
+ id: null,
+ name: '',
+ lastName: '',
+ category: 'none',
+ title: '',
+ role: '',
+ image: 'https://via.placeholder.com/150',
+ biography: '',
+ history: '',
+ physical: [],
+ psychological: [],
+ relations: [],
+ skills: [],
+ weaknesses: [],
+ strengths: [],
+ goals: [],
+ motivations: [],
+};
+
+export function CharacterComponent(props: any, ref: any) {
+ const t = useTranslations();
+ const {lang} = useContext(LangContext)
+ const {session} = useContext(SessionContext);
+ const {book} = useContext(BookContext);
+ const {errorMessage, successMessage} = useContext(AlertContext);
+ const [characters, setCharacters] = useState([]);
+ const [selectedCharacter, setSelectedCharacter] = useState(null);
+
+ useImperativeHandle(ref, function () {
+ return {
+ handleSave: handleSaveCharacter,
+ };
+ });
+
+ useEffect((): void => {
+ getCharacters().then();
+ }, []);
+
+ async function getCharacters(): Promise {
+ try {
+ const response: CharacterProps[] = await System.authGetQueryToServer(`character/list`, session.accessToken, lang, {
+ bookid: book?.bookId,
+ });
+ if (response) {
+ setCharacters(response);
+ }
+ } catch (e: unknown) {
+ if (e instanceof Error) {
+ errorMessage(e.message);
+ } else {
+ errorMessage(t("common.unknownError"));
+ }
+ }
+ }
+
+ function handleCharacterClick(character: CharacterProps): void {
+ setSelectedCharacter({...character});
+ }
+
+ function handleAddCharacter(): void {
+ setSelectedCharacter({...initialCharacterState});
+ }
+
+ async function handleSaveCharacter(): Promise {
+ if (selectedCharacter) {
+ const updatedCharacter: CharacterProps = {...selectedCharacter};
+ if (selectedCharacter.id === null) {
+ await addNewCharacter(updatedCharacter);
+ } else {
+ await updateCharacter(updatedCharacter);
+ }
+ }
+ }
+
+ async function addNewCharacter(updatedCharacter: CharacterProps): Promise {
+ if (!updatedCharacter.name) {
+ errorMessage(t("characterComponent.errorNameRequired"));
+ return;
+ }
+ if (updatedCharacter.category === 'none') {
+ errorMessage(t("characterComponent.errorCategoryRequired"));
+ return;
+ }
+ try {
+ const characterId: string = await System.authPostToServer(`character/add`, {
+ bookId: book?.bookId,
+ character: updatedCharacter,
+ }, session.accessToken, lang);
+ if (!characterId) {
+ errorMessage(t("characterComponent.errorAddCharacter"));
+ return;
+ }
+ updatedCharacter.id = characterId;
+ setCharacters([...characters, updatedCharacter]);
+ setSelectedCharacter(null);
+ } catch (e: unknown) {
+ if (e instanceof Error) {
+ errorMessage(e.message);
+ } else {
+ errorMessage(t("common.unknownError"));
+ }
+ }
+ }
+
+ async function updateCharacter(updatedCharacter: CharacterProps,): Promise {
+ try {
+ const response: boolean = await System.authPostToServer(`character/update`, {
+ character: updatedCharacter,
+ }, session.accessToken, lang);
+ if (!response) {
+ errorMessage(t("characterComponent.errorUpdateCharacter"));
+ return;
+ }
+ setCharacters(
+ characters.map((char: CharacterProps): CharacterProps =>
+ char.id === updatedCharacter.id ? updatedCharacter : char,
+ ),
+ );
+ setSelectedCharacter(null);
+ successMessage(t("characterComponent.successUpdate"));
+ } catch (e: unknown) {
+ if (e instanceof Error) {
+ errorMessage(e.message);
+ } else {
+ errorMessage(t("common.unknownError"));
+ }
+ }
+ }
+
+ function handleCharacterChange(
+ key: keyof CharacterProps,
+ value: string,
+ ): void {
+ if (selectedCharacter) {
+ setSelectedCharacter({...selectedCharacter, [key]: value});
+ }
+ }
+
+ async function handleAddElement(
+ section: keyof CharacterProps,
+ value: Attribute,
+ ): Promise {
+ if (selectedCharacter) {
+ if (selectedCharacter.id === null) {
+ const updatedSection: any[] = [
+ ...(selectedCharacter[section] as any[]),
+ value,
+ ];
+ setSelectedCharacter({...selectedCharacter, [section]: updatedSection});
+ } else {
+ try {
+ const attributeId: string = await System.authPostToServer(`character/attribute/add`, {
+ characterId: selectedCharacter.id,
+ type: section,
+ name: value.name,
+ }, session.accessToken, lang);
+ if (!attributeId) {
+ errorMessage(t("characterComponent.errorAddAttribute"));
+ return;
+ }
+ const newValue: Attribute = {
+ name: value.name,
+ id: attributeId,
+ };
+ const updatedSection: Attribute[] = [...(selectedCharacter[section] as Attribute[]), newValue,];
+ setSelectedCharacter({...selectedCharacter, [section]: updatedSection});
+ } catch (e: unknown) {
+ if (e instanceof Error) {
+ errorMessage(e.message);
+ } else {
+ errorMessage(t("common.unknownError"));
+ }
+ }
+ }
+ }
+ }
+
+ async function handleRemoveElement(
+ section: keyof CharacterProps,
+ index: number,
+ attrId: string,
+ ): Promise {
+ if (selectedCharacter) {
+ if (selectedCharacter.id === null) {
+ const updatedSection: Attribute[] = (
+ selectedCharacter[section] as Attribute[]
+ ).filter((_, i: number): boolean => i !== index);
+ setSelectedCharacter({...selectedCharacter, [section]: updatedSection});
+ } else {
+ try {
+ const response: boolean = await System.authDeleteToServer(`character/attribute/delete`, {
+ attributeId: attrId,
+ }, session.accessToken, lang);
+ if (!response) {
+ errorMessage(t("characterComponent.errorRemoveAttribute"));
+ return;
+ }
+ const updatedSection: Attribute[] = (
+ selectedCharacter[section] as Attribute[]
+ ).filter((_, i: number): boolean => i !== index);
+ setSelectedCharacter({
+ ...selectedCharacter,
+ [section]: updatedSection,
+ });
+ } catch (e: unknown) {
+ if (e instanceof Error) {
+ errorMessage(e.message);
+ } else {
+ errorMessage(t("common.unknownError"));
+ }
+ }
+ }
+ }
+ }
+
+ return (
+
+ {selectedCharacter ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+export default forwardRef(CharacterComponent);
\ No newline at end of file
diff --git a/components/book/settings/characters/CharacterDetail.tsx b/components/book/settings/characters/CharacterDetail.tsx
new file mode 100644
index 0000000..55e1d88
--- /dev/null
+++ b/components/book/settings/characters/CharacterDetail.tsx
@@ -0,0 +1,230 @@
+import CollapsableArea from "@/components/CollapsableArea";
+import InputField from "@/components/form/InputField";
+import TexteAreaInput from "@/components/form/TexteAreaInput";
+import TextInput from "@/components/form/TextInput";
+import SelectBox from "@/components/form/SelectBox";
+import {AlertContext} from "@/context/AlertContext";
+import {SessionContext} from "@/context/SessionContext";
+import {
+ CharacterAttribute,
+ characterCategories,
+ CharacterElement,
+ characterElementCategory,
+ CharacterProps,
+ characterTitle
+} from "@/lib/models/Character";
+import System from "@/lib/models/System";
+import {
+ faAddressCard,
+ faArrowLeft,
+ faBook,
+ faLayerGroup,
+ faPlus,
+ faSave,
+ faScroll,
+ faUser
+} from "@fortawesome/free-solid-svg-icons";
+import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
+import {Dispatch, SetStateAction, useContext, useEffect} from "react";
+import CharacterSectionElement from "@/components/book/settings/characters/CharacterSectionElement";
+import {useTranslations} from "next-intl";
+import {LangContext} from "@/context/LangContext";
+
+interface CharacterDetailProps {
+ selectedCharacter: CharacterProps | null;
+ setSelectedCharacter: Dispatch>;
+ handleCharacterChange: (key: keyof CharacterProps, value: string) => void;
+ handleAddElement: (section: keyof CharacterProps, element: any) => void;
+ handleRemoveElement: (
+ section: keyof CharacterProps,
+ index: number,
+ attrId: string,
+ ) => void;
+ handleSaveCharacter: () => void;
+}
+
+export default function CharacterDetail(
+ {
+ setSelectedCharacter,
+ selectedCharacter,
+ handleCharacterChange,
+ handleRemoveElement,
+ handleAddElement,
+ handleSaveCharacter,
+ }: CharacterDetailProps
+) {
+ const t = useTranslations();
+ const {lang} = useContext(LangContext);
+ const {session} = useContext(SessionContext);
+ const {errorMessage} = useContext(AlertContext);
+
+ useEffect((): void => {
+ if (selectedCharacter?.id !== null) {
+ getAttributes().then();
+ }
+ }, []);
+
+ async function getAttributes(): Promise {
+ try {
+ const response: CharacterAttribute = await System.authGetQueryToServer(`character/attribute`, session.accessToken, lang, {
+ characterId: selectedCharacter?.id,
+ });
+ if (response) {
+ setSelectedCharacter({
+ id: selectedCharacter?.id ?? '',
+ name: selectedCharacter?.name ?? '',
+ image: selectedCharacter?.image ?? '',
+ lastName: selectedCharacter?.lastName ?? '',
+ category: selectedCharacter?.category ?? 'none',
+ title: selectedCharacter?.title ?? '',
+ biography: selectedCharacter?.biography,
+ history: selectedCharacter?.history,
+ role: selectedCharacter?.role ?? '',
+ physical: response.physical ?? [],
+ psychological: response.psychological ?? [],
+ relations: response.relations ?? [],
+ skills: response.skills ?? [],
+ weaknesses: response.weaknesses ?? [],
+ strengths: response.strengths ?? [],
+ goals: response.goals ?? [],
+ motivations: response.motivations ?? [],
+ });
+ }
+ } catch (e: unknown) {
+ if (e instanceof Error) {
+ errorMessage(e.message);
+ } else {
+ errorMessage(t("characterDetail.fetchAttributesError"));
+ }
+ }
+ }
+
+ return (
+
+
+ setSelectedCharacter(null)}
+ className="flex items-center gap-2 bg-secondary/50 py-2 px-4 rounded-xl border border-secondary/50 hover:bg-secondary hover:border-secondary hover:shadow-md hover:scale-105 transition-all duration-200">
+
+ {t("characterDetail.back")}
+
+
+ {selectedCharacter?.name || t("characterDetail.newCharacter")}
+
+
+
+
+
+
+
+
+
+ handleCharacterChange('name', e.target.value)}
+ placeholder={t("characterDetail.namePlaceholder")}
+ />
+ }
+ />
+
+ handleCharacterChange('lastName', e.target.value)}
+ placeholder={t("characterDetail.lastNamePlaceholder")}
+ />
+ }
+ />
+
+ setSelectedCharacter(prev =>
+ prev ? {...prev, category: e.target.value as CharacterProps['category']} : prev
+ )}
+ data={characterCategories}
+ />
+ }
+ icon={faLayerGroup}
+ />
+
+ handleCharacterChange('title', e.target.value)}
+ data={characterTitle}
+ />
+ }
+ icon={faAddressCard}
+ />
+
+
+
+
+
+ handleCharacterChange('biography', e.target.value)}
+ placeholder={t("characterDetail.biographyPlaceholder")}
+ />
+ }
+ icon={faBook}
+ />
+
+ handleCharacterChange('history', e.target.value)}
+ placeholder={t("characterDetail.historyPlaceholder")}
+ />
+ }
+ icon={faScroll}
+ />
+
+ handleCharacterChange('role', e.target.value)}
+ placeholder={t("characterDetail.roleFullPlaceholder")}
+ />
+ }
+ icon={faScroll}
+ />
+
+
+
+ {characterElementCategory.map((item: CharacterElement, index: number) => (
+
+ ))}
+
+
+ );
+}
\ No newline at end of file
diff --git a/components/book/settings/characters/CharacterList.tsx b/components/book/settings/characters/CharacterList.tsx
new file mode 100644
index 0000000..efd9176
--- /dev/null
+++ b/components/book/settings/characters/CharacterList.tsx
@@ -0,0 +1,124 @@
+import {characterCategories, CharacterProps} from "@/lib/models/Character";
+import InputField from "@/components/form/InputField";
+import TextInput from "@/components/form/TextInput";
+import {faChevronRight, faPlus, faUser} from "@fortawesome/free-solid-svg-icons";
+import {SelectBoxProps} from "@/shared/interface";
+import CollapsableArea from "@/components/CollapsableArea";
+import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
+import {useState} from "react";
+import {useTranslations} from "next-intl";
+
+interface CharacterListProps {
+ characters: CharacterProps[];
+ handleCharacterClick: (character: CharacterProps) => void;
+ handleAddCharacter: () => void;
+}
+
+export default function CharacterList(
+ {
+ characters,
+ handleCharacterClick,
+ handleAddCharacter,
+ }: CharacterListProps) {
+ const t = useTranslations();
+ const [searchQuery, setSearchQuery] = useState('');
+
+ function getFilteredCharacters(
+ characters: CharacterProps[],
+ searchQuery: string,
+ ): CharacterProps[] {
+ return characters.filter(
+ (char: CharacterProps) =>
+ char.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ (char.lastName &&
+ char.lastName.toLowerCase().includes(searchQuery.toLowerCase())),
+ );
+ }
+
+ const filteredCharacters: CharacterProps[] = getFilteredCharacters(
+ characters,
+ searchQuery,
+ );
+ return (
+
+
+ setSearchQuery(e.target.value)}
+ placeholder={t("characterList.search")}
+ />
+ }
+ actionIcon={faPlus}
+ actionLabel={t("characterList.add")}
+ addButtonCallBack={async () => handleAddCharacter()}
+ />
+
+
+
+ {characterCategories.map((category: SelectBoxProps) => {
+ const categoryCharacters = filteredCharacters.filter(
+ (char: CharacterProps) => char.category === category.value
+ );
+
+ if (categoryCharacters.length === 0) {
+ return null;
+ }
+
+ return (
+
+ {categoryCharacters.map(char => (
+ handleCharacterClick(char)}
+ className="group flex items-center p-4 bg-secondary/30 rounded-xl border-l-4 border-primary border border-secondary/50 cursor-pointer hover:bg-secondary hover:shadow-md hover:scale-102 transition-all duration-200 hover:border-primary/50"
+ >
+
+ {char.image ? (
+
+ ) : (
+
+ {char.name?.charAt(0)?.toUpperCase() || '?'}
+
+ )}
+
+
+
+
{char.name || t("characterList.unknown")}
+
{char.lastName || t("characterList.noLastName")}
+
+
+
+
{char.title || t("characterList.noTitle")}
+
{char.role || t("characterList.noRole")}
+
+
+
+
+
+
+ ))}
+ }
+ />
+ );
+ })}
+
+
+ )
+}
\ No newline at end of file
diff --git a/components/book/settings/characters/CharacterSectionElement.tsx b/components/book/settings/characters/CharacterSectionElement.tsx
new file mode 100644
index 0000000..202355c
--- /dev/null
+++ b/components/book/settings/characters/CharacterSectionElement.tsx
@@ -0,0 +1,89 @@
+import CollapsableArea from "@/components/CollapsableArea";
+import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
+import {useState} from "react";
+import {faTrash} from "@fortawesome/free-solid-svg-icons";
+import InputField from "@/components/form/InputField";
+import TextInput from "@/components/form/TextInput";
+import {Attribute, CharacterProps} from "@/lib/models/Character";
+import {IconDefinition} from "@fortawesome/fontawesome-svg-core";
+import {useTranslations} from "next-intl";
+
+interface CharacterSectionElementProps {
+ title: string;
+ section: keyof CharacterProps;
+ placeholder: string;
+ icon: IconDefinition;
+ selectedCharacter: CharacterProps;
+ setSelectedCharacter: (character: CharacterProps) => void;
+ handleAddElement: (section: keyof CharacterProps, element: Attribute) => void;
+ handleRemoveElement: (
+ section: keyof CharacterProps,
+ index: number,
+ attrId: string,
+ ) => void;
+}
+
+export default function CharacterSectionElement(
+ {
+ title,
+ section,
+ placeholder,
+ icon,
+ selectedCharacter,
+ setSelectedCharacter,
+ handleAddElement,
+ handleRemoveElement,
+ }: CharacterSectionElementProps) {
+ const t = useTranslations();
+ const [element, setElement] = useState('');
+
+ function handleAddNewElement() {
+ handleAddElement(section, {id: '', name: element});
+ setElement('');
+ }
+
+ return (
+
+
+ {Array.isArray(selectedCharacter?.[section]) &&
+ selectedCharacter?.[section].map((item, index: number) => (
+
+ {
+ const updatedSection = [...(selectedCharacter[section] as any[])];
+ updatedSection[index].name = e.target.value;
+ setSelectedCharacter({
+ ...selectedCharacter,
+ [section]: updatedSection,
+ });
+ }}
+ placeholder={placeholder}
+ />
+ handleRemoveElement(section, index, item.id)}
+ className="bg-error/90 hover:bg-error w-9 h-9 rounded-full flex items-center justify-center mr-2 shadow-md hover:shadow-lg hover:scale-110 transition-all duration-200"
+ >
+
+
+
+ ))}
+
+
+ setElement(e.target.value)}
+ placeholder={t("characterSectionElement.newItem", {item: title.toLowerCase()})}
+ />
+ }
+ addButtonCallBack={async () => handleAddNewElement()}
+ />
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/components/book/settings/goals/page.tsx b/components/book/settings/goals/page.tsx
new file mode 100644
index 0000000..dbe5307
--- /dev/null
+++ b/components/book/settings/goals/page.tsx
@@ -0,0 +1,181 @@
+'use client'
+import React, {useState} from 'react';
+
+interface TimeGoal {
+ desiredReleaseDate: string;
+ maxReleaseDate: string;
+}
+
+interface NumbersGoal {
+ minWordsCount: number;
+ maxWordsCount: number;
+ desiredWordsCountByChapter: number;
+ desiredChapterCount: number;
+}
+
+interface Goal {
+ id: number;
+ name: string;
+ timeGoal: TimeGoal;
+ numbersGoal: NumbersGoal;
+}
+
+export default function GoalsPage() {
+ const [goals, setGoals] = useState([
+ {
+ id: 1,
+ name: 'First Goal',
+ timeGoal: {
+ desiredReleaseDate: '',
+ maxReleaseDate: '',
+ },
+ numbersGoal: {
+ minWordsCount: 0,
+ maxWordsCount: 0,
+ desiredWordsCountByChapter: 0,
+ desiredChapterCount: 0,
+ },
+ },
+ ]);
+
+ const [selectedGoalIndex, setSelectedGoalIndex] = useState(0);
+ const [newGoalName, setNewGoalName] = useState('');
+
+ const handleAddGoal = () => {
+ const newGoal: Goal = {
+ id: goals.length + 1,
+ name: newGoalName,
+ timeGoal: {
+ desiredReleaseDate: '',
+ maxReleaseDate: '',
+ },
+ numbersGoal: {
+ minWordsCount: 0,
+ maxWordsCount: 0,
+ desiredWordsCountByChapter: 0,
+ desiredChapterCount: 0,
+ },
+ };
+ setGoals([...goals, newGoal]);
+ setNewGoalName('');
+ };
+
+ const handleInputChange = (e: React.ChangeEvent, field: keyof Goal, subField?: keyof TimeGoal | keyof NumbersGoal) => {
+ const updatedGoals = [...goals];
+ if (subField) {
+ if (field === 'timeGoal' && subField in updatedGoals[selectedGoalIndex].timeGoal) {
+ (updatedGoals[selectedGoalIndex].timeGoal[subField as keyof TimeGoal] as string) = e.target.value;
+ } else if (field === 'numbersGoal' && subField in updatedGoals[selectedGoalIndex].numbersGoal) {
+ (updatedGoals[selectedGoalIndex].numbersGoal[subField as keyof NumbersGoal] as number) = Number(e.target.value);
+ }
+ } else {
+ (updatedGoals[selectedGoalIndex][field] as string) = e.target.value;
+ }
+ setGoals(updatedGoals);
+ };
+
+ return (
+
+
+ Goals
+
+
+ setSelectedGoalIndex(parseInt(e.target.value))}
+ >
+ {goals.map((goal, index) => (
+ {goal.name}
+ ))}
+
+ setNewGoalName(e.target.value)}
+ className="w-full px-4 py-2.5 rounded-xl bg-secondary/50 text-text-primary border border-secondary/50 outline-none hover:bg-secondary hover:border-secondary focus:border-primary focus:ring-4 focus:ring-primary/20 transition-all duration-200"
+ placeholder="New Goal Name"
+ />
+
+ Add Goal
+
+
+
+
+ {goals[selectedGoalIndex].name}
+
+
Time Goal
+ Desired
+ Release Date
+ handleInputChange(e, 'timeGoal', 'desiredReleaseDate')}
+ className="w-full p-2 rounded-lg bg-gray-800 text-white border-none outline-none"
+ />
+ Max Release Date
+ handleInputChange(e, 'timeGoal', 'maxReleaseDate')}
+ className="w-full p-2 rounded-lg bg-gray-800 text-white border-none outline-none"
+ />
+
+
+
+
Numbers Goal
+ Min Words
+ Count
+ handleInputChange(e, 'numbersGoal', 'minWordsCount')}
+ className="w-full p-2 rounded-lg bg-gray-800 text-white border-none outline-none"
+ />
+ Max Words Count
+ handleInputChange(e, 'numbersGoal', 'maxWordsCount')}
+ className="w-full p-2 rounded-lg bg-gray-800 text-white border-none outline-none"
+ />
+ Desired Words
+ Count by Chapter
+ handleInputChange(e, 'numbersGoal', 'desiredWordsCountByChapter')}
+ className="w-full p-2 rounded-lg bg-gray-800 text-white border-none outline-none"
+ />
+ Desired Chapter
+ Count
+ handleInputChange(e, 'numbersGoal', 'desiredChapterCount')}
+ className="w-full p-2 rounded-lg bg-gray-800 text-white border-none outline-none"
+ />
+
+
+
+
+ Update
+
+
+
+
+ );
+}
diff --git a/components/book/settings/guide-line/GuideLineSetting.tsx b/components/book/settings/guide-line/GuideLineSetting.tsx
new file mode 100644
index 0000000..a001a68
--- /dev/null
+++ b/components/book/settings/guide-line/GuideLineSetting.tsx
@@ -0,0 +1,421 @@
+'use client'
+import React, {ChangeEvent, forwardRef, useContext, useEffect, useImperativeHandle, useState} from 'react';
+import System from '@/lib/models/System';
+import {AlertContext} from "@/context/AlertContext";
+import {BookContext} from '@/context/BookContext';
+import {SessionContext} from "@/context/SessionContext";
+import {GuideLine, GuideLineAI} from "@/lib/models/Book";
+import TexteAreaInput from "@/components/form/TexteAreaInput";
+import InputField from "@/components/form/InputField";
+import TextInput from "@/components/form/TextInput";
+import SelectBox from "@/components/form/SelectBox";
+import {
+ advancedDialogueTypes,
+ advancedNarrativePersons,
+ beginnerDialogueTypes,
+ beginnerNarrativePersons,
+ intermediateDialogueTypes,
+ intermediateNarrativePersons,
+ langues,
+ verbalTime
+} from "@/lib/models/Story";
+import {useTranslations} from "next-intl";
+import {LangContext} from "@/context/LangContext";
+
+function GuideLineSetting(props: any, ref: any) {
+ const t = useTranslations();
+ const {lang} = useContext(LangContext);
+ const {book} = useContext(BookContext);
+ const {session} = useContext(SessionContext);
+ const userToken: string = session?.accessToken ? session?.accessToken : '';
+ const {errorMessage, successMessage} = useContext(AlertContext);
+ const bookId = book?.bookId as string;
+ const [activeTab, setActiveTab] = useState('personal');
+ const authorLevel: string = session.user?.writingLevel?.toString() ?? '1';
+
+ const [tone, setTone] = useState('');
+ const [atmosphere, setAtmosphere] = useState('');
+ const [writingStyle, setWritingStyle] = useState('');
+ const [themes, setThemes] = useState('');
+ const [symbolism, setSymbolism] = useState('');
+ const [motifs, setMotifs] = useState('');
+ const [narrativeVoice, setNarrativeVoice] = useState('');
+ const [pacing, setPacing] = useState('');
+ const [intendedAudience, setIntendedAudience] = useState('');
+ const [keyMessages, setKeyMessages] = useState('');
+
+ const [plotSummary, setPlotSummary] = useState('');
+ const [narrativeType, setNarrativeType] = useState('');
+ const [verbTense, setVerbTense] = useState('');
+ const [dialogueType, setDialogueType] = useState('');
+ const [toneAtmosphere, setToneAtmosphere] = useState('');
+ const [language, setLanguage] = useState('');
+
+ useEffect((): void => {
+ if (activeTab === 'personal') {
+ getGuideLine().then();
+ } else {
+ getAIGuideLine().then();
+ }
+ }, [activeTab]);
+
+ useImperativeHandle(ref, () => {
+ {
+ if (activeTab === 'personal') {
+ return {
+ handleSave: savePersonal
+ };
+ } else {
+ return {
+ handleSave: saveQuillSense
+ };
+ }
+ }
+ });
+
+ async function getAIGuideLine(): Promise {
+ try {
+ const response: GuideLineAI = await System.authGetQueryToServer(`book/ai/guideline`, userToken, lang, {id: bookId});
+ if (response) {
+ setPlotSummary(response.globalResume);
+ setVerbTense(response.verbeTense?.toString() || '');
+ setNarrativeType(response.narrativeType?.toString() || '');
+ setDialogueType(response.dialogueType?.toString() || '');
+ setToneAtmosphere(response.atmosphere);
+ setLanguage(response.langue?.toString() || '');
+ setThemes(response.themes);
+ }
+ } catch (e: unknown) {
+ if (e instanceof Error) {
+ errorMessage(e.message);
+ } else {
+ errorMessage(t("guideLineSetting.errorUnknown"));
+ }
+ }
+ }
+
+ async function getGuideLine(): Promise {
+ try {
+ const response: GuideLine =
+ await System.authGetQueryToServer(
+ `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 {
+ try {
+ const response: boolean =
+ await System.authPostToServer(
+ '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"));
+ }
+ }
+ }
+
+ async function saveQuillSense(): Promise {
+ try {
+ const response: boolean = await System.authPostToServer(
+ 'quillsense/book/guide-line',
+ {
+ bookId: bookId,
+ plotSummary: plotSummary,
+ verbTense: verbTense,
+ narrativeType: narrativeType,
+ dialogueType: dialogueType,
+ toneAtmosphere: toneAtmosphere,
+ language: language,
+ themes: themes,
+ },
+ userToken,
+ lang,
+ );
+ if (response) {
+ successMessage(t("guideLineSetting.saveSuccess"));
+ } else {
+ errorMessage(t("guideLineSetting.saveError"));
+ }
+ } catch (e: unknown) {
+ if (e instanceof Error) {
+ errorMessage(e.message);
+ } else {
+ errorMessage(t("guideLineSetting.errorUnknown"));
+ }
+ }
+ }
+
+ return (
+
+
+ setActiveTab('personal')}
+ >
+ {t("guideLineSetting.personal")}
+
+ setActiveTab('quillsense')}
+ >
+ {t("guideLineSetting.quillsense")}
+
+
+
+ {activeTab === 'personal' && (
+
+
+ ) => setTone(e.target.value)}
+ placeholder={t("guideLineSetting.tonePlaceholder")}
+ />
+ }/>
+
+
+
+ ) => setAtmosphere(e.target.value)}
+ placeholder={t("guideLineSetting.atmospherePlaceholder")}
+ />
+ }/>
+
+
+
+ ): void => setWritingStyle(e.target.value)}
+ placeholder={t("guideLineSetting.writingStylePlaceholder")}
+ />
+ }/>
+
+
+
+ ): void => setThemes(e.target.value)}
+ placeholder={t("guideLineSetting.themesPlaceholder")}
+ />
+ }/>
+
+
+
+ ): void => setSymbolism(e.target.value)}
+ placeholder={t("guideLineSetting.symbolismPlaceholder")}
+ />
+ }/>
+
+
+
+ ): void => setMotifs(e.target.value)}
+ placeholder={t("guideLineSetting.motifsPlaceholder")}
+ />
+ }/>
+
+
+
+ ): void => setNarrativeVoice(e.target.value)}
+ placeholder={t("guideLineSetting.narrativeVoicePlaceholder")}
+ />
+ }/>
+
+
+
+ ): void => setPacing(e.target.value)}
+ placeholder={t("guideLineSetting.pacingPlaceholder")}
+ />
+ }/>
+
+
+
+ ): void => setIntendedAudience(e.target.value)}
+ placeholder={t("guideLineSetting.intendedAudiencePlaceholder")}
+ />
+ }/>
+
+
+
+ ): void => setKeyMessages(e.target.value)}
+ placeholder={t("guideLineSetting.keyMessagesPlaceholder")}
+ />
+ }/>
+
+
+ )}
+
+ {activeTab === 'quillsense' && (
+
+
+ ): void => setPlotSummary(e.target.value)}
+ placeholder={t("guideLineSetting.plotSummaryPlaceholder")}
+ />
+ }/>
+
+
+
+ ): void => setToneAtmosphere(e.target.value)}
+ placeholder={t("guideLineSetting.toneAtmospherePlaceholder")}
+ />
+ }/>
+
+
+
+ ) => setThemes(e.target.value)}
+ placeholder={t("guideLineSetting.themesPlaceholderQuill")}
+ />
+ }/>
+
+
+ ): void => setVerbTense(event.target.value)}
+ data={verbalTime}
+ placeholder={t("guideLineSetting.verbTensePlaceholder")}
+ />
+ }/>
+
+
+ ): void => {
+ setNarrativeType(event.target.value)
+ }} placeholder={t("guideLineSetting.narrativeTypePlaceholder")}/>
+ }/>
+
+
+
+ ) => {
+ setDialogueType(event.target.value)
+ }} placeholder={t("guideLineSetting.dialogueTypePlaceholder")}/>
+ }/>
+
+
+
+ ) => {
+ setLanguage(event.target.value)
+ }} placeholder={t("guideLineSetting.languagePlaceholder")}/>
+ }/>
+
+
+ )}
+
+ );
+}
+
+export default forwardRef(GuideLineSetting);
\ No newline at end of file
diff --git a/components/book/settings/locations/LocationComponent.tsx b/components/book/settings/locations/LocationComponent.tsx
new file mode 100644
index 0000000..11327b4
--- /dev/null
+++ b/components/book/settings/locations/LocationComponent.tsx
@@ -0,0 +1,444 @@
+'use client'
+import {faMapMarkerAlt, faPlus, faTrash} from '@fortawesome/free-solid-svg-icons';
+import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
+import React, {ChangeEvent, forwardRef, useContext, useEffect, useImperativeHandle, useState} from 'react';
+import {SessionContext} from "@/context/SessionContext";
+import {AlertContext} from "@/context/AlertContext";
+import {BookContext} from "@/context/BookContext";
+import System from '@/lib/models/System';
+import InputField from "@/components/form/InputField";
+import TextInput from '@/components/form/TextInput';
+import TexteAreaInput from "@/components/form/TexteAreaInput";
+import {useTranslations} from "next-intl";
+import {LangContext, LangContextProps} from "@/context/LangContext";
+
+interface SubElement {
+ id: string;
+ name: string;
+ description: string;
+}
+
+interface Element {
+ id: string;
+ name: string;
+ description: string;
+ subElements: SubElement[];
+}
+
+interface LocationProps {
+ id: string;
+ name: string;
+ elements: Element[];
+}
+
+export function LocationComponent(props: any, ref: any) {
+ const t = useTranslations();
+ const {lang} = useContext(LangContext);
+ const {session} = useContext(SessionContext);
+ const {successMessage, errorMessage} = useContext(AlertContext);
+ const {book} = useContext(BookContext);
+
+ const bookId: string | undefined = book?.bookId;
+ const token: string = session.accessToken;
+
+ const [sections, setSections] = useState([]);
+ const [newSectionName, setNewSectionName] = useState('');
+ const [newElementNames, setNewElementNames] = useState<{ [key: string]: string }>({});
+ const [newSubElementNames, setNewSubElementNames] = useState<{ [key: string]: string }>({});
+
+ useImperativeHandle(ref, function () {
+ return {
+ handleSave: handleSave,
+ };
+ });
+
+ useEffect((): void => {
+ getAllLocations().then();
+ }, []);
+
+ async function getAllLocations(): Promise {
+ try {
+ const response: LocationProps[] = await System.authGetQueryToServer(`location/all`, token, lang, {
+ bookid: bookId,
+ });
+ if (response && response.length > 0) {
+ setSections(response);
+ }
+ } catch (e: unknown) {
+ if (e instanceof Error) {
+ errorMessage(e.message);
+ } else {
+ errorMessage(t('locationComponent.errorUnknownFetchLocations'));
+ }
+ }
+ }
+
+ async function handleAddSection(): Promise {
+ if (!newSectionName.trim()) {
+ errorMessage(t('locationComponent.errorSectionNameEmpty'))
+ return
+ }
+ try {
+ const sectionId: string = await System.authPostToServer(`location/section/add`, {
+ bookId: bookId,
+ locationName: newSectionName,
+ }, token, lang);
+ if (!sectionId) {
+ errorMessage(t('locationComponent.errorUnknownAddSection'));
+ return;
+ }
+ const newLocation: LocationProps = {
+ id: sectionId,
+ name: newSectionName,
+ elements: [],
+ };
+ setSections([...sections, newLocation]);
+ setNewSectionName('');
+ } catch (e: unknown) {
+ if (e instanceof Error) {
+ errorMessage(e.message);
+ } else {
+ errorMessage(t('locationComponent.errorUnknownAddSection'));
+ }
+ }
+ }
+
+ async function handleAddElement(sectionId: string): Promise {
+ if (!newElementNames[sectionId]?.trim()) {
+ errorMessage(t('locationComponent.errorElementNameEmpty'))
+ return
+ }
+ try {
+ const elementId: string = await System.authPostToServer(`location/element/add`, {
+ bookId: bookId,
+ locationId: sectionId,
+ elementName: newElementNames[sectionId],
+ },
+ token, lang);
+ if (!elementId) {
+ errorMessage(t('locationComponent.errorUnknownAddElement'));
+ return;
+ }
+ const updatedSections: LocationProps[] = [...sections];
+ const sectionIndex: number = updatedSections.findIndex(
+ (section: LocationProps): boolean => section.id === sectionId,
+ );
+ updatedSections[sectionIndex].elements.push({
+ id: elementId,
+ name: newElementNames[sectionId],
+ description: '',
+ subElements: [],
+ });
+ setSections(updatedSections);
+ setNewElementNames({...newElementNames, [sectionId]: ''});
+ } catch (e: unknown) {
+ if (e instanceof Error) {
+ errorMessage(e.message);
+ } else {
+ errorMessage(t('locationComponent.errorUnknownAddElement'));
+ }
+ }
+ }
+
+ function handleElementChange(
+ sectionId: string,
+ elementIndex: number,
+ field: keyof Element,
+ value: string,
+ ): void {
+ const updatedSections: LocationProps[] = [...sections];
+ const sectionIndex: number = updatedSections.findIndex(
+ (section: LocationProps): boolean => section.id === sectionId,
+ );
+ // @ts-ignore
+ updatedSections[sectionIndex].elements[elementIndex][field] = value;
+ setSections(updatedSections);
+ }
+
+ async function handleAddSubElement(
+ sectionId: string,
+ elementIndex: number,
+ ): Promise {
+ if (!newSubElementNames[elementIndex]?.trim()) {
+ errorMessage(t('locationComponent.errorSubElementNameEmpty'))
+ return
+ }
+ const sectionIndex: number = sections.findIndex(
+ (section: LocationProps): boolean => section.id === sectionId,
+ );
+ try {
+ const subElementId: string = await System.authPostToServer(`location/sub-element/add`, {
+ elementId: sections[sectionIndex].elements[elementIndex].id,
+ subElementName: newSubElementNames[elementIndex],
+ }, token, lang);
+ if (!subElementId) {
+ errorMessage(t('locationComponent.errorUnknownAddSubElement'));
+ return;
+ }
+ const updatedSections: LocationProps[] = [...sections];
+ updatedSections[sectionIndex].elements[elementIndex].subElements.push({
+ id: subElementId,
+ name: newSubElementNames[elementIndex],
+ description: '',
+ });
+ setSections(updatedSections);
+ setNewSubElementNames({...newSubElementNames, [elementIndex]: ''});
+ } catch (e: unknown) {
+ if (e instanceof Error) {
+ errorMessage(e.message);
+ } else {
+ errorMessage(t('locationComponent.errorUnknownAddSubElement'));
+ }
+ }
+ }
+
+ function handleSubElementChange(
+ sectionId: string,
+ elementIndex: number,
+ subElementIndex: number,
+ field: keyof SubElement,
+ value: string,
+ ): void {
+ const updatedSections: LocationProps[] = [...sections];
+ const sectionIndex: number = updatedSections.findIndex(
+ (section: LocationProps): boolean => section.id === sectionId,
+ );
+ updatedSections[sectionIndex].elements[elementIndex].subElements[
+ subElementIndex
+ ][field] = value;
+ setSections(updatedSections);
+ }
+
+ async function handleRemoveElement(
+ sectionId: string,
+ elementIndex: number,
+ ): Promise {
+ try {
+ const response: boolean = await System.authDeleteToServer(`location/element/delete`, {
+ elementId: sections.find((section: LocationProps): boolean => section.id === sectionId)
+ ?.elements[elementIndex].id,
+ }, token, lang);
+ if (!response) {
+ errorMessage(t('locationComponent.errorUnknownDeleteElement'));
+ return;
+ }
+ const updatedSections: LocationProps[] = [...sections];
+ const sectionIndex: number = updatedSections.findIndex((section: LocationProps): boolean => section.id === sectionId,);
+ updatedSections[sectionIndex].elements.splice(elementIndex, 1);
+ setSections(updatedSections);
+ } catch (e: unknown) {
+ if (e instanceof Error) {
+ errorMessage(e.message);
+ } else {
+ errorMessage(t('locationComponent.errorUnknownDeleteElement'));
+ }
+ }
+ }
+
+ async function handleRemoveSubElement(
+ sectionId: string,
+ elementIndex: number,
+ subElementIndex: number,
+ ): Promise {
+ try {
+ const response: boolean = await System.authDeleteToServer(`location/sub-element/delete`, {
+ subElementId: sections.find((section: LocationProps): boolean => section.id === sectionId)?.elements[elementIndex].subElements[subElementIndex].id,
+ }, token, lang);
+ if (!response) {
+ errorMessage(t('locationComponent.errorUnknownDeleteSubElement'));
+ return;
+ }
+ const updatedSections: LocationProps[] = [...sections];
+ const sectionIndex: number = updatedSections.findIndex((section: LocationProps): boolean => section.id === sectionId,);
+ updatedSections[sectionIndex].elements[elementIndex].subElements.splice(subElementIndex, 1,);
+ setSections(updatedSections);
+ } catch (e: unknown) {
+ if (e instanceof Error) {
+ errorMessage(e.message);
+ } else {
+ errorMessage(t('locationComponent.errorUnknownDeleteSubElement'));
+ }
+ }
+ }
+
+ async function handleRemoveSection(sectionId: string): Promise {
+ try {
+ const response: boolean = await System.authDeleteToServer(`location/delete`, {
+ locationId: sectionId,
+ }, token, lang);
+ if (!response) {
+ errorMessage(t('locationComponent.errorUnknownDeleteSection'));
+ return;
+ }
+ const updatedSections: LocationProps[] = sections.filter((section: LocationProps): boolean => section.id !== sectionId,);
+ setSections(updatedSections);
+ } catch (e: unknown) {
+ if (e instanceof Error) {
+ errorMessage(e.message);
+ } else {
+ errorMessage(t('locationComponent.errorUnknownDeleteSection'));
+ }
+ }
+ }
+
+ async function handleSave(): Promise {
+ try {
+ const response: boolean = await System.authPostToServer(`location/update`, {
+ locations: sections,
+ }, token, lang);
+ if (!response) {
+ errorMessage(t('locationComponent.errorUnknownSave'));
+ return;
+ }
+ successMessage(t('locationComponent.successSave'));
+ } catch (e: unknown) {
+ if (e instanceof Error) {
+ errorMessage(e.message);
+ } else {
+ errorMessage(t('locationComponent.errorUnknownSave'));
+ }
+ }
+ }
+
+ return (
+
+
+
+ ) => setNewSectionName(e.target.value)}
+ placeholder={t("locationComponent.newSectionPlaceholder")}
+ />
+ }
+ actionIcon={faPlus}
+ actionLabel={t("locationComponent.addSectionLabel")}
+ addButtonCallBack={handleAddSection}
+ />
+
+
+
+ {sections.length > 0 ? (
+ sections.map((section: LocationProps) => (
+
+
+
+ {section.name}
+
+ {section.elements.length || 0}
+
+ => handleRemoveSection(section.id)}
+ className="ml-auto bg-dark-background text-text-primary rounded-full p-1.5 hover:bg-secondary transition-colors shadow-md">
+
+
+
+
+ {section.elements.length > 0 ? (
+ section.elements.map((element, elementIndex) => (
+
+
+ ) =>
+ handleElementChange(section.id, elementIndex, 'name', e.target.value)
+ }
+ placeholder={t("locationComponent.elementNamePlaceholder")}
+ />
+ }
+ removeButtonCallBack={(): Promise => handleRemoveElement(section.id, elementIndex)}
+ />
+
+
): void => handleElementChange(section.id, elementIndex, 'description', e.target.value)}
+ placeholder={t("locationComponent.elementDescriptionPlaceholder")}
+ />
+
+
+ {element.subElements.length > 0 && (
+
{t("locationComponent.subElementsHeading")}
+ )}
+
+ {element.subElements.map((subElement: SubElement, subElementIndex: number) => (
+
+
+ ): void =>
+ handleSubElementChange(section.id, elementIndex, subElementIndex, 'name', e.target.value)
+ }
+ placeholder={t("locationComponent.subElementNamePlaceholder")}
+ />
+ }
+ removeButtonCallBack={(): Promise => handleRemoveSubElement(section.id, elementIndex, subElementIndex)}
+ />
+
+
+ handleSubElementChange(section.id, elementIndex, subElementIndex, 'description', e.target.value)
+ }
+ placeholder={t("locationComponent.subElementDescriptionPlaceholder")}
+ />
+
+ ))}
+
+
) =>
+ setNewSubElementNames({
+ ...newSubElementNames,
+ [elementIndex]: e.target.value
+ })
+ }
+ placeholder={t("locationComponent.newSubElementPlaceholder")}
+ />
+ }
+ addButtonCallBack={(): Promise => handleAddSubElement(section.id, elementIndex)}
+ />
+
+
+ ))
+ ) : (
+
+ {t("locationComponent.noElementAvailable")}
+
+ )}
+
+
) =>
+ setNewElementNames({...newElementNames, [section.id]: e.target.value})
+ }
+ placeholder={t("locationComponent.newElementPlaceholder")}
+ />
+ }
+ addButtonCallBack={(): Promise => handleAddElement(section.id)}
+ />
+
+
+ ))
+ ) : (
+
+
{t("locationComponent.noSectionAvailable")}
+
+ )}
+
+ );
+}
+
+export default forwardRef(LocationComponent);
\ No newline at end of file
diff --git a/components/book/settings/objects/page.tsx b/components/book/settings/objects/page.tsx
new file mode 100644
index 0000000..b998e75
--- /dev/null
+++ b/components/book/settings/objects/page.tsx
@@ -0,0 +1,327 @@
+'use client';
+import React, {useState} from 'react';
+import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
+import {faArrowLeft} from '@fortawesome/free-solid-svg-icons';
+
+interface RelatedItem {
+ name: string;
+ type: string;
+ description: string;
+ history: string;
+}
+
+interface Item {
+ id: number | null;
+ name: string;
+ description: string;
+ history: string;
+ location: string;
+ ownedBy: string;
+ functionality: string;
+ image: string;
+ relatedItems: RelatedItem[];
+}
+
+const initialItemState: Item = {
+ id: null,
+ name: '',
+ description: '',
+ history: '',
+ location: '',
+ ownedBy: '',
+ functionality: '',
+ image: '',
+ relatedItems: [],
+};
+
+export default function Items() {
+ const [items, setItems] = useState- ([
+ {
+ id: 1,
+ name: 'Sword of Destiny',
+ description: 'A powerful sword',
+ history: 'Forged in the ancient times...',
+ location: 'Castle',
+ ownedBy: 'John Doe',
+ functionality: 'Cuts through anything',
+ image: 'https://via.placeholder.com/150',
+ relatedItems: []
+ },
+ {
+ id: 2,
+ name: 'Shield of Valor',
+ description: 'An unbreakable shield',
+ history: 'Used by the legendary hero...',
+ location: 'Fortress',
+ ownedBy: 'Jane Doe',
+ functionality: 'Deflects any attack',
+ image: 'https://via.placeholder.com/150',
+ relatedItems: []
+ }
+ ]);
+
+ const [selectedItem, setSelectedItem] = useState
- (null);
+ const [searchQuery, setSearchQuery] = useState
('');
+ const [newItem, setNewItem] = useState- (initialItemState);
+ const [newRelatedItem, setNewRelatedItem] = useState
({
+ name: '',
+ type: '',
+ description: '',
+ history: ''
+ });
+
+ const filteredItems = items.filter(
+ (item) =>
+ item.name.toLowerCase().includes(searchQuery.toLowerCase())
+ );
+
+ const handleItemClick = (item: Item) => {
+ setSelectedItem(item);
+ };
+
+ const handleAddItem = () => {
+ setSelectedItem(newItem);
+ };
+
+ const handleSaveItem = () => {
+ if (selectedItem) {
+ if (selectedItem.id === null) {
+ setItems([...items, {...selectedItem, id: items.length + 1}]);
+ } else {
+ setItems(items.map((item) => (item.id === selectedItem.id ? selectedItem : item)));
+ }
+ setSelectedItem(null);
+ setNewItem(initialItemState);
+ }
+ };
+
+ const handleItemChange = (key: keyof Item, value: string) => {
+ if (selectedItem) {
+ setSelectedItem({...selectedItem, [key]: value});
+ }
+ };
+
+ const handleElementChange = (section: keyof Item, index: number, key: keyof RelatedItem, value: string) => {
+ if (selectedItem) {
+ const updatedSection = [...(selectedItem[section] as RelatedItem[])];
+ updatedSection[index][key] = value;
+ setSelectedItem({...selectedItem, [section]: updatedSection});
+ }
+ };
+
+ const handleAddElement = (section: keyof Item, value: RelatedItem) => {
+ if (selectedItem) {
+ const updatedSection = [...(selectedItem[section] as RelatedItem[]), value];
+ setSelectedItem({...selectedItem, [section]: updatedSection});
+ }
+ };
+
+ const handleRemoveElement = (section: keyof Item, index: number) => {
+ if (selectedItem) {
+ const updatedSection = (selectedItem[section] as RelatedItem[]).filter((_, i) => i !== index);
+ setSelectedItem({...selectedItem, [section]: updatedSection});
+ }
+ };
+
+ return (
+
+ {selectedItem ? (
+
+
+ setSelectedItem(null)}
+ className="flex items-center gap-2 text-text-primary bg-secondary/50 hover:bg-secondary px-4 py-2 rounded-xl transition-all duration-200 hover:scale-105 shadow-md">
+ Back
+
+
{selectedItem.name}
+
+ Save Item
+
+
+
+
+ Name
+ handleItemChange('name', e.target.value)}
+ className="w-full px-4 py-2.5 rounded-xl bg-secondary/50 text-text-primary border border-secondary/50 outline-none hover:bg-secondary hover:border-secondary focus:border-primary focus:ring-4 focus:ring-primary/20 transition-all duration-200"
+ />
+
+
+ Description
+
+
+
+ History
+
+
+
+ Location
+ handleItemChange('location', e.target.value)}
+ className="w-full px-4 py-2.5 rounded-xl bg-secondary/50 text-text-primary border border-secondary/50 outline-none hover:bg-secondary hover:border-secondary focus:border-primary focus:ring-4 focus:ring-primary/20 transition-all duration-200"
+ >
+ Select Location
+ Castle
+ Fortress
+
+
+
+ Owned By
+ handleItemChange('ownedBy', e.target.value)}
+ className="w-full px-4 py-2.5 rounded-xl bg-secondary/50 text-text-primary border border-secondary/50 outline-none hover:bg-secondary hover:border-secondary focus:border-primary focus:ring-4 focus:ring-primary/20 transition-all duration-200"
+ >
+ Select Owner
+ {items.map((item) => (
+ {item.name}
+ ))}
+
+
+
+ Functionality
+
+
+
+ Image URL
+ handleItemChange('image', e.target.value)}
+ className="w-full px-4 py-2.5 rounded-xl bg-secondary/50 text-text-primary border border-secondary/50 outline-none hover:bg-secondary hover:border-secondary focus:border-primary focus:ring-4 focus:ring-primary/20 transition-all duration-200"
+ />
+
+
+
+
Related Items
+
+ {selectedItem.relatedItems.map((relatedItem, index) => (
+
+ {relatedItem.name}
+
+ Description
+
+ History
+
+ handleRemoveElement('relatedItems', index)}
+ className="bg-error/90 hover:bg-error text-text-primary font-semibold py-2 px-4 rounded-xl shadow-md hover:shadow-lg hover:scale-105 transition-all duration-200 mt-2"
+ >
+ Remove
+
+
+
+ ))}
+
+ setNewRelatedItem({...newRelatedItem, name: e.target.value})}
+ value={newRelatedItem.name}
+ >
+ Select Related Item
+ {items.map((item) => (
+ {item.name}
+ ))}
+
+ setNewRelatedItem({...newRelatedItem, type: e.target.value})}
+ value={newRelatedItem.type}
+ >
+ Relation Type
+ Related
+ Similar
+ {/* Add more relation types as needed */}
+
+ {
+ handleAddElement('relatedItems', {...newRelatedItem});
+ setNewRelatedItem({name: '', type: '', description: '', history: ''});
+ }}
+ className="bg-primary hover:bg-primary-dark text-text-primary font-semibold py-2.5 px-5 rounded-xl shadow-md hover:shadow-lg hover:scale-105 transition-all duration-200"
+ >
+ Add Related Item
+
+
+
+
+
+ ) : (
+
+
+ setSearchQuery(e.target.value)}
+ className="w-full px-4 py-2.5 rounded-xl bg-secondary/50 text-text-primary border border-secondary/50 outline-none hover:bg-secondary hover:border-secondary focus:border-primary focus:ring-4 focus:ring-primary/20 transition-all duration-200"
+ placeholder="Search Items"
+ />
+
+ Add New Item
+
+
+
+
Items
+
+ {filteredItems.map((item) => (
+
handleItemClick(item)}
+ className="cursor-pointer bg-tertiary/90 backdrop-blur-sm p-4 rounded-xl shadow-lg hover:shadow-xl hover:scale-105 transition-all duration-200 border border-secondary/50">
+
+
{item.name}
+
{item.description}
+
+ ))}
+
+
+
+ )}
+
+ );
+}
diff --git a/components/book/settings/story/Act.tsx b/components/book/settings/story/Act.tsx
new file mode 100644
index 0000000..ef87b63
--- /dev/null
+++ b/components/book/settings/story/Act.tsx
@@ -0,0 +1,608 @@
+import React, {Dispatch, SetStateAction, useContext, useState} from 'react';
+import {
+ faFire,
+ faFlag,
+ faPuzzlePiece,
+ faScaleBalanced,
+ faTrophy,
+ IconDefinition,
+} from '@fortawesome/free-solid-svg-icons';
+import {Act as ActType, Incident, PlotPoint} from '@/lib/models/Book';
+import {ActChapter, ChapterListProps} from '@/lib/models/Chapter';
+import System from '@/lib/models/System';
+import {BookContext} from '@/context/BookContext';
+import {SessionContext} from '@/context/SessionContext';
+import {AlertContext} from '@/context/AlertContext';
+import CollapsableArea from '@/components/CollapsableArea';
+import ActDescription from '@/components/book/settings/story/act/ActDescription';
+import ActChaptersSection from '@/components/book/settings/story/act/ActChaptersSection';
+import ActIncidents from '@/components/book/settings/story/act/ActIncidents';
+import ActPlotPoints from '@/components/book/settings/story/act/ActPlotPoints';
+import {useTranslations} from 'next-intl';
+import {LangContext, LangContextProps} from "@/context/LangContext";
+
+interface ActProps {
+ acts: ActType[];
+ setActs: Dispatch>;
+ mainChapters: ChapterListProps[];
+}
+
+export default function Act({acts, setActs, mainChapters}: ActProps) {
+ const t = useTranslations('actComponent');
+ const {lang} = useContext(LangContext);
+ const {book} = useContext(BookContext);
+ const {session} = useContext(SessionContext);
+ const {errorMessage, successMessage} = useContext(AlertContext);
+
+ const bookId: string | undefined = book?.bookId;
+ const token: string = session.accessToken;
+
+ const [expandedSections, setExpandedSections] = useState<{
+ [key: string]: boolean;
+ }>({});
+
+ const [newIncidentTitle, setNewIncidentTitle] = useState('');
+ const [newPlotPointTitle, setNewPlotPointTitle] = useState('');
+ const [selectedIncidentId, setSelectedIncidentId] = useState('');
+
+ function toggleSection(sectionKey: string): void {
+ setExpandedSections(prev => ({
+ ...prev,
+ [sectionKey]: !prev[sectionKey],
+ }));
+ }
+
+ function updateActSummary(actId: number, summary: string): void {
+ const updatedActs: ActType[] = acts.map((act: ActType): ActType => {
+ if (act.id === actId) {
+ return {...act, summary};
+ }
+ return act;
+ });
+ setActs(updatedActs);
+ }
+
+ function getIncidents(): Incident[] {
+ const act2: ActType | undefined = acts.find((act: ActType): boolean => act.id === 2);
+ return act2?.incidents || [];
+ }
+
+ async function addIncident(actId: number): Promise {
+ if (newIncidentTitle.trim() === '') return;
+
+ try {
+ const incidentId: string =
+ await System.authPostToServer('book/incident/new', {
+ bookId,
+ name: newIncidentTitle,
+ }, token, lang);
+ if (!incidentId) {
+ errorMessage(t('errorAddIncident'));
+ return;
+ }
+ const updatedActs: ActType[] = acts.map((act: ActType): ActType => {
+ if (act.id === actId) {
+ const newIncident: Incident = {
+ incidentId: incidentId,
+ title: newIncidentTitle,
+ summary: '',
+ chapters: [],
+ };
+
+ return {
+ ...act,
+ incidents: [...(act.incidents || []), newIncident],
+ };
+ }
+ return act;
+ });
+ setActs(updatedActs);
+ setNewIncidentTitle('');
+ } catch (e: unknown) {
+ if (e instanceof Error) {
+ errorMessage(t('errorAddIncident'));
+ } else {
+ errorMessage(t('errorUnknownAddIncident'));
+ }
+ }
+ }
+
+ async function deleteIncident(actId: number, incidentId: string): Promise {
+ try {
+ const response: boolean = await System.authDeleteToServer('book/incident/remove', {
+ bookId,
+ incidentId,
+ }, token, lang);
+ if (!response) {
+ errorMessage(t('errorDeleteIncident'));
+ return;
+ }
+ const updatedActs: ActType[] = acts.map((act: ActType): ActType => {
+ if (act.id === actId) {
+ return {
+ ...act,
+ incidents: (act.incidents || []).filter(
+ (inc: Incident): boolean => inc.incidentId !== incidentId,
+ ),
+ };
+ }
+ return act;
+ });
+ setActs(updatedActs);
+ } catch (e: unknown) {
+ if (e instanceof Error) {
+ errorMessage(e.message);
+ } else {
+ errorMessage(t('errorUnknownDeleteIncident'));
+ }
+ }
+ }
+
+ async function addPlotPoint(actId: number): Promise {
+ if (newPlotPointTitle.trim() === '') return;
+ try {
+ const plotId: string = await System.authPostToServer('book/plot/new', {
+ bookId,
+ name: newPlotPointTitle,
+ incidentId: selectedIncidentId,
+ }, token, lang);
+ if (!plotId) {
+ errorMessage(t('errorAddPlotPoint'));
+ return;
+ }
+ const updatedActs: ActType[] = acts.map((act: ActType): ActType => {
+ if (act.id === actId) {
+ const newPlotPoint: PlotPoint = {
+ plotPointId: plotId,
+ title: newPlotPointTitle,
+ summary: '',
+ linkedIncidentId: selectedIncidentId,
+ chapters: [],
+ };
+ return {
+ ...act,
+ plotPoints: [...(act.plotPoints || []), newPlotPoint],
+ };
+ }
+ return act;
+ });
+ setActs(updatedActs);
+ setNewPlotPointTitle('');
+ setSelectedIncidentId('');
+ } catch (e: unknown) {
+ if (e instanceof Error) {
+ errorMessage(t('errorAddPlotPoint'));
+ } else {
+ errorMessage(t('errorUnknownAddPlotPoint'));
+ }
+ }
+ }
+
+ async function deletePlotPoint(actId: number, plotPointId: string): Promise {
+ try {
+ const response: boolean = await System.authDeleteToServer('book/plot/remove', {
+ plotId: plotPointId,
+ }, token, lang);
+ if (!response) {
+ errorMessage(t('errorDeletePlotPoint'));
+ return;
+ }
+ const updatedActs: ActType[] = acts.map((act: ActType): ActType => {
+ if (act.id === actId) {
+ return {
+ ...act,
+ plotPoints: (act.plotPoints || []).filter(
+ (pp: PlotPoint): boolean => pp.plotPointId !== plotPointId,
+ ),
+ };
+ }
+ return act;
+ });
+ setActs(updatedActs);
+ } catch (e: unknown) {
+ if (e instanceof Error) {
+ errorMessage(e.message);
+ } else {
+ errorMessage(t('errorUnknownDeletePlotPoint'));
+ }
+ }
+ }
+
+ async function linkChapter(
+ actId: number,
+ chapterId: string,
+ destination: 'act' | 'incident' | 'plotPoint',
+ itemId?: string,
+ ): Promise {
+ const chapterToLink: ChapterListProps | undefined = mainChapters.find((chapter: ChapterListProps): boolean => chapter.chapterId === chapterId);
+ if (!chapterToLink) {
+ errorMessage(t('errorChapterNotFound'));
+ return;
+ }
+ try {
+ const linkId: string =
+ await System.authPostToServer('chapter/resume/add', {
+ bookId,
+ chapterId: chapterId,
+ actId: actId,
+ plotId: destination === 'plotPoint' ? itemId : null,
+ incidentId: destination === 'incident' ? itemId : null,
+ }, token, lang);
+ if (!linkId) {
+ errorMessage(t('errorLinkChapter'));
+ return;
+ }
+ const newChapter: ActChapter = {
+ chapterInfoId: linkId,
+ chapterId: chapterId,
+ title: chapterToLink.title,
+ chapterOrder: chapterToLink.chapterOrder || 0,
+ actId: actId,
+ incidentId: destination === 'incident' ? itemId : '0',
+ plotPointId: destination === 'plotPoint' ? itemId : '0',
+ summary: '',
+ goal: '',
+ };
+
+ const updatedActs: ActType[] = acts.map((act: ActType): ActType => {
+ if (act.id === actId) {
+ switch (destination) {
+ case 'act':
+ return {
+ ...act,
+ chapters: [...(act.chapters || []), newChapter],
+ };
+ case 'incident':
+ return {
+ ...act,
+ incidents:
+ act.incidents?.map((incident: Incident): Incident =>
+ incident.incidentId === itemId
+ ? {
+ ...incident,
+ chapters: [...(incident.chapters || []), newChapter],
+ }
+ : incident,
+ ) || [],
+ };
+ case 'plotPoint':
+ return {
+ ...act,
+ plotPoints:
+ act.plotPoints?.map(
+ (plotPoint: PlotPoint): PlotPoint =>
+ plotPoint.plotPointId === itemId
+ ? {
+ ...plotPoint,
+ chapters: [...(plotPoint.chapters || []), newChapter],
+ }
+ : plotPoint,
+ ) || [],
+ };
+ }
+ }
+ return act;
+ });
+
+ setActs(updatedActs);
+ } catch (e: unknown) {
+ if (e instanceof Error) {
+ errorMessage(e.message);
+ } else {
+ errorMessage(t('errorUnknownLinkChapter'));
+ }
+ }
+ }
+
+ async function unlinkChapter(
+ chapterInfoId: string,
+ actId: number,
+ chapterId: string,
+ destination: 'act' | 'incident' | 'plotPoint',
+ itemId?: string,
+ ): Promise {
+ try {
+ const response: boolean = await System.authDeleteToServer('chapter/resume/remove', {
+ chapterInfoId,
+ }, token, lang);
+ if (!response) {
+ errorMessage(t('errorUnlinkChapter'));
+ return;
+ }
+ const updatedActs: ActType[] = acts.map((act: ActType): ActType => {
+ if (act.id === actId) {
+ switch (destination) {
+ case 'act':
+ return {
+ ...act,
+ chapters: (act.chapters || []).filter(
+ (ch: ActChapter): boolean => ch.chapterId !== chapterId,
+ ),
+ };
+
+ case 'incident':
+ if (!itemId) return act;
+
+ return {
+ ...act,
+ incidents:
+ act.incidents?.map((incident: Incident): Incident => {
+ if (incident.incidentId === itemId) {
+ return {
+ ...incident,
+ chapters: (incident.chapters || []).filter(
+ (ch: ActChapter): boolean =>
+ ch.chapterId !== chapterId,
+ ),
+ };
+ }
+ return incident;
+ }) || [],
+ };
+
+ case 'plotPoint':
+ if (!itemId) return act;
+
+ return {
+ ...act,
+ plotPoints:
+ act.plotPoints?.map((plotPoint: PlotPoint): PlotPoint => {
+ if (plotPoint.plotPointId === itemId) {
+ return {
+ ...plotPoint,
+ chapters: (plotPoint.chapters || []).filter((chapter: ActChapter): boolean => chapter.chapterId !== chapterId),
+ };
+ }
+ return plotPoint;
+ }) || [],
+ };
+ }
+ }
+ return act;
+ });
+
+ setActs(updatedActs);
+ } catch (e: unknown) {
+ if (e instanceof Error) {
+ errorMessage(e.message);
+ } else {
+ errorMessage(t('errorUnknownUnlinkChapter'));
+ }
+ }
+ }
+
+ function updateLinkedChapterSummary(
+ actId: number,
+ chapterId: string,
+ summary: string,
+ destination: 'act' | 'incident' | 'plotPoint',
+ itemId?: string,
+ ): void {
+ const updatedActs: ActType[] = acts.map((act: ActType): ActType => {
+ if (act.id === actId) {
+ switch (destination) {
+ case 'act':
+ return {
+ ...act,
+ chapters: (act.chapters || []).map((chapter: ActChapter): ActChapter => {
+ if (chapter.chapterId === chapterId) {
+ return {...chapter, summary};
+ }
+ return chapter;
+ }),
+ };
+
+ case 'incident':
+ if (!itemId) return act;
+
+ return {
+ ...act,
+ incidents:
+ act.incidents?.map((incident: Incident): Incident => {
+ if (incident.incidentId === itemId) {
+ return {
+ ...incident,
+ chapters: (incident.chapters || []).map((chapter: ActChapter) => {
+ if (chapter.chapterId === chapterId) {
+ return {...chapter, summary};
+ }
+ return chapter;
+ }),
+ };
+ }
+ return incident;
+ }) || [],
+ };
+
+ case 'plotPoint':
+ if (!itemId) return act;
+
+ return {
+ ...act,
+ plotPoints:
+ act.plotPoints?.map((plotPoint: PlotPoint): PlotPoint => {
+ if (plotPoint.plotPointId === itemId) {
+ return {
+ ...plotPoint,
+ chapters: (plotPoint.chapters || []).map((chapter: ActChapter): ActChapter => {
+ if (chapter.chapterId === chapterId) {
+ return {...chapter, summary};
+ }
+ return chapter;
+ }),
+ };
+ }
+ return plotPoint;
+ }) || [],
+ };
+ }
+ }
+ return act;
+ });
+ setActs(updatedActs);
+ }
+
+ function getSectionKey(actId: number, section: string): string {
+ return `section_${actId}_${section}`;
+ }
+
+
+ function renderActChapters(act: ActType) {
+ if (act.id === 2 || act.id === 3) {
+ return null;
+ }
+
+ const sectionKey: string = getSectionKey(act.id, 'chapters');
+ const isExpanded: boolean = expandedSections[sectionKey];
+
+ return (
+ linkChapter(actId, chapterId, 'act')}
+ onUpdateChapterSummary={(chapterId, summary) =>
+ updateLinkedChapterSummary(act.id, chapterId, summary, 'act')
+ }
+ onUnlinkChapter={(chapterInfoId, chapterId) =>
+ unlinkChapter(chapterInfoId, act.id, chapterId, 'act')
+ }
+ sectionKey={sectionKey}
+ isExpanded={isExpanded}
+ onToggleSection={toggleSection}
+ />
+ );
+ }
+
+ function renderActDescription(act: ActType) {
+ if (act.id === 2 || act.id === 3) {
+ return null;
+ }
+
+ return (
+
+ );
+ }
+
+ function renderIncidents(act: ActType) {
+ if (act.id !== 2) return null;
+
+ const sectionKey: string = getSectionKey(act.id, 'incidents');
+ const isExpanded: boolean = expandedSections[sectionKey];
+
+ return (
+
+ linkChapter(actId, chapterId, 'incident', incidentId)
+ }
+ onUpdateChapterSummary={(chapterId, summary, incidentId) =>
+ updateLinkedChapterSummary(act.id, chapterId, summary, 'incident', incidentId)
+ }
+ onUnlinkChapter={(chapterInfoId, chapterId, incidentId) =>
+ unlinkChapter(chapterInfoId, act.id, chapterId, 'incident', incidentId)
+ }
+ sectionKey={sectionKey}
+ isExpanded={isExpanded}
+ onToggleSection={toggleSection}
+ />
+ );
+ }
+
+ function renderPlotPoints(act: ActType) {
+ if (act.id !== 3) return null;
+
+ const sectionKey: string = getSectionKey(act.id, 'plotPoints');
+ const isExpanded: boolean = expandedSections[sectionKey];
+
+ return (
+
+ linkChapter(actId, chapterId, 'plotPoint', plotPointId)
+ }
+ onUpdateChapterSummary={(chapterId, summary, plotPointId) =>
+ updateLinkedChapterSummary(act.id, chapterId, summary, 'plotPoint', plotPointId)
+ }
+ onUnlinkChapter={(chapterInfoId, chapterId, plotPointId) =>
+ unlinkChapter(chapterInfoId, act.id, chapterId, 'plotPoint', plotPointId)
+ }
+ sectionKey={sectionKey}
+ isExpanded={isExpanded}
+ onToggleSection={toggleSection}
+ />
+ );
+ }
+
+ function renderActIcon(actId: number): IconDefinition {
+ switch (actId) {
+ case 1:
+ return faFlag;
+ case 2:
+ return faFire;
+ case 3:
+ return faPuzzlePiece;
+ case 4:
+ return faScaleBalanced;
+ case 5:
+ return faTrophy;
+ default:
+ return faFlag;
+ }
+ }
+
+ function renderActTitle(actId: number): string {
+ switch (actId) {
+ case 1:
+ return t('act1Title');
+ case 2:
+ return t('act2Title');
+ case 3:
+ return t('act3Title');
+ case 4:
+ return t('act4Title');
+ case 5:
+ return t('act5Title');
+ default:
+ return '';
+ }
+ }
+
+ return (
+
+ {acts.map((act: ActType) => (
+
+ {renderActDescription(act)}
+ {renderActChapters(act)}
+ {renderIncidents(act)}
+ {renderPlotPoints(act)}
+ >
+ }
+ />
+ ))}
+
+ );
+}
\ No newline at end of file
diff --git a/components/book/settings/story/Issue.tsx b/components/book/settings/story/Issue.tsx
new file mode 100644
index 0000000..f0c7015
--- /dev/null
+++ b/components/book/settings/story/Issue.tsx
@@ -0,0 +1,149 @@
+import React, {ChangeEvent, useContext, useState} from 'react';
+import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
+import {faPlus, faTrash, faWarning,} from '@fortawesome/free-solid-svg-icons';
+import {Issue} from '@/lib/models/Book';
+import System from '@/lib/models/System';
+import {BookContext} from '@/context/BookContext';
+import {SessionContext} from '@/context/SessionContext';
+import {AlertContext} from '@/context/AlertContext';
+import CollapsableArea from "@/components/CollapsableArea";
+import {useTranslations} from "next-intl";
+import {LangContext, LangContextProps} from "@/context/LangContext";
+
+interface IssuesProps {
+ issues: Issue[];
+ setIssues: React.Dispatch>;
+}
+
+export default function Issues({issues, setIssues}: IssuesProps) {
+ const t = useTranslations();
+ const {lang} = useContext(LangContext);
+ const {book} = useContext(BookContext);
+ const {session} = useContext(SessionContext);
+ const {errorMessage} = useContext(AlertContext);
+
+ const bookId: string | undefined = book?.bookId;
+ const token: string = session.accessToken;
+
+ const [newIssueName, setNewIssueName] = useState('');
+
+ async function addNewIssue(): Promise {
+ if (newIssueName.trim() === '') {
+ errorMessage(t("issues.errorEmptyName"));
+ return;
+ }
+ try {
+ const issueId: string = await System.authPostToServer('book/issue/add', {
+ bookId,
+ name: newIssueName,
+ }, token, lang);
+ if (!issueId) {
+ errorMessage(t("issues.errorAdd"));
+ return;
+ }
+ const newIssue: Issue = {
+ name: newIssueName,
+ id: issueId,
+ };
+
+ setIssues([...issues, newIssue]);
+ setNewIssueName('');
+ } catch (e: unknown) {
+ if (e instanceof Error) {
+ errorMessage(e.message);
+ } else {
+ errorMessage(t("issues.errorUnknownAdd"));
+ }
+ }
+ }
+
+ async function deleteIssue(issueId: string): Promise {
+ if (issueId === undefined) {
+ errorMessage(t("issues.errorInvalidId"));
+ }
+
+
+ try {
+ const response: boolean = await System.authDeleteToServer(
+ 'book/issue/remove',
+ {
+ bookId,
+ issueId,
+ },
+ token,
+ lang
+ );
+ if (response) {
+ const updatedIssues: Issue[] = issues.filter((issue: Issue): boolean => issue.id !== issueId,);
+ setIssues(updatedIssues);
+ } else {
+ errorMessage(t("issues.errorDelete"));
+ }
+ } catch (e: unknown) {
+ if (e instanceof Error) {
+ errorMessage(e.message);
+ } else {
+ errorMessage(t("issues.errorUnknownDelete"));
+ }
+ }
+ }
+
+ function updateIssueName(issueId: string, name: string): void {
+ const updatedIssues: Issue[] = issues.map((issue: Issue): Issue => {
+ if (issue.id === issueId) {
+ return {...issue, name};
+ }
+ return issue;
+ });
+ setIssues(updatedIssues);
+ }
+
+ return (
+
+ {issues && issues.length > 0 ? (
+ issues.map((item: Issue) => (
+
+
+ updateIssueName(item.id, e.target.value)}
+ placeholder={t("issues.issueNamePlaceholder")}
+ />
+ deleteIssue(item.id)}
+ >
+
+
+
+
+ ))
+ ) : (
+
+ {t("issues.noIssue")}
+
+ )}
+
+ ) => setNewIssueName(e.target.value)}
+ placeholder={t("issues.newIssuePlaceholder")}
+ />
+
+
+
+
+
+ } icon={faWarning}/>
+ );
+}
\ No newline at end of file
diff --git a/components/book/settings/story/MainChapter.tsx b/components/book/settings/story/MainChapter.tsx
new file mode 100644
index 0000000..e184624
--- /dev/null
+++ b/components/book/settings/story/MainChapter.tsx
@@ -0,0 +1,278 @@
+'use client'
+
+import React, {ChangeEvent, useContext, useEffect, useState} from 'react';
+import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
+import {faArrowDown, faArrowUp, faBookmark, faMinus, faPlus, faTrash,} from '@fortawesome/free-solid-svg-icons';
+import {ChapterListProps} from '@/lib/models/Chapter';
+import System from '@/lib/models/System';
+import {BookContext} from '@/context/BookContext';
+import {SessionContext} from '@/context/SessionContext';
+import {AlertContext} from '@/context/AlertContext';
+import AlertBox from "@/components/AlertBox";
+import CollapsableArea from "@/components/CollapsableArea";
+import {useTranslations} from "next-intl";
+import {LangContext} from "@/context/LangContext";
+
+interface MainChapterProps {
+ chapters: ChapterListProps[];
+ setChapters: React.Dispatch>;
+}
+
+export default function MainChapter({chapters, setChapters}: MainChapterProps) {
+ const t = useTranslations();
+ const {lang} = useContext(LangContext)
+ const {book} = useContext(BookContext);
+ const {session} = useContext(SessionContext);
+ const {errorMessage, successMessage} = useContext(AlertContext);
+
+ const bookId: string | undefined = book?.bookId;
+ const token: string = session.accessToken;
+
+ const [newChapterTitle, setNewChapterTitle] = useState('');
+ const [newChapterOrder, setNewChapterOrder] = useState(0);
+
+ const [deleteConfirmMessage, setDeleteConfirmMessage] = useState(false);
+ const [chapterIdToRemove, setChapterIdToRemove] = useState('');
+
+ function handleChapterTitleChange(chapterId: string, newTitle: string) {
+ const updatedChapters: ChapterListProps[] = chapters.map((chapter: ChapterListProps): ChapterListProps => {
+ if (chapter.chapterId === chapterId) {
+ return {...chapter, title: newTitle};
+ }
+ return chapter;
+ });
+ setChapters(updatedChapters);
+ }
+
+ function moveChapter(index: number, direction: number): void {
+ const visibleChapters: ChapterListProps[] = chapters
+ .filter((chapter: ChapterListProps): boolean => chapter.chapterOrder !== -1)
+ .sort((a: ChapterListProps, b: ChapterListProps): number => (a.chapterOrder || 0) - (b.chapterOrder || 0));
+
+ const currentChapter: ChapterListProps = visibleChapters[index];
+ const allChaptersIndex: number = chapters.findIndex(
+ (chapter: ChapterListProps): boolean => chapter.chapterId === currentChapter.chapterId,
+ );
+
+ const updatedChapters: ChapterListProps[] = [...chapters];
+
+ const currentOrder: number = updatedChapters[allChaptersIndex].chapterOrder || 0;
+ const newOrder: number = Math.max(0, currentOrder + direction);
+
+ updatedChapters[allChaptersIndex] = {
+ ...updatedChapters[allChaptersIndex],
+ chapterOrder: newOrder,
+ };
+
+ setChapters(updatedChapters);
+ }
+
+ function moveChapterUp(index: number): void {
+ moveChapter(index, -1);
+ }
+
+ function moveChapterDown(index: number): void {
+ moveChapter(index, 1);
+ }
+
+ async function deleteChapter(): Promise {
+ try {
+ setDeleteConfirmMessage(false);
+ const response: boolean = await System.authDeleteToServer(
+ 'chapter/remove',
+ {
+ bookId,
+ chapterId: chapterIdToRemove,
+ },
+ token,
+ lang,
+ );
+ if (!response) {
+ errorMessage(t("mainChapter.errorDelete"));
+ }
+ const updatedChapters: ChapterListProps[] = chapters.filter((chapter: ChapterListProps): boolean => chapter.chapterId !== chapterIdToRemove,);
+ setChapters(updatedChapters);
+ } catch (e: unknown) {
+ if (e instanceof Error) {
+ errorMessage(e.message)
+ } else {
+ errorMessage(t("mainChapter.errorUnknownDelete"));
+ }
+ }
+ }
+
+ async function addNewChapter(): Promise {
+ if (newChapterTitle.trim() === '') {
+ return;
+ }
+
+ try {
+ const responseId: string = await System.authPostToServer(
+ 'chapter/add',
+ {
+ bookId: bookId,
+ wordsCount: 0,
+ chapterOrder: newChapterOrder ? newChapterOrder : 0,
+ title: newChapterTitle,
+ },
+ token,
+ );
+ if (!responseId) {
+ errorMessage(t("mainChapter.errorAdd"));
+ return;
+ }
+ const newChapter: ChapterListProps = {
+ chapterId: responseId as string,
+ title: newChapterTitle,
+ chapterOrder: newChapterOrder,
+ summary: '',
+ goal: '',
+ };
+ setChapters([...chapters, newChapter]);
+ setNewChapterTitle('');
+
+ setNewChapterOrder(newChapterOrder + 1);
+ } catch (e: unknown) {
+ if (e instanceof Error) {
+ errorMessage(e.message)
+ } else {
+ errorMessage(t("mainChapter.errorUnknownAdd"));
+ }
+ }
+ }
+
+ function decrementNewChapterOrder(): void {
+ if (newChapterOrder > 0) {
+ setNewChapterOrder(newChapterOrder - 1);
+ }
+ }
+
+ function incrementNewChapterOrder(): void {
+ setNewChapterOrder(newChapterOrder + 1);
+ }
+
+ useEffect((): void => {
+ const visibleChapters: ChapterListProps[] = chapters
+ .filter((chapter: ChapterListProps): boolean => chapter.chapterOrder !== -1)
+ .sort((a: ChapterListProps, b: ChapterListProps): number =>
+ (a.chapterOrder || 0) - (b.chapterOrder || 0),
+ );
+
+ const nextOrder: number =
+ visibleChapters.length > 0
+ ? (visibleChapters[visibleChapters.length - 1].chapterOrder || 0) + 1
+ : 0;
+
+ setNewChapterOrder(nextOrder);
+ }, [chapters]);
+
+ const visibleChapters: ChapterListProps[] = chapters
+ .filter((chapter: ChapterListProps): boolean => chapter.chapterOrder !== -1)
+ .sort((a: ChapterListProps, b: ChapterListProps): number => (a.chapterOrder || 0) - (b.chapterOrder || 0));
+
+ return (
+
+
+ {visibleChapters.length > 0 ? (
+
+ {visibleChapters.map((item: ChapterListProps, index: number) => (
+
+