From 81679488811fb589bfdc30b960805039da27cfca Mon Sep 17 00:00:00 2001 From: natreex Date: Sun, 16 Nov 2025 11:00:04 -0500 Subject: [PATCH] Add components for Act management and integrate Electron setup --- .gitignore | 38 + README.md | 116 + app/globals.css | 551 + app/layout.tsx | 26 + app/page.tsx | 339 + components/AlertBox.tsx | 103 + components/AlertStack.tsx | 57 + components/CollapsableArea.tsx | 52 + components/CollapsableButton.tsx | 33 + components/Collapse.tsx | 39 + components/CreditMeters.tsx | 30 + components/ExportBook.tsx | 190 + components/GuideTour.tsx | 383 + components/ListItem.tsx | 154 + components/Modal.tsx | 91 + components/NoPicture.tsx | 13 + components/PanelHeader.tsx | 88 + components/QSTextGeneratedPreview.tsx | 121 + components/ScribeControllerBar.tsx | 184 + components/ScribeFooterBar.tsx | 99 + components/ScribeTopBar.tsx | 40 + components/ShortStoryGenerator.tsx | 692 ++ components/StaticAlert.tsx | 134 + components/TermsOfUse.tsx | 125 + components/TwoFactorSetup.tsx | 196 + components/UserMenu.tsx | 69 + components/book/AddNewBookForm.tsx | 295 + components/book/BookCard.tsx | 80 + components/book/BookCardSkeleton.tsx | 26 + components/book/BookList.tsx | 291 + components/book/SearchBook.tsx | 33 + .../book/settings/BasicInformationSetting.tsx | 232 + components/book/settings/BookSetting.tsx | 18 + .../book/settings/BookSettingOption.tsx | 118 + .../book/settings/BookSettingSidebar.tsx | 91 + components/book/settings/DeleteBook.tsx | 90 + .../characters/CharacterComponent.tsx | 264 + .../settings/characters/CharacterDetail.tsx | 230 + .../settings/characters/CharacterList.tsx | 124 + .../characters/CharacterSectionElement.tsx | 89 + components/book/settings/goals/page.tsx | 181 + .../settings/guide-line/GuideLineSetting.tsx | 421 + .../settings/locations/LocationComponent.tsx | 444 + components/book/settings/objects/page.tsx | 327 + components/book/settings/story/Act.tsx | 608 + components/book/settings/story/Issue.tsx | 149 + .../book/settings/story/MainChapter.tsx | 278 + .../book/settings/story/StorySetting.tsx | 167 + .../book/settings/story/act/ActChapter.tsx | 37 + .../settings/story/act/ActChaptersSection.tsx | 93 + .../settings/story/act/ActDescription.tsx | 60 + .../book/settings/story/act/ActIncidents.tsx | 176 + .../book/settings/story/act/ActPlotPoints.tsx | 202 + .../book/settings/world/WorldElement.tsx | 132 + .../book/settings/world/WorldSetting.tsx | 309 + components/editor/DraftCompanion.tsx | 574 + components/editor/NoBookHome.tsx | 26 + components/editor/ScribeEditor.tsx | 33 + components/editor/TextEditor.tsx | 516 + components/editor/UserEditorSetting.tsx | 240 + components/form/AddActionButton.tsx | 20 + components/form/CancelButton.tsx | 21 + components/form/CheckBox.tsx | 53 + components/form/ConfirmButton.tsx | 40 + components/form/DatePicker.tsx | 21 + components/form/InlineAddInput.tsx | 71 + components/form/InputField.tsx | 92 + components/form/NumberInput.tsx | 44 + components/form/RadioBox.tsx | 62 + components/form/RadioGroup.tsx | 51 + components/form/SearchInputWithSelect.tsx | 62 + components/form/SelectBox.tsx | 43 + components/form/SubmitButtonWLoading.tsx | 55 + components/form/SuggestFieldInput.tsx | 79 + components/form/TextInput.tsx | 39 + components/form/TexteAreaInput.tsx | 117 + components/ghostwriter/GhostWriter.tsx | 426 + .../ghostwriter/GhostWriterSettings.tsx | 58 + components/ghostwriter/GhostWriterTags.tsx | 265 + components/input/BackButton.tsx | 28 + components/input/SelectOptionField.tsx | 48 + components/input/TextInputField.tsx | 68 + components/leftbar/ScribeChapterComponent.tsx | 309 + components/leftbar/ScribeLeftBar.tsx | 137 + components/quillsense/QuillSenseComponent.tsx | 131 + components/quillsense/modes/Conjugator.tsx | 262 + components/quillsense/modes/Dictionary.tsx | 158 + components/quillsense/modes/InspireMe.tsx | 223 + .../quillsense/modes/QuillConversation.tsx | 452 + components/quillsense/modes/QuillList.tsx | 84 + components/quillsense/modes/Synonyms.tsx | 178 + components/rightbar/AboutERitors.tsx | 112 + components/rightbar/ComposerRightBar.tsx | 213 + package-lock.json | 10274 ++++++++++++++++ package.json | 105 +- tsconfig.electron.json | 22 + tsconfig.json | 41 + 97 files changed, 25378 insertions(+), 3 deletions(-) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/page.tsx create mode 100644 components/AlertBox.tsx create mode 100644 components/AlertStack.tsx create mode 100644 components/CollapsableArea.tsx create mode 100644 components/CollapsableButton.tsx create mode 100644 components/Collapse.tsx create mode 100644 components/CreditMeters.tsx create mode 100644 components/ExportBook.tsx create mode 100644 components/GuideTour.tsx create mode 100644 components/ListItem.tsx create mode 100644 components/Modal.tsx create mode 100644 components/NoPicture.tsx create mode 100644 components/PanelHeader.tsx create mode 100644 components/QSTextGeneratedPreview.tsx create mode 100644 components/ScribeControllerBar.tsx create mode 100644 components/ScribeFooterBar.tsx create mode 100644 components/ScribeTopBar.tsx create mode 100644 components/ShortStoryGenerator.tsx create mode 100644 components/StaticAlert.tsx create mode 100644 components/TermsOfUse.tsx create mode 100644 components/TwoFactorSetup.tsx create mode 100644 components/UserMenu.tsx create mode 100644 components/book/AddNewBookForm.tsx create mode 100644 components/book/BookCard.tsx create mode 100644 components/book/BookCardSkeleton.tsx create mode 100644 components/book/BookList.tsx create mode 100644 components/book/SearchBook.tsx create mode 100644 components/book/settings/BasicInformationSetting.tsx create mode 100644 components/book/settings/BookSetting.tsx create mode 100644 components/book/settings/BookSettingOption.tsx create mode 100644 components/book/settings/BookSettingSidebar.tsx create mode 100644 components/book/settings/DeleteBook.tsx create mode 100644 components/book/settings/characters/CharacterComponent.tsx create mode 100644 components/book/settings/characters/CharacterDetail.tsx create mode 100644 components/book/settings/characters/CharacterList.tsx create mode 100644 components/book/settings/characters/CharacterSectionElement.tsx create mode 100644 components/book/settings/goals/page.tsx create mode 100644 components/book/settings/guide-line/GuideLineSetting.tsx create mode 100644 components/book/settings/locations/LocationComponent.tsx create mode 100644 components/book/settings/objects/page.tsx create mode 100644 components/book/settings/story/Act.tsx create mode 100644 components/book/settings/story/Issue.tsx create mode 100644 components/book/settings/story/MainChapter.tsx create mode 100644 components/book/settings/story/StorySetting.tsx create mode 100644 components/book/settings/story/act/ActChapter.tsx create mode 100644 components/book/settings/story/act/ActChaptersSection.tsx create mode 100644 components/book/settings/story/act/ActDescription.tsx create mode 100644 components/book/settings/story/act/ActIncidents.tsx create mode 100644 components/book/settings/story/act/ActPlotPoints.tsx create mode 100644 components/book/settings/world/WorldElement.tsx create mode 100644 components/book/settings/world/WorldSetting.tsx create mode 100644 components/editor/DraftCompanion.tsx create mode 100644 components/editor/NoBookHome.tsx create mode 100644 components/editor/ScribeEditor.tsx create mode 100644 components/editor/TextEditor.tsx create mode 100644 components/editor/UserEditorSetting.tsx create mode 100644 components/form/AddActionButton.tsx create mode 100644 components/form/CancelButton.tsx create mode 100644 components/form/CheckBox.tsx create mode 100644 components/form/ConfirmButton.tsx create mode 100644 components/form/DatePicker.tsx create mode 100644 components/form/InlineAddInput.tsx create mode 100644 components/form/InputField.tsx create mode 100644 components/form/NumberInput.tsx create mode 100644 components/form/RadioBox.tsx create mode 100644 components/form/RadioGroup.tsx create mode 100644 components/form/SearchInputWithSelect.tsx create mode 100644 components/form/SelectBox.tsx create mode 100644 components/form/SubmitButtonWLoading.tsx create mode 100644 components/form/SuggestFieldInput.tsx create mode 100644 components/form/TextInput.tsx create mode 100644 components/form/TexteAreaInput.tsx create mode 100644 components/ghostwriter/GhostWriter.tsx create mode 100644 components/ghostwriter/GhostWriterSettings.tsx create mode 100644 components/ghostwriter/GhostWriterTags.tsx create mode 100644 components/input/BackButton.tsx create mode 100644 components/input/SelectOptionField.tsx create mode 100644 components/input/TextInputField.tsx create mode 100644 components/leftbar/ScribeChapterComponent.tsx create mode 100644 components/leftbar/ScribeLeftBar.tsx create mode 100644 components/quillsense/QuillSenseComponent.tsx create mode 100644 components/quillsense/modes/Conjugator.tsx create mode 100644 components/quillsense/modes/Dictionary.tsx create mode 100644 components/quillsense/modes/InspireMe.tsx create mode 100644 components/quillsense/modes/QuillConversation.tsx create mode 100644 components/quillsense/modes/QuillList.tsx create mode 100644 components/quillsense/modes/Synonyms.tsx create mode 100644 components/rightbar/AboutERitors.tsx create mode 100644 components/rightbar/ComposerRightBar.tsx create mode 100644 package-lock.json create mode 100644 tsconfig.electron.json create mode 100644 tsconfig.json 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 ( +
+
+
+ ERitors Logo +
+
+
+
+
+
+

+ {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 = ( +
+
+
+
+
+ +
+

{title}

+
+
+ +
+

{message}

+ +
+ + +
+
+
+
+ ); + + 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 ( +
+ + + {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 ( + + ) +} 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 ( +
+ + {isOpen && ( +
+
{content}
+
+ )} +
+ ); +} \ 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) => ( +
+ ))} +
+
+
+ +
+
+
+
+ {step.content} +
+
+
+
+ {currentStep > 0 ? ( + + ) : ( +
+ )} + +
+
+
+ ); +} \ 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="" + /> +
    + + +
    + ) : ( + {text} + ) + } + { + !editMode && isEditable && ( +
    + + +
    + ) + } + { + editMode && isEditable && ( +
    + + +
    + ) + } +
    +
  • + ) +} 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 && ( +
    + + +
    + ) + } +
    +
    + ); + + 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 && ( + + ) + } + { + 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 ? ( +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + ) : ( +
    +
    + {value} +
    +
    + )} +
    +
    +
    + +
    +
    +
    + ); + + 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 && ( + + )} + { + book && ( + + ) + } +
    +
    + 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 && + + } +
    +
    + +
    + + +
    + +
    + { + 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.logoAlt")} +
    + {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")} +

    + +
    +
    + ); + } + + 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 => ( + + ))} +
    + +
    + {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")} +
    +
    + )} +
    + )} +
    + +
    + + +
    + + + {activeTab < 3 ? ( + + ) : activeTab === 3 && ( + + )} +
    +
    +
    +
    + ); +} \ 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 ( +
    +
    +
    +
    + +
    +
    +
    {message}
    +
    + +
    +
    +
    +
    + +
    + ); +} 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 + + +
    +
    +
    +
    +
    + ); +} \ 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: +

    +
      +
    1. + + Download a two-factor authentication app like Google Authenticator or Authy. +
    2. +
    3. + + Open the app and select the option to scan a QR code. +
    4. +
    5. + + Proceed to the next step to scan the QR code provided. +
    6. +
    + +
    + )} + {step === 2 && ( +
    +

    + Scan the QR code below with your authentication app to link your account. +

    +
    +
    + {loadingQRCode ? ( +
    Loading QR Code...
    + ) : qrCode ? ( + QR Code + ) : ( +
    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 */} +
    + + +
    +
    + ); +} 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 ( +
    + + {isProfileMenuOpen && ( +
    +
    +

    {session.user?.username}

    +

    {session.user?.email}

    +
    + + Paramètres + + + Déconnexion + +
    + )} +
    + ) +} 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")} +

    + +
    + +
    +
    + ): 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 + ) : ( +
    +
    + + {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 ? ( +
    +
    + {t('basicInformationSetting.fields.coverImageAlt')} + +
    +
    + ) : ( +
    +
    +
    + { + }} 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 ( +
    + +
    + ) +} 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 ( +
    +
    + + + {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} + ) : ( +
    + {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} + /> + +
    + ))} + +
    + 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

    +
    +
    + + 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" + /> + +
    +
    + +

    {goals[selectedGoalIndex].name}

    +
    +

    Time Goal

    + + handleInputChange(e, 'timeGoal', 'desiredReleaseDate')} + className="w-full p-2 rounded-lg bg-gray-800 text-white border-none outline-none" + /> + + handleInputChange(e, 'timeGoal', 'maxReleaseDate')} + className="w-full p-2 rounded-lg bg-gray-800 text-white border-none outline-none" + /> +
    + +
    +

    Numbers Goal

    + + handleInputChange(e, 'numbersGoal', 'minWordsCount')} + className="w-full p-2 rounded-lg bg-gray-800 text-white border-none outline-none" + /> + + handleInputChange(e, 'numbersGoal', 'maxWordsCount')} + className="w-full p-2 rounded-lg bg-gray-800 text-white border-none outline-none" + /> + + handleInputChange(e, 'numbersGoal', 'desiredWordsCountByChapter')} + className="w-full p-2 rounded-lg bg-gray-800 text-white border-none outline-none" + /> + + handleInputChange(e, 'numbersGoal', 'desiredChapterCount')} + className="w-full p-2 rounded-lg bg-gray-800 text-white border-none outline-none" + /> +
    + +
    + +
    +
    +
    + ); +} 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 ( +
    +
    + + +
    + + {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} + + +

    +
    + {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 ? ( +
    +
    + +

    {selectedItem.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" + /> +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + 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} +
    + + + + + +
    +
    + ))} +
    + + + +
    +
    +
    +
    + ) : ( +
    +
    + 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" + /> + +
    +
    +

    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.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")} + /> + +
    +
    + )) + ) : ( +

    + {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) => ( +
    +
    + + {item.chapterOrder !== undefined ? item.chapterOrder : index} + + ) => handleChapterTitleChange(item.chapterId, e.target.value)} + placeholder={t("mainChapter.chapterTitlePlaceholder")} + /> +
    + + + +
    +
    +
    + ))} +
    + ) : ( +

    + {t("mainChapter.noChapter")} +

    + )} + +
    +
    +
    + + + {newChapterOrder} + + +
    + setNewChapterTitle(e.target.value)} + placeholder={t("mainChapter.newChapterPlaceholder")} + /> + +
    +
    +
    + }/> + { + deleteConfirmMessage && + setDeleteConfirmMessage(false)}/> + } +
    + ); +} \ No newline at end of file diff --git a/components/book/settings/story/StorySetting.tsx b/components/book/settings/story/StorySetting.tsx new file mode 100644 index 0000000..1ec80c3 --- /dev/null +++ b/components/book/settings/story/StorySetting.tsx @@ -0,0 +1,167 @@ +'use client' + +import React, {createContext, forwardRef, useContext, useEffect, useImperativeHandle, useState} from 'react'; +import {BookContext} from '@/context/BookContext'; +import {SessionContext} from '@/context/SessionContext'; +import {AlertContext} from '@/context/AlertContext'; +import System from '@/lib/models/System'; +import {Act as ActType, Issue} from '@/lib/models/Book'; +import {ActChapter, ChapterListProps} from '@/lib/models/Chapter'; +import MainChapter from "@/components/book/settings/story/MainChapter"; +import Issues from "@/components/book/settings/story/Issue"; +import Act from "@/components/book/settings/story/Act"; +import {useTranslations} from "next-intl"; +import {LangContext, LangContextProps} from "@/context/LangContext"; + +export const StoryContext = createContext<{ + acts: ActType[]; + setActs: React.Dispatch>; + mainChapters: ChapterListProps[]; + setMainChapters: React.Dispatch>; + issues: Issue[]; + setIssues: React.Dispatch>; +}>({ + acts: [], + setActs: (): void => { + }, + mainChapters: [], + setMainChapters: (): void => { + }, + issues: [], + setIssues: (): void => { + }, +}); + +interface StoryFetchData { + mainChapter: ChapterListProps[]; + acts: ActType[]; + issues: Issue[]; +} + +export function Story(props: any, ref: any) { + const t = useTranslations(); + const {lang} = useContext(LangContext); + const {book} = useContext(BookContext); + const bookId: string = book?.bookId ? book.bookId.toString() : ''; + const {session} = useContext(SessionContext); + const userToken: string = session.accessToken; + const {errorMessage, successMessage} = useContext(AlertContext); + + const [acts, setActs] = useState([]); + const [issues, setIssues] = useState([]); + const [mainChapters, setMainChapters] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useImperativeHandle(ref, function () { + return { + handleSave: handleSave + }; + }); + + useEffect((): void => { + getStoryData().then(); + }, [userToken]); + + useEffect((): void => { + cleanupDeletedChapters(); + }, [mainChapters]); + + async function getStoryData(): Promise { + try { + const response: StoryFetchData = await System.authGetQueryToServer(`book/story`, userToken, lang, { + bookid: bookId, + }); + if (response) { + setActs(response.acts); + setMainChapters(response.mainChapter); + setIssues(response.issues); + setIsLoading(false); + } + } catch (e: unknown) { + setIsLoading(false); + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t("story.errorUnknownFetch")); + } + } + } + + function cleanupDeletedChapters(): void { + const existingChapterIds: string[] = mainChapters.map(ch => ch.chapterId); + + const updatedActs = acts.map((act: ActType) => { + const filteredChapters: ActChapter[] = act.chapters?.filter((chapter: ActChapter): boolean => + existingChapterIds.includes(chapter.chapterId)) || []; + const updatedIncidents = act.incidents?.map(incident => { + return { + ...incident, + chapters: incident.chapters?.filter(chapter => + existingChapterIds.includes(chapter.chapterId)) || [] + }; + }) || []; + const updatedPlotPoints = act.plotPoints?.map(plotPoint => { + return { + ...plotPoint, + chapters: plotPoint.chapters?.filter(chapter => + existingChapterIds.includes(chapter.chapterId)) || [] + }; + }) || []; + return { + ...act, + chapters: filteredChapters, + incidents: updatedIncidents, + plotPoints: updatedPlotPoints, + }; + }); + setActs(updatedActs); + } + + async function handleSave(): Promise { + try { + const response: boolean = + await System.authPostToServer('book/story', { + bookId, + acts, + mainChapters, + issues, + }, userToken, lang); + if (!response) { + errorMessage(t("story.errorSave")) + } + successMessage(t("story.successSave")); + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t("story.errorUnknownSave")); + } + } + } + + return ( + +
    +
    +
    + +
    + +
    + +
    +
    +
    +
    + ); +} + +export default forwardRef(Story); \ No newline at end of file diff --git a/components/book/settings/story/act/ActChapter.tsx b/components/book/settings/story/act/ActChapter.tsx new file mode 100644 index 0000000..2be3056 --- /dev/null +++ b/components/book/settings/story/act/ActChapter.tsx @@ -0,0 +1,37 @@ +import React, {ChangeEvent} from 'react'; +import {faTrash} from '@fortawesome/free-solid-svg-icons'; +import {ActChapter} from '@/lib/models/Chapter'; +import InputField from '@/components/form/InputField'; +import TexteAreaInput from '@/components/form/TexteAreaInput'; +import {useTranslations} from 'next-intl'; + +interface ActChapterItemProps { + chapter: ActChapter; + onUpdateSummary: (chapterId: string, summary: string) => void; + onUnlink: (chapterInfoId: string, chapterId: string) => Promise; +} + +export default function ActChapterItem({chapter, onUpdateSummary, onUnlink}: ActChapterItemProps) { + const t = useTranslations('actComponent'); + + return ( +
    + ) => + onUpdateSummary(chapter.chapterId, e.target.value) + } + placeholder={t('chapterSummaryPlaceholder')} + /> + } + actionIcon={faTrash} + fieldName={chapter.title} + action={(): Promise => onUnlink(chapter.chapterInfoId, chapter.chapterId)} + actionLabel={t('remove')} + /> +
    + ); +} diff --git a/components/book/settings/story/act/ActChaptersSection.tsx b/components/book/settings/story/act/ActChaptersSection.tsx new file mode 100644 index 0000000..8255365 --- /dev/null +++ b/components/book/settings/story/act/ActChaptersSection.tsx @@ -0,0 +1,93 @@ +import React, {useState} from 'react'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import {faChevronDown, faChevronUp} from '@fortawesome/free-solid-svg-icons'; +import {ActChapter, ChapterListProps} from '@/lib/models/Chapter'; +import {SelectBoxProps} from '@/shared/interface'; +import ActChapterItem from './ActChapter'; +import InputField from '@/components/form/InputField'; +import SelectBox from '@/components/form/SelectBox'; +import {useTranslations} from 'next-intl'; + +interface ActChaptersSectionProps { + actId: number; + chapters: ActChapter[]; + mainChapters: ChapterListProps[]; + onLinkChapter: (actId: number, chapterId: string) => Promise; + onUpdateChapterSummary: (chapterId: string, summary: string) => void; + onUnlinkChapter: (chapterInfoId: string, chapterId: string) => Promise; + sectionKey: string; + isExpanded: boolean; + onToggleSection: (sectionKey: string) => void; +} + +export default function ActChaptersSection({ + actId, + chapters, + mainChapters, + onLinkChapter, + onUpdateChapterSummary, + onUnlinkChapter, + sectionKey, + isExpanded, + onToggleSection, + }: ActChaptersSectionProps) { + const t = useTranslations('actComponent'); + const [selectedChapterId, setSelectedChapterId] = useState(''); + + function mainChaptersData(): SelectBoxProps[] { + return mainChapters.map((chapter: ChapterListProps): SelectBoxProps => ({ + value: chapter.chapterId, + label: `${chapter.chapterOrder}. ${chapter.title}`, + })); + } + + return ( +
    + + + {isExpanded && ( +
    + {chapters && chapters.length > 0 ? ( + chapters.map((chapter: ActChapter) => ( + + onUpdateChapterSummary(chapterId, summary) + } + onUnlink={(chapterInfoId, chapterId) => + onUnlinkChapter(chapterInfoId, chapterId) + } + /> + )) + ) : ( +

    + {t('noLinkedChapter')} +

    + )} + => onLinkChapter(actId, selectedChapterId)} + input={ + setSelectedChapterId(e.target.value)} + data={mainChaptersData()} + placeholder={t('selectChapterPlaceholder')} + /> + } + isAddButtonDisabled={!selectedChapterId} + /> +
    + )} +
    + ); +} diff --git a/components/book/settings/story/act/ActDescription.tsx b/components/book/settings/story/act/ActDescription.tsx new file mode 100644 index 0000000..cfaa64a --- /dev/null +++ b/components/book/settings/story/act/ActDescription.tsx @@ -0,0 +1,60 @@ +import React, {ChangeEvent} from 'react'; +import {faTrash} from '@fortawesome/free-solid-svg-icons'; +import InputField from '@/components/form/InputField'; +import TexteAreaInput from '@/components/form/TexteAreaInput'; +import {useTranslations} from 'next-intl'; + +interface ActDescriptionProps { + actId: number; + summary: string; + onUpdateSummary: (actId: number, summary: string) => void; +} + +export default function ActDescription({actId, summary, onUpdateSummary}: ActDescriptionProps) { + const t = useTranslations('actComponent'); + + function getActSummaryTitle(actId: number): string { + switch (actId) { + case 1: + return t('act1Summary'); + case 4: + return t('act4Summary'); + case 5: + return t('act5Summary'); + default: + return t('actSummary'); + } + } + + function getActSummaryPlaceholder(actId: number): string { + switch (actId) { + case 1: + return t('act1SummaryPlaceholder'); + case 4: + return t('act4SummaryPlaceholder'); + case 5: + return t('act5SummaryPlaceholder'); + default: + return t('actSummaryPlaceholder'); + } + } + + return ( +
    + ) => + onUpdateSummary(actId, e.target.value) + } + placeholder={getActSummaryPlaceholder(actId)} + /> + } + actionIcon={faTrash} + actionLabel={t('delete')} + /> +
    + ); +} diff --git a/components/book/settings/story/act/ActIncidents.tsx b/components/book/settings/story/act/ActIncidents.tsx new file mode 100644 index 0000000..37728ee --- /dev/null +++ b/components/book/settings/story/act/ActIncidents.tsx @@ -0,0 +1,176 @@ +import React, {useState} from 'react'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import {faChevronDown, faChevronUp, faPlus, faTrash} from '@fortawesome/free-solid-svg-icons'; +import {Incident} from '@/lib/models/Book'; +import {ActChapter, ChapterListProps} from '@/lib/models/Chapter'; +import ActChapterItem from './ActChapter'; +import {useTranslations} from 'next-intl'; + +interface ActIncidentsProps { + incidents: Incident[]; + actId: number; + mainChapters: ChapterListProps[]; + newIncidentTitle: string; + setNewIncidentTitle: (title: string) => void; + onAddIncident: (actId: number) => Promise; + onDeleteIncident: (actId: number, incidentId: string) => Promise; + onLinkChapter: (actId: number, chapterId: string, incidentId: string) => Promise; + onUpdateChapterSummary: (chapterId: string, summary: string, incidentId: string) => void; + onUnlinkChapter: (chapterInfoId: string, chapterId: string, incidentId: string) => Promise; + sectionKey: string; + isExpanded: boolean; + onToggleSection: (sectionKey: string) => void; +} + +export default function ActIncidents({ + incidents, + actId, + mainChapters, + newIncidentTitle, + setNewIncidentTitle, + onAddIncident, + onDeleteIncident, + onLinkChapter, + onUpdateChapterSummary, + onUnlinkChapter, + sectionKey, + isExpanded, + onToggleSection, + }: ActIncidentsProps) { + const t = useTranslations('actComponent'); + const [expandedItems, setExpandedItems] = useState<{ [key: string]: boolean }>({}); + const [selectedChapterId, setSelectedChapterId] = useState(''); + + function toggleItem(itemKey: string): void { + setExpandedItems(prev => ({ + ...prev, + [itemKey]: !prev[itemKey], + })); + } + + return ( +
    + + + {isExpanded && ( +
    + {incidents && incidents.length > 0 ? ( + <> + {incidents.map((item: Incident) => { + const itemKey = `incident_${item.incidentId}`; + const isItemExpanded: boolean = expandedItems[itemKey]; + + return ( +
    + +
    + + + {isItemExpanded && ( +
    + {item.chapters && item.chapters.length > 0 ? ( + <> + {item.chapters.map((chapter: ActChapter) => ( + + onUpdateChapterSummary(chapterId, summary, item.incidentId) + } + onUnlink={(chapterInfoId, chapterId) => + onUnlinkChapter(chapterInfoId, chapterId, item.incidentId) + } + /> + ))} + + ) : ( +

    + {t('noLinkedChapter')} +

    + )} + +
    + + +
    +
    + )} +
    + ); + })} + + ) : ( +

    + {t('noIncidentAdded')} +

    + )} + +
    + setNewIncidentTitle(e.target.value)} + placeholder={t('newIncidentPlaceholder')} + /> + +
    +
    + )} + + ); +} diff --git a/components/book/settings/story/act/ActPlotPoints.tsx b/components/book/settings/story/act/ActPlotPoints.tsx new file mode 100644 index 0000000..6d9bceb --- /dev/null +++ b/components/book/settings/story/act/ActPlotPoints.tsx @@ -0,0 +1,202 @@ +import React, {useState} from 'react'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import {faChevronDown, faChevronUp, faPlus, faTrash} from '@fortawesome/free-solid-svg-icons'; +import {Incident, PlotPoint} from '@/lib/models/Book'; +import {ActChapter, ChapterListProps} from '@/lib/models/Chapter'; +import {SelectBoxProps} from '@/shared/interface'; +import ActChapterItem from './ActChapter'; +import InputField from '@/components/form/InputField'; +import SelectBox from '@/components/form/SelectBox'; +import {useTranslations} from 'next-intl'; + +interface ActPlotPointsProps { + plotPoints: PlotPoint[]; + incidents: Incident[]; + actId: number; + mainChapters: ChapterListProps[]; + newPlotPointTitle: string; + setNewPlotPointTitle: (title: string) => void; + selectedIncidentId: string; + setSelectedIncidentId: (id: string) => void; + onAddPlotPoint: (actId: number) => Promise; + onDeletePlotPoint: (actId: number, plotPointId: string) => Promise; + onLinkChapter: (actId: number, chapterId: string, plotPointId: string) => Promise; + onUpdateChapterSummary: (chapterId: string, summary: string, plotPointId: string) => void; + onUnlinkChapter: (chapterInfoId: string, chapterId: string, plotPointId: string) => Promise; + sectionKey: string; + isExpanded: boolean; + onToggleSection: (sectionKey: string) => void; +} + +export default function ActPlotPoints({ + plotPoints, + incidents, + actId, + mainChapters, + newPlotPointTitle, + setNewPlotPointTitle, + selectedIncidentId, + setSelectedIncidentId, + onAddPlotPoint, + onDeletePlotPoint, + onLinkChapter, + onUpdateChapterSummary, + onUnlinkChapter, + sectionKey, + isExpanded, + onToggleSection, + }: ActPlotPointsProps) { + const t = useTranslations('actComponent'); + const [expandedItems, setExpandedItems] = useState<{ [key: string]: boolean }>({}); + const [selectedChapterId, setSelectedChapterId] = useState(''); + + function toggleItem(itemKey: string): void { + setExpandedItems(prev => ({ + ...prev, + [itemKey]: !prev[itemKey], + })); + } + + function getIncidentData(): SelectBoxProps[] { + return incidents.map((incident: Incident): SelectBoxProps => ({ + value: incident.incidentId, + label: incident.title, + })); + } + + return ( +
    + + + {isExpanded && ( +
    + {plotPoints && plotPoints.length > 0 ? ( + plotPoints.map((item: PlotPoint) => { + const itemKey = `plotpoint_${item.plotPointId}`; + const isItemExpanded: boolean = expandedItems[itemKey]; + const linkedIncident: Incident | undefined = incidents.find( + (inc: Incident): boolean => inc.incidentId === item.linkedIncidentId + ); + + return ( +
    + +
    + + + {isItemExpanded && ( +
    + {item.chapters && item.chapters.length > 0 ? ( + item.chapters.map((chapter: ActChapter) => ( + + onUpdateChapterSummary(chapterId, summary, item.plotPointId) + } + onUnlink={(chapterInfoId, chapterId) => + onUnlinkChapter(chapterInfoId, chapterId, item.plotPointId) + } + /> + )) + ) : ( +

    + {t('noLinkedChapter')} +

    + )} + +
    + + +
    +
    + )} +
    + ); + }) + ) : ( +

    + {t('noPlotPointAdded')} +

    + )} + +
    +
    + setNewPlotPointTitle(e.target.value)} + placeholder={t('newPlotPointPlaceholder')} + /> +
    + setSelectedIncidentId(e.target.value)} + data={getIncidentData()} + /> + } + addButtonCallBack={(): Promise => onAddPlotPoint(actId)} + isAddButtonDisabled={newPlotPointTitle.trim() === ''} + /> +
    +
    + )} + + ); +} diff --git a/components/book/settings/world/WorldElement.tsx b/components/book/settings/world/WorldElement.tsx new file mode 100644 index 0000000..bf4f0ed --- /dev/null +++ b/components/book/settings/world/WorldElement.tsx @@ -0,0 +1,132 @@ +'use client'; + +import {ChangeEvent, useContext, useState} from "react"; +import {WorldContext} from "@/context/WorldContext"; +import TextInput from "@/components/form/TextInput"; +import TexteAreaInput from "@/components/form/TexteAreaInput"; +import {WorldElement, WorldProps} from "@/lib/models/World"; +import {AlertContext} from "@/context/AlertContext"; +import {SessionContext} from "@/context/SessionContext"; +import System from "@/lib/models/System"; +import InputField from "@/components/form/InputField"; +import {useTranslations} from "next-intl"; +import {LangContext, LangContextProps} from "@/context/LangContext"; + +interface WorldElementInputProps { + sectionLabel: string; + sectionType: string; +} + +export default function WorldElementComponent({sectionLabel, sectionType}: WorldElementInputProps) { + const t = useTranslations(); + const {lang} = useContext(LangContext); + const {worlds, setWorlds, selectedWorldIndex} = useContext(WorldContext); + const {errorMessage, successMessage} = useContext(AlertContext); + const {session} = useContext(SessionContext); + + const [newElementName, setNewElementName] = useState(''); + + async function handleRemoveElement( + section: keyof WorldProps, + index: number, + ): Promise { + try { + const response: boolean = await System.authDeleteToServer('book/world/element/delete', { + elementId: (worlds[selectedWorldIndex][section] as WorldElement[])[index].id, + }, session.accessToken, lang); + if (!response) { + errorMessage(t("worldSetting.unknownError")) + } + const updatedWorlds: WorldProps[] = [...worlds]; + (updatedWorlds[selectedWorldIndex][section] as WorldElement[]).splice( + index, + 1, + ); + setWorlds(updatedWorlds); + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.toString()); + } else { + errorMessage(t("worldElementComponent.errorUnknown")); + } + } + } + + async function handleAddElement(section: keyof WorldProps): Promise { + if (newElementName.trim() === '') { + errorMessage(t("worldElementComponent.emptyField", {section: sectionLabel})); + return; + } + try { + const elementId: string = await System.authPostToServer('book/world/element/add', { + elementType: section, + worldId: worlds[selectedWorldIndex].id, + elementName: newElementName, + }, session.accessToken, lang); + if (!elementId) { + errorMessage(t("worldSetting.unknownError")) + return; + } + const updatedWorlds: WorldProps[] = [...worlds]; + (updatedWorlds[selectedWorldIndex][section] as WorldElement[]).push({ + id: elementId, + name: newElementName, + description: '', + }); + setWorlds(updatedWorlds); + setNewElementName(''); + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t("worldElementComponent.errorUnknown")); + } + } + } + + function handleElementChange( + section: keyof WorldProps, + index: number, + field: keyof WorldElement, + value: string, + ): void { + const updatedWorlds: WorldProps[] = [...worlds]; + const sectionElements = updatedWorlds[selectedWorldIndex][ + section + ] as WorldElement[]; + sectionElements[index] = {...sectionElements[index], [field]: value}; + setWorlds(updatedWorlds); + } + + return ( +
    + {Array.isArray(worlds[selectedWorldIndex][sectionType as keyof WorldProps]) && + (worlds[selectedWorldIndex][sectionType as keyof WorldProps] as WorldElement[]).map( + (element: WorldElement, index: number) => ( +
    +
    + ) => handleElementChange(sectionType as keyof WorldProps, index, 'name', e.target.value)} + placeholder={t("worldElementComponent.namePlaceholder", {section: sectionLabel.toLowerCase()})} + />} + removeButtonCallBack={(): Promise => handleRemoveElement(sectionType as keyof WorldProps, index)}/> +
    + handleElementChange(sectionType as keyof WorldProps, index, 'description', e.target.value)} + placeholder={t("worldElementComponent.descriptionPlaceholder", {section: sectionLabel.toLowerCase()})} + /> +
    + ) + ) + } + ): void => setNewElementName(e.target.value)} + placeholder={t("worldElementComponent.newPlaceholder", {section: sectionLabel.toLowerCase()})} + />} addButtonCallBack={(): Promise => handleAddElement(sectionType as keyof WorldProps)}/> +
    + ); +} \ No newline at end of file diff --git a/components/book/settings/world/WorldSetting.tsx b/components/book/settings/world/WorldSetting.tsx new file mode 100644 index 0000000..93dc2ff --- /dev/null +++ b/components/book/settings/world/WorldSetting.tsx @@ -0,0 +1,309 @@ +'use client' +import React, {ChangeEvent, forwardRef, useContext, useEffect, useImperativeHandle, useState} from 'react'; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {faPlus, IconDefinition} from "@fortawesome/free-solid-svg-icons"; +import {WorldContext} from '@/context/WorldContext'; +import {BookContext} from "@/context/BookContext"; +import {AlertContext} from "@/context/AlertContext"; +import {SelectBoxProps} from "@/shared/interface"; +import System from "@/lib/models/System"; +import {elementSections, WorldProps} from "@/lib/models/World"; +import {SessionContext} from "@/context/SessionContext"; +import InputField from "@/components/form/InputField"; +import TextInput from '@/components/form/TextInput'; +import TexteAreaInput from "@/components/form/TexteAreaInput"; +import WorldElementComponent from './WorldElement'; +import SelectBox from "@/components/form/SelectBox"; +import {useTranslations} from "next-intl"; +import {LangContext, LangContextProps} from "@/context/LangContext"; + +export interface ElementSection { + title: string; + section: keyof WorldProps; + icon: IconDefinition; +} + +export function WorldSetting(props: any, ref: any) { + const t = useTranslations(); + const {lang} = useContext(LangContext); + const {errorMessage, successMessage} = useContext(AlertContext); + const {session} = useContext(SessionContext); + const {book} = useContext(BookContext); + const bookId: string = book?.bookId ? book.bookId.toString() : ''; + + const [worlds, setWorlds] = useState([]); + const [newWorldName, setNewWorldName] = useState(''); + const [selectedWorldIndex, setSelectedWorldIndex] = useState(0); + const [worldsSelector, setWorldsSelector] = useState([]); + const [showAddNewWorld, setShowAddNewWorld] = useState(false); + + useImperativeHandle(ref, function () { + return { + handleSave: handleUpdateWorld, + }; + }); + + useEffect((): void => { + getWorlds().then(); + }, []); + + async function getWorlds() { + try { + const response: WorldProps[] = await System.authGetQueryToServer(`book/worlds`, session.accessToken, lang, { + bookid: bookId, + }); + if (response) { + setWorlds(response); + const formattedWorlds: SelectBoxProps[] = response.map( + (world: WorldProps): SelectBoxProps => ({ + label: world.name, + value: world.id.toString(), + }), + ); + setWorldsSelector(formattedWorlds); + } + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t("worldSetting.unknownError")) + } + } + } + + async function handleAddNewWorld(): Promise { + if (newWorldName.trim() === '') { + errorMessage(t("worldSetting.newWorldNameError")); + return; + } + try { + const worldId: string = await System.authPostToServer('book/world/add', { + worldName: newWorldName, + bookId: bookId, + }, session.accessToken, lang); + if (!worldId) { + errorMessage(t("worldSetting.addWorldError")); + return; + } + const newWorldId: string = worldId; + const newWorld: WorldProps = { + id: newWorldId, + name: newWorldName, + history: '', + politics: '', + economy: '', + religion: '', + languages: '', + laws: [], + biomes: [], + issues: [], + customs: [], + kingdoms: [], + climate: [], + resources: [], + wildlife: [], + arts: [], + ethnicGroups: [], + socialClasses: [], + importantCharacters: [], + }; + setWorlds([...worlds, newWorld]); + setWorldsSelector([ + ...worldsSelector, + {label: newWorldName, value: newWorldId.toString()}, + ]); + setNewWorldName(''); + setShowAddNewWorld(false); + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t("worldSetting.unknownError")) + } + } + } + + async function handleUpdateWorld(): Promise { + try { + const response: boolean = await System.authPutToServer('book/world/update', { + world: worlds[selectedWorldIndex], + bookId: bookId, + }, session.accessToken, lang); + if (!response) { + errorMessage(t("worldSetting.updateWorldError")); + return; + } + successMessage(t("worldSetting.updateWorldSuccess")); + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t("worldSetting.unknownError")) + } + } + } + + function handleInputChange(value: string, field: keyof WorldProps) { + const updatedWorlds = [...worlds] as WorldProps[]; + (updatedWorlds[selectedWorldIndex][field] as string) = value; + setWorlds(updatedWorlds); + } + + return ( +
    +
    +
    + { + const worldId = e.target.value; + const index = worlds.findIndex(world => world.id.toString() === worldId); + if (index !== -1) { + setSelectedWorldIndex(index); + } + }} + data={worldsSelector.length > 0 ? worldsSelector : [{ + label: t("worldSetting.noWorldAvailable"), + value: '0' + }]} + defaultValue={worlds[selectedWorldIndex]?.id.toString() || '0'} + placeholder={t("worldSetting.selectWorldPlaceholder")} + /> + } + actionIcon={faPlus} + actionLabel={t("worldSetting.addWorldLabel")} + action={async () => setShowAddNewWorld(!showAddNewWorld)} + /> + + {showAddNewWorld && ( + ) => setNewWorldName(e.target.value)} + placeholder={t("worldSetting.newWorldPlaceholder")} + /> + } + actionIcon={faPlus} + actionLabel={t("worldSetting.createWorldLabel")} + addButtonCallBack={handleAddNewWorld} + /> + )} +
    +
    + + {worlds.length > 0 && worlds[selectedWorldIndex] ? ( + +
    +
    + ) => { + const updatedWorlds: WorldProps[] = [...worlds]; + updatedWorlds[selectedWorldIndex].name = e.target.value + setWorlds(updatedWorlds); + }} + placeholder={t("worldSetting.worldNamePlaceholder")} + /> + } + /> +
    + handleInputChange(e.target.value, 'history')} + placeholder={t("worldSetting.worldHistoryPlaceholder")} + /> + } + /> +
    + +
    +
    + handleInputChange(e.target.value, 'politics')} + placeholder={t("worldSetting.politicsPlaceholder")} + /> + } + /> + handleInputChange(e.target.value, 'economy')} + placeholder={t("worldSetting.economyPlaceholder")} + /> + } + /> +
    +
    + +
    +
    + handleInputChange(e.target.value, 'religion')} + placeholder={t("worldSetting.religionPlaceholder")} + /> + } + /> + handleInputChange(e.target.value, 'languages')} + placeholder={t("worldSetting.languagesPlaceholder")} + /> + } + /> +
    +
    + + {elementSections.map((section, index) => ( +
    +

    + + {section.title} + + {worlds[selectedWorldIndex][section.section]?.length || 0} + +

    + +
    + ))} +
    + ) : ( +
    +

    {t("worldSetting.noWorldAvailable")}

    +
    + )} +
    + ); +} + +export default forwardRef(WorldSetting); \ No newline at end of file diff --git a/components/editor/DraftCompanion.tsx b/components/editor/DraftCompanion.tsx new file mode 100644 index 0000000..52b7f2a --- /dev/null +++ b/components/editor/DraftCompanion.tsx @@ -0,0 +1,574 @@ +import React, {ChangeEvent, useContext, useEffect, useState} from "react"; +import {Editor, EditorContent, useEditor} from "@tiptap/react"; +import StarterKit from "@tiptap/starter-kit"; +import Underline from "@tiptap/extension-underline"; +import TextAlign from "@tiptap/extension-text-align"; +import System from "@/lib/models/System"; +import {ChapterContext} from "@/context/ChapterContext"; +import {BookContext} from "@/context/BookContext"; +import {SelectBoxProps} from "@/shared/interface"; +import {AlertContext} from "@/context/AlertContext"; +import {SessionContext} from "@/context/SessionContext"; +import { + faCubes, + faFeather, + faGlobe, + faMagicWandSparkles, + faMapPin, + faPalette, + faUser +} from "@fortawesome/free-solid-svg-icons"; +import SubmitButtonWLoading from "@/components/form/SubmitButtonWLoading"; +import QSTextGeneratedPreview from "@/components/QSTextGeneratedPreview"; +import {EditorContext} from "@/context/EditorContext"; +import {useTranslations} from "next-intl"; +import QuillSense from "@/lib/models/QuillSense"; +import TextInput from "@/components/form/TextInput"; +import InputField from "@/components/form/InputField"; +import TexteAreaInput from "@/components/form/TexteAreaInput"; +import SuggestFieldInput from "@/components/form/SuggestFieldInput"; +import Collapse from "@/components/Collapse"; +import {LangContext, LangContextProps} from "@/context/LangContext"; +import {BookTags} from "@/lib/models/Book"; +import {AIUsageContext, AIUsageContextProps} from "@/context/AIUsageContext"; +import {configs} from "@/lib/configs"; + +interface CompanionContent { + version: number; + content: string; + wordsCount: number; +} + +export default function DraftCompanion() { + const t = useTranslations(); + const {setTotalPrice, setTotalCredits} = useContext(AIUsageContext) + const {lang} = useContext(LangContext) + + const mainEditor: Editor | null = useEditor({ + extensions: [ + StarterKit, + Underline, + TextAlign.configure({ + types: ['heading', 'paragraph'], + }), + ], + injectCSS: false, + editable: false, + immediatelyRender: false, + }); + const {editor} = useContext(EditorContext); + const {chapter} = useContext(ChapterContext); + const {book} = useContext(BookContext); + const {session} = useContext(SessionContext); + const {errorMessage, infoMessage} = useContext(AlertContext); + + const [draftVersion, setDraftVersion] = useState(0); + const [draftWordCount, setDraftWordCount] = useState(0); + const [refinedText, setRefinedText] = useState(''); + const [isRefining, setIsRefining] = useState(false); + const [showRefinedText, setShowRefinedText] = useState(false); + const [showEnhancer, setShowEnhancer] = useState(false); + const [abortController, setAbortController] = useState | null>(null); + + const [toneAtmosphere, setToneAtmosphere] = useState(''); + const [specifications, setSpecifications] = useState(''); + + const [characters, setCharacters] = useState([]); + const [locations, setLocations] = useState([]); + const [objects, setObjects] = useState([]); + const [worldElements, setWorldElements] = useState([]); + + const [taguedCharacters, setTaguedCharacters] = useState([]); + const [taguedLocations, setTaguedLocations] = useState([]); + const [taguedObjects, setTaguedObjects] = useState([]); + const [taguedWorldElements, setTaguedWorldElements] = useState([]); + + const [searchCharacters, setSearchCharacters] = useState(''); + const [searchLocations, setSearchLocations] = useState(''); + const [searchObjects, setSearchObjects] = useState(''); + const [searchWorldElements, setSearchWorldElements] = useState(''); + + const [showCharacterSuggestions, setShowCharacterSuggestions] = useState(false); + const [showLocationSuggestions, setShowLocationSuggestions] = useState(false); + const [showObjectSuggestions, setShowObjectSuggestions] = useState(false); + const [showWorldElementSuggestions, setShowWorldElementSuggestions] = useState(false); + + const isGPTEnabled: boolean = QuillSense.isOpenAIEnabled(session); + const isSubTierTree: boolean = QuillSense.getSubLevel(session) === 3; + const hasAccess: boolean = isGPTEnabled || isSubTierTree; + + useEffect((): void => { + getDraftContent().then(); + if (showEnhancer) { + fetchTags().then(); + } + }, [mainEditor, chapter, showEnhancer]); + + async function getDraftContent(): Promise { + try { + const response: CompanionContent = await System.authGetQueryToServer(`chapter/content/companion`, session.accessToken, lang, { + bookid: book?.bookId, + chapterid: chapter?.chapterId, + version: chapter?.chapterContent.version, + }); + if (response && mainEditor) { + mainEditor.commands.setContent(JSON.parse(response.content)); + setDraftVersion(response.version); + setDraftWordCount(response.wordsCount); + } else if (response && response.content.length === 0 && mainEditor) { + mainEditor.commands.setContent({ + "type": "doc", + "content": [ + { + "type": "heading", + "attrs": { + "level": 1 + }, + "content": [ + { + "type": "text", + "text": t("draftCompanion.noPreviousVersion") + } + ] + } + ] + }); + } + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t("draftCompanion.unknownError")); + } + } + } + + async function fetchTags(): Promise { + try { + const responseTags: BookTags = await System.authGetQueryToServer(`book/tags`, session.accessToken, lang, { + bookId: book?.bookId + }); + if (responseTags) { + setCharacters(responseTags.characters); + setLocations(responseTags.locations); + setObjects(responseTags.objects); + setWorldElements(responseTags.worldElements); + } + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t("draftCompanion.unknownError")); + } + } + } + + async function handleStopRefining(): Promise { + if (abortController) { + await abortController.cancel(); + setAbortController(null); + infoMessage(t("draftCompanion.abortSuccess")); + } + } + + async function handleQuillSenseRefined(): Promise { + if (chapter && session?.accessToken) { + setIsRefining(true); + setShowRefinedText(false); + setRefinedText(''); + + try { + const response: Response = await fetch(`${configs.apiUrl}quillsense/refine`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${session.accessToken}`, + }, + body: JSON.stringify({ + chapterId: chapter?.chapterId, + bookId: book?.bookId, + toneAndAtmosphere: toneAtmosphere, + advancedPrompt: specifications, + tags: { + characters: taguedCharacters, + locations: taguedLocations, + objects: taguedObjects, + worldElements: taguedWorldElements, + } + }), + }); + + if (!response.ok) { + const error: { message?: string } = await response.json(); + errorMessage(error.message || t('draftCompanion.errorRefineDraft')); + setIsRefining(false); + return; + } + + const reader: ReadableStreamDefaultReader | undefined = response.body?.getReader(); + const decoder: TextDecoder = new TextDecoder(); + let accumulatedText: string = ''; + + if (!reader) { + errorMessage(t('draftCompanion.errorRefineDraft')); + setIsRefining(false); + return; + } + + setAbortController(reader); + + while (true) { + try { + const {done, value}: ReadableStreamReadResult = await reader.read(); + + if (done) break; + + const chunk: string = decoder.decode(value, {stream: true}); + const lines: string[] = chunk.split('\n'); + + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const dataStr: string = line.slice(6); + const data: { + content?: string; + totalCost?: number; + totalPrice?: number; + useYourKey?: boolean; + aborted?: boolean; + } = JSON.parse(dataStr); + + // Si c'est le message final avec les totaux + if ('totalCost' in data && 'useYourKey' in data && 'totalPrice' in data) { + console.log(data); + if (data.useYourKey) { + setTotalPrice((prev: number): number => prev + data.totalPrice!); + } else { + setTotalCredits(data.totalPrice!); + } + } else if ('content' in data && data.content && data.content !== 'starting') { + accumulatedText += data.content; + setRefinedText(accumulatedText); + } + } catch (e: unknown) { + console.error('Error parsing SSE data:', e); + } + } + } + } catch (e: unknown) { + break; + } + } + + setIsRefining(false); + setShowRefinedText(true); + setAbortController(null); + } catch (e: unknown) { + setIsRefining(false); + setAbortController(null); + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t('draftCompanion.unknownErrorRefineDraft')); + } + } + } + } + + function insertText(): void { + if (editor && refinedText) { + editor.commands.focus('end'); + if (editor.getText().length > 0) { + editor.commands.insertContent('\n\n'); + } + editor.commands.insertContent(System.textContentToHtml(refinedText)); + setShowRefinedText(false); + } + } + + function filteredCharacters(): SelectBoxProps[] { + if (searchCharacters.trim().length === 0) return []; + return characters + .filter((item: SelectBoxProps): boolean => item.label.toLowerCase().includes(searchCharacters.toLowerCase()) && !taguedCharacters.includes(item.value)) + .slice(0, 3); + } + + function filteredLocations(): SelectBoxProps[] { + if (searchLocations.trim().length === 0) return []; + return locations + .filter((item: SelectBoxProps): boolean => item.label.toLowerCase().includes(searchLocations.toLowerCase()) && !taguedLocations.includes(item.value)) + .slice(0, 3); + } + + function filteredObjects(): SelectBoxProps[] { + if (searchObjects.trim().length === 0) return []; + return objects + .filter((item: SelectBoxProps): boolean => item.label.toLowerCase().includes(searchObjects.toLowerCase()) && !taguedObjects.includes(item.value)) + .slice(0, 3); + } + + function filteredWorldElements(): SelectBoxProps[] { + if (searchWorldElements.trim().length === 0) return []; + return worldElements + .filter((item: SelectBoxProps): boolean => item.label.toLowerCase().includes(searchWorldElements.toLowerCase()) && !taguedWorldElements.includes(item.value)) + .slice(0, 3); + } + + function handleAddCharacter(value: string): void { + if (!taguedCharacters.includes(value)) { + const newCharacters: string[] = [...taguedCharacters, value]; + setTaguedCharacters(newCharacters); + } + setSearchCharacters(''); + setShowCharacterSuggestions(false); + } + + function handleAddLocation(value: string): void { + if (!taguedLocations.includes(value)) { + const newLocations: string[] = [...taguedLocations, value]; + setTaguedLocations(newLocations); + } + setSearchLocations(''); + setShowLocationSuggestions(false); + } + + function handleAddObject(value: string): void { + if (!taguedObjects.includes(value)) { + const newObjects: string[] = [...taguedObjects, value]; + setTaguedObjects(newObjects); + } + setSearchObjects(''); + setShowObjectSuggestions(false); + } + + function handleAddWorldElement(value: string): void { + if (!taguedWorldElements.includes(value)) { + const newWorldElements: string[] = [...taguedWorldElements, value]; + setTaguedWorldElements(newWorldElements); + } + setSearchWorldElements(''); + setShowWorldElementSuggestions(false); + } + + function handleRemoveCharacter(value: string): void { + setTaguedCharacters(taguedCharacters.filter((tag: string): boolean => tag !== value)); + } + + function handleRemoveLocation(value: string): void { + setTaguedLocations(taguedLocations.filter((tag: string): boolean => tag !== value)); + } + + function handleRemoveObject(value: string): void { + setTaguedObjects(taguedObjects.filter((tag: string): boolean => tag !== value)); + } + + function handleRemoveWorldElement(value: string): void { + setTaguedWorldElements(taguedWorldElements.filter((tag: string): boolean => tag !== value)); + } + + function handleCharacterSearch(text: string): void { + setSearchCharacters(text); + setShowCharacterSuggestions(text.trim().length > 0); + } + + function handleLocationSearch(text: string): void { + setSearchLocations(text); + setShowLocationSuggestions(text.trim().length > 0); + } + + function handleObjectSearch(text: string): void { + setSearchObjects(text); + setShowObjectSuggestions(text.trim().length > 0); + } + + function handleWorldElementSearch(text: string): void { + setSearchWorldElements(text); + setShowWorldElementSuggestions(text.trim().length > 0); + } + + function getCharacterLabel(value: string): string { + const character: SelectBoxProps | undefined = characters.find((item: SelectBoxProps): boolean => item.value === value); + return character ? character.label : value; + } + + function getLocationLabel(value: string): string { + const location: SelectBoxProps | undefined = locations.find((item: SelectBoxProps): boolean => item.value === value); + return location ? location.label : value; + } + + function getObjectLabel(value: string): string { + const object: SelectBoxProps | undefined = objects.find((item: SelectBoxProps): boolean => item.value === value); + return object ? object.label : value; + } + + function getWorldElementLabel(value: string): string { + const element: SelectBoxProps | undefined = worldElements.find((item: SelectBoxProps): boolean => item.value === value); + return element ? element.label : value; + } + + if (showEnhancer && hasAccess) { + return ( +
    +
    +

    Amélioration de texte

    + +
    + +
    + + ) => setToneAtmosphere(e.target.value)} + placeholder={t("ghostWriter.tonePlaceholder")} + /> + } + /> +
    + } + /> + + + handleCharacterSearch(e.target.value)} + handleAddTag={handleAddCharacter} + handleRemoveTag={handleRemoveCharacter} + filteredTags={filteredCharacters} + showTagSuggestions={showCharacterSuggestions} + setShowTagSuggestions={setShowCharacterSuggestions} + getTagLabel={getCharacterLabel} + /> + + handleLocationSearch(e.target.value)} + handleAddTag={handleAddLocation} + handleRemoveTag={handleRemoveLocation} + filteredTags={filteredLocations} + showTagSuggestions={showLocationSuggestions} + setShowTagSuggestions={setShowLocationSuggestions} + getTagLabel={getLocationLabel} + /> + + handleObjectSearch(e.target.value)} + handleAddTag={handleAddObject} + handleRemoveTag={handleRemoveObject} + filteredTags={filteredObjects} + showTagSuggestions={showObjectSuggestions} + setShowTagSuggestions={setShowObjectSuggestions} + getTagLabel={getObjectLabel} + /> + + handleWorldElementSearch(e.target.value)} + handleAddTag={handleAddWorldElement} + handleRemoveTag={handleRemoveWorldElement} + filteredTags={filteredWorldElements} + showTagSuggestions={showWorldElementSuggestions} + setShowTagSuggestions={setShowWorldElementSuggestions} + getTagLabel={getWorldElementLabel} + /> +
    + } + /> + +
    + ) => setSpecifications(e.target.value)} + placeholder="Spécifications particulières pour l'amélioration..." + maxLength={600} + /> + } + /> +
    + + +
    +
    + +
    +
    + {(showRefinedText || isRefining) && ( + setShowRefinedText(false)} + onRefresh={handleQuillSenseRefined} + value={refinedText} + onInsert={insertText} + isGenerating={isRefining} + onStop={handleStopRefining} + /> + )} + + ); + } + + return ( +
    +
    +
    + {t("draftCompanion.words")}: + {draftWordCount} +
    + { + hasAccess && chapter?.chapterContent.version === 3 && ( +
    + setShowEnhancer(true)} + isLoading={isRefining} + text={t("draftCompanion.refine")} + loadingText={t("draftCompanion.refining")} + icon={faFeather} + /> +
    + ) + } +
    +
    + +
    +
    + ); +} \ No newline at end of file diff --git a/components/editor/NoBookHome.tsx b/components/editor/NoBookHome.tsx new file mode 100644 index 0000000..dbd3ae1 --- /dev/null +++ b/components/editor/NoBookHome.tsx @@ -0,0 +1,26 @@ +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {faBookOpen} from "@fortawesome/free-solid-svg-icons"; +import React from "react"; +import {useTranslations} from "next-intl"; + +export default function NoBookHome() { + const t = useTranslations(); + + return ( +
    +
    + +

    {t("noBookHome.title")}

    +

    + {t("noBookHome.description")} +

    +
    + + {t("noBookHome.hint")} +
    +
    +
    + ) +} \ No newline at end of file diff --git a/components/editor/ScribeEditor.tsx b/components/editor/ScribeEditor.tsx new file mode 100644 index 0000000..1201cda --- /dev/null +++ b/components/editor/ScribeEditor.tsx @@ -0,0 +1,33 @@ +import React, {useContext, useState} from "react"; +import {ChapterContext} from "@/context/ChapterContext"; +import {BookContext} from "@/context/BookContext"; +import {SettingBookContext} from "@/context/SettingBookContext"; +import TextEditor from "./TextEditor"; +import BookList from "@/components/book/BookList"; +import BookSettingOption from "@/components/book/settings/BookSettingOption"; +import NoBookHome from "@/components/editor/NoBookHome"; + +export default function ScribeEditor() { + const {chapter} = useContext(ChapterContext); + const {book} = useContext(BookContext); + + const [bookSettingId, setBookSettingId] = useState(''); + + return ( + +
    + { + chapter ? ( + + ) : book ? ( + + ) : book === null ? ( + + ) : bookSettingId && ( + + ) + } +
    +
    + ); +} diff --git a/components/editor/TextEditor.tsx b/components/editor/TextEditor.tsx new file mode 100644 index 0000000..083958e --- /dev/null +++ b/components/editor/TextEditor.tsx @@ -0,0 +1,516 @@ +'use client' +import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; +import {EditorContent} from '@tiptap/react'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import { + faAlignCenter, + faAlignLeft, + faAlignRight, + faBold, + faCog, + faFloppyDisk, + faGhost, + faHeading, + faLayerGroup, + faListOl, + faListUl, + faParagraph, + faUnderline +} from '@fortawesome/free-solid-svg-icons'; +import {EditorContext} from "@/context/EditorContext"; +import {ChapterContext} from '@/context/ChapterContext'; +import System from '@/lib/models/System'; +import {AlertContext} from '@/context/AlertContext'; +import {SessionContext} from "@/context/SessionContext"; +import DraftCompanion from "@/components/editor/DraftCompanion"; +import GhostWriter from "@/components/ghostwriter/GhostWriter"; +import SubmitButtonWLoading from "@/components/form/SubmitButtonWLoading"; +import CollapsableButton from "@/components/CollapsableButton"; +import {IconDefinition} from "@fortawesome/fontawesome-svg-core"; +import UserEditorSettings, {EditorDisplaySettings} from "@/components/editor/UserEditorSetting"; +import {useTranslations} from "next-intl"; +import {LangContext, LangContextProps} from "@/context/LangContext"; + +interface ToolbarButton { + action: () => void; + icon: IconDefinition; + isActive: boolean; + label?: string; +} + +interface EditorClasses { + base: string; + h1: string; + h2: string; + h3: string; + container: string; + theme: string; + paragraph: string; + lists: string; + listItems: string; +} + +const DEFAULT_EDITOR_SETTINGS: EditorDisplaySettings = { + zoomLevel: 3, + indent: 30, + lineHeight: 1.5, + theme: 'sombre', + fontFamily: 'lora', + maxWidth: 768, + focusMode: false +}; + +const FONT_SIZE_CLASSES = { + 1: 'text-sm', + 2: 'text-base', + 3: 'text-lg', + 4: 'text-xl', + 5: 'text-2xl' +} as const; + +const H1_SIZE_CLASSES = { + 1: 'text-xl', + 2: 'text-2xl', + 3: 'text-3xl', + 4: 'text-4xl', + 5: 'text-5xl' +} as const; + +const H2_SIZE_CLASSES = { + 1: 'text-lg', + 2: 'text-xl', + 3: 'text-2xl', + 4: 'text-3xl', + 5: 'text-4xl' +} as const; + +const H3_SIZE_CLASSES = { + 1: 'text-base', + 2: 'text-lg', + 3: 'text-xl', + 4: 'text-2xl', + 5: 'text-3xl' +} as const; + +const FONT_FAMILY_CLASSES = { + 'lora': 'Lora', + 'serif': 'font-serif', + 'sans-serif': 'font-sans', + 'monospace': 'font-mono' +} as const; + +const LINE_HEIGHT_CLASSES = { + 1.2: 'leading-tight', + 1.5: 'leading-normal', + 1.75: 'leading-relaxed', + 2: 'leading-loose' +} as const; + +const MAX_WIDTH_CLASSES = { + 600: 'max-w-xl', + 650: 'max-w-2xl', + 700: 'max-w-3xl', + 750: 'max-w-4xl', + 800: 'max-w-5xl', + 850: 'max-w-6xl', + 900: 'max-w-7xl', + 950: 'max-w-full', + 1000: 'max-w-full', + 1050: 'max-w-full', + 1100: 'max-w-full', + 1150: 'max-w-full', + 1200: 'max-w-full' +} as const; + +function getClosestKey>(value: number, obj: T): keyof T { + const keys: number[] = Object.keys(obj).map(Number).sort((a: number, b: number): number => a - b); + return keys.reduce((prev: number, curr: number): number => + Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev + ); +} + +export default function TextEditor() { + const t = useTranslations(); + const {lang} = useContext(LangContext) + const {editor} = useContext(EditorContext); + const {chapter} = useContext(ChapterContext); + const {errorMessage, successMessage} = useContext(AlertContext); + const {session} = useContext(SessionContext); + + const [mainTimer, setMainTimer] = useState(0); + const [showDraftCompanion, setShowDraftCompanion] = useState(false); + const [showGhostWriter, setShowGhostWriter] = useState(false); + const [showUserSettings, setShowUserSettings] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [editorSettings, setEditorSettings] = useState(DEFAULT_EDITOR_SETTINGS); + const [editorClasses, setEditorClasses] = useState({ + base: 'text-lg font-serif leading-normal', + h1: 'text-3xl font-bold', + h2: 'text-2xl font-bold', + h3: 'text-xl font-bold', + container: 'max-w-3xl', + theme: 'bg-tertiary/90 backdrop-blur-sm bg-opacity-25 text-text-primary', + paragraph: 'indent-6', + lists: 'pl-10', + listItems: 'text-lg' + }); + + const timerRef: React.RefObject = useRef(null); + const timeoutRef: React.RefObject = useRef(null); + + const updateEditorClasses: (settings: EditorDisplaySettings) => void = useCallback((settings: EditorDisplaySettings): void => { + const fontSizeKey = settings.zoomLevel as keyof typeof FONT_SIZE_CLASSES; + const h1SizeKey = settings.zoomLevel as keyof typeof H1_SIZE_CLASSES; + const h2SizeKey = settings.zoomLevel as keyof typeof H2_SIZE_CLASSES; + const h3SizeKey = settings.zoomLevel as keyof typeof H3_SIZE_CLASSES; + const fontFamilyKey = settings.fontFamily as keyof typeof FONT_FAMILY_CLASSES; + const lineHeightKey = settings.lineHeight as keyof typeof LINE_HEIGHT_CLASSES; + const maxWidthKey: number = getClosestKey(settings.maxWidth, MAX_WIDTH_CLASSES); + + const indentClass = `indent-${Math.round(settings.indent / 4)}`; + + const baseClass = `${FONT_SIZE_CLASSES[fontSizeKey]} ${FONT_FAMILY_CLASSES[fontFamilyKey]} ${LINE_HEIGHT_CLASSES[lineHeightKey]}`; + const h1Class = `${H1_SIZE_CLASSES[h1SizeKey]} font-bold ${FONT_FAMILY_CLASSES[fontFamilyKey]} ${LINE_HEIGHT_CLASSES[lineHeightKey]}`; + const h2Class = `${H2_SIZE_CLASSES[h2SizeKey]} font-bold ${FONT_FAMILY_CLASSES[fontFamilyKey]} ${LINE_HEIGHT_CLASSES[lineHeightKey]}`; + const h3Class = `${H3_SIZE_CLASSES[h3SizeKey]} font-bold ${FONT_FAMILY_CLASSES[fontFamilyKey]} ${LINE_HEIGHT_CLASSES[lineHeightKey]}`; + const containerClass = MAX_WIDTH_CLASSES[maxWidthKey as keyof typeof MAX_WIDTH_CLASSES]; + const listsClass = `pl-${Math.round((settings.indent + 20) / 4)}`; + + let themeClass: string = ''; + switch (settings.theme) { + case 'clair': + themeClass = 'bg-white text-black'; + break; + case 'sépia': + themeClass = 'text-amber-900'; + break; + default: + themeClass = 'bg-tertiary/90 backdrop-blur-sm bg-opacity-25 text-text-primary'; + } + + setEditorClasses({ + base: baseClass, + h1: h1Class, + h2: h2Class, + h3: h3Class, + container: containerClass, + theme: themeClass, + paragraph: indentClass, + lists: listsClass, + listItems: baseClass + }); + }, []); + + const containerStyle = useMemo(() => { + if (editorSettings.theme === 'sépia') { + return {backgroundColor: '#f4f1e8'}; + } + return {}; + }, [editorSettings.theme]); + + const toolbarButtons: ToolbarButton[] = (() => { + if (!editor) return []; + + return [ + { + action: (): boolean => editor.chain().focus().setParagraph().run(), + icon: faParagraph, + isActive: editor.isActive('paragraph') + }, + { + action: (): boolean => editor.chain().focus().toggleBold().run(), + icon: faBold, + isActive: editor.isActive('bold') + }, + { + action: (): boolean => editor.chain().focus().toggleUnderline().run(), + icon: faUnderline, + isActive: editor.isActive('underline') + }, + { + action: (): boolean => editor.chain().focus().setTextAlign('left').run(), + icon: faAlignLeft, + isActive: editor.isActive({textAlign: 'left'}) + }, + { + action: (): boolean => editor.chain().focus().setTextAlign('center').run(), + icon: faAlignCenter, + isActive: editor.isActive({textAlign: 'center'}) + }, + { + action: (): boolean => editor.chain().focus().setTextAlign('right').run(), + icon: faAlignRight, + isActive: editor.isActive({textAlign: 'right'}) + }, + { + action: (): boolean => editor.chain().focus().toggleBulletList().run(), + icon: faListUl, + isActive: editor.isActive('bulletList') + }, + { + action: (): boolean => editor.chain().focus().toggleOrderedList().run(), + icon: faListOl, + isActive: editor.isActive('orderedList') + }, + { + action: (): boolean => editor.chain().focus().toggleHeading({level: 1}).run(), + icon: faHeading, + isActive: editor.isActive('heading', {level: 1}), + label: '1' + }, + { + action: (): boolean => editor.chain().focus().toggleHeading({level: 2}).run(), + icon: faHeading, + isActive: editor.isActive('heading', {level: 2}), + label: '2' + }, + { + action: (): boolean => editor.chain().focus().toggleHeading({level: 3}).run(), + icon: faHeading, + isActive: editor.isActive('heading', {level: 3}), + label: '3' + }, + ]; + })(); + + const saveContent: () => Promise = useCallback(async (): Promise => { + if (!editor || !chapter) return; + + setIsSaving(true); + const content = editor.state.doc.toJSON(); + const chapterId: string = chapter.chapterId || ''; + const version: number = chapter.chapterContent.version || 0; + + try { + const response: boolean = await System.authPostToServer(`chapter/content`, { + chapterId, + version, + content, + totalWordCount: editor.getText().length, + currentTime: mainTimer + }, session?.accessToken ?? ''); + if (!response) { + errorMessage(t('editor.error.savedFailed')); + setIsSaving(false); + return; + } + setMainTimer(0); + successMessage(t('editor.success.saved')); + setIsSaving(false); + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t('editor.error.unknownError')); + } + setIsSaving(false); + } + }, [editor, chapter, mainTimer, session?.accessToken, successMessage, errorMessage]); + + const handleShowDraftCompanion: () => void = useCallback((): void => { + setShowDraftCompanion((prev: boolean): boolean => !prev); + setShowGhostWriter(false); + setShowUserSettings(false); + }, []); + + const handleShowGhostWriter: () => void = useCallback((): void => { + if (chapter?.chapterContent.version === 2) { + setShowGhostWriter((prev: boolean): boolean => !prev); + setShowDraftCompanion(false); + setShowUserSettings(false); + } + }, [chapter?.chapterContent.version]); + + const handleShowUserSettings: () => void = useCallback((): void => { + setShowUserSettings((prev: boolean): boolean => !prev); + setShowDraftCompanion(false); + setShowGhostWriter(false); + }, []); + + useEffect((): void => { + if (!editor) return; + + const editorElement: HTMLElement = editor.view.dom; + if (editorElement) { + const indentClasses: string[] = Array.from({length: 21}, (_, i) => `indent-${i}`); + editorElement.classList.remove(...indentClasses); + + if (editorClasses.paragraph) { + editorElement.classList.add(editorClasses.paragraph); + } + } + }, [editor, editorClasses.paragraph]); + + useEffect((): void => { + updateEditorClasses(editorSettings); + }, [editorSettings, updateEditorClasses]); + + useEffect((): () => void => { + function startTimer(): void { + if (timerRef.current === null) { + timerRef.current = window.setInterval(() => { + setMainTimer(prevTimer => prevTimer + 1); + }, 1000); + } + } + + function stopTimer(): void { + if (timerRef.current !== null) { + clearInterval(timerRef.current); + timerRef.current = null; + } + } + + function resetTimeout(): void { + if (timeoutRef.current !== null) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = window.setTimeout(stopTimer, 5000); + } + + function handleKeyDown(): void { + startTimer(); + resetTimeout(); + } + + window.addEventListener('keydown', handleKeyDown, {passive: true}); + + return (): void => { + window.removeEventListener('keydown', handleKeyDown); + if (timerRef.current !== null) { + clearInterval(timerRef.current); + } + if (timeoutRef.current !== null) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + useEffect((): () => void => { + document.addEventListener('keydown', handleKeyDown, {passive: false}); + return (): void => document.removeEventListener('keydown', handleKeyDown); + }, [saveContent]); + + useEffect((): void => { + if (!editor) return; + if (chapter?.chapterContent.content) { + try { + const parsedContent = JSON.parse(chapter.chapterContent.content); + editor.commands.setContent(parsedContent); + } catch (error) { + console.error('Erreur lors du parsing du contenu:', error); + editor.commands.setContent({ + type: "doc", + content: [{type: "paragraph", content: []}] + }); + } + } else { + editor.commands.setContent({ + type: "doc", + content: [{type: "paragraph", content: []}] + }); + } + + if (chapter?.chapterContent.version !== 2) { + setShowGhostWriter(false); + } + }, [editor, chapter?.chapterContent.content, chapter?.chapterContent.version]); + + async function handleKeyDown(event: KeyboardEvent): Promise { + if ((event.ctrlKey || event.metaKey) && event.key === 's') { + event.preventDefault(); + await saveContent(); + } + } + + if (!editor) { + return null; + } + + return ( +
    +
    +
    + {toolbarButtons.map((button: ToolbarButton, index: number) => ( + + ))} +
    +
    + + {chapter?.chapterContent.version === 2 && ( + + )} + {chapter?.chapterContent.version && chapter.chapterContent.version > 2 && ( + + )} + +
    +
    + +
    +
    +
    +
    +
    + +
    +
    + + {(showDraftCompanion || showGhostWriter || showUserSettings) && ( +
    + {showDraftCompanion && } + {showGhostWriter && } + {showUserSettings && ( + + )} +
    + )} +
    +
    + ); +} \ No newline at end of file diff --git a/components/editor/UserEditorSetting.tsx b/components/editor/UserEditorSetting.tsx new file mode 100644 index 0000000..5c31984 --- /dev/null +++ b/components/editor/UserEditorSetting.tsx @@ -0,0 +1,240 @@ +'use client' +import React, {ChangeEvent, useCallback, useEffect, useMemo} from 'react'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import {faEye, faFont, faIndent, faPalette, faTextHeight, faTextWidth} from '@fortawesome/free-solid-svg-icons'; +import {useTranslations} from "next-intl"; +import SelectBox from "@/components/form/SelectBox"; + +interface UserEditorSettingsProps { + settings: EditorDisplaySettings; + onSettingsChange: (settings: EditorDisplaySettings) => void; +} + +export interface EditorDisplaySettings { + zoomLevel: number; + indent: number; + lineHeight: number; + theme: 'clair' | 'sombre' | 'sépia'; + fontFamily: 'lora' | 'serif' | 'sans-serif' | 'monospace'; + maxWidth: number; + focusMode: boolean; +} + +const ZOOM_LABELS = ['Très petit', 'Petit', 'Normal', 'Grand', 'Très grand'] as const; +const FONT_SIZES = [14, 16, 18, 20, 22] as const; +const THEMES = ['clair', 'sombre', 'sépia'] as const; + +const DEFAULT_SETTINGS: EditorDisplaySettings = { + zoomLevel: 3, + indent: 30, + lineHeight: 1.5, + theme: 'sombre', + fontFamily: 'lora', + maxWidth: 768, + focusMode: false +}; + +export default function UserEditorSettings({settings, onSettingsChange}: UserEditorSettingsProps) { + const t = useTranslations(); + + const handleSettingChange = useCallback(( + key: K, + value: EditorDisplaySettings[K] + ) => { + onSettingsChange({...settings, [key]: value}); + }, [settings, onSettingsChange]); + + const resetToDefaults = useCallback(() => { + onSettingsChange(DEFAULT_SETTINGS); + }, [onSettingsChange]); + + const zoomOptions = useMemo(() => + ZOOM_LABELS.map((label, index) => ({ + value: (index + 1).toString(), + label: `${t(`userEditorSettings.zoom.${label}`)} (${FONT_SIZES[index]}px)` + })) + , [t]); + + const themeButtons = useMemo(() => + THEMES.map(theme => ({ + key: theme, + isActive: settings.theme === theme, + className: `p-2.5 rounded-xl border capitalize transition-all duration-200 font-medium ${ + settings.theme === theme + ? 'bg-primary text-text-primary border-primary shadow-md scale-105' + : 'bg-secondary/50 border-secondary/50 text-muted hover:text-text-primary hover:border-secondary hover:bg-secondary hover:scale-102' + }` + })) + , [settings.theme]); + + useEffect((): void => { + try { + const savedSettings: string | null = localStorage.getItem('userEditorSettings'); + if (savedSettings) { + const parsed = JSON.parse(savedSettings); + if (parsed && typeof parsed === 'object') { + onSettingsChange({...DEFAULT_SETTINGS, ...parsed}); + } + } + } catch (e: unknown) { + onSettingsChange(DEFAULT_SETTINGS); + } + }, [onSettingsChange]); + + useEffect((): () => void => { + const timeoutId = setTimeout((): void => { + try { + localStorage.setItem('userEditorSettings', JSON.stringify(settings)); + } catch (error) { + console.error('Erreur lors de la sauvegarde des settings:', error); + } + }, 100); + + return (): void => clearTimeout(timeoutId); + }, [settings]); + + return ( +
    +
    + +

    {t("userEditorSettings.displayPreferences")}

    +
    + +
    +
    + + ) => { + handleSettingChange('zoomLevel', Number(e.target.value)) + }} + data={zoomOptions} + /> +
    + +
    + +
    + handleSettingChange('indent', Number(e.target.value))} + className="w-full accent-primary" + /> +
    + {t("userEditorSettings.indentNone")} + {settings.indent}px + {t("userEditorSettings.indentMax")} +
    +
    +
    + +
    + + ) => handleSettingChange('lineHeight', Number(e.target.value))} + data={[ + {value: "1.2", label: t("userEditorSettings.lineHeightCompact")}, + {value: "1.5", label: t("userEditorSettings.lineHeightNormal")}, + {value: "1.75", label: t("userEditorSettings.lineHeightSpaced")}, + {value: "2", label: t("userEditorSettings.lineHeightDouble")} + ]} + /> +
    + +
    + + ) => handleSettingChange('fontFamily', e.target.value as EditorDisplaySettings['fontFamily'])} + data={[ + {value: "lora", label: t("userEditorSettings.fontLora")}, + {value: "serif", label: t("userEditorSettings.fontSerif")}, + {value: "sans-serif", label: t("userEditorSettings.fontSansSerif")}, + {value: "monospace", label: t("userEditorSettings.fontMonospace")} + ]} + /> +
    + +
    + +
    + handleSettingChange('maxWidth', Number(e.target.value))} + className="w-full accent-primary" + /> +
    + {t("userEditorSettings.maxWidthNarrow")} + {settings.maxWidth}px + {t("userEditorSettings.maxWidthWide")} +
    +
    +
    + +
    + +
    + {themeButtons.map((themeBtn) => ( + + ))} +
    +
    + +
    + +
    + +
    + +
    +
    +
    + ); +} \ No newline at end of file diff --git a/components/form/AddActionButton.tsx b/components/form/AddActionButton.tsx new file mode 100644 index 0000000..87ce487 --- /dev/null +++ b/components/form/AddActionButton.tsx @@ -0,0 +1,20 @@ +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {faPlus} from "@fortawesome/free-solid-svg-icons"; +import React from "react"; + +interface AddActionButtonProps { + callBackAction: () => Promise; +} + +export default function AddActionButton( + { + callBackAction + }: AddActionButtonProps) { + return ( + + ) +} diff --git a/components/form/CancelButton.tsx b/components/form/CancelButton.tsx new file mode 100644 index 0000000..42bed3c --- /dev/null +++ b/components/form/CancelButton.tsx @@ -0,0 +1,21 @@ +import React from "react"; + +interface CancelButtonProps { + callBackFunction: () => void; + text?: string; +} + +export default function CancelButton( + { + callBackFunction, + text = "Annuler" + }: CancelButtonProps) { + return ( + + ); +} diff --git a/components/form/CheckBox.tsx b/components/form/CheckBox.tsx new file mode 100644 index 0000000..6d38975 --- /dev/null +++ b/components/form/CheckBox.tsx @@ -0,0 +1,53 @@ +import React, {Dispatch, SetStateAction} from "react"; + +interface CheckBoxProps { + isChecked: boolean; + setIsChecked: Dispatch>; + label: string; + description: string; + id: string; +} + +export default function CheckBox( + { + isChecked, + setIsChecked, + label, + description, + id, + }: CheckBoxProps) { + return ( +
    +
    + setIsChecked(!isChecked)} + className="hidden" + /> + +
    +
    + +

    + {description} +

    +
    +
    + ) +} diff --git a/components/form/ConfirmButton.tsx b/components/form/ConfirmButton.tsx new file mode 100644 index 0000000..b619290 --- /dev/null +++ b/components/form/ConfirmButton.tsx @@ -0,0 +1,40 @@ +import React from "react"; + +type ButtonType = 'alert' | 'danger' | 'informatif' | 'success'; + +interface ConfirmButtonProps { + text: string; + callBackFunction?: () => void; + buttonType?: ButtonType; +} + +export default function ConfirmButton( + { + text, + callBackFunction, + buttonType = 'success' + }: ConfirmButtonProps) { + function getButtonType(alertType: ButtonType): string { + switch (alertType) { + case 'alert': + return 'bg-warning'; + case 'danger': + return 'bg-error'; + case 'informatif': + return 'bg-info'; + case 'success': + default: + return 'bg-success'; + } + } + + const applyType: string = getButtonType(buttonType); + return ( + + ) +} diff --git a/components/form/DatePicker.tsx b/components/form/DatePicker.tsx new file mode 100644 index 0000000..28eca30 --- /dev/null +++ b/components/form/DatePicker.tsx @@ -0,0 +1,21 @@ +import React, {ChangeEvent} from "react"; + +interface DatePickerProps { + date: string; + setDate: (e: ChangeEvent) => void; +} + +export default function DatePicker( + { + setDate, + date + }: DatePickerProps) { + return ( + + ) +} diff --git a/components/form/InlineAddInput.tsx b/components/form/InlineAddInput.tsx new file mode 100644 index 0000000..30a0d37 --- /dev/null +++ b/components/form/InlineAddInput.tsx @@ -0,0 +1,71 @@ +import React, {ChangeEvent, KeyboardEvent, useRef, useState} from "react"; +import AddActionButton from "@/components/form/AddActionButton"; + +interface InlineAddInputProps { + value: string; + setValue: (value: string) => void; + numericalValue?: number; + setNumericalValue?: (value: number) => void; + placeholder: string; + onAdd: () => Promise; + showNumericalInput?: boolean; +} + +export default function InlineAddInput( + { + value, + setValue, + numericalValue, + setNumericalValue, + placeholder, + onAdd, + showNumericalInput = false + }: InlineAddInputProps) { + const [isAdding, setIsAdding] = useState(false); + const listItemRef = useRef(null); + + async function handleAdd(): Promise { + await onAdd(); + setIsAdding(false); + } + + return ( +
  • ): void => { + if (!listItemRef.current?.contains(e.relatedTarget)) { + setIsAdding(false); + } + }} + className="relative flex items-center gap-1 h-[44px] px-3 bg-secondary/30 rounded-xl border-2 border-dashed border-secondary/50 hover:border-primary/60 hover:bg-secondary/50 cursor-pointer transition-colors duration-200" + > + {showNumericalInput && numericalValue !== undefined && setNumericalValue && ( + ) => setNumericalValue(parseInt(e.target.value))} + tabIndex={isAdding ? 0 : -1} + /> + )} + setIsAdding(true)} + onKeyUp={async (e: KeyboardEvent) => { + if (e.key === 'Enter') { + await handleAdd(); + } + }} + className="flex-1 min-w-0 bg-transparent text-text-primary text-sm px-1 py-0.5 cursor-pointer focus:cursor-text placeholder:text-muted/60 transition-all duration-200 !outline-none !ring-0 !shadow-none focus-visible:!outline-none focus-visible:!ring-0 focus-visible:!shadow-none" + type="text" + onChange={(e: ChangeEvent) => setValue(e.target.value)} + value={value} + placeholder={placeholder} + /> + {isAdding && ( +
    + +
    + )} +
  • + ); +} diff --git a/components/form/InputField.tsx b/components/form/InputField.tsx new file mode 100644 index 0000000..60a198e --- /dev/null +++ b/components/form/InputField.tsx @@ -0,0 +1,92 @@ +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import React from "react"; +import {IconDefinition} from "@fortawesome/fontawesome-svg-core"; +import {faPlus, faTrash} from "@fortawesome/free-solid-svg-icons"; + +interface InputFieldProps { + icon?: IconDefinition, + fieldName?: string, + input: React.ReactNode, + addButtonCallBack?: () => Promise + removeButtonCallBack?: () => Promise + isAddButtonDisabled?: boolean + action?: () => Promise + actionLabel?: string + actionIcon?: IconDefinition + hint?: string, +} + +export default function InputField( + { + fieldName, + icon, + input, + addButtonCallBack, + removeButtonCallBack, + isAddButtonDisabled, + action, + actionLabel, + actionIcon, + hint + }: InputFieldProps) { + return ( +
    +
    + { + fieldName && ( +

    + { + icon && + } + {fieldName} +

    + ) + } + { + action && ( + + ) + } + {hint && ( + + {hint} + + )} +
    +
    + {input} + { + addButtonCallBack && ( + + ) + } + { + removeButtonCallBack && ( + + ) + } +
    +
    + ) +} diff --git a/components/form/NumberInput.tsx b/components/form/NumberInput.tsx new file mode 100644 index 0000000..2d5c56a --- /dev/null +++ b/components/form/NumberInput.tsx @@ -0,0 +1,44 @@ +import React, {ChangeEvent, Dispatch} from "react"; + +interface NumberInputProps { + value: number; + setValue: Dispatch>; + placeholder?: string; + readOnly?: boolean; + disabled?: boolean; +} + +export default function NumberInput( + { + value, + setValue, + placeholder, + readOnly = false, + disabled = false + }: NumberInputProps +) { + function handleChange(e: ChangeEvent) { + const newValue: number = parseInt(e.target.value); + if (!isNaN(newValue)) { + setValue(newValue); + } + } + + return ( + + ) +} diff --git a/components/form/RadioBox.tsx b/components/form/RadioBox.tsx new file mode 100644 index 0000000..18b4895 --- /dev/null +++ b/components/form/RadioBox.tsx @@ -0,0 +1,62 @@ +import {storyStates} from "@/lib/models/Story"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {faBookOpen, faKeyboard, faMagicWandSparkles, faPalette, faPenNib} from "@fortawesome/free-solid-svg-icons"; +import React, {Dispatch, SetStateAction} from "react"; + +export interface RadioBoxValue { + label: string; + value: number; +} + +interface RadioBoxProps { + selected: number; + setSelected: Dispatch>; + name: string; +} + +export default function RadioBox( + { + selected, + setSelected, + name + }: RadioBoxProps) { + return ( +
    + {storyStates.map((option: RadioBoxValue) => ( +
    + setSelected(option.value)} + className="hidden" + /> + +
    + ))} +
    + ) +} diff --git a/components/form/RadioGroup.tsx b/components/form/RadioGroup.tsx new file mode 100644 index 0000000..e26c6bc --- /dev/null +++ b/components/form/RadioGroup.tsx @@ -0,0 +1,51 @@ +import React from "react"; + +interface RadioOption { + value: string; + label: string; +} + +interface RadioGroupProps { + name: string; + value: string; + onChange: (value: string) => void; + options: RadioOption[]; + className?: string; +} + +export default function RadioGroup( + { + name, + value, + onChange, + options, + className = "" + }: RadioGroupProps) { + return ( +
    + {options.map((option: RadioOption) => ( +
    + onChange(option.value)} + className="hidden" + /> + +
    + ))} +
    + ); +} diff --git a/components/form/SearchInputWithSelect.tsx b/components/form/SearchInputWithSelect.tsx new file mode 100644 index 0000000..61f3134 --- /dev/null +++ b/components/form/SearchInputWithSelect.tsx @@ -0,0 +1,62 @@ +import React, {ChangeEvent} from "react"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {IconDefinition} from "@fortawesome/fontawesome-svg-core"; +import {SelectBoxProps} from "@/shared/interface"; + +interface SearchInputWithSelectProps { + selectValue: string; + setSelectValue: (value: string) => void; + selectOptions: SelectBoxProps[]; + inputValue: string; + setInputValue: (value: string) => void; + inputPlaceholder?: string; + searchIcon: IconDefinition; + onSearch: () => void; + onKeyDown?: (e: React.KeyboardEvent) => void; +} + +export default function SearchInputWithSelect( + { + selectValue, + setSelectValue, + selectOptions, + inputValue, + setInputValue, + inputPlaceholder, + searchIcon, + onSearch, + onKeyDown + }: SearchInputWithSelectProps) { + return ( +
    + + +
    + ) => setInputValue(e.target.value)} + placeholder={inputPlaceholder} + className="w-full bg-secondary/50 text-text-primary px-4 py-2.5 rounded-xl border border-secondary/50 focus:border-primary focus:ring-4 focus:ring-primary/20 focus:bg-secondary hover:bg-secondary hover:border-secondary placeholder:text-muted/60 outline-none transition-all duration-200 pr-12" + onKeyDown={onKeyDown} + /> + +
    +
    + ); +} diff --git a/components/form/SelectBox.tsx b/components/form/SelectBox.tsx new file mode 100644 index 0000000..8966391 --- /dev/null +++ b/components/form/SelectBox.tsx @@ -0,0 +1,43 @@ +import React, {ChangeEvent} from "react"; +import {SelectBoxProps} from "@/shared/interface"; + +export interface SelectBoxFormProps { + onChangeCallBack: (event: ChangeEvent) => void, + data: SelectBoxProps[], + defaultValue: string | null | undefined, + placeholder?: string, + disabled?: boolean +} + +export default function SelectBox( + { + onChangeCallBack, + data, + defaultValue, + placeholder, + disabled + }: SelectBoxFormProps) { + return ( + + ) +} diff --git a/components/form/SubmitButtonWLoading.tsx b/components/form/SubmitButtonWLoading.tsx new file mode 100644 index 0000000..e7e3f46 --- /dev/null +++ b/components/form/SubmitButtonWLoading.tsx @@ -0,0 +1,55 @@ +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import React from "react"; +import {IconDefinition} from "@fortawesome/fontawesome-svg-core"; +import {faSpinner} from "@fortawesome/free-solid-svg-icons"; + +interface SubmitButtonWLoadingProps { + callBackAction: () => Promise | void; + isLoading: boolean; + text: string; + loadingText: string; + icon?: IconDefinition; +} + +export default function SubmitButtonWLoading( + { + callBackAction, + isLoading, + icon, + text, + loadingText + }: SubmitButtonWLoadingProps) { + return ( + + ) +} diff --git a/components/form/SuggestFieldInput.tsx b/components/form/SuggestFieldInput.tsx new file mode 100644 index 0000000..b8df968 --- /dev/null +++ b/components/form/SuggestFieldInput.tsx @@ -0,0 +1,79 @@ +import {SelectBoxProps} from "@/shared/interface"; +import React, {ChangeEvent, Dispatch, SetStateAction} from "react"; +import InputField from "@/components/form/InputField"; +import TextInput from "@/components/form/TextInput"; + +interface SuggestFieldInputProps { + inputFieldName: string; + inputFieldIcon?: any; + searchTags: string; + tagued: string[]; + handleTagSearch: (e: ChangeEvent) => void; + handleAddTag: (characterId: string) => void; + handleRemoveTag: (characterId: string) => void; + filteredTags: () => SelectBoxProps[]; + showTagSuggestions: boolean; + setShowTagSuggestions: Dispatch>; + getTagLabel: (id: string) => string; +} + +export default function SuggestFieldInput( + { + inputFieldName, + inputFieldIcon, + searchTags, + tagued, + handleTagSearch, + handleAddTag, + handleRemoveTag, + filteredTags, + showTagSuggestions, + setShowTagSuggestions, + getTagLabel + }: SuggestFieldInputProps) { + return ( +
    + + setShowTagSuggestions(searchTags.trim().length > 0)} + placeholder="Rechercher et ajouter..."/> + {showTagSuggestions && filteredTags().length > 0 && ( +
    + {filteredTags().map((character: SelectBoxProps) => ( + + ))} +
    + )} +
    + }/> +
    + {tagued.length === 0 ? ( +

    Aucun élément ajouté

    + ) : ( + tagued.map((tag: string) => ( +
    + {getTagLabel(tag)} + +
    + )) + )} +
    + + ) +} \ No newline at end of file diff --git a/components/form/TextInput.tsx b/components/form/TextInput.tsx new file mode 100644 index 0000000..7053b2e --- /dev/null +++ b/components/form/TextInput.tsx @@ -0,0 +1,39 @@ +import React, {ChangeEvent} from "react"; + +interface TextInputProps { + value: string; + setValue?: (e: ChangeEvent) => void; + placeholder?: string; + readOnly?: boolean; + disabled?: boolean; + onFocus?: () => void; +} + +export default function TextInput( + { + value, + setValue, + placeholder, + readOnly = false, + disabled = false, + onFocus + }: TextInputProps) { + return ( + + ) +} diff --git a/components/form/TexteAreaInput.tsx b/components/form/TexteAreaInput.tsx new file mode 100644 index 0000000..e82f493 --- /dev/null +++ b/components/form/TexteAreaInput.tsx @@ -0,0 +1,117 @@ +import React, {ChangeEvent, useEffect, useState} from "react"; + +interface TextAreaInputProps { + value: string; + setValue: (e: ChangeEvent) => void; + placeholder: string; + maxLength?: number; +} + +export default function TextAreaInput( + { + value, + setValue, + placeholder, + maxLength + }: TextAreaInputProps) { + const [prevLength, setPrevLength] = useState(value.length); + const [isGrowing, setIsGrowing] = useState(false); + + useEffect(() => { + if (value.length > prevLength) { + setIsGrowing(true); + setTimeout(() => setIsGrowing(false), 200); + } + setPrevLength(value.length); + }, [value.length, prevLength]); + + const getProgressPercentage = () => { + if (!maxLength) return 0; + return Math.min((value.length / maxLength) * 100, 100); + }; + + const getStatusStyles = () => { + if (!maxLength) return {}; + const percentage = getProgressPercentage(); + + if (percentage >= 100) return { + textColor: 'text-error', + bgColor: 'bg-error/10', + borderColor: 'border-error/30', + progressColor: 'bg-error' + }; + + if (percentage >= 90) return { + textColor: 'text-warning', + bgColor: 'bg-warning/10', + borderColor: 'border-warning/30', + progressColor: 'bg-warning' + }; + + if (percentage >= 75) return { + textColor: 'text-warning', + bgColor: 'bg-warning/10', + borderColor: 'border-warning/30', + progressColor: 'bg-warning' + }; + + return { + textColor: 'text-success', + bgColor: 'bg-success/10', + borderColor: 'border-success/30', + progressColor: 'bg-success' + }; + }; + + const styles = getStatusStyles(); + + return ( +
    +