Add components for Act management and integrate Electron setup
This commit is contained in:
38
.gitignore
vendored
Normal file
38
.gitignore
vendored
Normal file
@@ -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
|
||||
116
README.md
Normal file
116
README.md
Normal file
@@ -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
|
||||
551
app/globals.css
Normal file
551
app/globals.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
26
app/layout.tsx
Normal file
26
app/layout.tsx
Normal file
@@ -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 (
|
||||
<html lang="fr">
|
||||
<body>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
339
app/page.tsx
Normal file
339
app/page.tsx
Normal file
@@ -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<SessionProps>({user: null, accessToken: '', isConnected: false});
|
||||
const [currentChapter, setCurrentChapter] = useState<ChapterProps | undefined>(undefined);
|
||||
const [currentBook, setCurrentBook] = useState<BookProps | null>(null);
|
||||
|
||||
const [currentCredits, setCurrentCredits] = useState<number>(160);
|
||||
const [amountSpent, setAmountSpent] = useState<number>(session.user?.aiUsage || 0);
|
||||
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
|
||||
const [sessionAttempts, setSessionAttempts] = useState<number>(0)
|
||||
|
||||
const [isTermsAccepted, setIsTermsAccepted] = useState<boolean>(false);
|
||||
const [homeStepsGuide, setHomeStepsGuide] = useState<boolean>(false);
|
||||
|
||||
const homeSteps: GuideStep[] = [
|
||||
{
|
||||
id: 0,
|
||||
x: 50,
|
||||
y: 50,
|
||||
title: t("homePage.guide.welcome", {name: session.user?.name || ''}),
|
||||
content: (
|
||||
<div>
|
||||
<p>{t("homePage.guide.step0.description1")}</p>
|
||||
<br/>
|
||||
<p>{t("homePage.guide.step0.description2")}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 1, position: 'right',
|
||||
targetSelector: `[data-guide="left-panel-container"]`,
|
||||
title: t("homePage.guide.step1.title"),
|
||||
content: (
|
||||
<div>
|
||||
<p className={'flex items-center space-x-2'}>
|
||||
<strong>
|
||||
<FontAwesomeIcon icon={faBookMedical} className={'w-5 h-5'}/> :
|
||||
</strong>
|
||||
{t("homePage.guide.step1.addBook")}
|
||||
</p>
|
||||
<br/>
|
||||
<p><strong><FontAwesomeIcon icon={faFeather}
|
||||
className={'w-5 h-5'}/> :</strong> {t("homePage.guide.step1.generateStory")}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: t("homePage.guide.step2.title"), position: 'bottom',
|
||||
targetSelector: `[data-guide="search-bar"]`,
|
||||
content: (
|
||||
<div>
|
||||
<p>{t("homePage.guide.step2.description")}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: t("homePage.guide.step3.title"),
|
||||
targetSelector: `[data-guide="user-dropdown"]`,
|
||||
position: 'auto',
|
||||
content: (
|
||||
<div>
|
||||
<p>{t("homePage.guide.step3.description")}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: t("homePage.guide.step4.title"),
|
||||
content: (
|
||||
<div>
|
||||
<p>{t("homePage.guide.step4.description1")}</p>
|
||||
<br/>
|
||||
<p>{t("homePage.guide.step4.description2")}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
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<void> {
|
||||
try {
|
||||
const response: boolean = await System.authPostToServer<boolean>('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<void> {
|
||||
const token: string | null = System.getCookie('token');
|
||||
if (token) {
|
||||
try {
|
||||
const user: UserProps = await System.authGetQueryToServer<UserProps>('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<void> {
|
||||
try {
|
||||
const response: boolean = await System.authPostToServer<boolean>(`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<void> {
|
||||
if (session?.accessToken) {
|
||||
try {
|
||||
const response: ChapterProps | null = await System.authGetQueryToServer<ChapterProps | null>(`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 (
|
||||
<div
|
||||
className="bg-background text-text-primary h-screen flex flex-col items-center justify-center font-['Lora']">
|
||||
<div className="flex flex-col items-center space-y-6">
|
||||
<div className="animate-pulse">
|
||||
<Image src={'/logo.png'} alt="ERitors Logo" width={400} height={400}/>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<div className="w-2 h-2 bg-primary rounded-full animate-bounce"></div>
|
||||
<div className="w-2 h-2 bg-primary rounded-full animate-bounce delay-100"></div>
|
||||
<div className="w-2 h-2 bg-primary rounded-full animate-bounce delay-200"></div>
|
||||
</div>
|
||||
<p className="text-text-secondary text-sm">
|
||||
{t("homePage.loading")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SessionContext.Provider value={{session: session, setSession: setSession}}>
|
||||
<BookContext.Provider value={{book: currentBook, setBook: setCurrentBook}}>
|
||||
<ChapterContext.Provider value={{chapter: currentChapter, setChapter: setCurrentChapter}}>
|
||||
<AIUsageContext.Provider value={{
|
||||
totalCredits: currentCredits,
|
||||
setTotalCredits: setCurrentCredits,
|
||||
totalPrice: amountSpent,
|
||||
setTotalPrice: setAmountSpent
|
||||
}}>
|
||||
<div
|
||||
className="bg-background text-text-primary h-screen flex flex-col font-['Lora']">
|
||||
<ScribeTopBar/>
|
||||
<EditorContext.Provider value={{editor: editor}}>
|
||||
<ScribeControllerBar/>
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<ScribeLeftBar/>
|
||||
<ScribeEditor/>
|
||||
<ComposerRightBar/>
|
||||
</div>
|
||||
<ScribeFooterBar/>
|
||||
</EditorContext.Provider>
|
||||
</div>
|
||||
{
|
||||
homeStepsGuide &&
|
||||
<GuideTour stepId={0} steps={homeSteps} onComplete={handleHomeTour}
|
||||
onClose={(): void => setHomeStepsGuide(false)}/>
|
||||
}
|
||||
{
|
||||
!isTermsAccepted && <TermsOfUse onAccept={handleTermsAcceptance}/>
|
||||
}
|
||||
</AIUsageContext.Provider>
|
||||
</ChapterContext.Provider>
|
||||
</BookContext.Provider>
|
||||
</SessionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<LangContext.Provider value={{lang: locale, setLang: setLocale}}>
|
||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||
<AlertProvider>
|
||||
<ScribeContent/>
|
||||
</AlertProvider>
|
||||
</NextIntlClientProvider>
|
||||
</LangContext.Provider>
|
||||
);
|
||||
}
|
||||
103
components/AlertBox.tsx
Normal file
103
components/AlertBox.tsx
Normal file
@@ -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<void>;
|
||||
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 = (
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-black/60 backdrop-blur-md animate-fadeIn">
|
||||
<div
|
||||
className="relative w-full max-w-md rounded-2xl bg-tertiary shadow-2xl border border-secondary/50 overflow-hidden">
|
||||
<div className={`${alertSettings.background} px-6 py-4 shadow-lg`}>
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className={`w-12 h-12 rounded-xl ${alertSettings.iconBg} flex items-center justify-center`}>
|
||||
<FontAwesomeIcon icon={alertSettings.icon} className="w-6 h-6 text-white"/>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-white tracking-wide">{title}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-dark-background/30">
|
||||
<p className="mb-6 text-text-primary whitespace-pre-line leading-relaxed">{message}</p>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<CancelButton callBackFunction={onCancel} text={cancelText}/>
|
||||
<ConfirmButton text={confirmText} buttonType={type} callBackFunction={onConfirm}/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return createPortal(alertContent, document.body);
|
||||
}
|
||||
57
components/AlertStack.tsx
Normal file
57
components/AlertStack.tsx
Normal file
@@ -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 = (
|
||||
<div className="fixed top-4 right-4 z-50 flex flex-col gap-3 pointer-events-none">
|
||||
{alerts.map((alert, index) => (
|
||||
<div
|
||||
key={alert.id}
|
||||
className="pointer-events-auto"
|
||||
style={{
|
||||
animation: 'slideInFromRight 0.3s ease-out forwards',
|
||||
animationDelay: `${index * 50}ms`,
|
||||
}}
|
||||
>
|
||||
<StaticAlert
|
||||
type={alert.type}
|
||||
message={alert.message}
|
||||
onClose={() => onClose(alert.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<style jsx>{`
|
||||
@keyframes slideInFromRight {
|
||||
from {
|
||||
transform: translateX(400px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(alertContent, document.body);
|
||||
}
|
||||
52
components/CollapsableArea.tsx
Normal file
52
components/CollapsableArea.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-4 border border-secondary/50 mb-4 shadow-md hover:shadow-lg transition-all duration-200">
|
||||
<button
|
||||
className="flex justify-between items-center w-full text-left group hover:scale-[1.02] transition-all duration-200"
|
||||
onClick={() => setIsExpanded(!isExpanded)}>
|
||||
<div className="flex items-center">
|
||||
{
|
||||
icon && (
|
||||
<div
|
||||
className="w-8 h-8 rounded-full bg-primary flex items-center justify-center mr-2 shadow-md group-hover:shadow-lg group-hover:scale-110 transition-all duration-200">
|
||||
<FontAwesomeIcon
|
||||
icon={icon}
|
||||
className="text-white w-4 h-4 group-hover:rotate-12 transition-transform duration-200"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<span className="text-text-primary font-bold">{title}</span>
|
||||
</div>
|
||||
<FontAwesomeIcon
|
||||
icon={isExpanded ? faChevronUp : faChevronDown}
|
||||
className="text-primary w-5 h-5 group-hover:scale-110 transition-all duration-200"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-4 p-3 bg-secondary/20 rounded-lg border border-secondary/30 animate-fadeIn">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
components/CollapsableButton.tsx
Normal file
33
components/CollapsableButton.tsx
Normal file
@@ -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 (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`group px-4 py-2 rounded-lg mr-2 transition-all duration-200 flex items-center gap-2 ${
|
||||
showCollapsable
|
||||
? 'bg-primary/20 text-primary border border-primary/40 shadow-md shadow-primary/20 scale-105'
|
||||
: 'bg-secondary/30 text-muted hover:text-text-primary hover:bg-secondary hover:shadow-sm hover:scale-105'
|
||||
}`}
|
||||
>
|
||||
{icon && <FontAwesomeIcon icon={icon}
|
||||
className="w-4 h-4 transition-transform duration-200 group-hover:scale-110"/>}
|
||||
<span className={'hidden lg:block text-sm font-medium'}>{text}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
39
components/Collapse.tsx
Normal file
39
components/Collapse.tsx
Normal file
@@ -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<boolean>(false);
|
||||
|
||||
function toggleCollapse(): void {
|
||||
setIsOpen(!isOpen);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-4 shadow-md hover:shadow-lg transition-all duration-200">
|
||||
<button
|
||||
onClick={toggleCollapse}
|
||||
className={`w-full text-left bg-secondary/50 hover:bg-secondary transition-all duration-200 p-4 flex items-center justify-between group border border-secondary/50 ${
|
||||
isOpen ? 'rounded-t-xl' : 'rounded-xl'
|
||||
}`}
|
||||
>
|
||||
<span className="text-text-primary font-medium">{title}</span>
|
||||
<FontAwesomeIcon
|
||||
icon={isOpen ? faChevronDown : faChevronRight}
|
||||
className="text-primary w-4 h-4 transition-transform group-hover:scale-110"
|
||||
/>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div
|
||||
className="bg-secondary/30 border-l-4 border-primary p-4 rounded-b-xl border border-t-0 border-secondary/50 animate-fadeIn">
|
||||
<div className="text-text-primary">{content}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
components/CreditMeters.tsx
Normal file
30
components/CreditMeters.tsx
Normal file
@@ -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<AIUsageContextProps>(AIUsageContext)
|
||||
|
||||
if (isCredit) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center space-x-2 bg-secondary/50 rounded-xl px-3 py-2 border border-secondary/50 shadow-sm">
|
||||
<FontAwesomeIcon icon={faCoins} className="w-4 h-4 text-warning"/>
|
||||
<span className="text-sm text-text-primary font-medium">
|
||||
{Math.round(totalCredits)} crédits
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center space-x-2 bg-secondary/50 rounded-xl px-3 py-2 border border-secondary/50 shadow-sm">
|
||||
<FontAwesomeIcon icon={faDollarSign} className="w-4 h-4 text-primary"/>
|
||||
<span className="text-sm text-text-primary font-medium">
|
||||
{totalPrice ? totalPrice.toFixed(2) : '0.00'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
190
components/ExportBook.tsx
Normal file
190
components/ExportBook.tsx
Normal file
@@ -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<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(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 (
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
onClick={toggleMenu}
|
||||
className="text-muted hover:text-primary transition-all duration-200 p-1.5 rounded-lg hover:bg-secondary/50 hover:scale-110"
|
||||
>
|
||||
<FontAwesomeIcon icon={faDownload} className={'w-4 h-4'}/>
|
||||
</button>
|
||||
|
||||
{showMenu && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="absolute z-50 bg-tertiary/90 backdrop-blur-sm shadow-2xl rounded-xl border border-secondary/50"
|
||||
style={{
|
||||
width: '110px',
|
||||
right: '-30px',
|
||||
top: '100%',
|
||||
marginTop: '8px',
|
||||
}}
|
||||
>
|
||||
<ul className="py-2">
|
||||
<li
|
||||
className="px-3 py-2 hover:bg-secondary cursor-pointer text-sm text-muted hover:text-text-primary transition-all duration-200 hover:scale-105 font-medium"
|
||||
onClick={handleDownloadEpub}
|
||||
>
|
||||
EPUB
|
||||
</li>
|
||||
<li
|
||||
className="px-3 py-2 hover:bg-secondary cursor-pointer text-sm text-muted hover:text-text-primary transition-all duration-200 hover:scale-105 font-medium"
|
||||
onClick={handleDownloadPdf}
|
||||
>
|
||||
PDF
|
||||
</li>
|
||||
<li
|
||||
className="px-3 py-2 hover:bg-secondary cursor-pointer text-sm text-muted hover:text-text-primary transition-all duration-200 hover:scale-105 font-medium"
|
||||
onClick={handleDownloadDocx}
|
||||
>
|
||||
DOCX
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
383
components/GuideTour.tsx
Normal file
383
components/GuideTour.tsx
Normal file
@@ -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<number>(0);
|
||||
const [isVisible, setIsVisible] = useState<boolean>(false);
|
||||
const [rendered, setRendered] = useState<boolean>(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<NodeJS.Timeout | null>(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 (
|
||||
<div className="fixed inset-0 z-50 font-['Lora']">
|
||||
<div
|
||||
className="absolute inset-0 transition-opacity duration-500"
|
||||
style={{
|
||||
background: rendered ? getSpotlightBackground(currentStepData) : 'rgba(0, 0, 0, 0.5)',
|
||||
opacity: isVisible ? 1 : 0
|
||||
}}
|
||||
onClick={onClose}
|
||||
/>
|
||||
{rendered && (
|
||||
<GuidePopup
|
||||
step={currentStepData}
|
||||
isVisible={isVisible}
|
||||
currentStep={currentStep}
|
||||
totalSteps={filteredSteps.length}
|
||||
onPrevious={handlePrevious}
|
||||
onNext={handleNext}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div
|
||||
className={`absolute bg-tertiary border border-primary/30 rounded-xl shadow-2xl w-96 transition-all duration-300 ${
|
||||
isVisible ? 'opacity-100 scale-100' : 'opacity-0 scale-95'
|
||||
}`}
|
||||
style={positionStyle}
|
||||
>
|
||||
<div className="px-8 py-6 border-b border-secondary/40">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 mr-6">
|
||||
<h3 className="text-text-primary font-semibold text-xl mb-3">
|
||||
{step.title}
|
||||
</h3>
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-primary text-sm font-medium bg-primary/10 px-3 py-1 rounded-full">
|
||||
Étape {currentStep + 1} sur {totalSteps}
|
||||
</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
{Array.from({length: totalSteps}).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`w-2.5 h-2.5 rounded-full transition-all duration-300 ${
|
||||
index <= currentStep ? 'bg-primary scale-110' : 'bg-secondary/60'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-muted hover:text-text-primary transition-colors p-2 hover:bg-secondary/30 rounded-lg"
|
||||
type="button"
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} className="text-lg"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-8 py-8">
|
||||
<div className="text-text-secondary text-base leading-relaxed space-y-4">
|
||||
{step.content}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-8 py-6 bg-secondary/20 border-t border-secondary/30 rounded-b-xl">
|
||||
<div className="flex items-center justify-between">
|
||||
{currentStep > 0 ? (
|
||||
<button
|
||||
onClick={onPrevious}
|
||||
className="text-muted hover:text-text-primary text-sm px-4 py-2 rounded-lg hover:bg-secondary/30 transition-all"
|
||||
type="button"
|
||||
>
|
||||
← Précédent
|
||||
</button>
|
||||
) : (
|
||||
<div></div>
|
||||
)}
|
||||
<button
|
||||
onClick={onNext}
|
||||
className="bg-primary hover:bg-primary-dark text-text-primary px-6 py-3 rounded-lg transition-all duration-200 text-sm font-medium shadow-lg hover:shadow-xl transform hover:scale-105"
|
||||
type="button"
|
||||
>
|
||||
{currentStep === totalSteps - 1 ? '🎉 Terminer' : 'Continuer →'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
154
components/ListItem.tsx
Normal file
154
components/ListItem.tsx
Normal file
@@ -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<boolean>(false);
|
||||
const [editMode, setEditMode] = useState<boolean>(false);
|
||||
|
||||
const [newName, setNewName] = useState<string>('');
|
||||
const [newChapterOrder, setNewChapterOrder] = useState<number>(numericalIdentifier ?? 0);
|
||||
|
||||
function handleEdit(itemName: string): void {
|
||||
setNewName(itemName)
|
||||
setEditMode(true)
|
||||
}
|
||||
|
||||
function handleSave(): void {
|
||||
if (!handleUpdate) return;
|
||||
handleUpdate(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 (
|
||||
<li onMouseOver={(): void => 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) && (
|
||||
<span className="text-primary font-bold mr-3 text-sm min-w-[24px]">
|
||||
{newChapterOrder >= 0 ? newChapterOrder : numericalIdentifier}.
|
||||
</span>
|
||||
)
|
||||
}
|
||||
{
|
||||
icon && (
|
||||
<div className="mr-3 w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<FontAwesomeIcon icon={icon} className={'w-4 h-4 text-primary'}/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div className={'flex justify-between items-center w-full gap-2'}>
|
||||
{
|
||||
editMode ? (
|
||||
<div className={'flex gap-2 w-full items-center'}>
|
||||
<div className="flex-1">
|
||||
<TextInput
|
||||
value={newName ? newName : text}
|
||||
setValue={(e: ChangeEvent<HTMLInputElement>): void => setNewName(e.target.value)}
|
||||
placeholder=""
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className={`p-2 rounded-lg transition-all ${
|
||||
numericalIdentifier === 0
|
||||
? 'text-muted opacity-40 cursor-not-allowed'
|
||||
: 'text-text-primary hover:text-primary hover:bg-primary/10'
|
||||
}`}
|
||||
onClick={(): void => moveItem('up')}
|
||||
disabled={numericalIdentifier === 0}
|
||||
>
|
||||
<FontAwesomeIcon icon={faArrowUp} size="sm"/>
|
||||
</button>
|
||||
<button
|
||||
className="p-2 rounded-lg text-text-primary hover:text-primary hover:bg-primary/10 transition-all"
|
||||
onClick={(): void => moveItem("down")}
|
||||
>
|
||||
<FontAwesomeIcon icon={faArrowDown} size="sm"/>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span
|
||||
className={'cursor-pointer text-sm font-medium flex-1 group-hover:text-text-primary transition-colors'}
|
||||
onClick={onClick}>{text}</span>
|
||||
)
|
||||
}
|
||||
{
|
||||
!editMode && isEditable && (
|
||||
<div
|
||||
className={'absolute right-1 flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity'}>
|
||||
<button onClick={(): void => handleEdit(text)}
|
||||
className="p-1 rounded-lg bg-secondary hover:bg-primary/10 transition-colors">
|
||||
<FontAwesomeIcon icon={faPen} className={'w-3.5 h-3.5 text-primary'}/>
|
||||
</button>
|
||||
<button onClick={(): void | undefined => handleDelete && handleDelete(id.toString())}
|
||||
className="p-1 rounded-lg bg-secondary hover:bg-error/10 transition-colors">
|
||||
<FontAwesomeIcon icon={faTrash} className={'w-3.5 h-3.5 text-error'}/>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
editMode && isEditable && (
|
||||
<div className={'flex gap-1'}>
|
||||
<button onClick={handleSave}
|
||||
className="p-2 rounded-lg hover:bg-primary/10 transition-all">
|
||||
<FontAwesomeIcon icon={faCheck} className={'w-3.5 h-3.5 text-primary'}/>
|
||||
</button>
|
||||
<button onClick={(): void => setEditMode(false)}
|
||||
className="p-2 rounded-lg hover:bg-error/10 transition-all">
|
||||
<FontAwesomeIcon icon={faX} className={'w-3.5 h-3.5 text-error'}/>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
91
components/Modal.tsx
Normal file
91
components/Modal.tsx
Normal file
@@ -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 = (
|
||||
<div
|
||||
className="fixed inset-0 z-40 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md animate-fadeIn">
|
||||
<div
|
||||
className={`relative bg-tertiary text-text-primary rounded-2xl border border-secondary/50 shadow-2xl max-h-[90vh] overflow-hidden flex flex-col ${getSizeClasses(size)}`}>
|
||||
<div
|
||||
className="flex justify-between items-center bg-primary px-6 py-4 rounded-t-2xl shadow-lg border-b border-primary-dark">
|
||||
<h2 className="font-['ADLaM_Display'] text-xl tracking-wide">{title}</h2>
|
||||
<button
|
||||
className="group text-white/80 hover:text-white p-2 rounded-lg hover:bg-white/10 transition-all hover:scale-110"
|
||||
onClick={onClose}>
|
||||
<FontAwesomeIcon icon={faX} className={'w-5 h-5 transition-transform group-hover:rotate-90'}/>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{children}
|
||||
</div>
|
||||
{
|
||||
enableFooter && (
|
||||
<div
|
||||
className="flex justify-end gap-3 px-6 py-4 border-t border-secondary/50 bg-dark-background/30">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-5 py-2.5 rounded-lg bg-secondary/50 text-text-primary hover:bg-secondary border border-secondary/50 hover:border-primary/30 transition-all hover:shadow-md hover:scale-105"
|
||||
>
|
||||
{cancelText || 'Annuler'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="px-5 py-2.5 rounded-lg bg-primary text-text-primary hover:bg-primary-dark shadow-md hover:shadow-lg hover:shadow-primary/30 transition-all hover:scale-105"
|
||||
>
|
||||
{confirmText || 'Confirmer'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return createPortal(modalContent, document.body);
|
||||
}
|
||||
13
components/NoPicture.tsx
Normal file
13
components/NoPicture.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React, {useContext} from "react";
|
||||
import {SessionContext} from "@/context/SessionContext";
|
||||
|
||||
export default function NoPicture() {
|
||||
const {session} = useContext(SessionContext);
|
||||
return (
|
||||
<div
|
||||
className="bg-primary text-text-primary rounded-full w-8 h-8 border-2 border-primary-dark hover:bg-primary-dark flex items-center justify-center text-sm font-semibold transition-all duration-200 hover:scale-110 shadow-md hover:shadow-lg">
|
||||
<span>{session.user?.name && session.user.name.charAt(0).toUpperCase()}</span>
|
||||
<span>{session.user?.lastName && session.user.lastName.charAt(0).toUpperCase()}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
88
components/PanelHeader.tsx
Normal file
88
components/PanelHeader.tsx
Normal file
@@ -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<void>;
|
||||
secondActionIcon?: IconDefinition;
|
||||
secondActionCallback?: () => Promise<void>;
|
||||
actionIcon?: IconDefinition;
|
||||
actionText?: string;
|
||||
}
|
||||
|
||||
export default function PanelHeader(
|
||||
{
|
||||
title,
|
||||
badge,
|
||||
description,
|
||||
icon,
|
||||
callBackAction,
|
||||
secondActionCallback,
|
||||
secondActionIcon = faSave,
|
||||
actionIcon = faX,
|
||||
actionText
|
||||
}: PanelHeaderProps) {
|
||||
return (
|
||||
<div className={'border-b border-primary/30 shrink-0 bg-gradient-to-r from-dark-background/50 to-transparent'}>
|
||||
<div className="flex justify-between items-center p-4">
|
||||
<div className="flex-1">
|
||||
<h2 className="text-lg lg:text-xl text-primary font-bold flex items-center gap-3 flex-wrap">
|
||||
{
|
||||
icon && (
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<FontAwesomeIcon icon={icon} className="text-primary w-5 h-5"/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<span className="tracking-wide">{title}</span>
|
||||
{
|
||||
badge &&
|
||||
<span
|
||||
className="text-xs bg-primary/20 text-primary px-3 py-1 rounded-full font-medium border border-primary/30">{badge}</span>
|
||||
}
|
||||
</h2>
|
||||
{description && <p className="text-text-secondary text-xs mt-2 ml-13">{description}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{
|
||||
actionText && (
|
||||
<button onClick={callBackAction}
|
||||
className="group text-text-primary px-4 py-2 text-sm bg-secondary/50 rounded-xl hover:bg-secondary transition-all border border-secondary/50 hover:border-primary/30 flex items-center gap-2 hover:shadow-md hover:scale-105">
|
||||
<div
|
||||
className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center transition-transform group-hover:scale-110">
|
||||
<FontAwesomeIcon icon={actionIcon} className="w-4 h-4"/>
|
||||
</div>
|
||||
{
|
||||
actionText && <span className="font-medium">{actionText}</span>
|
||||
}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
{
|
||||
secondActionCallback && (
|
||||
<button onClick={secondActionCallback}
|
||||
className="group w-10 h-10 bg-primary/10 hover:bg-primary/20 rounded-lg flex items-center justify-center transition-all hover:shadow-md hover:scale-110 border border-primary/30">
|
||||
<FontAwesomeIcon icon={secondActionIcon}
|
||||
className="w-4 h-4 text-primary transition-transform group-hover:scale-110"/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
{
|
||||
callBackAction && actionIcon && !actionText && (
|
||||
<button onClick={callBackAction}
|
||||
className="group text-muted hover:text-text-primary transition-all hover:scale-110">
|
||||
<FontAwesomeIcon icon={actionIcon}
|
||||
className={'w-5 h-5 transition-transform group-hover:rotate-90'}/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
121
components/QSTextGeneratedPreview.tsx
Normal file
121
components/QSTextGeneratedPreview.tsx
Normal file
@@ -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 = (
|
||||
<div
|
||||
className={`fixed inset-0 z-50 flex items-center justify-center font-['Lora'] transition-opacity duration-300 ${isVisible ? 'opacity-100' : 'opacity-0'}`}>
|
||||
<div className="absolute inset-0 bg-overlay" onClick={handleClose}></div>
|
||||
<div
|
||||
className={`relative w-[90%] max-w-2xl h-[80%] bg-tertiary/90 backdrop-blur-sm rounded-2xl overflow-hidden border border-secondary/50 shadow-2xl flex flex-col transition-all duration-300 ${isVisible ? 'scale-100 opacity-100' : 'scale-95 opacity-0'}`}>
|
||||
<div
|
||||
className="flex justify-between items-center px-5 py-4 bg-secondary/30 backdrop-blur-sm border-b border-secondary/50 shadow-sm">
|
||||
<div className="flex items-center">
|
||||
<h2 className="text-xl font-['ADLaM_Display'] text-text-primary">{t("qsTextPreview.title")}</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
{isGenerating && onStop ? (
|
||||
<button
|
||||
onClick={onStop}
|
||||
className="w-9 h-9 rounded-xl bg-red-500 text-white hover:bg-red-600 transition-all duration-200 hover:scale-110 flex justify-center items-center shadow-sm hover:shadow-md"
|
||||
>
|
||||
<FontAwesomeIcon icon={faStop}/>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
className="w-9 h-9 rounded-xl bg-secondary/50 text-primary hover:bg-secondary transition-all duration-200 hover:scale-110 flex justify-center items-center shadow-sm hover:shadow-md border border-secondary/50"
|
||||
>
|
||||
<FontAwesomeIcon icon={faSync}/>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="text-muted hover:text-text-primary p-2 rounded-xl hover:bg-secondary transition-all duration-200 hover:scale-110"
|
||||
>
|
||||
<FontAwesomeIcon icon={faX} className={'h-5 w-5'}/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 p-5 overflow-auto custom-scrollbar">
|
||||
<div
|
||||
className="w-full bg-darkest-background text-text-primary p-5 rounded-xl border border-secondary/50 shadow-inner">
|
||||
{isGenerating && !value ? (
|
||||
<div className="space-y-4 animate-pulse">
|
||||
<div className="h-4 bg-secondary/30 rounded w-full"></div>
|
||||
<div className="h-4 bg-secondary/30 rounded w-11/12"></div>
|
||||
<div className="h-4 bg-secondary/30 rounded w-full"></div>
|
||||
<div className="h-4 bg-secondary/30 rounded w-10/12"></div>
|
||||
<div className="h-4 bg-secondary/30 rounded w-full"></div>
|
||||
<div className="h-4 bg-secondary/30 rounded w-9/12"></div>
|
||||
<div className="h-4 bg-secondary/30 rounded w-full"></div>
|
||||
<div className="h-4 bg-secondary/30 rounded w-11/12"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="text-justify leading-relaxed whitespace-pre-wrap fade-in-text">
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="px-5 py-4 bg-secondary/30 backdrop-blur-sm border-t border-secondary/50 flex justify-end shadow-inner">
|
||||
<button
|
||||
onClick={onInsert}
|
||||
className="flex items-center py-2.5 px-5 rounded-xl bg-primary text-text-primary hover:bg-primary-dark transition-all duration-200 hover:scale-105 shadow-md hover:shadow-lg font-medium"
|
||||
>
|
||||
<FontAwesomeIcon icon={faPaperPlane} className="mr-2"/>
|
||||
{t("qsTextPreview.insert")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(modalContent, document.body);
|
||||
}
|
||||
184
components/ScribeControllerBar.tsx
Normal file
184
components/ScribeControllerBar.tsx
Normal file
@@ -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<LangContextProps>(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<boolean>(false);
|
||||
|
||||
async function handleChapterVersionChanged(version: number) {
|
||||
try {
|
||||
const response: ChapterProps = await System.authGetQueryToServer<ChapterProps>(`chapter/whole`, session.accessToken, lang, {
|
||||
bookid: book?.bookId,
|
||||
id: chapter?.chapterId,
|
||||
version: version,
|
||||
});
|
||||
if (!response) {
|
||||
errorMessage(t("controllerBar.chapterNotFound"));
|
||||
return;
|
||||
}
|
||||
setChapter(response);
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
} else {
|
||||
errorMessage(t("controllerBar.unknownChapterError"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getBook(bookId: string): Promise<void> {
|
||||
try {
|
||||
const response: BookListProps = await System.authGetQueryToServer<BookListProps>(`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 (
|
||||
<div
|
||||
className="flex items-center justify-between px-6 py-3 bg-tertiary/90 backdrop-blur-sm border-b border-secondary/50 shadow-md">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{book && (
|
||||
<button onClick={(): void => setShowSettingPanel(true)}
|
||||
className="group p-2 rounded-lg text-muted hover:text-text-primary hover:bg-secondary/50 transition-all hover:scale-110">
|
||||
<FontAwesomeIcon icon={faGear}
|
||||
className={'w-5 h-5 transition-transform group-hover:rotate-90'}/>
|
||||
</button>
|
||||
)}
|
||||
{
|
||||
book && (
|
||||
<button onClick={(): void => {
|
||||
setBook && setBook(null)
|
||||
setChapter && setChapter(undefined)
|
||||
}}
|
||||
className="group p-2 rounded-lg text-muted hover:text-primary hover:bg-secondary/50 transition-all hover:scale-110">
|
||||
<FontAwesomeIcon icon={faHome}
|
||||
className={'w-5 h-5 transition-transform group-hover:scale-110'}/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div className="min-w-[200px]">
|
||||
<SelectBox onChangeCallBack={(e) => getBook(e.target.value)}
|
||||
data={Book.booksToSelectBox(session.user?.books ?? [])} defaultValue={book?.bookId}
|
||||
placeholder={t("controllerBar.selectBook")}/>
|
||||
</div>
|
||||
{chapter && (
|
||||
<div className="min-w-[180px]">
|
||||
<SelectBox onChangeCallBack={(e) => 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()}/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
{
|
||||
hasAccess &&
|
||||
<CreditCounter isCredit={isSubTierTwo}/>
|
||||
}
|
||||
<div
|
||||
className="flex items-center bg-secondary/50 rounded-xl overflow-hidden border border-secondary shadow-sm">
|
||||
<div className="flex items-center px-3 py-2 bg-dark-background/50 border-r border-secondary">
|
||||
<FontAwesomeIcon icon={faGlobe} className="w-4 h-4 text-primary"/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleLanguageChange('fr')}
|
||||
className={`px-4 py-2 text-sm font-semibold transition-all ${
|
||||
lang === 'fr'
|
||||
? 'bg-primary text-text-primary shadow-md'
|
||||
: 'bg-transparent text-text-secondary hover:bg-secondary/50 hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
FR
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleLanguageChange('en')}
|
||||
className={`px-4 py-2 text-sm font-semibold transition-all ${
|
||||
lang === 'en'
|
||||
? 'bg-primary text-text-primary shadow-md'
|
||||
: 'bg-transparent text-text-secondary hover:bg-secondary/50 hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
EN
|
||||
</button>
|
||||
</div>
|
||||
<UserMenu/>
|
||||
</div>
|
||||
{
|
||||
showSettingPanel &&
|
||||
<Modal title={t("controllerBar.bookSettings")}
|
||||
size={'large'}
|
||||
onClose={() => setShowSettingPanel(false)}
|
||||
onConfirm={() => {
|
||||
}}
|
||||
children={<BookSetting/>}
|
||||
enableFooter={false}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
99
components/ScribeFooterBar.tsx
Normal file
99
components/ScribeFooterBar.tsx
Normal file
@@ -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<number>(0);
|
||||
|
||||
useEffect((): void => {
|
||||
getWordCount();
|
||||
}, [editor?.state.doc.textContent]);
|
||||
|
||||
function getWordCount(): void {
|
||||
if (editor) {
|
||||
try {
|
||||
const content: string = editor?.state.doc.textContent;
|
||||
const texteNormalise: string = content
|
||||
.replace(/'/g, ' ')
|
||||
.replace(/-/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
const mots: string[] = texteNormalise.split(' ');
|
||||
const wordCount: number = mots.filter(
|
||||
(mot: string): boolean => mot.length > 0,
|
||||
).length;
|
||||
setWordsCount(wordCount);
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(t('errors.wordCountError') + ` (${e.message})`);
|
||||
} else {
|
||||
errorMessage(t('errors.wordCountError'));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="px-6 py-3 bg-tertiary/90 backdrop-blur-sm border-t border-secondary/50 text-text-primary flex justify-between items-center shadow-lg">
|
||||
<div>
|
||||
<span className="flex items-center gap-2">
|
||||
{chapter && (
|
||||
<span
|
||||
className="inline-flex items-center px-3 py-1 rounded-lg bg-primary/10 border border-primary/30">
|
||||
<span className="text-primary font-bold text-sm">
|
||||
{chapter.chapterOrder < 0 ? t('scribeFooterBar.sheet') : `${chapter.chapterOrder}.`}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<span className={'flex items-center gap-2 font-medium'}>
|
||||
{chapter?.title || (
|
||||
<>
|
||||
<span>{t('scribeFooterBar.madeWith')}</span>
|
||||
<FontAwesomeIcon color={'red'} icon={faHeart} className={'w-4 h-4 animate-pulse'}/>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{
|
||||
chapter ? (
|
||||
<div className="flex items-center space-x-3">
|
||||
<div
|
||||
className="flex items-center gap-2 bg-secondary/50 px-4 py-2 rounded-xl border border-secondary shadow-sm">
|
||||
<FontAwesomeIcon icon={faChartSimple} className="text-primary text-sm w-4 h-4"/>
|
||||
<span className="text-muted text-sm font-medium">{t('scribeFooterBar.words')}:</span>
|
||||
<span className="text-text-primary font-bold">{wordsCount}</span>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-2 bg-secondary/50 px-4 py-2 rounded-xl border border-secondary shadow-sm">
|
||||
<FontAwesomeIcon icon={faSheetPlastic} className={'text-primary w-4 h-4'}/>
|
||||
<span className="text-text-primary font-bold">{Math.ceil(wordsCount / 300)}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-3">
|
||||
<div
|
||||
className="flex items-center gap-2 bg-secondary/50 px-4 py-2 rounded-xl border border-secondary shadow-sm">
|
||||
<FontAwesomeIcon icon={faBook} className={'text-primary w-4 h-4'}/>
|
||||
<span className="text-muted text-sm font-medium mr-1">{t('scribeFooterBar.books')}:</span>
|
||||
<span className="text-text-primary font-bold">{session.user?.books?.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
40
components/ScribeTopBar.tsx
Normal file
40
components/ScribeTopBar.tsx
Normal file
@@ -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 (
|
||||
<div className="flex items-center justify-between px-6 py-3 bg-primary shadow-lg border-b border-primary-dark">
|
||||
<div className="flex items-center space-x-4 group">
|
||||
<div className="transition-transform duration-300 group-hover:scale-110">
|
||||
<Image src={logo} alt={t("scribeTopBar.logoAlt")} width={24} height={24}/>
|
||||
</div>
|
||||
<span
|
||||
className="font-['ADLaM_Display'] text-xl tracking-wide text-white/95">{t("scribeTopBar.scribe")}</span>
|
||||
</div>
|
||||
{book.book && (
|
||||
<div
|
||||
className="flex items-center space-x-3 bg-white/10 backdrop-blur-sm px-4 py-2 rounded-lg border border-white/20">
|
||||
<div className="h-8 w-1 bg-white/40 rounded-full"></div>
|
||||
<div className="text-center">
|
||||
<p className="text-text-primary font-semibold text-base tracking-wide">
|
||||
{book.book.title}
|
||||
</p>
|
||||
{book.book.subTitle && (
|
||||
<p className="text-white/70 text-xs italic mt-0.5">
|
||||
{book.book.subTitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-8 w-1 bg-white/40 rounded-full"></div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center space-x-2 min-w-[120px] justify-end">
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
692
components/ShortStoryGenerator.tsx
Normal file
692
components/ShortStoryGenerator.tsx
Normal file
@@ -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<LangContextProps>(LangContext)
|
||||
const t = useTranslations();
|
||||
const {setTotalPrice, setTotalCredits} = useContext<AIUsageContextProps>(AIUsageContext)
|
||||
|
||||
const [tone, setTone] = useState<string>('');
|
||||
const [atmosphere, setAtmosphere] = useState<string>('');
|
||||
const [verbTense, setVerbTense] = useState<string>('0');
|
||||
const [person, setPerson] = useState<string>('0');
|
||||
const [characters, setCharacters] = useState<string>('');
|
||||
const [language, setLanguage] = useState<string>(
|
||||
session.user?.writingLang.toString() ?? '0',
|
||||
);
|
||||
const [dialogueType, setDialogueType] = useState<string>('0');
|
||||
const [wordsCount, setWordsCount] = useState<number>(500)
|
||||
const [directives, setDirectives] = useState<string>('');
|
||||
const [authorLevel, setAuthorLevel] = useState<string>(
|
||||
session.user?.writingLevel.toString() ?? '0',
|
||||
);
|
||||
const [presetType, setPresetType] = useState<string>('0');
|
||||
|
||||
const [activeTab, setActiveTab] = useState<number>(1);
|
||||
const [progress, setProgress] = useState<number>(25);
|
||||
const modalRef: RefObject<HTMLDivElement | null> = useRef<HTMLDivElement>(null);
|
||||
const [isGenerating, setIsGenerating] = useState<boolean>(false);
|
||||
|
||||
const [generatedText, setGeneratedText] = useState<string>('');
|
||||
const [generatedStoryTitle, setGeneratedStoryTitle] = useState<string>('');
|
||||
const [resume, setResume] = useState<string>('');
|
||||
const [totalWordsCount, setTotalWordsCount] = useState<number>(0);
|
||||
|
||||
const [hasGenerated, setHasGenerated] = useState<boolean>(false);
|
||||
const [abortController, setAbortController] = useState<ReadableStreamDefaultReader<Uint8Array> | 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<void> {
|
||||
if (abortController) {
|
||||
await abortController.cancel();
|
||||
setAbortController(null);
|
||||
infoMessage(t("shortStoryGenerator.result.abortSuccess"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGeneration(): Promise<void> {
|
||||
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<Uint8Array> | 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<Uint8Array> = await reader.read();
|
||||
|
||||
if (done) break;
|
||||
|
||||
const chunk: string = decoder.decode(value, {stream: true});
|
||||
const lines: string[] = chunk.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const 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<void> {
|
||||
let content: string = '';
|
||||
if (editor) content = editor?.state?.doc.toJSON();
|
||||
try {
|
||||
const bookId: string = await System.authPostToServer<string>(
|
||||
`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 (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center bg-darkest-background/80 z-50 backdrop-blur-sm">
|
||||
<div
|
||||
className="bg-dark-background text-text-primary rounded-lg border border-secondary shadow-xl w-full max-w-md p-6">
|
||||
<h2 className="flex items-center font-['ADLaM_Display'] text-xl text-text-primary mb-4">
|
||||
<FontAwesomeIcon icon={faMagicWandSparkles} className="mr-3 w-5 h-5"/>
|
||||
{t("shortStoryGenerator.accessDenied.title")}
|
||||
</h2>
|
||||
<p className="text-text-secondary mb-6">
|
||||
{t("shortStoryGenerator.accessDenied.message")}
|
||||
</p>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full bg-primary text-text-primary px-4 py-2 rounded-lg hover:bg-primary-dark transition-colors"
|
||||
>
|
||||
{t("shortStoryGenerator.accessDenied.close")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-overlay z-40 backdrop-blur-sm">
|
||||
<div ref={modalRef}
|
||||
className="bg-tertiary/90 backdrop-blur-sm text-text-primary rounded-2xl border border-secondary/50 shadow-2xl w-full max-w-4xl max-h-[90vh] flex flex-col">
|
||||
|
||||
<div className="flex justify-between items-center bg-primary px-6 py-4 rounded-t-2xl shadow-md">
|
||||
<h2 className="font-['ADLaM_Display'] text-xl text-text-primary flex items-center">
|
||||
<FontAwesomeIcon icon={faMagicWandSparkles} className="mr-3 w-5 h-5"/>
|
||||
{t("shortStoryGenerator.title")}
|
||||
</h2>
|
||||
<button
|
||||
className="text-text-primary hover:bg-primary-dark p-2 rounded-xl transition-all duration-200 hover:scale-110"
|
||||
onClick={onClose}
|
||||
disabled={isGenerating}
|
||||
>
|
||||
<FontAwesomeIcon icon={faX} className="w-5 h-5"/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-b border-secondary/50">
|
||||
<div className="w-full bg-secondary/50 rounded-full h-2.5 shadow-inner">
|
||||
<div
|
||||
className="bg-primary h-2.5 rounded-full transition-all duration-300 shadow-sm"
|
||||
style={{width: `${progress}%`}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex border-b border-secondary/50">
|
||||
{[
|
||||
{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 => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
disabled={isGenerating}
|
||||
className={`flex items-center px-6 py-3 font-medium transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'text-primary border-b-2 border-primary bg-primary/5'
|
||||
: 'text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
<FontAwesomeIcon icon={tab.icon} className="mr-2 w-4 h-4"/>
|
||||
{tab.label}
|
||||
{tab.id === 4 && isGenerating && !generatedText && (
|
||||
<FontAwesomeIcon icon={faSpinner} className="ml-2 animate-spin w-4 h-4"/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
{activeTab === 1 && (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<InputField
|
||||
icon={faGraduationCap}
|
||||
fieldName={t("shortStoryGenerator.fields.complexity")}
|
||||
input={
|
||||
<SelectBox
|
||||
onChangeCallBack={(e) => setAuthorLevel(e.target.value)}
|
||||
data={writingLevel}
|
||||
defaultValue={authorLevel}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InputField
|
||||
icon={faBookOpen}
|
||||
fieldName={t("shortStoryGenerator.fields.preset")}
|
||||
input={
|
||||
<SelectBox
|
||||
onChangeCallBack={(e) => setPresetType(e.target.value)}
|
||||
data={
|
||||
authorLevel === '1'
|
||||
? beginnerPredefinedType
|
||||
: authorLevel === '2'
|
||||
? intermediatePredefinedType
|
||||
: advancedPredefinedType
|
||||
}
|
||||
defaultValue={presetType}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InputField
|
||||
icon={faLanguage}
|
||||
fieldName={t("shortStoryGenerator.fields.language")}
|
||||
input={
|
||||
<SelectBox
|
||||
onChangeCallBack={(e) => setLanguage(e.target.value)}
|
||||
data={langues}
|
||||
defaultValue={language}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InputField
|
||||
icon={faChartSimple}
|
||||
fieldName={t("shortStoryGenerator.fields.wordCount")}
|
||||
input={
|
||||
<NumberInput
|
||||
value={wordsCount}
|
||||
setValue={setWordsCount}
|
||||
placeholder="500"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 2 && (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<InputField
|
||||
icon={faClock}
|
||||
fieldName={t("shortStoryGenerator.fields.tense")}
|
||||
input={
|
||||
<SelectBox
|
||||
onChangeCallBack={(e) => setVerbTense(e.target.value)}
|
||||
data={verbalTime}
|
||||
defaultValue={verbTense}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InputField
|
||||
icon={faUserEdit}
|
||||
fieldName={t("shortStoryGenerator.fields.narrative")}
|
||||
input={
|
||||
<SelectBox
|
||||
onChangeCallBack={(e) => setPerson(e.target.value)}
|
||||
data={
|
||||
authorLevel === '1'
|
||||
? beginnerNarrativePersons
|
||||
: authorLevel === '2'
|
||||
? intermediateNarrativePersons
|
||||
: advancedNarrativePersons
|
||||
}
|
||||
defaultValue={person}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<InputField
|
||||
icon={faComments}
|
||||
fieldName={t("shortStoryGenerator.fields.dialogue")}
|
||||
input={
|
||||
<SelectBox
|
||||
onChangeCallBack={(e) => setDialogueType(e.target.value)}
|
||||
data={
|
||||
authorLevel === '1'
|
||||
? beginnerDialogueTypes
|
||||
: authorLevel === '2'
|
||||
? intermediateDialogueTypes
|
||||
: advancedDialogueTypes
|
||||
}
|
||||
defaultValue={dialogueType}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
icon={faPencilAlt}
|
||||
fieldName={t("shortStoryGenerator.fields.directives")}
|
||||
input={
|
||||
<TexteAreaInput
|
||||
value={directives}
|
||||
setValue={(e: ChangeEvent<HTMLTextAreaElement>) => setDirectives(e.target.value)}
|
||||
placeholder={t("shortStoryGenerator.placeholders.directives")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 3 && (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="space-y-4">
|
||||
<InputField
|
||||
icon={faMusic}
|
||||
fieldName={t("shortStoryGenerator.fields.tone")}
|
||||
input={
|
||||
<TextInput
|
||||
value={tone}
|
||||
setValue={(e: ChangeEvent<HTMLInputElement>) => setTone(e.target.value)}
|
||||
placeholder={t("shortStoryGenerator.placeholders.tone")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<InputField
|
||||
icon={faCloudSun}
|
||||
fieldName={t("shortStoryGenerator.fields.atmosphere")}
|
||||
input={
|
||||
<TextInput
|
||||
value={atmosphere}
|
||||
setValue={(e: ChangeEvent<HTMLInputElement>) => setAtmosphere(e.target.value)}
|
||||
placeholder={t("shortStoryGenerator.placeholders.atmosphere")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<InputField
|
||||
icon={faUserAstronaut}
|
||||
fieldName={t("shortStoryGenerator.fields.character")}
|
||||
input={
|
||||
<TextInput
|
||||
value={characters}
|
||||
setValue={(e: ChangeEvent<HTMLInputElement>) => setCharacters(e.target.value)}
|
||||
placeholder={t("shortStoryGenerator.placeholders.character")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 4 && (
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="font-semibold text-lg">
|
||||
{generatedStoryTitle || t("shortStoryGenerator.result.title")}
|
||||
</h3>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
{isGenerating ? (
|
||||
<button
|
||||
onClick={handleStopGeneration}
|
||||
className="p-2 rounded-xl bg-red-500 hover:bg-red-600 transition-all duration-200 hover:scale-110 shadow-md"
|
||||
title={t("shortStoryGenerator.actions.stop")}
|
||||
>
|
||||
<FontAwesomeIcon icon={faStop} className="w-4 h-4"/>
|
||||
</button>
|
||||
) : generatedText && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleGeneration}
|
||||
className="p-2 rounded-xl bg-secondary/50 hover:bg-secondary transition-all duration-200 hover:scale-110 shadow-sm border border-secondary/50"
|
||||
title={t("shortStoryGenerator.actions.regenerate")}
|
||||
>
|
||||
<FontAwesomeIcon icon={faRotateRight} className="w-4 h-4"/>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="p-2 rounded-xl bg-primary hover:bg-primary-dark transition-all duration-200 hover:scale-110 shadow-md"
|
||||
title={t("shortStoryGenerator.actions.save")}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBookBookmark} className="w-4 h-4"/>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isGenerating && !generatedText ? (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<FontAwesomeIcon icon={faSpinner}
|
||||
className="animate-spin text-primary mb-4 w-8 h-8"/>
|
||||
<p className="text-text-secondary">{t("shortStoryGenerator.result.generating")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="bg-darkest-background rounded-lg p-6 overflow-auto max-h-96 fade-in-text">
|
||||
<EditorContent editor={editor} className="prose prose-invert max-w-none"/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{generatedText && (
|
||||
<div className="flex justify-between items-center mt-4 pt-4 border-t border-secondary">
|
||||
<div className="flex items-center text-sm text-text-secondary">
|
||||
<FontAwesomeIcon icon={faChartSimple} className="mr-2 w-4 h-4"/>
|
||||
{totalWordsCount} {t("shortStoryGenerator.result.words")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex justify-between items-center p-6 border-t border-secondary/50 bg-secondary/30 backdrop-blur-sm shadow-inner">
|
||||
<button
|
||||
onClick={() => setActiveTab(Math.max(1, activeTab - 1))}
|
||||
className={`px-4 py-2 rounded-xl transition-all duration-200 flex items-center ${
|
||||
activeTab > 1 && !isGenerating
|
||||
? 'text-text-secondary hover:text-text-primary hover:bg-secondary hover:scale-105 shadow-sm'
|
||||
: 'text-muted cursor-not-allowed'
|
||||
}`}
|
||||
disabled={activeTab === 1 || isGenerating}
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronRight} className="mr-2 rotate-180 w-4 h-4"/>
|
||||
{t("shortStoryGenerator.navigation.previous")}
|
||||
</button>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-6 py-2.5 rounded-xl bg-secondary/50 text-text-primary hover:bg-secondary transition-all duration-200 hover:scale-105 shadow-sm border border-secondary/50 font-medium"
|
||||
disabled={isGenerating}
|
||||
>
|
||||
{activeTab === 4 && hasGenerated ? t("shortStoryGenerator.navigation.close") : t("shortStoryGenerator.navigation.cancel")}
|
||||
</button>
|
||||
|
||||
{activeTab < 3 ? (
|
||||
<button
|
||||
onClick={() => setActiveTab(activeTab + 1)}
|
||||
disabled={isGenerating}
|
||||
className="px-6 py-2.5 rounded-xl bg-primary text-text-primary hover:bg-primary-dark transition-all duration-200 hover:scale-105 flex items-center disabled:opacity-50 shadow-md hover:shadow-lg font-medium"
|
||||
>
|
||||
{t("shortStoryGenerator.navigation.next")}
|
||||
<FontAwesomeIcon icon={faChevronRight} className="ml-2 w-4 h-4"/>
|
||||
</button>
|
||||
) : activeTab === 3 && (
|
||||
<button
|
||||
onClick={handleGeneration}
|
||||
disabled={isGenerating}
|
||||
className="px-6 py-2.5 rounded-xl bg-primary text-text-primary hover:bg-primary-dark transition-all duration-200 hover:scale-105 flex items-center disabled:opacity-50 shadow-md hover:shadow-lg font-medium"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<FontAwesomeIcon icon={faSpinner} className="animate-spin mr-2 w-4 h-4"/>
|
||||
{t("shortStoryGenerator.actions.generating")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FontAwesomeIcon icon={faMagicWandSparkles} className="mr-2 w-4 h-4"/>
|
||||
{t("shortStoryGenerator.actions.generate")}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
134
components/StaticAlert.tsx
Normal file
134
components/StaticAlert.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
className={`max-w-sm rounded-xl shadow-2xl transition-all duration-500 ease-in-out transform ${
|
||||
visible ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0'
|
||||
} overflow-hidden font-['Montserrat'] border border-secondary/50 backdrop-blur-sm`}
|
||||
>
|
||||
<div className={`p-4 ${bgColorMap[type]} flex items-center relative`}>
|
||||
<div className="absolute top-0 left-0 w-full h-1 bg-white/30 rounded-t-xl"></div>
|
||||
<div
|
||||
className="mr-4 flex-shrink-0 rounded-full bg-white/20 p-2.5 text-text-primary flex items-center justify-center shadow-md">
|
||||
<FontAwesomeIcon
|
||||
icon={iconMap[type]}
|
||||
size="lg"
|
||||
className="animate-pulse"
|
||||
style={{
|
||||
animation: 'pulse 2s infinite'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-grow mr-3">
|
||||
<div className="text-text-primary font-medium text-base">{message}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="text-text-primary/90 hover:text-text-primary p-1.5 rounded-lg hover:bg-white/20 transition-all duration-300"
|
||||
style={{
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'rotate(90deg)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'rotate(0deg)';
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTimes}/>
|
||||
</button>
|
||||
</div>
|
||||
<div className="h-1.5 w-full bg-secondary/50 relative">
|
||||
<div
|
||||
className={`h-full ${
|
||||
type === 'success' ? 'bg-success' :
|
||||
type === 'error' ? 'bg-error' :
|
||||
type === 'warning' ? 'bg-warning' :
|
||||
'bg-info'
|
||||
} shadow-sm`}
|
||||
style={{
|
||||
animation: 'shrink 5s linear forwards',
|
||||
width: '100%'
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
<style jsx>{`
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shrink {
|
||||
0% {
|
||||
width: 100%;
|
||||
}
|
||||
100% {
|
||||
width: 0%;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
125
components/TermsOfUse.tsx
Normal file
125
components/TermsOfUse.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/90 backdrop-blur-sm flex items-center justify-center p-6 font-['Lora']">
|
||||
<div
|
||||
className="bg-tertiary border border-primary/40 rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-hidden">
|
||||
<div className="px-8 py-6 border-b border-secondary/40 bg-gradient-to-r from-primary/10 to-primary/5">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="bg-primary/20 p-3 rounded-xl">
|
||||
<FontAwesomeIcon icon={faFileContract} className="text-primary text-2xl"/>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-text-primary font-bold text-2xl">Termes d'utilisation</h2>
|
||||
<p className="text-text-secondary text-sm mt-1">Acceptation requise pour accéder à ERitors
|
||||
Scribe</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-8 py-8 overflow-y-auto max-h-[60vh]">
|
||||
<div className="space-y-6">
|
||||
<div className="bg-primary/5 border border-primary/20 rounded-xl p-6">
|
||||
<h3 className="text-text-primary font-semibold text-lg mb-4">
|
||||
Acceptation obligatoire
|
||||
</h3>
|
||||
<div className="text-text-secondary text-base leading-relaxed space-y-4">
|
||||
<p>
|
||||
Pour pouvoir utiliser nos services, tel qu'<strong className="text-primary">ERitors
|
||||
Scribe</strong>,
|
||||
vous devez accepter les termes d'utilisation en cliquant
|
||||
sur <strong>J'accepte</strong>.
|
||||
</p>
|
||||
<p>
|
||||
Veuillez lire attentivement la page détaillée des termes et conditions d'utilisation
|
||||
avant de procéder à l'acceptation.
|
||||
</p>
|
||||
<p>
|
||||
Si vous n'acceptez pas ces conditions, vous ne pourrez pas accéder à nos services
|
||||
et serez redirigé vers la page d'accueil.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-secondary/20 border border-secondary/30 rounded-xl p-6">
|
||||
<h3 className="text-text-primary font-semibold text-lg mb-4">
|
||||
Documentation complète
|
||||
</h3>
|
||||
<p className="text-text-secondary text-base leading-relaxed mb-4">
|
||||
Pour consulter l'intégralité de nos termes et conditions d'utilisation,
|
||||
veuillez visiter notre page dédiée :
|
||||
</p>
|
||||
<a
|
||||
href="/terms"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center space-x-2 text-primary hover:text-primary-light transition-colors duration-200 font-medium"
|
||||
>
|
||||
<span>Consulter les termes complets</span>
|
||||
<FontAwesomeIcon icon={faExternalLinkAlt} className="text-sm"/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="bg-warning/10 border border-warning/30 rounded-xl p-6">
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="bg-warning/20 p-2 rounded-lg mt-1">
|
||||
<FontAwesomeIcon icon={faFileContract} className="text-warning text-lg"/>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-text-primary font-semibold text-base mb-2">
|
||||
Importance capitale
|
||||
</h4>
|
||||
<p className="text-text-secondary text-sm leading-relaxed">
|
||||
Cette acceptation est obligatoire et constitue un prérequis légal
|
||||
pour l'utilisation de nos services d'édition assistée par intelligence
|
||||
artificielle.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-8 py-6 bg-secondary/10 border-t border-secondary/30 rounded-b-2xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2 text-text-secondary text-sm">
|
||||
<FontAwesomeIcon icon={faFileContract} className="text-primary"/>
|
||||
<span>Décision requise pour continuer</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link
|
||||
href="https://eritors.com"
|
||||
className="text-muted hover:text-text-primary px-6 py-3 rounded-xl hover:bg-secondary/30 transition-all duration-200 text-sm font-medium hover:scale-105"
|
||||
type="button"
|
||||
>
|
||||
Refuser et quitter
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleAcceptTerm}
|
||||
className="bg-primary hover:bg-primary-dark text-text-primary px-8 py-3 rounded-xl transition-all duration-200 text-sm font-bold shadow-lg hover:shadow-xl transform hover:scale-105"
|
||||
type="button"
|
||||
>
|
||||
J'accepte les termes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
196
components/TwoFactorSetup.tsx
Normal file
196
components/TwoFactorSetup.tsx
Normal file
@@ -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<SetStateAction<boolean>> }) {
|
||||
const {session} = useContext(SessionContext);
|
||||
const alert: AlertContextProps = useContext(AlertContext);
|
||||
|
||||
const [step, setStep] = useState<number>(1);
|
||||
const [token, setToken] = useState<string>('')
|
||||
const [qrCode, setQrCode] = useState<string | null>(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 (
|
||||
<div
|
||||
className="rounded-2xl shadow-2xl bg-tertiary/90 backdrop-blur-sm m-auto p-8 w-full border border-secondary/50">
|
||||
<h2 className="text-2xl font-['ADLaM_Display'] text-text-primary text-center mb-6">
|
||||
Setup Two-Factor Authentication
|
||||
</h2>
|
||||
|
||||
{/* Step Indicator */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center">
|
||||
<div className={getProgressClass(1)}></div>
|
||||
<div className="w-4"></div>
|
||||
<div className={getProgressClass(2)}></div>
|
||||
<div className="w-4"></div>
|
||||
<div className={getProgressClass(3)}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step Content */}
|
||||
<div className="mb-6">
|
||||
{step === 1 && (
|
||||
<div className="text-muted">
|
||||
<p className="mb-4 text-text-primary">
|
||||
Follow these steps to enable two-factor authentication for your account:
|
||||
</p>
|
||||
<ol className="list-decimal list-inside space-y-3">
|
||||
<li className="flex items-start">
|
||||
<FontAwesomeIcon icon={faMobileAlt} className="text-primary mr-3 mt-1"/>
|
||||
Download a two-factor authentication app like Google Authenticator or Authy.
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<FontAwesomeIcon icon={faCheck} className="text-primary mr-3 mt-1"/>
|
||||
Open the app and select the option to scan a QR code.
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<FontAwesomeIcon icon={faQrcode} className="text-primary mr-3 mt-1"/>
|
||||
Proceed to the next step to scan the QR code provided.
|
||||
</li>
|
||||
</ol>
|
||||
<div className="mt-6 space-y-4">
|
||||
<a
|
||||
href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center px-4 py-3 bg-primary text-text-primary rounded-xl hover:bg-primary-dark transition-all duration-200 hover:scale-105 shadow-md hover:shadow-lg font-medium"
|
||||
>
|
||||
<FontAwesomeIcon icon={faGooglePlay} className="mr-2"/>
|
||||
Download on Google Play
|
||||
</a>
|
||||
<a
|
||||
href="https://apps.apple.com/app/google-authenticator/id388497605"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center px-4 py-3 bg-primary text-text-primary rounded-xl hover:bg-primary-dark transition-all duration-200 hover:scale-105 shadow-md hover:shadow-lg font-medium"
|
||||
>
|
||||
<FontAwesomeIcon icon={faApple} className="mr-2"/>
|
||||
Download on App Store
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<div className="text-muted text-center">
|
||||
<p className="mb-4 text-text-primary">
|
||||
Scan the QR code below with your authentication app to link your account.
|
||||
</p>
|
||||
<div className="flex justify-center">
|
||||
<div className="bg-secondary/20 p-6 rounded-xl shadow-lg border border-secondary/50">
|
||||
{loadingQRCode ? (
|
||||
<div className="text-muted">Loading QR Code...</div>
|
||||
) : qrCode ? (
|
||||
<img src={qrCode} alt="QR Code" className="w-48 h-48 mx-auto"/>
|
||||
) : (
|
||||
<div className="text-muted">Failed to load QR Code.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-muted">
|
||||
Having trouble? Make sure your app supports QR code scanning.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{step === 3 && (
|
||||
<div className="text-text-secondary">
|
||||
<p className="mb-4">
|
||||
Enter the 6-digit code generated by your authentication app to verify the setup.
|
||||
</p>
|
||||
<div className="relative">
|
||||
<TextInput
|
||||
value={token}
|
||||
setValue={(e: ChangeEvent<HTMLInputElement>) => setToken(e.target.value)}
|
||||
placeholder="Enter 6-digit code"
|
||||
/>
|
||||
<FontAwesomeIcon
|
||||
icon={faKey}
|
||||
className="absolute right-3 top-3 text-muted pointer-events-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation Buttons */}
|
||||
<div className="flex justify-between">
|
||||
<button
|
||||
onClick={handlePrevStep}
|
||||
disabled={step === 1}
|
||||
className={`px-6 py-2.5 rounded-xl transition-all duration-200 font-medium ${
|
||||
step === 1 ? 'bg-secondary/30 text-muted cursor-not-allowed' : 'bg-secondary/50 hover:bg-secondary text-text-primary hover:scale-105 shadow-sm border border-secondary/50'
|
||||
}`}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNextStep}
|
||||
className={`px-6 py-2.5 rounded-xl transition-all duration-200 font-medium ${
|
||||
step === 3 ? 'bg-success hover:bg-success/90 text-text-primary shadow-md hover:shadow-lg hover:scale-105' : 'bg-primary hover:bg-primary-dark text-text-primary shadow-md hover:shadow-lg hover:scale-105'
|
||||
}`}
|
||||
>
|
||||
{step === 3 ? 'Finish' : 'Next'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
components/UserMenu.tsx
Normal file
69
components/UserMenu.tsx
Normal file
@@ -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<HTMLDivElement | null> = useRef<HTMLDivElement>(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 (
|
||||
<div className="relative" data-guide="user-dropdown" ref={profileMenuRef}>
|
||||
<button
|
||||
className="group bg-secondary/50 hover:bg-secondary p-2.5 rounded-full transition-all duration-200 flex items-center border border-secondary/50 hover:border-primary/30 hover:shadow-md hover:scale-110"
|
||||
onClick={session.user ? handleProfileClick : () => document.location.href = "/login"}
|
||||
>
|
||||
{
|
||||
session.user && <NoPicture/>
|
||||
}
|
||||
</button>
|
||||
{isProfileMenuOpen && (
|
||||
<div
|
||||
className="absolute right-0 mt-3 w-56 bg-tertiary rounded-xl shadow-2xl py-2 z-[100] border border-secondary/50 backdrop-blur-sm animate-fadeIn">
|
||||
<div
|
||||
className="px-4 py-3 border-b border-secondary/30 bg-gradient-to-r from-primary/10 to-transparent">
|
||||
<p className="text-text-primary font-bold text-sm tracking-wide">{session.user?.username}</p>
|
||||
<p className="text-text-secondary text-xs mt-0.5">{session.user?.email}</p>
|
||||
</div>
|
||||
<a href="https://eritors.com/settings"
|
||||
className="group flex items-center gap-3 px-4 py-2.5 text-text-primary hover:bg-secondary/50 transition-all hover:pl-5">
|
||||
<span
|
||||
className="text-sm font-medium group-hover:text-primary transition-colors">Paramètres</span>
|
||||
</a>
|
||||
<a onClick={handleLogout} href="#"
|
||||
className="group flex items-center gap-3 px-4 py-2.5 text-error hover:bg-error/10 transition-all hover:pl-5 rounded-b-xl">
|
||||
<span className="text-sm font-medium">Déconnexion</span>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
295
components/book/AddNewBookForm.tsx
Normal file
295
components/book/AddNewBookForm.tsx
Normal file
@@ -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<SetStateAction<boolean>> }) {
|
||||
const t = useTranslations();
|
||||
const {lang} = useContext<LangContextProps>(LangContext);
|
||||
const {session, setSession} = useContext(SessionContext);
|
||||
const {errorMessage} = useContext(AlertContext);
|
||||
const modalRef: React.RefObject<HTMLDivElement | null> = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [title, setTitle] = useState<string>('');
|
||||
const [subtitle, setSubtitle] = useState<string>('');
|
||||
const [summary, setSummary] = useState<string>('');
|
||||
const [publicationDate, setPublicationDate] = useState<string>('');
|
||||
const [wordCount, setWordCount] = useState<number>(0);
|
||||
const [selectedBookType, setSelectedBookType] = useState<string>('');
|
||||
|
||||
const [isAddingBook, setIsAddingBook] = useState<boolean>(false);
|
||||
const [bookTypeHint, setBookTypeHint] = useState<boolean>(false);
|
||||
|
||||
const token: string = session?.accessToken ?? '';
|
||||
|
||||
const bookTypesHint: GuideStep[] = [{
|
||||
id: 0,
|
||||
x: 80,
|
||||
y: 50,
|
||||
title: t("addNewBookForm.bookTypeHint.title"),
|
||||
content: (
|
||||
<div className="space-y-4 max-h-96 overflow-y-auto custom-scrollbar">
|
||||
<div className="space-y-3">
|
||||
<div className="border-l-4 border-primary pl-4 bg-secondary/10 p-3 rounded-r-xl">
|
||||
<h4 className="font-semibold text-lg text-text-primary mb-1">{t("addNewBookForm.bookTypeHint.nouvelle.title")}</h4>
|
||||
<p className="text-sm text-muted mb-2">{t("addNewBookForm.bookTypeHint.nouvelle.range")}</p>
|
||||
<p className="text-sm text-text-secondary">{t("addNewBookForm.bookTypeHint.nouvelle.description")}</p>
|
||||
</div>
|
||||
<div className="border-l-4 border-primary pl-4 bg-secondary/10 p-3 rounded-r-xl">
|
||||
<h4 className="font-semibold text-lg text-text-primary mb-1">{t("addNewBookForm.bookTypeHint.novelette.title")}</h4>
|
||||
<p className="text-sm text-muted mb-2">{t("addNewBookForm.bookTypeHint.novelette.range")}</p>
|
||||
<p className="text-sm text-text-secondary">{t("addNewBookForm.bookTypeHint.novelette.description")}</p>
|
||||
</div>
|
||||
<div className="border-l-4 border-primary pl-4 bg-secondary/10 p-3 rounded-r-xl">
|
||||
<h4 className="font-semibold text-lg text-text-primary mb-1">{t("addNewBookForm.bookTypeHint.novella.title")}</h4>
|
||||
<p className="text-sm text-muted mb-2">{t("addNewBookForm.bookTypeHint.novella.range")}</p>
|
||||
<p className="text-sm text-text-secondary">{t("addNewBookForm.bookTypeHint.novella.description")}</p>
|
||||
</div>
|
||||
<div className="border-l-4 border-primary pl-4 bg-secondary/10 p-3 rounded-r-xl">
|
||||
<h4 className="font-semibold text-lg text-text-primary mb-1">{t("addNewBookForm.bookTypeHint.chapbook.title")}</h4>
|
||||
<p className="text-sm text-muted mb-2">{t("addNewBookForm.bookTypeHint.chapbook.range")}</p>
|
||||
<p className="text-sm text-text-secondary">{t("addNewBookForm.bookTypeHint.chapbook.description")}</p>
|
||||
</div>
|
||||
<div className="border-l-4 border-primary pl-4 bg-secondary/10 p-3 rounded-r-xl">
|
||||
<h4 className="font-semibold text-lg text-text-primary mb-1">{t("addNewBookForm.bookTypeHint.roman.title")}</h4>
|
||||
<p className="text-sm text-muted mb-2">{t("addNewBookForm.bookTypeHint.roman.range")}</p>
|
||||
<p className="text-sm text-text-secondary">{t("addNewBookForm.bookTypeHint.roman.description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-primary/10 border border-primary/30 p-4 rounded-xl">
|
||||
<p className="text-sm text-text-primary font-medium">
|
||||
{t("addNewBookForm.bookTypeHint.tip")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}]
|
||||
|
||||
useEffect((): () => void => {
|
||||
document.body.style.overflow = 'hidden';
|
||||
return (): void => {
|
||||
document.body.style.overflow = 'auto';
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function handleAddBook(): Promise<void> {
|
||||
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<string>('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 (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center bg-black/60 z-50 backdrop-blur-md animate-fadeIn">
|
||||
<div ref={modalRef}
|
||||
className="bg-tertiary/95 backdrop-blur-sm text-text-primary rounded-2xl border border-secondary/50 shadow-2xl md:w-3/4 xl:w-1/4 lg:w-2/4 sm:w-11/12 max-h-[85vh] flex flex-col">
|
||||
<div className="flex justify-between items-center bg-primary px-6 py-4 rounded-t-2xl shadow-lg">
|
||||
<h2 className="flex items-center gap-3 font-['ADLaM_Display'] text-2xl text-text-primary">
|
||||
<FontAwesomeIcon icon={faBook} className="w-6 h-6"/>
|
||||
{t("addNewBookForm.title")}
|
||||
</h2>
|
||||
<button
|
||||
className="text-background hover:text-background w-10 h-10 rounded-xl hover:bg-white/20 transition-all duration-200 flex items-center justify-center hover:scale-110"
|
||||
onClick={(): void => setCloseForm(false)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faX} className={'w-5 h-5'}/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-5 overflow-y-auto flex-grow custom-scrollbar">
|
||||
<div className="space-y-6">
|
||||
<InputField icon={faBookOpen} fieldName={t("addNewBookForm.type")} input={
|
||||
<SelectBox
|
||||
onChangeCallBack={(e: ChangeEvent<HTMLSelectElement>): 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<void> => setBookTypeHint(true)} actionIcon={faInfo}/>
|
||||
<InputField icon={faPencilAlt} fieldName={t("addNewBookForm.bookTitle")} input={
|
||||
<TextInput value={title}
|
||||
setValue={(e: ChangeEvent<HTMLInputElement>): void => setTitle(e.target.value)}
|
||||
placeholder={t("addNewBookForm.bookTitlePlaceholder")}/>
|
||||
}/>
|
||||
{
|
||||
selectedBookType !== 'lyric' && (
|
||||
<InputField icon={faPencilAlt} fieldName={t("addNewBookForm.subtitle")} input={
|
||||
<TextInput value={subtitle}
|
||||
setValue={(e: ChangeEvent<HTMLInputElement>): void => setSubtitle(e.target.value)}
|
||||
placeholder={t("addNewBookForm.subtitlePlaceholder")}/>
|
||||
}/>
|
||||
)
|
||||
}
|
||||
|
||||
<InputField icon={faCalendarAlt} fieldName={t("addNewBookForm.publicationDate")} input={
|
||||
<DatePicker date={publicationDate}
|
||||
setDate={(e: React.ChangeEvent<HTMLInputElement>): void => setPublicationDate(e.target.value)}/>
|
||||
}/>
|
||||
|
||||
{
|
||||
selectedBookType !== 'lyric' && (
|
||||
<>
|
||||
<InputField icon={faFileWord} fieldName={t("addNewBookForm.wordGoal")}
|
||||
hint={selectedBookType && `${maxWordsCountHint().min.toLocaleString('fr-FR')} - ${maxWordsCountHint().max > 0 ? maxWordsCountHint().max.toLocaleString('fr-FR') : '∞'} ${t("addNewBookForm.words")}`}
|
||||
input={
|
||||
<NumberInput value={wordCount} setValue={setWordCount}
|
||||
placeholder={t("addNewBookForm.wordGoalPlaceholder")}/>
|
||||
}/>
|
||||
|
||||
<InputField
|
||||
icon={faFileWord}
|
||||
fieldName={t("addNewBookForm.summary")}
|
||||
input={
|
||||
<TexteAreaInput
|
||||
value={summary}
|
||||
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setSummary(e.target.value)}
|
||||
placeholder={t("addNewBookForm.summaryPlaceholder")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex justify-between items-center p-5 border-t border-secondary/50 bg-secondary/20 rounded-b-2xl">
|
||||
<div></div>
|
||||
<div className="flex gap-3">
|
||||
<CancelButton callBackFunction={() => setCloseForm(false)}/>
|
||||
<SubmitButtonWLoading callBackAction={handleAddBook} isLoading={isAddingBook}
|
||||
text={t("addNewBookForm.add")}
|
||||
loadingText={t("addNewBookForm.adding")} icon={faBook}/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{bookTypeHint && <GuideTour stepId={0} steps={bookTypesHint} onClose={(): void => setBookTypeHint(false)}
|
||||
onComplete={async (): Promise<void> => setBookTypeHint(false)}/>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
components/book/BookCard.tsx
Normal file
80
components/book/BookCard.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
className="group bg-tertiary/90 backdrop-blur-sm rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-300 h-full border border-secondary/50 hover:border-primary/50 flex flex-col hover:scale-105">
|
||||
<div className="relative w-full h-[400px] sm:h-32 md:h-48 lg:h-64 xl:h-80 flex-shrink-0 overflow-hidden">
|
||||
<Link onClick={(): void => onClickCallback(book.bookId)} href={``}>
|
||||
{book.coverImage ? (
|
||||
<img
|
||||
src={book.coverImage}
|
||||
alt={book.title || t("bookCard.noCoverAlt")}
|
||||
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="relative w-full h-full bg-gradient-to-br from-secondary via-secondary to-gray-dark flex items-center justify-center overflow-hidden">
|
||||
<div className="absolute inset-0 bg-primary/5"></div>
|
||||
<span
|
||||
className="relative text-primary/80 text-4xl sm:text-5xl md:text-6xl font-['ADLaM_Display'] tracking-wider">
|
||||
{book.title.charAt(0).toUpperCase()}{t("bookCard.initialsSeparator")}{book.subTitle ? book.subTitle.charAt(0).toUpperCase() : ''}
|
||||
</span>
|
||||
<div
|
||||
className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-primary/30 to-transparent"></div>
|
||||
<div
|
||||
className="absolute bottom-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-primary/30 to-transparent"></div>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-tertiary via-tertiary/50 to-transparent h-24"></div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 flex-1 flex flex-col justify-between">
|
||||
<div className="flex-1">
|
||||
<Link onClick={(): void => onClickCallback(book.bookId)} href={``}>
|
||||
<h3 className="text-text-primary text-center font-bold text-base mb-2 truncate group-hover:text-primary transition-colors tracking-wide">
|
||||
{book.title}
|
||||
</h3>
|
||||
</Link>
|
||||
<div className="flex items-center justify-center mb-3 h-5">
|
||||
{book.subTitle ? (
|
||||
<>
|
||||
<div className="h-px w-8 bg-primary/30"></div>
|
||||
<p className="text-muted text-center mx-2 text-xs italic truncate px-2">
|
||||
{book.subTitle}
|
||||
</p>
|
||||
<div className="h-px w-8 bg-primary/30"></div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center pt-3 border-t border-secondary/30">
|
||||
<span
|
||||
className="bg-primary/10 text-primary text-xs px-3 py-1 rounded-full font-medium border border-primary/30"></span>
|
||||
<div className="flex items-center gap-1" {...index === 0 && {'data-guide': 'bottom-book-card'}}>
|
||||
<ExportBook bookTitle={book.title} bookId={book.bookId}/>
|
||||
<DeleteBook bookId={book.bookId}/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
26
components/book/BookCardSkeleton.tsx
Normal file
26
components/book/BookCardSkeleton.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
export default function BookCardSkeleton() {
|
||||
return (
|
||||
<div
|
||||
className="bg-tertiary/90 backdrop-blur-sm rounded-xl shadow-lg h-full border border-secondary/50 flex flex-col animate-pulse">
|
||||
<div className="relative w-full h-[400px] sm:h-32 md:h-48 lg:h-64 xl:h-80 flex-shrink-0">
|
||||
<div className="w-full h-full bg-secondary/30 rounded-t-xl"></div>
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-background to-transparent h-20"></div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 flex-1 flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="h-4 bg-secondary/30 rounded-lg mb-2 w-3/4 mx-auto"></div>
|
||||
<div className="h-3 bg-secondary/20 rounded-lg w-1/2 mx-auto"></div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mt-4">
|
||||
<div className="h-6 bg-secondary/30 rounded-full w-16"></div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="h-8 w-8 bg-secondary/30 rounded-lg"></div>
|
||||
<div className="h-8 w-8 bg-secondary/30 rounded-lg"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
291
components/book/BookList.tsx
Normal file
291
components/book/BookList.tsx
Normal file
@@ -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<LangContextProps>(LangContext)
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
const [groupedBooks, setGroupedBooks] = useState<Record<string, BookProps[]>>({});
|
||||
const [isLoadingBooks, setIsLoadingBooks] = useState<boolean>(true);
|
||||
|
||||
const [bookGuide, setBookGuide] = useState<boolean>(false);
|
||||
|
||||
const bookGuideSteps: GuideStep[] = [
|
||||
{
|
||||
id: 0,
|
||||
targetSelector: '[data-guide="book-category"]',
|
||||
position: 'left',
|
||||
highlightRadius: -200,
|
||||
title: `${t("bookList.guideStep0Title")} ${session.user?.name}`,
|
||||
content: (
|
||||
<div>
|
||||
<p>{t("bookList.guideStep0Content")}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
targetSelector: '[data-guide="book-card"]',
|
||||
position: 'left',
|
||||
title: t("bookList.guideStep1Title"),
|
||||
content: (
|
||||
<div>
|
||||
<p>{t("bookList.guideStep1Content")}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
targetSelector: '[data-guide="bottom-book-card"]',
|
||||
position: 'left',
|
||||
title: t("bookList.guideStep2Title"),
|
||||
content: (
|
||||
<div>
|
||||
<p>
|
||||
<FontAwesomeIcon icon={faGear} className="mr-2 text-primary w-5 h-5"/>
|
||||
{t("bookList.guideStep2ContentGear")}
|
||||
</p>
|
||||
<p>
|
||||
<FontAwesomeIcon icon={faDownload} className="mr-2 text-primary w-5 h-5"/>
|
||||
{t("bookList.guideStep2ContentDownload")}
|
||||
</p>
|
||||
<p>
|
||||
<FontAwesomeIcon icon={faTrash} className="mr-2 text-primary w-5 h-5"/>
|
||||
{t("bookList.guideStep2ContentTrash")}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
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<void> {
|
||||
try {
|
||||
const response: boolean = await System.authPostToServer<boolean>(
|
||||
'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<void> {
|
||||
setIsLoadingBooks(true);
|
||||
try {
|
||||
const bookResponse: BookListProps[] = await System.authGetQueryToServer<BookListProps[]>('books', accessToken, lang);
|
||||
if (bookResponse) {
|
||||
const booksByType: Record<string, BookProps[]> = bookResponse.reduce((groups: Record<string, BookProps[]>, book: BookListProps): Record<string, BookProps[]> => {
|
||||
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<string, BookProps[]> = Object.entries(groupedBooks).reduce(
|
||||
(acc: Record<string, BookProps[]>, [category, books]: [string, BookProps[]]): Record<string, BookProps[]> => {
|
||||
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<void> {
|
||||
try {
|
||||
const bookResponse: BookListProps = await System.authGetQueryToServer<BookListProps>(
|
||||
`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 (
|
||||
<div
|
||||
className="flex flex-col items-center h-full overflow-hidden w-full text-text-primary font-['Lora']">
|
||||
{session?.user && (
|
||||
<div data-guide="search-bar" className="w-full max-w-3xl px-4 pt-6 pb-4">
|
||||
<SearchBook searchQuery={searchQuery} setSearchQuery={setSearchQuery}/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col w-full overflow-y-auto h-full min-h-0 flex-grow">
|
||||
{
|
||||
isLoadingBooks ? (
|
||||
<>
|
||||
<div className="text-center mb-8 px-6">
|
||||
<h1 className="font-['ADLaM_Display'] text-4xl mb-3 text-text-primary">{t("bookList.library")}</h1>
|
||||
<p className="text-muted italic text-lg">{t("bookList.booksAreMirrors")}</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full mb-10">
|
||||
<div className="flex justify-between items-center w-full max-w-5xl mx-auto mb-4 px-6">
|
||||
<div className="h-8 bg-secondary/30 rounded-xl w-32 animate-pulse"></div>
|
||||
<div className="h-6 bg-secondary/20 rounded-lg w-24 animate-pulse"></div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap justify-center items-start w-full px-4">
|
||||
{Array.from({length: 6}).map((_, id: number) => (
|
||||
<div key={id}
|
||||
className="w-full sm:w-1/3 md:w-1/4 lg:w-1/5 xl:w-1/6 p-2 box-border">
|
||||
<BookCardSkeleton/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : Object.entries(filteredGroupedBooks).length > 0 ? (
|
||||
<>
|
||||
<div className="text-center mb-8 px-6">
|
||||
<h1 className="font-['ADLaM_Display'] text-4xl mb-3 text-text-primary">{t("bookList.library")}</h1>
|
||||
<p className="text-muted italic text-lg">{t("bookList.booksAreMirrors")}</p>
|
||||
</div>
|
||||
|
||||
{Object.entries(filteredGroupedBooks).map(([category, books], index) => (
|
||||
<div {...(index === 0 && {'data-guide': 'book-category'})} key={category}
|
||||
className="w-full mb-10">
|
||||
<div
|
||||
className="flex justify-between items-center w-full max-w-5xl mx-auto mb-6 px-6">
|
||||
<h2 className="text-3xl text-text-primary capitalize font-['ADLaM_Display'] flex items-center gap-3">
|
||||
<span className="w-1 h-8 bg-primary rounded-full"></span>
|
||||
{category}
|
||||
</h2>
|
||||
<span
|
||||
className="text-muted text-lg font-medium bg-secondary/30 px-4 py-1.5 rounded-full">{books.length} {t("bookList.works")}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap justify-center items-start w-full px-4">
|
||||
{
|
||||
books.map((book: BookProps, idx) => (
|
||||
<div key={book.bookId}
|
||||
{...(idx === 0 && {'data-guide': 'book-card'})}
|
||||
className={`w-full sm:w-1/3 md:w-1/4 lg:w-1/5 xl:w-1/6 p-2 box-border ${User.guideTourDone(session.user?.guideTour || [], 'new-first-book') && 'mb-[200px]'}`}>
|
||||
<BookCard book={book}
|
||||
onClickCallback={getBook}
|
||||
index={idx}/>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center p-8 max-w-lg">
|
||||
<div
|
||||
className="w-24 h-24 bg-primary/20 text-primary rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-lg animate-pulse">
|
||||
<FontAwesomeIcon icon={faBook} className={'w-12 h-12'}/>
|
||||
</div>
|
||||
<h2 className="text-4xl font-['ADLaM_Display'] mb-4 text-text-primary">{t("bookList.welcomeWritingWorkshop")}</h2>
|
||||
<p className="text-muted mb-6 text-lg leading-relaxed">
|
||||
{t("bookList.whitePageText")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{
|
||||
bookGuide && <GuideTour stepId={0} steps={bookGuideSteps} onComplete={handleFirstBookGuide}
|
||||
onClose={() => setBookGuide(false)}/>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
components/book/SearchBook.tsx
Normal file
33
components/book/SearchBook.tsx
Normal file
@@ -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<SetStateAction<string>>
|
||||
}) {
|
||||
|
||||
return (
|
||||
<div className="flex items-center relative my-5 w-full max-w-3xl">
|
||||
<div className="relative flex-grow">
|
||||
<div className="relative">
|
||||
<FontAwesomeIcon icon={faSearch}
|
||||
className="absolute left-4 top-1/2 transform -translate-y-1/2 text-primary w-5 h-5 pointer-events-none z-10"/>
|
||||
<div className="pl-11">
|
||||
<TextInput
|
||||
value={searchQuery}
|
||||
setValue={(e: ChangeEvent<HTMLInputElement>) => setSearchQuery(e.target.value)}
|
||||
placeholder={t("searchBook.placeholder")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
232
components/book/settings/BasicInformationSetting.tsx
Normal file
232
components/book/settings/BasicInformationSetting.tsx
Normal file
@@ -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<LangContextProps>(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<string>(book?.coverImage ?? '');
|
||||
const [title, setTitle] = useState<string>(book?.title ? book?.title : '');
|
||||
const [subTitle, setSubTitle] = useState<string>(book?.subTitle ? book?.subTitle : '');
|
||||
const [summary, setSummary] = useState<string>(book?.summary ? book?.summary : '');
|
||||
const [publicationDate, setPublicationDate] = useState<string>(book?.publicationDate ? book?.publicationDate : '');
|
||||
const [wordCount, setWordCount] = useState<number>(book?.desiredWordCount ? book?.desiredWordCount : 0);
|
||||
|
||||
useImperativeHandle(ref, function () {
|
||||
return {
|
||||
handleSave: handleSave
|
||||
};
|
||||
});
|
||||
|
||||
async function handleCoverImageChange(e: ChangeEvent<HTMLInputElement>): Promise<void> {
|
||||
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<ArrayBuffer> = 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<void> {
|
||||
try {
|
||||
const response: boolean = await System.authDeleteToServer<boolean>(`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<void> {
|
||||
if (!title) {
|
||||
errorMessage(t('basicInformationSetting.error.titleRequired'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response: boolean = await System.authPostToServer<boolean>('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 (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<InputField fieldName={t('basicInformationSetting.fields.title')} input={<TextInput
|
||||
value={title}
|
||||
setValue={(e: ChangeEvent<HTMLInputElement>) => setTitle(e.target.value)}
|
||||
placeholder={t('basicInformationSetting.fields.titlePlaceholder')}
|
||||
/>}/>
|
||||
<InputField fieldName={t('basicInformationSetting.fields.subtitle')} input={<TextInput
|
||||
value={subTitle}
|
||||
setValue={(e: ChangeEvent<HTMLInputElement>) => setSubTitle(e.target.value)}
|
||||
placeholder={t('basicInformationSetting.fields.subtitlePlaceholder')}
|
||||
/>}/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
|
||||
<InputField fieldName={t('basicInformationSetting.fields.summary')} input={<TexteAreaInput
|
||||
value={summary}
|
||||
setValue={(e: ChangeEvent<HTMLTextAreaElement>) => setSummary(e.target.value)}
|
||||
placeholder={t('basicInformationSetting.fields.summaryPlaceholder')}
|
||||
/>}/>
|
||||
</div>
|
||||
|
||||
<div className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<InputField fieldName={t('basicInformationSetting.fields.publicationDate')} input={
|
||||
<DatePicker
|
||||
date={publicationDate}
|
||||
setDate={(e: ChangeEvent<HTMLInputElement>) => setPublicationDate(e.target.value)}
|
||||
/>
|
||||
}/>
|
||||
<InputField fieldName={t('basicInformationSetting.fields.wordCount')} input={
|
||||
<NumberInput value={wordCount} setValue={setWordCount}
|
||||
placeholder={t('basicInformationSetting.fields.wordCountPlaceholder')}/>
|
||||
}/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
|
||||
{currentImage ? (
|
||||
<div className="flex justify-center">
|
||||
<div className="relative w-40">
|
||||
<img src={currentImage} alt={t('basicInformationSetting.fields.coverImageAlt')}
|
||||
className="rounded-lg border border-secondary shadow-md w-full h-auto"/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute -top-2 -right-2 bg-error/90 hover:bg-error text-white rounded-full w-8 h-8 flex items-center justify-center hover:scale-110 transition-all duration-200 shadow-lg"
|
||||
onClick={handleRemoveCurrentImage}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTimes} className={'w-5 h-5'}/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center">
|
||||
<div className="w-full max-w-lg">
|
||||
<div
|
||||
className="p-6 border-2 border-dashed border-secondary/50 rounded-xl bg-secondary/20 hover:border-primary/60 hover:bg-secondary/30 transition-all duration-200 shadow-inner">
|
||||
<InputField fieldName={t('basicInformationSetting.fields.coverImage')}
|
||||
actionIcon={faFeather}
|
||||
actionLabel={t('basicInformationSetting.fields.generateWithQuillSense')}
|
||||
action={async () => {
|
||||
}} input={<input
|
||||
type="file"
|
||||
id="coverImage"
|
||||
accept="image/png, image/jpeg"
|
||||
onChange={handleCoverImageChange}
|
||||
className="w-full text-text-secondary focus:outline-none file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-primary file:text-background hover:file:bg-primary-dark file:cursor-pointer"
|
||||
/>}/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef(BasicInformationSetting);
|
||||
18
components/book/settings/BookSetting.tsx
Normal file
18
components/book/settings/BookSetting.tsx
Normal file
@@ -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<string>('basic-information')
|
||||
return (
|
||||
<div
|
||||
className={'flex justify-start bg-tertiary/90 backdrop-blur-sm rounded-2xl overflow-hidden border border-secondary/50 shadow-2xl'}>
|
||||
<div className={'bg-secondary/30 backdrop-blur-sm w-1/4 border-r border-secondary/50'}>
|
||||
<BookSettingSidebar selectedSetting={currentSetting} setSelectedSetting={setCurrentSetting}/>
|
||||
</div>
|
||||
<div className={'flex-1 setting-container bg-tertiary/50 p-6'}>
|
||||
<BookSettingOption setting={currentSetting}/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
118
components/book/settings/BookSettingOption.tsx
Normal file
118
components/book/settings/BookSettingOption.tsx
Normal file
@@ -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<void> } | null> = useRef<{
|
||||
handleSave: () => Promise<void>
|
||||
}>(null);
|
||||
const guideLineRef: RefObject<{ handleSave: () => Promise<void> } | null> = useRef<{
|
||||
handleSave: () => Promise<void>
|
||||
}>(null);
|
||||
const storyRef: RefObject<{ handleSave: () => Promise<void> } | null> = useRef<{
|
||||
handleSave: () => Promise<void>
|
||||
}>(null);
|
||||
const worldRef: RefObject<{ handleSave: () => Promise<void> } | null> = useRef<{
|
||||
handleSave: () => Promise<void>
|
||||
}>(null);
|
||||
const locationRef: RefObject<{ handleSave: () => Promise<void> } | null> = useRef<{
|
||||
handleSave: () => Promise<void>
|
||||
}>(null);
|
||||
const characterRef: RefObject<{ handleSave: () => Promise<void> } | null> = useRef<{
|
||||
handleSave: () => Promise<void>
|
||||
}>(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<void> {
|
||||
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 (
|
||||
<div className="space-y-5">
|
||||
<PanelHeader
|
||||
icon={faPen}
|
||||
badge={`BI`}
|
||||
title={renderTitle()}
|
||||
description={``}
|
||||
secondActionCallback={handleSaveClick}
|
||||
callBackAction={handleSaveClick}
|
||||
secondActionIcon={faSave}
|
||||
/>
|
||||
<div className="bg-secondary/10 rounded-xl overflow-auto max-h-[calc(100vh-250px)] p-1">
|
||||
{
|
||||
setting === 'basic-information' ? (
|
||||
<BasicInformationSetting ref={basicInfoRef}/>
|
||||
) : setting === 'guide-line' ? (
|
||||
<GuideLineSetting ref={guideLineRef}/>
|
||||
) : setting === 'story' ? (
|
||||
<StorySetting ref={storyRef}/>
|
||||
) : setting === 'world' ? (
|
||||
<WorldSetting ref={worldRef}/>
|
||||
) : setting === 'locations' ? (
|
||||
<LocationComponent ref={locationRef}/>
|
||||
) : setting === 'characters' ? (
|
||||
<CharacterComponent ref={characterRef}/>
|
||||
) : <div
|
||||
className="text-text-secondary py-4 text-center">{t("bookSettingOption.notAvailable")}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
91
components/book/settings/BookSettingSidebar.tsx
Normal file
91
components/book/settings/BookSettingSidebar.tsx
Normal file
@@ -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<SetStateAction<string>>
|
||||
}) {
|
||||
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 (
|
||||
<div className="py-6 px-3">
|
||||
<nav className="space-y-1">
|
||||
{
|
||||
settings.map((setting: BookSettingOption) => (
|
||||
<Link
|
||||
key={setting.id}
|
||||
href={''}
|
||||
onClick={(): void => setSelectedSetting(setting.id)}
|
||||
className={`flex items-center text-base rounded-xl transition-all duration-200 ${
|
||||
selectedSetting === setting.id
|
||||
? 'bg-primary/20 text-text-primary border-l-4 border-primary font-semibold shadow-md scale-105'
|
||||
: 'text-text-secondary hover:bg-secondary/50 hover:text-text-primary hover:scale-102'
|
||||
} p-3 mb-1`}>
|
||||
<FontAwesomeIcon icon={setting.icon}
|
||||
className={`mr-3 ${selectedSetting === setting.id ? 'text-primary w-5 h-5' : 'text-text-secondary w-5 h-5'}`}/>
|
||||
{t(setting.name)}
|
||||
</Link>
|
||||
))
|
||||
}
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
90
components/book/settings/DeleteBook.tsx
Normal file
90
components/book/settings/DeleteBook.tsx
Normal file
@@ -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<LangContextProps>(LangContext)
|
||||
const [showConfirmBox, setShowConfirmBox] = useState<boolean>(false);
|
||||
const {errorMessage} = useContext<AlertContextProps>(AlertContext)
|
||||
|
||||
function handleConfirmation(): void {
|
||||
setShowConfirmBox(true);
|
||||
}
|
||||
|
||||
async function handleDeleteBook(): Promise<void> {
|
||||
try {
|
||||
const response: boolean = await System.authDeleteToServer<boolean>(
|
||||
`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 (
|
||||
<>
|
||||
<button onClick={handleConfirmation}
|
||||
className="text-muted hover:text-error hover:bg-error/10 transition-all duration-200 p-2 rounded-lg hover:scale-110">
|
||||
<FontAwesomeIcon icon={faTrash} className={'w-5 h-5'}/>
|
||||
</button>
|
||||
{
|
||||
showConfirmBox && (
|
||||
<AlertBox title={'Suppression du livre'}
|
||||
message={'Vous être sur le point de supprimer votre livre définitivement.'} type={"danger"}
|
||||
onConfirm={handleDeleteBook} onCancel={() => setShowConfirmBox(false)}
|
||||
confirmText={'Supprimer'} cancelText={'Annuler'}/>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
264
components/book/settings/characters/CharacterComponent.tsx
Normal file
264
components/book/settings/characters/CharacterComponent.tsx
Normal file
@@ -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<SetStateAction<CharacterProps | null>>;
|
||||
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<LangContextProps>(LangContext)
|
||||
const {session} = useContext(SessionContext);
|
||||
const {book} = useContext(BookContext);
|
||||
const {errorMessage, successMessage} = useContext(AlertContext);
|
||||
const [characters, setCharacters] = useState<CharacterProps[]>([]);
|
||||
const [selectedCharacter, setSelectedCharacter] = useState<CharacterProps | null>(null);
|
||||
|
||||
useImperativeHandle(ref, function () {
|
||||
return {
|
||||
handleSave: handleSaveCharacter,
|
||||
};
|
||||
});
|
||||
|
||||
useEffect((): void => {
|
||||
getCharacters().then();
|
||||
}, []);
|
||||
|
||||
async function getCharacters(): Promise<void> {
|
||||
try {
|
||||
const response: CharacterProps[] = await System.authGetQueryToServer<CharacterProps[]>(`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<void> {
|
||||
if (selectedCharacter) {
|
||||
const updatedCharacter: CharacterProps = {...selectedCharacter};
|
||||
if (selectedCharacter.id === null) {
|
||||
await addNewCharacter(updatedCharacter);
|
||||
} else {
|
||||
await updateCharacter(updatedCharacter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function addNewCharacter(updatedCharacter: CharacterProps): Promise<void> {
|
||||
if (!updatedCharacter.name) {
|
||||
errorMessage(t("characterComponent.errorNameRequired"));
|
||||
return;
|
||||
}
|
||||
if (updatedCharacter.category === 'none') {
|
||||
errorMessage(t("characterComponent.errorCategoryRequired"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const characterId: string = await System.authPostToServer<string>(`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<void> {
|
||||
try {
|
||||
const response: boolean = await System.authPostToServer<boolean>(`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<void> {
|
||||
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<string>(`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<void> {
|
||||
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<boolean>(`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 (
|
||||
<div className="space-y-5">
|
||||
{selectedCharacter ? (
|
||||
<CharacterDetail
|
||||
selectedCharacter={selectedCharacter}
|
||||
setSelectedCharacter={setSelectedCharacter}
|
||||
handleAddElement={handleAddElement}
|
||||
handleRemoveElement={handleRemoveElement}
|
||||
handleCharacterChange={handleCharacterChange}
|
||||
handleSaveCharacter={handleSaveCharacter}
|
||||
/>
|
||||
) : (
|
||||
<CharacterList
|
||||
characters={characters}
|
||||
handleAddCharacter={handleAddCharacter}
|
||||
handleCharacterClick={handleCharacterClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef(CharacterComponent);
|
||||
230
components/book/settings/characters/CharacterDetail.tsx
Normal file
230
components/book/settings/characters/CharacterDetail.tsx
Normal file
@@ -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<SetStateAction<CharacterProps | null>>;
|
||||
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<void> {
|
||||
try {
|
||||
const response: CharacterAttribute = await System.authGetQueryToServer<CharacterAttribute>(`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 (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className="flex justify-between items-center p-4 border-b border-secondary/50 bg-tertiary/50 backdrop-blur-sm">
|
||||
<button onClick={() => setSelectedCharacter(null)}
|
||||
className="flex items-center gap-2 bg-secondary/50 py-2 px-4 rounded-xl border border-secondary/50 hover:bg-secondary hover:border-secondary hover:shadow-md hover:scale-105 transition-all duration-200">
|
||||
<FontAwesomeIcon icon={faArrowLeft} className="text-primary w-4 h-4"/>
|
||||
<span className="text-text-primary font-medium">{t("characterDetail.back")}</span>
|
||||
</button>
|
||||
<span className="text-text-primary font-semibold text-lg">
|
||||
{selectedCharacter?.name || t("characterDetail.newCharacter")}
|
||||
</span>
|
||||
<button onClick={handleSaveCharacter}
|
||||
className="flex items-center justify-center bg-primary w-10 h-10 rounded-xl border border-primary-dark shadow-md hover:shadow-lg hover:scale-110 transition-all duration-200">
|
||||
<FontAwesomeIcon icon={selectedCharacter?.id ? faSave : faPlus}
|
||||
className="text-text-primary w-5 h-5"/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto max-h-[calc(100vh-350px)] space-y-4 px-2 pb-4">
|
||||
<CollapsableArea title={t("characterDetail.basicInfo")} icon={faUser}>
|
||||
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<InputField
|
||||
fieldName={t("characterDetail.name")}
|
||||
input={
|
||||
<TextInput
|
||||
value={selectedCharacter?.name || ''}
|
||||
setValue={(e) => handleCharacterChange('name', e.target.value)}
|
||||
placeholder={t("characterDetail.namePlaceholder")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
fieldName={t("characterDetail.lastName")}
|
||||
input={
|
||||
<TextInput
|
||||
value={selectedCharacter?.lastName || ''}
|
||||
setValue={(e) => handleCharacterChange('lastName', e.target.value)}
|
||||
placeholder={t("characterDetail.lastNamePlaceholder")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
fieldName={t("characterDetail.role")}
|
||||
input={
|
||||
<SelectBox
|
||||
defaultValue={selectedCharacter?.category || 'none'}
|
||||
onChangeCallBack={(e) => setSelectedCharacter(prev =>
|
||||
prev ? {...prev, category: e.target.value as CharacterProps['category']} : prev
|
||||
)}
|
||||
data={characterCategories}
|
||||
/>
|
||||
}
|
||||
icon={faLayerGroup}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
fieldName={t("characterDetail.title")}
|
||||
input={
|
||||
<SelectBox
|
||||
defaultValue={selectedCharacter?.title || 'none'}
|
||||
onChangeCallBack={(e) => handleCharacterChange('title', e.target.value)}
|
||||
data={characterTitle}
|
||||
/>
|
||||
}
|
||||
icon={faAddressCard}
|
||||
/>
|
||||
</div>
|
||||
</CollapsableArea>
|
||||
|
||||
<CollapsableArea title={t("characterDetail.historySection")} icon={faUser}>
|
||||
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<InputField
|
||||
fieldName={t("characterDetail.biography")}
|
||||
input={
|
||||
<TexteAreaInput
|
||||
value={selectedCharacter?.biography || ''}
|
||||
setValue={(e) => handleCharacterChange('biography', e.target.value)}
|
||||
placeholder={t("characterDetail.biographyPlaceholder")}
|
||||
/>
|
||||
}
|
||||
icon={faBook}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
fieldName={t("characterDetail.history")}
|
||||
input={
|
||||
<TexteAreaInput
|
||||
value={selectedCharacter?.history || ''}
|
||||
setValue={(e) => handleCharacterChange('history', e.target.value)}
|
||||
placeholder={t("characterDetail.historyPlaceholder")}
|
||||
/>
|
||||
}
|
||||
icon={faScroll}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
fieldName={t("characterDetail.roleFull")}
|
||||
input={
|
||||
<TexteAreaInput
|
||||
value={selectedCharacter?.role || ''}
|
||||
setValue={(e) => handleCharacterChange('role', e.target.value)}
|
||||
placeholder={t("characterDetail.roleFullPlaceholder")}
|
||||
/>
|
||||
}
|
||||
icon={faScroll}
|
||||
/>
|
||||
</div>
|
||||
</CollapsableArea>
|
||||
|
||||
{characterElementCategory.map((item: CharacterElement, index: number) => (
|
||||
<CharacterSectionElement
|
||||
key={index}
|
||||
title={item.title}
|
||||
section={item.section}
|
||||
placeholder={item.placeholder}
|
||||
icon={item.icon}
|
||||
selectedCharacter={selectedCharacter as CharacterProps}
|
||||
setSelectedCharacter={setSelectedCharacter}
|
||||
handleAddElement={handleAddElement}
|
||||
handleRemoveElement={handleRemoveElement}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
124
components/book/settings/characters/CharacterList.tsx
Normal file
124
components/book/settings/characters/CharacterList.tsx
Normal file
@@ -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<string>('');
|
||||
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<div className="px-4 mb-4">
|
||||
<InputField
|
||||
input={
|
||||
<TextInput
|
||||
value={searchQuery}
|
||||
setValue={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t("characterList.search")}
|
||||
/>
|
||||
}
|
||||
actionIcon={faPlus}
|
||||
actionLabel={t("characterList.add")}
|
||||
addButtonCallBack={async () => handleAddCharacter()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto max-h-[calc(100vh-350px)] px-2">
|
||||
{characterCategories.map((category: SelectBoxProps) => {
|
||||
const categoryCharacters = filteredCharacters.filter(
|
||||
(char: CharacterProps) => char.category === category.value
|
||||
);
|
||||
|
||||
if (categoryCharacters.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<CollapsableArea
|
||||
key={category.value}
|
||||
title={category.label}
|
||||
icon={faUser}
|
||||
children={<div className="space-y-2 p-2">
|
||||
{categoryCharacters.map(char => (
|
||||
<div
|
||||
key={char.id}
|
||||
onClick={() => 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"
|
||||
>
|
||||
<div
|
||||
className="w-14 h-14 rounded-full border-2 border-primary overflow-hidden bg-secondary shadow-md group-hover:scale-110 transition-transform">
|
||||
{char.image ? (
|
||||
<img
|
||||
src={char.image}
|
||||
alt={char.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="w-full h-full flex items-center justify-center bg-primary/10 text-primary font-bold text-lg">
|
||||
{char.name?.charAt(0)?.toUpperCase() || '?'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ml-4 flex-1">
|
||||
<div
|
||||
className="text-text-primary font-bold text-base group-hover:text-primary transition-colors">{char.name || t("characterList.unknown")}</div>
|
||||
<div
|
||||
className="text-text-secondary text-sm mt-0.5">{char.lastName || t("characterList.noLastName")}</div>
|
||||
</div>
|
||||
|
||||
<div className="w-28 px-3">
|
||||
<div
|
||||
className="text-primary text-sm font-semibold truncate">{char.title || t("characterList.noTitle")}</div>
|
||||
<div
|
||||
className="text-muted text-xs truncate mt-0.5">{char.role || t("characterList.noRole")}</div>
|
||||
</div>
|
||||
|
||||
<div className="w-8 flex justify-center">
|
||||
<FontAwesomeIcon icon={faChevronRight}
|
||||
className="text-muted group-hover:text-primary group-hover:translate-x-1 transition-all w-4 h-4"/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<string>('');
|
||||
|
||||
function handleAddNewElement() {
|
||||
handleAddElement(section, {id: '', name: element});
|
||||
setElement('');
|
||||
}
|
||||
|
||||
return (
|
||||
<CollapsableArea title={title} icon={icon}>
|
||||
<div className="space-y-3 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
{Array.isArray(selectedCharacter?.[section]) &&
|
||||
selectedCharacter?.[section].map((item, index: number) => (
|
||||
<div key={index}
|
||||
className="flex items-center gap-2 bg-secondary/30 rounded-xl border-l-4 border-primary shadow-sm hover:shadow-md transition-all duration-200">
|
||||
<input
|
||||
className="flex-1 bg-transparent text-text-primary px-3 py-2.5 focus:outline-none placeholder:text-muted/60"
|
||||
value={item.name || item.type || item.description || item.history || ''}
|
||||
onChange={(e) => {
|
||||
const updatedSection = [...(selectedCharacter[section] as any[])];
|
||||
updatedSection[index].name = e.target.value;
|
||||
setSelectedCharacter({
|
||||
...selectedCharacter,
|
||||
[section]: updatedSection,
|
||||
});
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleRemoveElement(section, index, item.id)}
|
||||
className="bg-error/90 hover:bg-error w-9 h-9 rounded-full flex items-center justify-center mr-2 shadow-md hover:shadow-lg hover:scale-110 transition-all duration-200"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} className="text-white w-4 h-4"/>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="border-t border-secondary/50 mt-4 pt-4">
|
||||
<InputField
|
||||
input={
|
||||
<TextInput
|
||||
value={element}
|
||||
setValue={(e) => setElement(e.target.value)}
|
||||
placeholder={t("characterSectionElement.newItem", {item: title.toLowerCase()})}
|
||||
/>
|
||||
}
|
||||
addButtonCallBack={async () => handleAddNewElement()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsableArea>
|
||||
)
|
||||
}
|
||||
181
components/book/settings/goals/page.tsx
Normal file
181
components/book/settings/goals/page.tsx
Normal file
@@ -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<Goal[]>([
|
||||
{
|
||||
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<HTMLInputElement | HTMLTextAreaElement>, 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 (
|
||||
<main className="flex-grow p-8 overflow-y-auto">
|
||||
<section id="goals">
|
||||
<h2 className="text-4xl font-['ADLaM_Display'] text-text-primary mb-6">Goals</h2>
|
||||
<div className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-6 shadow-lg mb-6">
|
||||
<div className="flex space-x-4 items-center">
|
||||
<select
|
||||
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"
|
||||
value={selectedGoalIndex}
|
||||
onChange={(e) => setSelectedGoalIndex(parseInt(e.target.value))}
|
||||
>
|
||||
{goals.map((goal, index) => (
|
||||
<option key={goal.id} value={index}>{goal.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
value={newGoalName}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddGoal}
|
||||
className="bg-primary hover:bg-primary-dark text-text-primary font-semibold py-2.5 px-5 rounded-xl shadow-md hover:shadow-lg hover:scale-105 transition-all duration-200"
|
||||
>
|
||||
Add Goal
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-3xl font-['ADLaM_Display'] text-text-primary mb-6">{goals[selectedGoalIndex].name}</h2>
|
||||
<div className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-6 shadow-lg mb-6">
|
||||
<h3 className="text-2xl font-bold text-text-primary mb-4">Time Goal</h3>
|
||||
<label className="block text-text-primary font-medium mb-2" htmlFor="desiredReleaseDate">Desired
|
||||
Release Date</label>
|
||||
<input
|
||||
type="date"
|
||||
id="desiredReleaseDate"
|
||||
value={goals[selectedGoalIndex].timeGoal.desiredReleaseDate}
|
||||
onChange={(e) => handleInputChange(e, 'timeGoal', 'desiredReleaseDate')}
|
||||
className="w-full p-2 rounded-lg bg-gray-800 text-white border-none outline-none"
|
||||
/>
|
||||
<label className="block text-white mb-2 mt-4" htmlFor="maxReleaseDate">Max Release Date</label>
|
||||
<input
|
||||
type="date"
|
||||
id="maxReleaseDate"
|
||||
value={goals[selectedGoalIndex].timeGoal.maxReleaseDate}
|
||||
onChange={(e) => handleInputChange(e, 'timeGoal', 'maxReleaseDate')}
|
||||
className="w-full p-2 rounded-lg bg-gray-800 text-white border-none outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-6 shadow-lg mb-6">
|
||||
<h3 className="text-2xl font-bold text-text-primary mb-4">Numbers Goal</h3>
|
||||
<label className="block text-text-primary font-medium mb-2" htmlFor="minWordsCount">Min Words
|
||||
Count</label>
|
||||
<input
|
||||
type="number"
|
||||
id="minWordsCount"
|
||||
value={goals[selectedGoalIndex].numbersGoal.minWordsCount}
|
||||
onChange={(e) => handleInputChange(e, 'numbersGoal', 'minWordsCount')}
|
||||
className="w-full p-2 rounded-lg bg-gray-800 text-white border-none outline-none"
|
||||
/>
|
||||
<label className="block text-white mb-2 mt-4" htmlFor="maxWordsCount">Max Words Count</label>
|
||||
<input
|
||||
type="number"
|
||||
id="maxWordsCount"
|
||||
value={goals[selectedGoalIndex].numbersGoal.maxWordsCount}
|
||||
onChange={(e) => handleInputChange(e, 'numbersGoal', 'maxWordsCount')}
|
||||
className="w-full p-2 rounded-lg bg-gray-800 text-white border-none outline-none"
|
||||
/>
|
||||
<label className="block text-white mb-2 mt-4" htmlFor="desiredWordsCountByChapter">Desired Words
|
||||
Count by Chapter</label>
|
||||
<input
|
||||
type="number"
|
||||
id="desiredWordsCountByChapter"
|
||||
value={goals[selectedGoalIndex].numbersGoal.desiredWordsCountByChapter}
|
||||
onChange={(e) => handleInputChange(e, 'numbersGoal', 'desiredWordsCountByChapter')}
|
||||
className="w-full p-2 rounded-lg bg-gray-800 text-white border-none outline-none"
|
||||
/>
|
||||
<label className="block text-white mb-2 mt-4" htmlFor="desiredChapterCount">Desired Chapter
|
||||
Count</label>
|
||||
<input
|
||||
type="number"
|
||||
id="desiredChapterCount"
|
||||
value={goals[selectedGoalIndex].numbersGoal.desiredChapterCount}
|
||||
onChange={(e) => handleInputChange(e, 'numbersGoal', 'desiredChapterCount')}
|
||||
className="w-full p-2 rounded-lg bg-gray-800 text-white border-none outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-center mt-8">
|
||||
<button
|
||||
type="button"
|
||||
className="bg-primary hover:bg-primary-dark text-text-primary font-semibold py-2.5 px-6 rounded-xl shadow-md hover:shadow-lg hover:scale-105 transition-all duration-200"
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
421
components/book/settings/guide-line/GuideLineSetting.tsx
Normal file
421
components/book/settings/guide-line/GuideLineSetting.tsx
Normal file
@@ -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<string>('');
|
||||
const [atmosphere, setAtmosphere] = useState<string>('');
|
||||
const [writingStyle, setWritingStyle] = useState<string>('');
|
||||
const [themes, setThemes] = useState<string>('');
|
||||
const [symbolism, setSymbolism] = useState<string>('');
|
||||
const [motifs, setMotifs] = useState<string>('');
|
||||
const [narrativeVoice, setNarrativeVoice] = useState<string>('');
|
||||
const [pacing, setPacing] = useState<string>('');
|
||||
const [intendedAudience, setIntendedAudience] = useState<string>('');
|
||||
const [keyMessages, setKeyMessages] = useState<string>('');
|
||||
|
||||
const [plotSummary, setPlotSummary] = useState<string>('');
|
||||
const [narrativeType, setNarrativeType] = useState<string>('');
|
||||
const [verbTense, setVerbTense] = useState<string>('');
|
||||
const [dialogueType, setDialogueType] = useState<string>('');
|
||||
const [toneAtmosphere, setToneAtmosphere] = useState<string>('');
|
||||
const [language, setLanguage] = useState<string>('');
|
||||
|
||||
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<void> {
|
||||
try {
|
||||
const response: GuideLineAI = await System.authGetQueryToServer<GuideLineAI>(`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<void> {
|
||||
try {
|
||||
const response: GuideLine =
|
||||
await System.authGetQueryToServer<GuideLine>(
|
||||
`book/guide-line`,
|
||||
userToken,
|
||||
lang,
|
||||
{id: bookId},
|
||||
);
|
||||
if (response) {
|
||||
setTone(response.tone);
|
||||
setAtmosphere(response.atmosphere);
|
||||
setWritingStyle(response.writingStyle);
|
||||
setThemes(response.themes);
|
||||
setSymbolism(response.symbolism);
|
||||
setMotifs(response.motifs);
|
||||
setNarrativeVoice(response.narrativeVoice);
|
||||
setPacing(response.pacing);
|
||||
setIntendedAudience(response.intendedAudience);
|
||||
setKeyMessages(response.keyMessages);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
errorMessage(error.message);
|
||||
} else {
|
||||
errorMessage(t("guideLineSetting.errorUnknown"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function savePersonal(): Promise<void> {
|
||||
try {
|
||||
const response: boolean =
|
||||
await System.authPostToServer<boolean>(
|
||||
'book/guide-line',
|
||||
{
|
||||
bookId: bookId,
|
||||
tone: tone,
|
||||
atmosphere: atmosphere,
|
||||
writingStyle: writingStyle,
|
||||
themes: themes,
|
||||
symbolism: symbolism,
|
||||
motifs: motifs,
|
||||
narrativeVoice: narrativeVoice,
|
||||
pacing: pacing,
|
||||
intendedAudience: intendedAudience,
|
||||
keyMessages: keyMessages,
|
||||
},
|
||||
userToken,
|
||||
lang,
|
||||
);
|
||||
if (!response) {
|
||||
errorMessage(t("guideLineSetting.saveError"));
|
||||
return;
|
||||
}
|
||||
successMessage(t("guideLineSetting.saveSuccess"));
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
errorMessage(error.message);
|
||||
} else {
|
||||
errorMessage(t("guideLineSetting.errorUnknown"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function saveQuillSense(): Promise<void> {
|
||||
try {
|
||||
const response: boolean = await System.authPostToServer<boolean>(
|
||||
'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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex gap-2 border-b border-secondary/50 mb-6">
|
||||
<button
|
||||
className={`px-5 py-2.5 font-medium rounded-t-xl transition-all duration-200 ${
|
||||
activeTab === 'personal'
|
||||
? 'border-b-2 border-primary text-primary bg-primary/10 shadow-md'
|
||||
: 'text-text-secondary hover:text-text-primary hover:bg-secondary/30'
|
||||
}`}
|
||||
onClick={(): void => setActiveTab('personal')}
|
||||
>
|
||||
{t("guideLineSetting.personal")}
|
||||
</button>
|
||||
<button
|
||||
className={`px-5 py-2.5 font-medium rounded-t-xl transition-all duration-200 ${
|
||||
activeTab === 'quillsense'
|
||||
? 'border-b-2 border-primary text-primary bg-primary/10 shadow-md'
|
||||
: 'text-text-secondary hover:text-text-primary hover:bg-secondary/30'
|
||||
}`}
|
||||
onClick={() => setActiveTab('quillsense')}
|
||||
>
|
||||
{t("guideLineSetting.quillsense")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'personal' && (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
|
||||
<InputField fieldName={t("guideLineSetting.tone")} input={
|
||||
<TexteAreaInput
|
||||
value={tone}
|
||||
setValue={(e: ChangeEvent<HTMLTextAreaElement>) => setTone(e.target.value)}
|
||||
placeholder={t("guideLineSetting.tonePlaceholder")}
|
||||
/>
|
||||
}/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
|
||||
<InputField fieldName={t("guideLineSetting.atmosphere")} input={
|
||||
<TexteAreaInput
|
||||
value={atmosphere}
|
||||
setValue={(e: ChangeEvent<HTMLTextAreaElement>) => setAtmosphere(e.target.value)}
|
||||
placeholder={t("guideLineSetting.atmospherePlaceholder")}
|
||||
/>
|
||||
}/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
|
||||
<InputField fieldName={t("guideLineSetting.writingStyle")} input={
|
||||
<TexteAreaInput
|
||||
value={writingStyle}
|
||||
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setWritingStyle(e.target.value)}
|
||||
placeholder={t("guideLineSetting.writingStylePlaceholder")}
|
||||
/>
|
||||
}/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
|
||||
<InputField fieldName={t("guideLineSetting.themes")} input={
|
||||
<TexteAreaInput
|
||||
value={themes}
|
||||
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setThemes(e.target.value)}
|
||||
placeholder={t("guideLineSetting.themesPlaceholder")}
|
||||
/>
|
||||
}/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
|
||||
<InputField fieldName={t("guideLineSetting.symbolism")} input={
|
||||
<TexteAreaInput
|
||||
value={symbolism}
|
||||
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setSymbolism(e.target.value)}
|
||||
placeholder={t("guideLineSetting.symbolismPlaceholder")}
|
||||
/>
|
||||
}/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
|
||||
<InputField fieldName={t("guideLineSetting.motifs")} input={
|
||||
<TexteAreaInput
|
||||
value={motifs}
|
||||
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setMotifs(e.target.value)}
|
||||
placeholder={t("guideLineSetting.motifsPlaceholder")}
|
||||
/>
|
||||
}/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
|
||||
<InputField fieldName={t("guideLineSetting.narrativeVoice")} input={
|
||||
<TexteAreaInput
|
||||
value={narrativeVoice}
|
||||
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setNarrativeVoice(e.target.value)}
|
||||
placeholder={t("guideLineSetting.narrativeVoicePlaceholder")}
|
||||
/>
|
||||
}/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
|
||||
<InputField fieldName={t("guideLineSetting.pacing")} input={
|
||||
<TexteAreaInput
|
||||
value={pacing}
|
||||
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setPacing(e.target.value)}
|
||||
placeholder={t("guideLineSetting.pacingPlaceholder")}
|
||||
/>
|
||||
}/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
|
||||
<InputField fieldName={t("guideLineSetting.intendedAudience")} input={
|
||||
<TexteAreaInput
|
||||
value={intendedAudience}
|
||||
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setIntendedAudience(e.target.value)}
|
||||
placeholder={t("guideLineSetting.intendedAudiencePlaceholder")}
|
||||
/>
|
||||
}/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
|
||||
<InputField fieldName={t("guideLineSetting.keyMessages")} input={
|
||||
<TexteAreaInput
|
||||
value={keyMessages}
|
||||
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setKeyMessages(e.target.value)}
|
||||
placeholder={t("guideLineSetting.keyMessagesPlaceholder")}
|
||||
/>
|
||||
}/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'quillsense' && (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
|
||||
<InputField fieldName={t("guideLineSetting.plotSummary")} input={
|
||||
<TexteAreaInput
|
||||
value={plotSummary}
|
||||
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setPlotSummary(e.target.value)}
|
||||
placeholder={t("guideLineSetting.plotSummaryPlaceholder")}
|
||||
/>
|
||||
}/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
|
||||
<InputField fieldName={t("guideLineSetting.toneAtmosphere")} input={
|
||||
<TextInput
|
||||
value={toneAtmosphere}
|
||||
setValue={(e: ChangeEvent<HTMLInputElement>): void => setToneAtmosphere(e.target.value)}
|
||||
placeholder={t("guideLineSetting.toneAtmospherePlaceholder")}
|
||||
/>
|
||||
}/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
|
||||
<InputField fieldName={t("guideLineSetting.themes")} input={
|
||||
<TextInput
|
||||
value={themes}
|
||||
setValue={(e: ChangeEvent<HTMLInputElement>) => setThemes(e.target.value)}
|
||||
placeholder={t("guideLineSetting.themesPlaceholderQuill")}
|
||||
/>
|
||||
}/>
|
||||
</div>
|
||||
<div
|
||||
className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
|
||||
<InputField fieldName={t("guideLineSetting.verbTense")} input={
|
||||
<SelectBox
|
||||
defaultValue={verbTense}
|
||||
onChangeCallBack={(event: ChangeEvent<HTMLSelectElement>): void => setVerbTense(event.target.value)}
|
||||
data={verbalTime}
|
||||
placeholder={t("guideLineSetting.verbTensePlaceholder")}
|
||||
/>
|
||||
}/>
|
||||
</div>
|
||||
<div
|
||||
className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
|
||||
<InputField fieldName={t("guideLineSetting.narrativeType")} input={
|
||||
<SelectBox defaultValue={narrativeType} data={
|
||||
authorLevel === '1'
|
||||
? beginnerNarrativePersons
|
||||
: authorLevel === '2'
|
||||
? intermediateNarrativePersons
|
||||
: advancedNarrativePersons
|
||||
} onChangeCallBack={(event: ChangeEvent<HTMLSelectElement>): void => {
|
||||
setNarrativeType(event.target.value)
|
||||
}} placeholder={t("guideLineSetting.narrativeTypePlaceholder")}/>
|
||||
}/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
|
||||
<InputField fieldName={t("guideLineSetting.dialogueType")} input={
|
||||
<SelectBox defaultValue={dialogueType} data={authorLevel === '1'
|
||||
? beginnerDialogueTypes
|
||||
: authorLevel === '2'
|
||||
? intermediateDialogueTypes
|
||||
: advancedDialogueTypes}
|
||||
onChangeCallBack={(event: ChangeEvent<HTMLSelectElement>) => {
|
||||
setDialogueType(event.target.value)
|
||||
}} placeholder={t("guideLineSetting.dialogueTypePlaceholder")}/>
|
||||
}/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
|
||||
<InputField fieldName={t("guideLineSetting.language")} input={
|
||||
<SelectBox defaultValue={language} data={langues}
|
||||
onChangeCallBack={(event: ChangeEvent<HTMLSelectElement>) => {
|
||||
setLanguage(event.target.value)
|
||||
}} placeholder={t("guideLineSetting.languagePlaceholder")}/>
|
||||
}/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef(GuideLineSetting);
|
||||
444
components/book/settings/locations/LocationComponent.tsx
Normal file
444
components/book/settings/locations/LocationComponent.tsx
Normal file
@@ -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<LangContextProps>(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<LocationProps[]>([]);
|
||||
const [newSectionName, setNewSectionName] = useState<string>('');
|
||||
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<void> {
|
||||
try {
|
||||
const response: LocationProps[] = await System.authGetQueryToServer<LocationProps[]>(`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<void> {
|
||||
if (!newSectionName.trim()) {
|
||||
errorMessage(t('locationComponent.errorSectionNameEmpty'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
const sectionId: string = await System.authPostToServer<string>(`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<void> {
|
||||
if (!newElementNames[sectionId]?.trim()) {
|
||||
errorMessage(t('locationComponent.errorElementNameEmpty'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
const elementId: string = await System.authPostToServer<string>(`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<void> {
|
||||
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<string>(`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<void> {
|
||||
try {
|
||||
const response: boolean = await System.authDeleteToServer<boolean>(`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<void> {
|
||||
try {
|
||||
const response: boolean = await System.authDeleteToServer<boolean>(`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<void> {
|
||||
try {
|
||||
const response: boolean = await System.authDeleteToServer<boolean>(`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<void> {
|
||||
try {
|
||||
const response: boolean = await System.authPostToServer<boolean>(`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 (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-tertiary/90 backdrop-blur-sm rounded-xl shadow-lg p-4 border border-secondary/50">
|
||||
<div className="grid grid-cols-1 gap-4 mb-4">
|
||||
<InputField
|
||||
input={
|
||||
<TextInput
|
||||
value={newSectionName}
|
||||
setValue={(e: ChangeEvent<HTMLInputElement>) => setNewSectionName(e.target.value)}
|
||||
placeholder={t("locationComponent.newSectionPlaceholder")}
|
||||
/>
|
||||
}
|
||||
actionIcon={faPlus}
|
||||
actionLabel={t("locationComponent.addSectionLabel")}
|
||||
addButtonCallBack={handleAddSection}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sections.length > 0 ? (
|
||||
sections.map((section: LocationProps) => (
|
||||
<div key={section.id}
|
||||
className="bg-tertiary/90 backdrop-blur-sm rounded-xl shadow-lg p-4 border border-secondary/50">
|
||||
<h3 className="text-lg font-semibold text-text-primary mb-4 flex items-center">
|
||||
<FontAwesomeIcon icon={faMapMarkerAlt} className="mr-2 w-5 h-5"/>
|
||||
{section.name}
|
||||
<span
|
||||
className="ml-2 text-sm bg-dark-background text-text-secondary py-0.5 px-2 rounded-full">
|
||||
{section.elements.length || 0}
|
||||
</span>
|
||||
<button onClick={(): Promise<void> => handleRemoveSection(section.id)}
|
||||
className="ml-auto bg-dark-background text-text-primary rounded-full p-1.5 hover:bg-secondary transition-colors shadow-md">
|
||||
<FontAwesomeIcon icon={faTrash} className={'w-5 h-5'}/>
|
||||
</button>
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{section.elements.length > 0 ? (
|
||||
section.elements.map((element, elementIndex) => (
|
||||
<div key={element.id}
|
||||
className="bg-dark-background rounded-lg p-3 border-l-4 border-primary">
|
||||
<div className="mb-2">
|
||||
<InputField
|
||||
input={
|
||||
<TextInput
|
||||
value={element.name}
|
||||
setValue={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
handleElementChange(section.id, elementIndex, 'name', e.target.value)
|
||||
}
|
||||
placeholder={t("locationComponent.elementNamePlaceholder")}
|
||||
/>
|
||||
}
|
||||
removeButtonCallBack={(): Promise<void> => handleRemoveElement(section.id, elementIndex)}
|
||||
/>
|
||||
</div>
|
||||
<TexteAreaInput
|
||||
value={element.description}
|
||||
setValue={(e: React.ChangeEvent<HTMLTextAreaElement>): void => handleElementChange(section.id, elementIndex, 'description', e.target.value)}
|
||||
placeholder={t("locationComponent.elementDescriptionPlaceholder")}
|
||||
/>
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-secondary/50">
|
||||
{element.subElements.length > 0 && (
|
||||
<h4 className="text-sm italic text-text-secondary mb-3">{t("locationComponent.subElementsHeading")}</h4>
|
||||
)}
|
||||
|
||||
{element.subElements.map((subElement: SubElement, subElementIndex: number) => (
|
||||
<div key={subElement.id}
|
||||
className="bg-darkest-background rounded-lg p-3 mb-3">
|
||||
<div className="mb-2">
|
||||
<InputField
|
||||
input={
|
||||
<TextInput
|
||||
value={subElement.name}
|
||||
setValue={(e: ChangeEvent<HTMLInputElement>): void =>
|
||||
handleSubElementChange(section.id, elementIndex, subElementIndex, 'name', e.target.value)
|
||||
}
|
||||
placeholder={t("locationComponent.subElementNamePlaceholder")}
|
||||
/>
|
||||
}
|
||||
removeButtonCallBack={(): Promise<void> => handleRemoveSubElement(section.id, elementIndex, subElementIndex)}
|
||||
/>
|
||||
</div>
|
||||
<TexteAreaInput
|
||||
value={subElement.description}
|
||||
setValue={(e) =>
|
||||
handleSubElementChange(section.id, elementIndex, subElementIndex, 'description', e.target.value)
|
||||
}
|
||||
placeholder={t("locationComponent.subElementDescriptionPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<InputField
|
||||
input={
|
||||
<TextInput
|
||||
value={newSubElementNames[elementIndex] || ''}
|
||||
setValue={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
setNewSubElementNames({
|
||||
...newSubElementNames,
|
||||
[elementIndex]: e.target.value
|
||||
})
|
||||
}
|
||||
placeholder={t("locationComponent.newSubElementPlaceholder")}
|
||||
/>
|
||||
}
|
||||
addButtonCallBack={(): Promise<void> => handleAddSubElement(section.id, elementIndex)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-4 text-text-secondary italic">
|
||||
{t("locationComponent.noElementAvailable")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<InputField
|
||||
input={
|
||||
<TextInput
|
||||
value={newElementNames[section.id] || ''}
|
||||
setValue={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
setNewElementNames({...newElementNames, [section.id]: e.target.value})
|
||||
}
|
||||
placeholder={t("locationComponent.newElementPlaceholder")}
|
||||
/>
|
||||
}
|
||||
addButtonCallBack={(): Promise<void> => handleAddElement(section.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div
|
||||
className="bg-tertiary/90 backdrop-blur-sm rounded-xl shadow-lg p-8 border border-secondary/50 text-center">
|
||||
<p className="text-text-secondary mb-4">{t("locationComponent.noSectionAvailable")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef(LocationComponent);
|
||||
327
components/book/settings/objects/page.tsx
Normal file
327
components/book/settings/objects/page.tsx
Normal file
@@ -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<Item[]>([
|
||||
{
|
||||
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<Item | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
const [newItem, setNewItem] = useState<Item>(initialItemState);
|
||||
const [newRelatedItem, setNewRelatedItem] = useState<RelatedItem>({
|
||||
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 (
|
||||
<main className="flex-grow p-8 overflow-y-auto">
|
||||
{selectedItem ? (
|
||||
<div>
|
||||
<div className="flex justify-between sticky top-0 z-10 bg-gray-900 py-4">
|
||||
<button onClick={() => setSelectedItem(null)}
|
||||
className="flex items-center gap-2 text-text-primary bg-secondary/50 hover:bg-secondary px-4 py-2 rounded-xl transition-all duration-200 hover:scale-105 shadow-md">
|
||||
<FontAwesomeIcon icon={faArrowLeft} className="mr-2 w-5 h-5"/> Back
|
||||
</button>
|
||||
<h2 className="text-3xl font-['ADLaM_Display'] text-center text-text-primary">{selectedItem.name}</h2>
|
||||
<button onClick={handleSaveItem}
|
||||
className="bg-primary hover:bg-primary-dark text-text-primary font-semibold py-2.5 px-6 rounded-xl shadow-md hover:shadow-lg hover:scale-105 transition-all duration-200">
|
||||
Save Item
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-gray-700 rounded-lg p-8 shadow-lg space-y-4">
|
||||
<div>
|
||||
<label className="block text-white mb-2" htmlFor="name">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
value={selectedItem.name}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white mb-2" htmlFor="description">Description</label>
|
||||
<textarea
|
||||
id="description"
|
||||
rows={4}
|
||||
value={selectedItem.description}
|
||||
onChange={(e) => handleItemChange('description', 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"
|
||||
></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white mb-2" htmlFor="history">History</label>
|
||||
<textarea
|
||||
id="history"
|
||||
rows={4}
|
||||
value={selectedItem.history}
|
||||
onChange={(e) => handleItemChange('history', 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"
|
||||
></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white mb-2" htmlFor="location">Location</label>
|
||||
<select
|
||||
id="location"
|
||||
value={selectedItem.location}
|
||||
onChange={(e) => handleItemChange('location', e.target.value)}
|
||||
className="w-full px-4 py-2.5 rounded-xl bg-secondary/50 text-text-primary border border-secondary/50 outline-none hover:bg-secondary hover:border-secondary focus:border-primary focus:ring-4 focus:ring-primary/20 transition-all duration-200"
|
||||
>
|
||||
<option value="">Select Location</option>
|
||||
<option value="Castle">Castle</option>
|
||||
<option value="Fortress">Fortress</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white mb-2" htmlFor="ownedBy">Owned By</label>
|
||||
<select
|
||||
id="ownedBy"
|
||||
value={selectedItem.ownedBy}
|
||||
onChange={(e) => handleItemChange('ownedBy', e.target.value)}
|
||||
className="w-full px-4 py-2.5 rounded-xl bg-secondary/50 text-text-primary border border-secondary/50 outline-none hover:bg-secondary hover:border-secondary focus:border-primary focus:ring-4 focus:ring-primary/20 transition-all duration-200"
|
||||
>
|
||||
<option value="">Select Owner</option>
|
||||
{items.map((item) => (
|
||||
<option key={item.id} value={item.name}>{item.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white mb-2" htmlFor="functionality">Functionality</label>
|
||||
<textarea
|
||||
id="functionality"
|
||||
rows={4}
|
||||
value={selectedItem.functionality}
|
||||
onChange={(e) => handleItemChange('functionality', 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"
|
||||
></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white mb-2" htmlFor="image">Image URL</label>
|
||||
<input
|
||||
type="text"
|
||||
id="image"
|
||||
value={selectedItem.image}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-700 rounded-lg p-8 shadow-lg space-y-4 mt-4">
|
||||
<h3 className="text-2xl font-['ADLaM_Display'] text-text-primary">Related Items</h3>
|
||||
<div className="space-y-2">
|
||||
{selectedItem.relatedItems.map((relatedItem, index) => (
|
||||
<details key={index}
|
||||
className="bg-secondary/30 rounded-xl mb-4 p-4 shadow-sm hover:shadow-md transition-all duration-200">
|
||||
<summary className="text-lg text-white cursor-pointer">{relatedItem.name}</summary>
|
||||
<div className="mt-2">
|
||||
<label className="block text-white mb-2"
|
||||
htmlFor={`related-item-description-${relatedItem.name}`}>Description</label>
|
||||
<textarea
|
||||
id={`related-item-description-${relatedItem.name}`}
|
||||
rows={3}
|
||||
value={relatedItem.description}
|
||||
onChange={(e) => handleElementChange('relatedItems', index, 'description', 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"
|
||||
></textarea>
|
||||
<label className="block text-white mb-2 mt-4"
|
||||
htmlFor={`related-item-history-${relatedItem.name}`}>History</label>
|
||||
<textarea
|
||||
id={`related-item-history-${relatedItem.name}`}
|
||||
rows={3}
|
||||
value={relatedItem.history}
|
||||
onChange={(e) => handleElementChange('relatedItems', index, 'history', 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"
|
||||
></textarea>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveElement('relatedItems', index)}
|
||||
className="bg-error/90 hover:bg-error text-text-primary font-semibold py-2 px-4 rounded-xl shadow-md hover:shadow-lg hover:scale-105 transition-all duration-200 mt-2"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
<div className="flex space-x-2 items-center mt-2">
|
||||
<select
|
||||
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"
|
||||
onChange={(e) => setNewRelatedItem({...newRelatedItem, name: e.target.value})}
|
||||
value={newRelatedItem.name}
|
||||
>
|
||||
<option value="">Select Related Item</option>
|
||||
{items.map((item) => (
|
||||
<option key={item.id} value={item.name}>{item.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
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"
|
||||
onChange={(e) => setNewRelatedItem({...newRelatedItem, type: e.target.value})}
|
||||
value={newRelatedItem.type}
|
||||
>
|
||||
<option value="">Relation Type</option>
|
||||
<option value="Related">Related</option>
|
||||
<option value="Similar">Similar</option>
|
||||
{/* Add more relation types as needed */}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
handleAddElement('relatedItems', {...newRelatedItem});
|
||||
setNewRelatedItem({name: '', type: '', description: '', history: ''});
|
||||
}}
|
||||
className="bg-primary hover:bg-primary-dark text-text-primary font-semibold py-2.5 px-5 rounded-xl shadow-md hover:shadow-lg hover:scale-105 transition-all duration-200"
|
||||
>
|
||||
Add Related Item
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="flex justify-between sticky top-0 z-10 bg-gray-900 py-4">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddItem}
|
||||
className="bg-primary hover:bg-primary-dark text-text-primary font-semibold py-2.5 px-5 rounded-xl ml-4 shadow-md hover:shadow-lg hover:scale-105 transition-all duration-200"
|
||||
>
|
||||
Add New Item
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-4xl font-['ADLaM_Display'] text-text-primary mb-6">Items</h2>
|
||||
<div className="flex flex-wrap space-x-4">
|
||||
{filteredItems.map((item) => (
|
||||
<div key={item.id} onClick={() => 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">
|
||||
<img src={item.image || 'https://via.placeholder.com/150'} alt={item.name}
|
||||
className="w-full h-32 object-cover rounded-lg mb-2"/>
|
||||
<h3 className="text-lg font-bold text-text-primary">{item.name}</h3>
|
||||
<p className="text-muted">{item.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
608
components/book/settings/story/Act.tsx
Normal file
608
components/book/settings/story/Act.tsx
Normal file
@@ -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<SetStateAction<ActType[]>>;
|
||||
mainChapters: ChapterListProps[];
|
||||
}
|
||||
|
||||
export default function Act({acts, setActs, mainChapters}: ActProps) {
|
||||
const t = useTranslations('actComponent');
|
||||
const {lang} = useContext<LangContextProps>(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<string>('');
|
||||
const [newPlotPointTitle, setNewPlotPointTitle] = useState<string>('');
|
||||
const [selectedIncidentId, setSelectedIncidentId] = useState<string>('');
|
||||
|
||||
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<void> {
|
||||
if (newIncidentTitle.trim() === '') return;
|
||||
|
||||
try {
|
||||
const incidentId: string =
|
||||
await System.authPostToServer<string>('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<void> {
|
||||
try {
|
||||
const response: boolean = await System.authDeleteToServer<boolean>('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<void> {
|
||||
if (newPlotPointTitle.trim() === '') return;
|
||||
try {
|
||||
const plotId: string = await System.authPostToServer<string>('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<void> {
|
||||
try {
|
||||
const response: boolean = await System.authDeleteToServer<boolean>('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<void> {
|
||||
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<string>('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<void> {
|
||||
try {
|
||||
const response: boolean = await System.authDeleteToServer<boolean>('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 (
|
||||
<ActChaptersSection
|
||||
actId={act.id}
|
||||
chapters={act.chapters || []}
|
||||
mainChapters={mainChapters}
|
||||
onLinkChapter={(actId, chapterId) => 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 (
|
||||
<ActDescription
|
||||
actId={act.id}
|
||||
summary={act.summary || ''}
|
||||
onUpdateSummary={updateActSummary}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function renderIncidents(act: ActType) {
|
||||
if (act.id !== 2) return null;
|
||||
|
||||
const sectionKey: string = getSectionKey(act.id, 'incidents');
|
||||
const isExpanded: boolean = expandedSections[sectionKey];
|
||||
|
||||
return (
|
||||
<ActIncidents
|
||||
incidents={act.incidents || []}
|
||||
actId={act.id}
|
||||
mainChapters={mainChapters}
|
||||
newIncidentTitle={newIncidentTitle}
|
||||
setNewIncidentTitle={setNewIncidentTitle}
|
||||
onAddIncident={addIncident}
|
||||
onDeleteIncident={deleteIncident}
|
||||
onLinkChapter={(actId, chapterId, incidentId) =>
|
||||
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 (
|
||||
<ActPlotPoints
|
||||
plotPoints={act.plotPoints || []}
|
||||
incidents={getIncidents()}
|
||||
actId={act.id}
|
||||
mainChapters={mainChapters}
|
||||
newPlotPointTitle={newPlotPointTitle}
|
||||
setNewPlotPointTitle={setNewPlotPointTitle}
|
||||
selectedIncidentId={selectedIncidentId}
|
||||
setSelectedIncidentId={setSelectedIncidentId}
|
||||
onAddPlotPoint={addPlotPoint}
|
||||
onDeletePlotPoint={deletePlotPoint}
|
||||
onLinkChapter={(actId, chapterId, plotPointId) =>
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{acts.map((act: ActType) => (
|
||||
<CollapsableArea key={`act-${act.id}`}
|
||||
title={renderActTitle(act.id)}
|
||||
icon={renderActIcon(act.id)}
|
||||
children={
|
||||
<>
|
||||
{renderActDescription(act)}
|
||||
{renderActChapters(act)}
|
||||
{renderIncidents(act)}
|
||||
{renderPlotPoints(act)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
149
components/book/settings/story/Issue.tsx
Normal file
149
components/book/settings/story/Issue.tsx
Normal file
@@ -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<React.SetStateAction<Issue[]>>;
|
||||
}
|
||||
|
||||
export default function Issues({issues, setIssues}: IssuesProps) {
|
||||
const t = useTranslations();
|
||||
const {lang} = useContext<LangContextProps>(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<string>('');
|
||||
|
||||
async function addNewIssue(): Promise<void> {
|
||||
if (newIssueName.trim() === '') {
|
||||
errorMessage(t("issues.errorEmptyName"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const issueId: string = await System.authPostToServer<string>('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<void> {
|
||||
if (issueId === undefined) {
|
||||
errorMessage(t("issues.errorInvalidId"));
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const response: boolean = await System.authDeleteToServer<boolean>(
|
||||
'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 (
|
||||
<CollapsableArea title={t("issues.title")} children={
|
||||
<div className="p-1">
|
||||
{issues && issues.length > 0 ? (
|
||||
issues.map((item: Issue) => (
|
||||
<div
|
||||
className="mb-2 bg-secondary/30 rounded-xl p-3 shadow-sm hover:shadow-md transition-all duration-200"
|
||||
key={`issue-${item.id}`}
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<input
|
||||
className="flex-1 text-text-primary text-base px-2 py-1 bg-transparent border-b border-secondary/50 focus:outline-none focus:border-primary transition-colors duration-200 placeholder:text-muted/60"
|
||||
value={item.name}
|
||||
onChange={(e) => updateIssueName(item.id, e.target.value)}
|
||||
placeholder={t("issues.issueNamePlaceholder")}
|
||||
/>
|
||||
<button
|
||||
className="p-1.5 ml-2 rounded-lg text-error hover:bg-error/20 hover:scale-110 transition-all duration-200"
|
||||
onClick={() => deleteIssue(item.id)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} size="sm"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-text-secondary text-center py-2 text-sm">
|
||||
{t("issues.noIssue")}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center mt-3 bg-secondary/30 p-3 rounded-xl shadow-sm">
|
||||
<input
|
||||
className="flex-1 text-text-primary text-base px-2 py-1 bg-transparent border-b border-secondary/50 focus:outline-none focus:border-primary transition-colors duration-200 placeholder:text-muted/60"
|
||||
value={newIssueName}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setNewIssueName(e.target.value)}
|
||||
placeholder={t("issues.newIssuePlaceholder")}
|
||||
/>
|
||||
<button
|
||||
className="bg-primary w-9 h-9 rounded-full flex justify-center items-center ml-2 text-text-primary shadow-md hover:shadow-lg hover:scale-110 hover:bg-primary-dark transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100"
|
||||
onClick={addNewIssue}
|
||||
disabled={newIssueName.trim() === ''}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus}/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} icon={faWarning}/>
|
||||
);
|
||||
}
|
||||
278
components/book/settings/story/MainChapter.tsx
Normal file
278
components/book/settings/story/MainChapter.tsx
Normal file
@@ -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<React.SetStateAction<ChapterListProps[]>>;
|
||||
}
|
||||
|
||||
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<string>('');
|
||||
const [newChapterOrder, setNewChapterOrder] = useState<number>(0);
|
||||
|
||||
const [deleteConfirmMessage, setDeleteConfirmMessage] = useState<boolean>(false);
|
||||
const [chapterIdToRemove, setChapterIdToRemove] = useState<string>('');
|
||||
|
||||
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<void> {
|
||||
try {
|
||||
setDeleteConfirmMessage(false);
|
||||
const response: boolean = await System.authDeleteToServer<boolean>(
|
||||
'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<void> {
|
||||
if (newChapterTitle.trim() === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const responseId: string = await System.authPostToServer<string>(
|
||||
'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 (
|
||||
<div>
|
||||
<CollapsableArea icon={faBookmark} title={t("mainChapter.chapters")} children={
|
||||
<div className="space-y-4">
|
||||
{visibleChapters.length > 0 ? (
|
||||
<div>
|
||||
{visibleChapters.map((item: ChapterListProps, index: number) => (
|
||||
<div key={item.chapterId}
|
||||
className="mb-2 bg-secondary/30 rounded-xl p-3 shadow-sm hover:shadow-md transition-all duration-200">
|
||||
<div className="flex items-center">
|
||||
<span
|
||||
className="bg-primary-dark text-white text-sm w-6 h-6 rounded-full text-center leading-6 mr-2">
|
||||
{item.chapterOrder !== undefined ? item.chapterOrder : index}
|
||||
</span>
|
||||
<input
|
||||
className="flex-1 text-text-primary text-base px-2 py-1 bg-transparent border-b border-secondary/50 focus:outline-none focus:border-primary transition-colors duration-200"
|
||||
value={item.title}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => handleChapterTitleChange(item.chapterId, e.target.value)}
|
||||
placeholder={t("mainChapter.chapterTitlePlaceholder")}
|
||||
/>
|
||||
<div className="flex ml-1">
|
||||
<button
|
||||
className={`p-1.5 mx-0.5 rounded-lg hover:bg-secondary/50 transition-all duration-200 ${
|
||||
item.chapterOrder === 0 ? 'text-muted cursor-not-allowed' : 'text-primary hover:scale-110'
|
||||
}`}
|
||||
onClick={(): void => moveChapterUp(index)}
|
||||
disabled={item.chapterOrder === 0}
|
||||
>
|
||||
<FontAwesomeIcon icon={faArrowUp} size="sm"/>
|
||||
</button>
|
||||
<button
|
||||
className="p-1.5 mx-0.5 rounded-lg text-primary hover:bg-secondary/50 hover:scale-110 transition-all duration-200"
|
||||
onClick={(): void => moveChapterDown(index)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faArrowDown} size="sm"/>
|
||||
</button>
|
||||
<button
|
||||
className="p-1.5 mx-0.5 rounded-lg text-error hover:bg-error/20 hover:scale-110 transition-all duration-200"
|
||||
onClick={(): void => {
|
||||
setChapterIdToRemove(item.chapterId);
|
||||
setDeleteConfirmMessage(true);
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} size="sm"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-text-secondary text-center py-2">
|
||||
{t("mainChapter.noChapter")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="bg-secondary/30 rounded-xl p-3 mt-3 shadow-sm">
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="flex items-center gap-1 bg-secondary/50 rounded-lg mr-2 px-1 py-0.5 shadow-inner">
|
||||
<button
|
||||
className={`w-6 h-6 rounded-full bg-secondary flex justify-center items-center hover:scale-110 transition-all duration-200 ${
|
||||
newChapterOrder <= 0 ? 'text-muted cursor-not-allowed opacity-50' : 'text-primary hover:bg-secondary-dark'
|
||||
}`}
|
||||
onClick={decrementNewChapterOrder}
|
||||
disabled={newChapterOrder <= 0}
|
||||
>
|
||||
<FontAwesomeIcon icon={faMinus} size="xs"/>
|
||||
</button>
|
||||
<span
|
||||
className="bg-primary-dark text-white text-sm w-6 h-6 rounded-full text-center leading-6 mx-0.5">
|
||||
{newChapterOrder}
|
||||
</span>
|
||||
<button
|
||||
className="w-6 h-6 rounded-full bg-secondary flex justify-center items-center text-primary hover:bg-secondary-dark hover:scale-110 transition-all duration-200"
|
||||
onClick={incrementNewChapterOrder}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} size="xs"/>
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
className="flex-1 text-text-primary text-base px-2 py-1 bg-transparent border-b border-secondary/50 focus:outline-none focus:border-primary transition-colors duration-200 placeholder:text-muted/60"
|
||||
value={newChapterTitle}
|
||||
onChange={e => setNewChapterTitle(e.target.value)}
|
||||
placeholder={t("mainChapter.newChapterPlaceholder")}
|
||||
/>
|
||||
<button
|
||||
className="bg-primary w-9 h-9 rounded-full flex justify-center items-center ml-2 text-text-primary shadow-md hover:shadow-lg hover:scale-110 hover:bg-primary-dark transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100"
|
||||
onClick={addNewChapter}
|
||||
disabled={newChapterTitle.trim() === ''}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} className={'w-5 h-5'}/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}/>
|
||||
{
|
||||
deleteConfirmMessage &&
|
||||
<AlertBox title={t("mainChapter.deleteTitle")} message={t("mainChapter.deleteMessage")}
|
||||
type={"danger"} onConfirm={deleteChapter}
|
||||
onCancel={(): void => setDeleteConfirmMessage(false)}/>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
167
components/book/settings/story/StorySetting.tsx
Normal file
167
components/book/settings/story/StorySetting.tsx
Normal file
@@ -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<React.SetStateAction<ActType[]>>;
|
||||
mainChapters: ChapterListProps[];
|
||||
setMainChapters: React.Dispatch<React.SetStateAction<ChapterListProps[]>>;
|
||||
issues: Issue[];
|
||||
setIssues: React.Dispatch<React.SetStateAction<Issue[]>>;
|
||||
}>({
|
||||
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<LangContextProps>(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<ActType[]>([]);
|
||||
const [issues, setIssues] = useState<Issue[]>([]);
|
||||
const [mainChapters, setMainChapters] = useState<ChapterListProps[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
|
||||
useImperativeHandle(ref, function () {
|
||||
return {
|
||||
handleSave: handleSave
|
||||
};
|
||||
});
|
||||
|
||||
useEffect((): void => {
|
||||
getStoryData().then();
|
||||
}, [userToken]);
|
||||
|
||||
useEffect((): void => {
|
||||
cleanupDeletedChapters();
|
||||
}, [mainChapters]);
|
||||
|
||||
async function getStoryData(): Promise<void> {
|
||||
try {
|
||||
const response: StoryFetchData = await System.authGetQueryToServer<StoryFetchData>(`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<void> {
|
||||
try {
|
||||
const response: boolean =
|
||||
await System.authPostToServer<boolean>('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 (
|
||||
<StoryContext.Provider
|
||||
value={{
|
||||
acts,
|
||||
setActs,
|
||||
mainChapters,
|
||||
setMainChapters,
|
||||
issues,
|
||||
setIssues,
|
||||
}}>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-grow overflow-auto py-4">
|
||||
<div className="space-y-6 px-4">
|
||||
<MainChapter chapters={mainChapters} setChapters={setMainChapters}/>
|
||||
<div className="space-y-4">
|
||||
<Act acts={acts} setActs={setActs} mainChapters={mainChapters}/>
|
||||
</div>
|
||||
<Issues issues={issues} setIssues={setIssues}/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</StoryContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef(Story);
|
||||
37
components/book/settings/story/act/ActChapter.tsx
Normal file
37
components/book/settings/story/act/ActChapter.tsx
Normal file
@@ -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<void>;
|
||||
}
|
||||
|
||||
export default function ActChapterItem({chapter, onUpdateSummary, onUnlink}: ActChapterItemProps) {
|
||||
const t = useTranslations('actComponent');
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-secondary/20 p-4 rounded-xl mb-3 border border-secondary/30 shadow-sm hover:shadow-md transition-all duration-200">
|
||||
<InputField
|
||||
input={
|
||||
<TexteAreaInput
|
||||
value={chapter.summary || ''}
|
||||
setValue={(e: ChangeEvent<HTMLTextAreaElement>) =>
|
||||
onUpdateSummary(chapter.chapterId, e.target.value)
|
||||
}
|
||||
placeholder={t('chapterSummaryPlaceholder')}
|
||||
/>
|
||||
}
|
||||
actionIcon={faTrash}
|
||||
fieldName={chapter.title}
|
||||
action={(): Promise<void> => onUnlink(chapter.chapterInfoId, chapter.chapterId)}
|
||||
actionLabel={t('remove')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
components/book/settings/story/act/ActChaptersSection.tsx
Normal file
93
components/book/settings/story/act/ActChaptersSection.tsx
Normal file
@@ -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<void>;
|
||||
onUpdateChapterSummary: (chapterId: string, summary: string) => void;
|
||||
onUnlinkChapter: (chapterInfoId: string, chapterId: string) => Promise<void>;
|
||||
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<string>('');
|
||||
|
||||
function mainChaptersData(): SelectBoxProps[] {
|
||||
return mainChapters.map((chapter: ChapterListProps): SelectBoxProps => ({
|
||||
value: chapter.chapterId,
|
||||
label: `${chapter.chapterOrder}. ${chapter.title}`,
|
||||
}));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<button
|
||||
className="flex justify-between items-center w-full bg-secondary/50 p-3 rounded-xl text-left hover:bg-secondary transition-all duration-200 shadow-sm"
|
||||
onClick={(): void => onToggleSection(sectionKey)}
|
||||
>
|
||||
<span className="font-bold text-text-primary">{t('chapters')}</span>
|
||||
<FontAwesomeIcon
|
||||
icon={isExpanded ? faChevronUp : faChevronDown}
|
||||
className="text-text-primary w-3.5 h-3.5"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="p-2">
|
||||
{chapters && chapters.length > 0 ? (
|
||||
chapters.map((chapter: ActChapter) => (
|
||||
<ActChapterItem
|
||||
key={`chapter-${chapter.chapterInfoId}`}
|
||||
chapter={chapter}
|
||||
onUpdateSummary={(chapterId, summary) =>
|
||||
onUpdateChapterSummary(chapterId, summary)
|
||||
}
|
||||
onUnlink={(chapterInfoId, chapterId) =>
|
||||
onUnlinkChapter(chapterInfoId, chapterId)
|
||||
}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p className="text-text-secondary text-center text-sm p-2">
|
||||
{t('noLinkedChapter')}
|
||||
</p>
|
||||
)}
|
||||
<InputField
|
||||
addButtonCallBack={(): Promise<void> => onLinkChapter(actId, selectedChapterId)}
|
||||
input={
|
||||
<SelectBox
|
||||
defaultValue={null}
|
||||
onChangeCallBack={(e) => setSelectedChapterId(e.target.value)}
|
||||
data={mainChaptersData()}
|
||||
placeholder={t('selectChapterPlaceholder')}
|
||||
/>
|
||||
}
|
||||
isAddButtonDisabled={!selectedChapterId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
components/book/settings/story/act/ActDescription.tsx
Normal file
60
components/book/settings/story/act/ActDescription.tsx
Normal file
@@ -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 (
|
||||
<div className="mb-4">
|
||||
<InputField
|
||||
fieldName={getActSummaryTitle(actId)}
|
||||
input={
|
||||
<TexteAreaInput
|
||||
value={summary || ''}
|
||||
setValue={(e: ChangeEvent<HTMLTextAreaElement>) =>
|
||||
onUpdateSummary(actId, e.target.value)
|
||||
}
|
||||
placeholder={getActSummaryPlaceholder(actId)}
|
||||
/>
|
||||
}
|
||||
actionIcon={faTrash}
|
||||
actionLabel={t('delete')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
176
components/book/settings/story/act/ActIncidents.tsx
Normal file
176
components/book/settings/story/act/ActIncidents.tsx
Normal file
@@ -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<void>;
|
||||
onDeleteIncident: (actId: number, incidentId: string) => Promise<void>;
|
||||
onLinkChapter: (actId: number, chapterId: string, incidentId: string) => Promise<void>;
|
||||
onUpdateChapterSummary: (chapterId: string, summary: string, incidentId: string) => void;
|
||||
onUnlinkChapter: (chapterInfoId: string, chapterId: string, incidentId: string) => Promise<void>;
|
||||
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<string>('');
|
||||
|
||||
function toggleItem(itemKey: string): void {
|
||||
setExpandedItems(prev => ({
|
||||
...prev,
|
||||
[itemKey]: !prev[itemKey],
|
||||
}));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<button
|
||||
className="flex justify-between items-center w-full bg-secondary/50 p-3 rounded-xl text-left hover:bg-secondary transition-all duration-200 shadow-sm"
|
||||
onClick={(): void => onToggleSection(sectionKey)}
|
||||
>
|
||||
<span className="font-bold text-text-primary">{t('incidentsTitle')}</span>
|
||||
<FontAwesomeIcon
|
||||
icon={isExpanded ? faChevronUp : faChevronDown}
|
||||
className="text-text-primary w-3.5 h-3.5"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="p-2">
|
||||
{incidents && incidents.length > 0 ? (
|
||||
<>
|
||||
{incidents.map((item: Incident) => {
|
||||
const itemKey = `incident_${item.incidentId}`;
|
||||
const isItemExpanded: boolean = expandedItems[itemKey];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`incident-${item.incidentId}`}
|
||||
className="bg-secondary/30 rounded-xl mb-3 overflow-hidden border border-secondary/40 shadow-sm hover:shadow-md transition-all duration-200"
|
||||
>
|
||||
<button
|
||||
className="flex justify-between items-center w-full p-2 text-left"
|
||||
onClick={(): void => toggleItem(itemKey)}
|
||||
>
|
||||
<span className="font-bold text-text-primary">{item.title}</span>
|
||||
<div className="flex items-center">
|
||||
<FontAwesomeIcon
|
||||
icon={isItemExpanded ? faChevronUp : faChevronDown}
|
||||
className="text-text-primary w-3.5 h-3.5 mr-2"
|
||||
/>
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
await onDeleteIncident(actId, item.incidentId);
|
||||
}}
|
||||
className="text-error hover:bg-error/20 p-1.5 rounded-lg transition-all duration-200 hover:scale-110"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} className="w-3.5 h-3.5"/>
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isItemExpanded && (
|
||||
<div className="p-3 bg-secondary/20">
|
||||
{item.chapters && item.chapters.length > 0 ? (
|
||||
<>
|
||||
{item.chapters.map((chapter: ActChapter) => (
|
||||
<ActChapterItem
|
||||
key={`inc-chapter-${chapter.chapterId}-${chapter.chapterInfoId}`}
|
||||
chapter={chapter}
|
||||
onUpdateSummary={(chapterId, summary) =>
|
||||
onUpdateChapterSummary(chapterId, summary, item.incidentId)
|
||||
}
|
||||
onUnlink={(chapterInfoId, chapterId) =>
|
||||
onUnlinkChapter(chapterInfoId, chapterId, item.incidentId)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-text-secondary text-center text-sm p-2">
|
||||
{t('noLinkedChapter')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center mt-2">
|
||||
<select
|
||||
onChange={(e) => setSelectedChapterId(e.target.value)}
|
||||
className="flex-1 bg-secondary/50 text-text-primary rounded-xl px-4 py-2.5 mr-2 border border-secondary/50 focus:outline-none focus:ring-4 focus:ring-primary/20 focus:border-primary hover:bg-secondary hover:border-secondary transition-all duration-200"
|
||||
>
|
||||
<option value="">{t('selectChapterPlaceholder')}</option>
|
||||
{mainChapters.map((chapter: ChapterListProps) => (
|
||||
<option key={chapter.chapterId} value={chapter.chapterId}>
|
||||
{`${chapter.chapterOrder}. ${chapter.title}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className="bg-primary text-text-primary w-9 h-9 rounded-full flex items-center justify-center disabled:opacity-50 disabled:cursor-not-allowed shadow-md hover:shadow-lg hover:scale-110 hover:bg-primary-dark transition-all duration-200"
|
||||
onClick={(): Promise<void> =>
|
||||
onLinkChapter(actId, selectedChapterId, item.incidentId)
|
||||
}
|
||||
disabled={selectedChapterId.length === 0}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} className="w-3.5 h-3.5"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-text-secondary text-center text-sm p-2">
|
||||
{t('noIncidentAdded')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center mt-2">
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 bg-secondary/50 text-text-primary rounded-xl px-4 py-2.5 mr-2 border border-secondary/50 focus:outline-none focus:ring-4 focus:ring-primary/20 focus:border-primary hover:bg-secondary hover:border-secondary transition-all duration-200 placeholder:text-muted/60"
|
||||
value={newIncidentTitle}
|
||||
onChange={(e) => setNewIncidentTitle(e.target.value)}
|
||||
placeholder={t('newIncidentPlaceholder')}
|
||||
/>
|
||||
<button
|
||||
className="bg-primary text-text-primary w-9 h-9 rounded-full flex items-center justify-center disabled:opacity-50 disabled:cursor-not-allowed shadow-md hover:shadow-lg hover:scale-110 hover:bg-primary-dark transition-all duration-200"
|
||||
onClick={(): Promise<void> => onAddIncident(actId)}
|
||||
disabled={newIncidentTitle.trim() === ''}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} className="w-3.5 h-3.5"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
202
components/book/settings/story/act/ActPlotPoints.tsx
Normal file
202
components/book/settings/story/act/ActPlotPoints.tsx
Normal file
@@ -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<void>;
|
||||
onDeletePlotPoint: (actId: number, plotPointId: string) => Promise<void>;
|
||||
onLinkChapter: (actId: number, chapterId: string, plotPointId: string) => Promise<void>;
|
||||
onUpdateChapterSummary: (chapterId: string, summary: string, plotPointId: string) => void;
|
||||
onUnlinkChapter: (chapterInfoId: string, chapterId: string, plotPointId: string) => Promise<void>;
|
||||
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<string>('');
|
||||
|
||||
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 (
|
||||
<div className="mb-4">
|
||||
<button
|
||||
className="flex justify-between items-center w-full bg-secondary/50 p-3 rounded-xl text-left hover:bg-secondary transition-all duration-200 shadow-sm"
|
||||
onClick={(): void => onToggleSection(sectionKey)}
|
||||
>
|
||||
<span className="font-bold text-text-primary">{t('plotPointsTitle')}</span>
|
||||
<FontAwesomeIcon
|
||||
icon={isExpanded ? faChevronUp : faChevronDown}
|
||||
className="text-text-primary w-3.5 h-3.5"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="p-2">
|
||||
{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 (
|
||||
<div
|
||||
key={`plot-point-${item.plotPointId}`}
|
||||
className="bg-secondary/30 rounded-xl mb-3 overflow-hidden border border-secondary/40 shadow-sm hover:shadow-md transition-all duration-200"
|
||||
>
|
||||
<button
|
||||
className="flex justify-between items-center w-full p-2 text-left"
|
||||
onClick={(): void => toggleItem(itemKey)}
|
||||
>
|
||||
<div>
|
||||
<p className="font-bold text-text-primary">{item.title}</p>
|
||||
{linkedIncident && (
|
||||
<p className="text-text-secondary text-sm italic">
|
||||
{t('linkedTo')}: {linkedIncident.title}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<FontAwesomeIcon
|
||||
icon={isItemExpanded ? faChevronUp : faChevronDown}
|
||||
className="text-text-primary w-3.5 h-3.5 mr-2"
|
||||
/>
|
||||
<button
|
||||
onClick={async (e): Promise<void> => {
|
||||
e.stopPropagation();
|
||||
await onDeletePlotPoint(actId, item.plotPointId);
|
||||
}}
|
||||
className="text-error hover:bg-error/20 p-1.5 rounded-lg transition-all duration-200 hover:scale-110"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} className="w-3.5 h-3.5"/>
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isItemExpanded && (
|
||||
<div className="p-3 bg-secondary/20">
|
||||
{item.chapters && item.chapters.length > 0 ? (
|
||||
item.chapters.map((chapter: ActChapter) => (
|
||||
<ActChapterItem
|
||||
key={`plot-chapter-${chapter.chapterId}-${chapter.chapterInfoId}`}
|
||||
chapter={chapter}
|
||||
onUpdateSummary={(chapterId, summary) =>
|
||||
onUpdateChapterSummary(chapterId, summary, item.plotPointId)
|
||||
}
|
||||
onUnlink={(chapterInfoId, chapterId) =>
|
||||
onUnlinkChapter(chapterInfoId, chapterId, item.plotPointId)
|
||||
}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p className="text-text-secondary text-center text-sm p-2">
|
||||
{t('noLinkedChapter')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center mt-2">
|
||||
<select
|
||||
onChange={(e) => setSelectedChapterId(e.target.value)}
|
||||
className="flex-1 bg-secondary/50 text-text-primary rounded-xl px-4 py-2.5 mr-2 border border-secondary/50 focus:outline-none focus:ring-4 focus:ring-primary/20 focus:border-primary hover:bg-secondary hover:border-secondary transition-all duration-200"
|
||||
>
|
||||
<option value="">{t('selectChapterPlaceholder')}</option>
|
||||
{mainChapters.map((chapter: ChapterListProps) => (
|
||||
<option key={chapter.chapterId} value={chapter.chapterId}>
|
||||
{`${chapter.chapterOrder}. ${chapter.title}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className="bg-primary text-text-primary w-9 h-9 rounded-full flex items-center justify-center disabled:opacity-50 disabled:cursor-not-allowed shadow-md hover:shadow-lg hover:scale-110 hover:bg-primary-dark transition-all duration-200"
|
||||
onClick={() => onLinkChapter(actId, selectedChapterId, item.plotPointId)}
|
||||
disabled={!selectedChapterId}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} className="w-3.5 h-3.5"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className="text-text-secondary text-center text-sm p-2">
|
||||
{t('noPlotPointAdded')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-2 space-y-2">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 bg-secondary/50 text-text-primary rounded-xl px-4 py-2.5 border border-secondary/50 focus:outline-none focus:ring-4 focus:ring-primary/20 focus:border-primary hover:bg-secondary hover:border-secondary transition-all duration-200 placeholder:text-muted/60"
|
||||
value={newPlotPointTitle}
|
||||
onChange={(e) => setNewPlotPointTitle(e.target.value)}
|
||||
placeholder={t('newPlotPointPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<InputField
|
||||
input={
|
||||
<SelectBox
|
||||
defaultValue={``}
|
||||
onChangeCallBack={(e) => setSelectedIncidentId(e.target.value)}
|
||||
data={getIncidentData()}
|
||||
/>
|
||||
}
|
||||
addButtonCallBack={(): Promise<void> => onAddPlotPoint(actId)}
|
||||
isAddButtonDisabled={newPlotPointTitle.trim() === ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
132
components/book/settings/world/WorldElement.tsx
Normal file
132
components/book/settings/world/WorldElement.tsx
Normal file
@@ -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<LangContextProps>(LangContext);
|
||||
const {worlds, setWorlds, selectedWorldIndex} = useContext(WorldContext);
|
||||
const {errorMessage, successMessage} = useContext(AlertContext);
|
||||
const {session} = useContext(SessionContext);
|
||||
|
||||
const [newElementName, setNewElementName] = useState<string>('');
|
||||
|
||||
async function handleRemoveElement(
|
||||
section: keyof WorldProps,
|
||||
index: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response: boolean = await System.authDeleteToServer<boolean>('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<void> {
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
{Array.isArray(worlds[selectedWorldIndex][sectionType as keyof WorldProps]) &&
|
||||
(worlds[selectedWorldIndex][sectionType as keyof WorldProps] as WorldElement[]).map(
|
||||
(element: WorldElement, index: number) => (
|
||||
<div key={element.id}
|
||||
className="bg-secondary/30 rounded-xl p-4 border-l-4 border-primary shadow-sm hover:shadow-md transition-all duration-200">
|
||||
<div className="mb-2">
|
||||
<InputField input={<TextInput
|
||||
value={element.name}
|
||||
setValue={(e: ChangeEvent<HTMLInputElement>) => handleElementChange(sectionType as keyof WorldProps, index, 'name', e.target.value)}
|
||||
placeholder={t("worldElementComponent.namePlaceholder", {section: sectionLabel.toLowerCase()})}
|
||||
/>}
|
||||
removeButtonCallBack={(): Promise<void> => handleRemoveElement(sectionType as keyof WorldProps, index)}/>
|
||||
</div>
|
||||
<TexteAreaInput
|
||||
value={element.description}
|
||||
setValue={(e) => handleElementChange(sectionType as keyof WorldProps, index, 'description', e.target.value)}
|
||||
placeholder={t("worldElementComponent.descriptionPlaceholder", {section: sectionLabel.toLowerCase()})}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
}
|
||||
<InputField input={<TextInput
|
||||
value={newElementName}
|
||||
setValue={(e: ChangeEvent<HTMLInputElement>): void => setNewElementName(e.target.value)}
|
||||
placeholder={t("worldElementComponent.newPlaceholder", {section: sectionLabel.toLowerCase()})}
|
||||
/>} addButtonCallBack={(): Promise<void> => handleAddElement(sectionType as keyof WorldProps)}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
309
components/book/settings/world/WorldSetting.tsx
Normal file
309
components/book/settings/world/WorldSetting.tsx
Normal file
@@ -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<LangContextProps>(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<WorldProps[]>([]);
|
||||
const [newWorldName, setNewWorldName] = useState<string>('');
|
||||
const [selectedWorldIndex, setSelectedWorldIndex] = useState<number>(0);
|
||||
const [worldsSelector, setWorldsSelector] = useState<SelectBoxProps[]>([]);
|
||||
const [showAddNewWorld, setShowAddNewWorld] = useState<boolean>(false);
|
||||
|
||||
useImperativeHandle(ref, function () {
|
||||
return {
|
||||
handleSave: handleUpdateWorld,
|
||||
};
|
||||
});
|
||||
|
||||
useEffect((): void => {
|
||||
getWorlds().then();
|
||||
}, []);
|
||||
|
||||
async function getWorlds() {
|
||||
try {
|
||||
const response: WorldProps[] = await System.authGetQueryToServer<WorldProps[]>(`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<void> {
|
||||
if (newWorldName.trim() === '') {
|
||||
errorMessage(t("worldSetting.newWorldNameError"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const worldId: string = await System.authPostToServer<string>('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<void> {
|
||||
try {
|
||||
const response: boolean = await System.authPutToServer<boolean>('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 (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-lg">
|
||||
<div className="grid grid-cols-1 gap-4 mb-4">
|
||||
<InputField
|
||||
fieldName={t("worldSetting.selectWorld")}
|
||||
input={
|
||||
<SelectBox
|
||||
onChangeCallBack={(e) => {
|
||||
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 && (
|
||||
<InputField
|
||||
input={
|
||||
<TextInput
|
||||
value={newWorldName}
|
||||
setValue={(e: ChangeEvent<HTMLInputElement>) => setNewWorldName(e.target.value)}
|
||||
placeholder={t("worldSetting.newWorldPlaceholder")}
|
||||
/>
|
||||
}
|
||||
actionIcon={faPlus}
|
||||
actionLabel={t("worldSetting.createWorldLabel")}
|
||||
addButtonCallBack={handleAddNewWorld}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{worlds.length > 0 && worlds[selectedWorldIndex] ? (
|
||||
<WorldContext.Provider value={{worlds, setWorlds, selectedWorldIndex}}>
|
||||
<div
|
||||
className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-lg">
|
||||
<div className="mb-4">
|
||||
<InputField
|
||||
fieldName={t("worldSetting.worldName")}
|
||||
input={
|
||||
<TextInput
|
||||
value={worlds[selectedWorldIndex].name}
|
||||
setValue={(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const updatedWorlds: WorldProps[] = [...worlds];
|
||||
updatedWorlds[selectedWorldIndex].name = e.target.value
|
||||
setWorlds(updatedWorlds);
|
||||
}}
|
||||
placeholder={t("worldSetting.worldNamePlaceholder")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<InputField
|
||||
fieldName={t("worldSetting.worldHistory")}
|
||||
input={
|
||||
<TexteAreaInput
|
||||
value={worlds[selectedWorldIndex].history || ''}
|
||||
setValue={(e) => handleInputChange(e.target.value, 'history')}
|
||||
placeholder={t("worldSetting.worldHistoryPlaceholder")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="bg-tertiary/90 backdrop-blur-sm rounded-xl shadow-lg p-4 border border-secondary/50">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<InputField
|
||||
fieldName={t("worldSetting.politics")}
|
||||
input={
|
||||
<TexteAreaInput
|
||||
value={worlds[selectedWorldIndex].politics || ''}
|
||||
setValue={(e) => handleInputChange(e.target.value, 'politics')}
|
||||
placeholder={t("worldSetting.politicsPlaceholder")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InputField
|
||||
fieldName={t("worldSetting.economy")}
|
||||
input={
|
||||
<TexteAreaInput
|
||||
value={worlds[selectedWorldIndex].economy || ''}
|
||||
setValue={(e) => handleInputChange(e.target.value, 'economy')}
|
||||
placeholder={t("worldSetting.economyPlaceholder")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="bg-tertiary/90 backdrop-blur-sm rounded-xl shadow-lg p-4 border border-secondary/50">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<InputField
|
||||
fieldName={t("worldSetting.religion")}
|
||||
input={
|
||||
<TexteAreaInput
|
||||
value={worlds[selectedWorldIndex].religion || ''}
|
||||
setValue={(e) => handleInputChange(e.target.value, 'religion')}
|
||||
placeholder={t("worldSetting.religionPlaceholder")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InputField
|
||||
fieldName={t("worldSetting.languages")}
|
||||
input={
|
||||
<TexteAreaInput
|
||||
value={worlds[selectedWorldIndex].languages || ''}
|
||||
setValue={(e) => handleInputChange(e.target.value, 'languages')}
|
||||
placeholder={t("worldSetting.languagesPlaceholder")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{elementSections.map((section, index) => (
|
||||
<div key={index}
|
||||
className="bg-tertiary/90 backdrop-blur-sm rounded-xl shadow-lg p-4 border border-secondary/50">
|
||||
<h3 className="text-lg font-semibold text-text-primary mb-4 flex items-center">
|
||||
<FontAwesomeIcon icon={section.icon} className="mr-2 w-5 h-5"/>
|
||||
{section.title}
|
||||
<span
|
||||
className="ml-2 text-sm bg-dark-background text-text-secondary py-0.5 px-2 rounded-full">
|
||||
{worlds[selectedWorldIndex][section.section]?.length || 0}
|
||||
</span>
|
||||
</h3>
|
||||
<WorldElementComponent
|
||||
sectionLabel={section.title}
|
||||
sectionType={section.section}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</WorldContext.Provider>
|
||||
) : (
|
||||
<div
|
||||
className="bg-tertiary/90 backdrop-blur-sm rounded-xl shadow-lg p-8 border border-secondary/50 text-center">
|
||||
<p className="text-text-secondary mb-4">{t("worldSetting.noWorldAvailable")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef(WorldSetting);
|
||||
574
components/editor/DraftCompanion.tsx
Normal file
574
components/editor/DraftCompanion.tsx
Normal file
@@ -0,0 +1,574 @@
|
||||
import React, {ChangeEvent, useContext, useEffect, useState} from "react";
|
||||
import {Editor, EditorContent, useEditor} from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Underline from "@tiptap/extension-underline";
|
||||
import TextAlign from "@tiptap/extension-text-align";
|
||||
import System from "@/lib/models/System";
|
||||
import {ChapterContext} from "@/context/ChapterContext";
|
||||
import {BookContext} from "@/context/BookContext";
|
||||
import {SelectBoxProps} from "@/shared/interface";
|
||||
import {AlertContext} from "@/context/AlertContext";
|
||||
import {SessionContext} from "@/context/SessionContext";
|
||||
import {
|
||||
faCubes,
|
||||
faFeather,
|
||||
faGlobe,
|
||||
faMagicWandSparkles,
|
||||
faMapPin,
|
||||
faPalette,
|
||||
faUser
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import SubmitButtonWLoading from "@/components/form/SubmitButtonWLoading";
|
||||
import QSTextGeneratedPreview from "@/components/QSTextGeneratedPreview";
|
||||
import {EditorContext} from "@/context/EditorContext";
|
||||
import {useTranslations} from "next-intl";
|
||||
import QuillSense from "@/lib/models/QuillSense";
|
||||
import TextInput from "@/components/form/TextInput";
|
||||
import InputField from "@/components/form/InputField";
|
||||
import TexteAreaInput from "@/components/form/TexteAreaInput";
|
||||
import SuggestFieldInput from "@/components/form/SuggestFieldInput";
|
||||
import Collapse from "@/components/Collapse";
|
||||
import {LangContext, LangContextProps} from "@/context/LangContext";
|
||||
import {BookTags} from "@/lib/models/Book";
|
||||
import {AIUsageContext, AIUsageContextProps} from "@/context/AIUsageContext";
|
||||
import {configs} from "@/lib/configs";
|
||||
|
||||
interface CompanionContent {
|
||||
version: number;
|
||||
content: string;
|
||||
wordsCount: number;
|
||||
}
|
||||
|
||||
export default function DraftCompanion() {
|
||||
const t = useTranslations();
|
||||
const {setTotalPrice, setTotalCredits} = useContext<AIUsageContextProps>(AIUsageContext)
|
||||
const {lang} = useContext<LangContextProps>(LangContext)
|
||||
|
||||
const mainEditor: Editor | null = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Underline,
|
||||
TextAlign.configure({
|
||||
types: ['heading', 'paragraph'],
|
||||
}),
|
||||
],
|
||||
injectCSS: false,
|
||||
editable: false,
|
||||
immediatelyRender: false,
|
||||
});
|
||||
const {editor} = useContext(EditorContext);
|
||||
const {chapter} = useContext(ChapterContext);
|
||||
const {book} = useContext(BookContext);
|
||||
const {session} = useContext(SessionContext);
|
||||
const {errorMessage, infoMessage} = useContext(AlertContext);
|
||||
|
||||
const [draftVersion, setDraftVersion] = useState<number>(0);
|
||||
const [draftWordCount, setDraftWordCount] = useState<number>(0);
|
||||
const [refinedText, setRefinedText] = useState<string>('');
|
||||
const [isRefining, setIsRefining] = useState<boolean>(false);
|
||||
const [showRefinedText, setShowRefinedText] = useState<boolean>(false);
|
||||
const [showEnhancer, setShowEnhancer] = useState<boolean>(false);
|
||||
const [abortController, setAbortController] = useState<ReadableStreamDefaultReader<Uint8Array> | null>(null);
|
||||
|
||||
const [toneAtmosphere, setToneAtmosphere] = useState<string>('');
|
||||
const [specifications, setSpecifications] = useState<string>('');
|
||||
|
||||
const [characters, setCharacters] = useState<SelectBoxProps[]>([]);
|
||||
const [locations, setLocations] = useState<SelectBoxProps[]>([]);
|
||||
const [objects, setObjects] = useState<SelectBoxProps[]>([]);
|
||||
const [worldElements, setWorldElements] = useState<SelectBoxProps[]>([]);
|
||||
|
||||
const [taguedCharacters, setTaguedCharacters] = useState<string[]>([]);
|
||||
const [taguedLocations, setTaguedLocations] = useState<string[]>([]);
|
||||
const [taguedObjects, setTaguedObjects] = useState<string[]>([]);
|
||||
const [taguedWorldElements, setTaguedWorldElements] = useState<string[]>([]);
|
||||
|
||||
const [searchCharacters, setSearchCharacters] = useState<string>('');
|
||||
const [searchLocations, setSearchLocations] = useState<string>('');
|
||||
const [searchObjects, setSearchObjects] = useState<string>('');
|
||||
const [searchWorldElements, setSearchWorldElements] = useState<string>('');
|
||||
|
||||
const [showCharacterSuggestions, setShowCharacterSuggestions] = useState<boolean>(false);
|
||||
const [showLocationSuggestions, setShowLocationSuggestions] = useState<boolean>(false);
|
||||
const [showObjectSuggestions, setShowObjectSuggestions] = useState<boolean>(false);
|
||||
const [showWorldElementSuggestions, setShowWorldElementSuggestions] = useState<boolean>(false);
|
||||
|
||||
const isGPTEnabled: boolean = QuillSense.isOpenAIEnabled(session);
|
||||
const isSubTierTree: boolean = QuillSense.getSubLevel(session) === 3;
|
||||
const hasAccess: boolean = isGPTEnabled || isSubTierTree;
|
||||
|
||||
useEffect((): void => {
|
||||
getDraftContent().then();
|
||||
if (showEnhancer) {
|
||||
fetchTags().then();
|
||||
}
|
||||
}, [mainEditor, chapter, showEnhancer]);
|
||||
|
||||
async function getDraftContent(): Promise<void> {
|
||||
try {
|
||||
const response: CompanionContent = await System.authGetQueryToServer<CompanionContent>(`chapter/content/companion`, session.accessToken, lang, {
|
||||
bookid: book?.bookId,
|
||||
chapterid: chapter?.chapterId,
|
||||
version: chapter?.chapterContent.version,
|
||||
});
|
||||
if (response && mainEditor) {
|
||||
mainEditor.commands.setContent(JSON.parse(response.content));
|
||||
setDraftVersion(response.version);
|
||||
setDraftWordCount(response.wordsCount);
|
||||
} else if (response && response.content.length === 0 && mainEditor) {
|
||||
mainEditor.commands.setContent({
|
||||
"type": "doc",
|
||||
"content": [
|
||||
{
|
||||
"type": "heading",
|
||||
"attrs": {
|
||||
"level": 1
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": t("draftCompanion.noPreviousVersion")
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
} else {
|
||||
errorMessage(t("draftCompanion.unknownError"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTags(): Promise<void> {
|
||||
try {
|
||||
const responseTags: BookTags = await System.authGetQueryToServer<BookTags>(`book/tags`, session.accessToken, lang, {
|
||||
bookId: book?.bookId
|
||||
});
|
||||
if (responseTags) {
|
||||
setCharacters(responseTags.characters);
|
||||
setLocations(responseTags.locations);
|
||||
setObjects(responseTags.objects);
|
||||
setWorldElements(responseTags.worldElements);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
} else {
|
||||
errorMessage(t("draftCompanion.unknownError"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStopRefining(): Promise<void> {
|
||||
if (abortController) {
|
||||
await abortController.cancel();
|
||||
setAbortController(null);
|
||||
infoMessage(t("draftCompanion.abortSuccess"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleQuillSenseRefined(): Promise<void> {
|
||||
if (chapter && session?.accessToken) {
|
||||
setIsRefining(true);
|
||||
setShowRefinedText(false);
|
||||
setRefinedText('');
|
||||
|
||||
try {
|
||||
const response: Response = await fetch(`${configs.apiUrl}quillsense/refine`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${session.accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
chapterId: chapter?.chapterId,
|
||||
bookId: book?.bookId,
|
||||
toneAndAtmosphere: toneAtmosphere,
|
||||
advancedPrompt: specifications,
|
||||
tags: {
|
||||
characters: taguedCharacters,
|
||||
locations: taguedLocations,
|
||||
objects: taguedObjects,
|
||||
worldElements: taguedWorldElements,
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error: { message?: string } = await response.json();
|
||||
errorMessage(error.message || t('draftCompanion.errorRefineDraft'));
|
||||
setIsRefining(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const reader: ReadableStreamDefaultReader<Uint8Array> | undefined = response.body?.getReader();
|
||||
const decoder: TextDecoder = new TextDecoder();
|
||||
let accumulatedText: string = '';
|
||||
|
||||
if (!reader) {
|
||||
errorMessage(t('draftCompanion.errorRefineDraft'));
|
||||
setIsRefining(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setAbortController(reader);
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const {done, value}: ReadableStreamReadResult<Uint8Array> = await reader.read();
|
||||
|
||||
if (done) break;
|
||||
|
||||
const chunk: string = decoder.decode(value, {stream: true});
|
||||
const lines: string[] = chunk.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const dataStr: string = line.slice(6);
|
||||
const data: {
|
||||
content?: string;
|
||||
totalCost?: number;
|
||||
totalPrice?: number;
|
||||
useYourKey?: boolean;
|
||||
aborted?: boolean;
|
||||
} = JSON.parse(dataStr);
|
||||
|
||||
// Si c'est le message final avec les totaux
|
||||
if ('totalCost' in data && 'useYourKey' in data && 'totalPrice' in data) {
|
||||
console.log(data);
|
||||
if (data.useYourKey) {
|
||||
setTotalPrice((prev: number): number => prev + data.totalPrice!);
|
||||
} else {
|
||||
setTotalCredits(data.totalPrice!);
|
||||
}
|
||||
} else if ('content' in data && data.content && data.content !== 'starting') {
|
||||
accumulatedText += data.content;
|
||||
setRefinedText(accumulatedText);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
console.error('Error parsing SSE data:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setIsRefining(false);
|
||||
setShowRefinedText(true);
|
||||
setAbortController(null);
|
||||
} catch (e: unknown) {
|
||||
setIsRefining(false);
|
||||
setAbortController(null);
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
} else {
|
||||
errorMessage(t('draftCompanion.unknownErrorRefineDraft'));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function insertText(): void {
|
||||
if (editor && refinedText) {
|
||||
editor.commands.focus('end');
|
||||
if (editor.getText().length > 0) {
|
||||
editor.commands.insertContent('\n\n');
|
||||
}
|
||||
editor.commands.insertContent(System.textContentToHtml(refinedText));
|
||||
setShowRefinedText(false);
|
||||
}
|
||||
}
|
||||
|
||||
function filteredCharacters(): SelectBoxProps[] {
|
||||
if (searchCharacters.trim().length === 0) return [];
|
||||
return characters
|
||||
.filter((item: SelectBoxProps): boolean => item.label.toLowerCase().includes(searchCharacters.toLowerCase()) && !taguedCharacters.includes(item.value))
|
||||
.slice(0, 3);
|
||||
}
|
||||
|
||||
function filteredLocations(): SelectBoxProps[] {
|
||||
if (searchLocations.trim().length === 0) return [];
|
||||
return locations
|
||||
.filter((item: SelectBoxProps): boolean => item.label.toLowerCase().includes(searchLocations.toLowerCase()) && !taguedLocations.includes(item.value))
|
||||
.slice(0, 3);
|
||||
}
|
||||
|
||||
function filteredObjects(): SelectBoxProps[] {
|
||||
if (searchObjects.trim().length === 0) return [];
|
||||
return objects
|
||||
.filter((item: SelectBoxProps): boolean => item.label.toLowerCase().includes(searchObjects.toLowerCase()) && !taguedObjects.includes(item.value))
|
||||
.slice(0, 3);
|
||||
}
|
||||
|
||||
function filteredWorldElements(): SelectBoxProps[] {
|
||||
if (searchWorldElements.trim().length === 0) return [];
|
||||
return worldElements
|
||||
.filter((item: SelectBoxProps): boolean => item.label.toLowerCase().includes(searchWorldElements.toLowerCase()) && !taguedWorldElements.includes(item.value))
|
||||
.slice(0, 3);
|
||||
}
|
||||
|
||||
function handleAddCharacter(value: string): void {
|
||||
if (!taguedCharacters.includes(value)) {
|
||||
const newCharacters: string[] = [...taguedCharacters, value];
|
||||
setTaguedCharacters(newCharacters);
|
||||
}
|
||||
setSearchCharacters('');
|
||||
setShowCharacterSuggestions(false);
|
||||
}
|
||||
|
||||
function handleAddLocation(value: string): void {
|
||||
if (!taguedLocations.includes(value)) {
|
||||
const newLocations: string[] = [...taguedLocations, value];
|
||||
setTaguedLocations(newLocations);
|
||||
}
|
||||
setSearchLocations('');
|
||||
setShowLocationSuggestions(false);
|
||||
}
|
||||
|
||||
function handleAddObject(value: string): void {
|
||||
if (!taguedObjects.includes(value)) {
|
||||
const newObjects: string[] = [...taguedObjects, value];
|
||||
setTaguedObjects(newObjects);
|
||||
}
|
||||
setSearchObjects('');
|
||||
setShowObjectSuggestions(false);
|
||||
}
|
||||
|
||||
function handleAddWorldElement(value: string): void {
|
||||
if (!taguedWorldElements.includes(value)) {
|
||||
const newWorldElements: string[] = [...taguedWorldElements, value];
|
||||
setTaguedWorldElements(newWorldElements);
|
||||
}
|
||||
setSearchWorldElements('');
|
||||
setShowWorldElementSuggestions(false);
|
||||
}
|
||||
|
||||
function handleRemoveCharacter(value: string): void {
|
||||
setTaguedCharacters(taguedCharacters.filter((tag: string): boolean => tag !== value));
|
||||
}
|
||||
|
||||
function handleRemoveLocation(value: string): void {
|
||||
setTaguedLocations(taguedLocations.filter((tag: string): boolean => tag !== value));
|
||||
}
|
||||
|
||||
function handleRemoveObject(value: string): void {
|
||||
setTaguedObjects(taguedObjects.filter((tag: string): boolean => tag !== value));
|
||||
}
|
||||
|
||||
function handleRemoveWorldElement(value: string): void {
|
||||
setTaguedWorldElements(taguedWorldElements.filter((tag: string): boolean => tag !== value));
|
||||
}
|
||||
|
||||
function handleCharacterSearch(text: string): void {
|
||||
setSearchCharacters(text);
|
||||
setShowCharacterSuggestions(text.trim().length > 0);
|
||||
}
|
||||
|
||||
function handleLocationSearch(text: string): void {
|
||||
setSearchLocations(text);
|
||||
setShowLocationSuggestions(text.trim().length > 0);
|
||||
}
|
||||
|
||||
function handleObjectSearch(text: string): void {
|
||||
setSearchObjects(text);
|
||||
setShowObjectSuggestions(text.trim().length > 0);
|
||||
}
|
||||
|
||||
function handleWorldElementSearch(text: string): void {
|
||||
setSearchWorldElements(text);
|
||||
setShowWorldElementSuggestions(text.trim().length > 0);
|
||||
}
|
||||
|
||||
function getCharacterLabel(value: string): string {
|
||||
const character: SelectBoxProps | undefined = characters.find((item: SelectBoxProps): boolean => item.value === value);
|
||||
return character ? character.label : value;
|
||||
}
|
||||
|
||||
function getLocationLabel(value: string): string {
|
||||
const location: SelectBoxProps | undefined = locations.find((item: SelectBoxProps): boolean => item.value === value);
|
||||
return location ? location.label : value;
|
||||
}
|
||||
|
||||
function getObjectLabel(value: string): string {
|
||||
const object: SelectBoxProps | undefined = objects.find((item: SelectBoxProps): boolean => item.value === value);
|
||||
return object ? object.label : value;
|
||||
}
|
||||
|
||||
function getWorldElementLabel(value: string): string {
|
||||
const element: SelectBoxProps | undefined = worldElements.find((item: SelectBoxProps): boolean => item.value === value);
|
||||
return element ? element.label : value;
|
||||
}
|
||||
|
||||
if (showEnhancer && hasAccess) {
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0 overflow-hidden">
|
||||
<div
|
||||
className="flex items-center justify-between p-4 bg-secondary/30 backdrop-blur-sm border-b border-secondary/50 flex-shrink-0 shadow-sm">
|
||||
<h2 className="text-text-primary font-['ADLaM_Display'] text-xl">Amélioration de texte</h2>
|
||||
<button
|
||||
onClick={(): void => setShowEnhancer(false)}
|
||||
className="px-5 py-2.5 bg-secondary/50 hover:bg-secondary text-text-primary rounded-xl transition-all duration-200 hover:scale-105 shadow-md border border-secondary/50 font-medium"
|
||||
>
|
||||
Retour
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4 custom-scrollbar">
|
||||
<Collapse
|
||||
title="Style d'écriture"
|
||||
content={
|
||||
<div className="space-y-4">
|
||||
<InputField
|
||||
icon={faPalette}
|
||||
fieldName={t("ghostWriter.toneAtmosphere")}
|
||||
input={
|
||||
<TextInput
|
||||
value={toneAtmosphere}
|
||||
setValue={(e: ChangeEvent<HTMLInputElement>) => setToneAtmosphere(e.target.value)}
|
||||
placeholder={t("ghostWriter.tonePlaceholder")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<Collapse
|
||||
title="Tags contextuels"
|
||||
content={
|
||||
<div className="space-y-4">
|
||||
<SuggestFieldInput inputFieldName={`Personnages`}
|
||||
inputFieldIcon={faUser}
|
||||
searchTags={searchCharacters}
|
||||
tagued={taguedCharacters}
|
||||
handleTagSearch={(e) => handleCharacterSearch(e.target.value)}
|
||||
handleAddTag={handleAddCharacter}
|
||||
handleRemoveTag={handleRemoveCharacter}
|
||||
filteredTags={filteredCharacters}
|
||||
showTagSuggestions={showCharacterSuggestions}
|
||||
setShowTagSuggestions={setShowCharacterSuggestions}
|
||||
getTagLabel={getCharacterLabel}
|
||||
/>
|
||||
|
||||
<SuggestFieldInput inputFieldName={`Lieux`}
|
||||
inputFieldIcon={faMapPin}
|
||||
searchTags={searchLocations}
|
||||
tagued={taguedLocations}
|
||||
handleTagSearch={(e) => handleLocationSearch(e.target.value)}
|
||||
handleAddTag={handleAddLocation}
|
||||
handleRemoveTag={handleRemoveLocation}
|
||||
filteredTags={filteredLocations}
|
||||
showTagSuggestions={showLocationSuggestions}
|
||||
setShowTagSuggestions={setShowLocationSuggestions}
|
||||
getTagLabel={getLocationLabel}
|
||||
/>
|
||||
|
||||
<SuggestFieldInput inputFieldName={`Objets`}
|
||||
inputFieldIcon={faCubes}
|
||||
searchTags={searchObjects}
|
||||
tagued={taguedObjects}
|
||||
handleTagSearch={(e) => handleObjectSearch(e.target.value)}
|
||||
handleAddTag={handleAddObject}
|
||||
handleRemoveTag={handleRemoveObject}
|
||||
filteredTags={filteredObjects}
|
||||
showTagSuggestions={showObjectSuggestions}
|
||||
setShowTagSuggestions={setShowObjectSuggestions}
|
||||
getTagLabel={getObjectLabel}
|
||||
/>
|
||||
|
||||
<SuggestFieldInput inputFieldName={`Éléments mondiaux`}
|
||||
inputFieldIcon={faGlobe}
|
||||
searchTags={searchWorldElements}
|
||||
tagued={taguedWorldElements}
|
||||
handleTagSearch={(e) => handleWorldElementSearch(e.target.value)}
|
||||
handleAddTag={handleAddWorldElement}
|
||||
handleRemoveTag={handleRemoveWorldElement}
|
||||
filteredTags={filteredWorldElements}
|
||||
showTagSuggestions={showWorldElementSuggestions}
|
||||
setShowTagSuggestions={setShowWorldElementSuggestions}
|
||||
getTagLabel={getWorldElementLabel}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="bg-secondary/20 rounded-xl p-5 shadow-inner border border-secondary/30">
|
||||
<InputField
|
||||
icon={faMagicWandSparkles}
|
||||
fieldName="Spécifications"
|
||||
input={
|
||||
<TexteAreaInput
|
||||
value={specifications}
|
||||
setValue={(e: ChangeEvent<HTMLTextAreaElement>) => setSpecifications(e.target.value)}
|
||||
placeholder="Spécifications particulières pour l'amélioration..."
|
||||
maxLength={600}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="p-5 border-t border-secondary/50 bg-secondary/30 backdrop-blur-sm shrink-0 shadow-inner">
|
||||
<div className="flex justify-center">
|
||||
<SubmitButtonWLoading
|
||||
callBackAction={handleQuillSenseRefined}
|
||||
isLoading={isRefining}
|
||||
text={t("draftCompanion.refine")}
|
||||
loadingText={t("draftCompanion.refining")}
|
||||
icon={faMagicWandSparkles}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{(showRefinedText || isRefining) && (
|
||||
<QSTextGeneratedPreview
|
||||
onClose={(): void => setShowRefinedText(false)}
|
||||
onRefresh={handleQuillSenseRefined}
|
||||
value={refinedText}
|
||||
onInsert={insertText}
|
||||
isGenerating={isRefining}
|
||||
onStop={handleStopRefining}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0 overflow-hidden font-['Lora']">
|
||||
<div
|
||||
className="flex items-center justify-between p-4 bg-secondary/30 backdrop-blur-sm border-b border-secondary/50 flex-shrink-0 font-['ADLaM_Display'] shadow-sm">
|
||||
<div className="mr-4 text-primary-light">
|
||||
<span>{t("draftCompanion.words")}: </span>
|
||||
<span className="text-text-primary">{draftWordCount}</span>
|
||||
</div>
|
||||
{
|
||||
hasAccess && chapter?.chapterContent.version === 3 && (
|
||||
<div className="flex gap-2">
|
||||
<SubmitButtonWLoading
|
||||
callBackAction={(): void => setShowEnhancer(true)}
|
||||
isLoading={isRefining}
|
||||
text={t("draftCompanion.refine")}
|
||||
loadingText={t("draftCompanion.refining")}
|
||||
icon={faFeather}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
<EditorContent
|
||||
className="w-full h-full tiptap-draft"
|
||||
editor={mainEditor}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
components/editor/NoBookHome.tsx
Normal file
26
components/editor/NoBookHome.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {faBookOpen} from "@fortawesome/free-solid-svg-icons";
|
||||
import React from "react";
|
||||
import {useTranslations} from "next-intl";
|
||||
|
||||
export default function NoBookHome() {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full p-8 text-center">
|
||||
<div
|
||||
className="max-w-md bg-tertiary/90 backdrop-blur-sm p-10 rounded-2xl shadow-2xl border border-secondary/50">
|
||||
<FontAwesomeIcon icon={faBookOpen} className={"text-primary w-20 h-20 mb-6 animate-pulse"}/>
|
||||
<h3 className="text-2xl font-['ADLaM_Display'] text-text-primary mb-4">{t("noBookHome.title")}</h3>
|
||||
<p className="text-muted mb-6 text-lg leading-relaxed">
|
||||
{t("noBookHome.description")}
|
||||
</p>
|
||||
<div
|
||||
className="flex items-center justify-center gap-3 text-sm text-muted bg-secondary/30 p-4 rounded-xl border border-secondary/40">
|
||||
<FontAwesomeIcon icon={faBookOpen} className="text-primary w-5 h-5"/>
|
||||
<span>{t("noBookHome.hint")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
33
components/editor/ScribeEditor.tsx
Normal file
33
components/editor/ScribeEditor.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React, {useContext, useState} from "react";
|
||||
import {ChapterContext} from "@/context/ChapterContext";
|
||||
import {BookContext} from "@/context/BookContext";
|
||||
import {SettingBookContext} from "@/context/SettingBookContext";
|
||||
import TextEditor from "./TextEditor";
|
||||
import BookList from "@/components/book/BookList";
|
||||
import BookSettingOption from "@/components/book/settings/BookSettingOption";
|
||||
import NoBookHome from "@/components/editor/NoBookHome";
|
||||
|
||||
export default function ScribeEditor() {
|
||||
const {chapter} = useContext(ChapterContext);
|
||||
const {book} = useContext(BookContext);
|
||||
|
||||
const [bookSettingId, setBookSettingId] = useState<string>('');
|
||||
|
||||
return (
|
||||
<SettingBookContext.Provider value={{bookSettingId, setBookSettingId}}>
|
||||
<div className="flex-1 bg-darkest-background">
|
||||
{
|
||||
chapter ? (
|
||||
<TextEditor/>
|
||||
) : book ? (
|
||||
<NoBookHome/>
|
||||
) : book === null ? (
|
||||
<BookList/>
|
||||
) : bookSettingId && (
|
||||
<BookSettingOption setting={bookSettingId}/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</SettingBookContext.Provider>
|
||||
);
|
||||
}
|
||||
516
components/editor/TextEditor.tsx
Normal file
516
components/editor/TextEditor.tsx
Normal file
@@ -0,0 +1,516 @@
|
||||
'use client'
|
||||
import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react';
|
||||
import {EditorContent} from '@tiptap/react';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faAlignCenter,
|
||||
faAlignLeft,
|
||||
faAlignRight,
|
||||
faBold,
|
||||
faCog,
|
||||
faFloppyDisk,
|
||||
faGhost,
|
||||
faHeading,
|
||||
faLayerGroup,
|
||||
faListOl,
|
||||
faListUl,
|
||||
faParagraph,
|
||||
faUnderline
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import {EditorContext} from "@/context/EditorContext";
|
||||
import {ChapterContext} from '@/context/ChapterContext';
|
||||
import System from '@/lib/models/System';
|
||||
import {AlertContext} from '@/context/AlertContext';
|
||||
import {SessionContext} from "@/context/SessionContext";
|
||||
import DraftCompanion from "@/components/editor/DraftCompanion";
|
||||
import GhostWriter from "@/components/ghostwriter/GhostWriter";
|
||||
import SubmitButtonWLoading from "@/components/form/SubmitButtonWLoading";
|
||||
import CollapsableButton from "@/components/CollapsableButton";
|
||||
import {IconDefinition} from "@fortawesome/fontawesome-svg-core";
|
||||
import UserEditorSettings, {EditorDisplaySettings} from "@/components/editor/UserEditorSetting";
|
||||
import {useTranslations} from "next-intl";
|
||||
import {LangContext, LangContextProps} from "@/context/LangContext";
|
||||
|
||||
interface ToolbarButton {
|
||||
action: () => void;
|
||||
icon: IconDefinition;
|
||||
isActive: boolean;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
interface EditorClasses {
|
||||
base: string;
|
||||
h1: string;
|
||||
h2: string;
|
||||
h3: string;
|
||||
container: string;
|
||||
theme: string;
|
||||
paragraph: string;
|
||||
lists: string;
|
||||
listItems: string;
|
||||
}
|
||||
|
||||
const DEFAULT_EDITOR_SETTINGS: EditorDisplaySettings = {
|
||||
zoomLevel: 3,
|
||||
indent: 30,
|
||||
lineHeight: 1.5,
|
||||
theme: 'sombre',
|
||||
fontFamily: 'lora',
|
||||
maxWidth: 768,
|
||||
focusMode: false
|
||||
};
|
||||
|
||||
const FONT_SIZE_CLASSES = {
|
||||
1: 'text-sm',
|
||||
2: 'text-base',
|
||||
3: 'text-lg',
|
||||
4: 'text-xl',
|
||||
5: 'text-2xl'
|
||||
} as const;
|
||||
|
||||
const H1_SIZE_CLASSES = {
|
||||
1: 'text-xl',
|
||||
2: 'text-2xl',
|
||||
3: 'text-3xl',
|
||||
4: 'text-4xl',
|
||||
5: 'text-5xl'
|
||||
} as const;
|
||||
|
||||
const H2_SIZE_CLASSES = {
|
||||
1: 'text-lg',
|
||||
2: 'text-xl',
|
||||
3: 'text-2xl',
|
||||
4: 'text-3xl',
|
||||
5: 'text-4xl'
|
||||
} as const;
|
||||
|
||||
const H3_SIZE_CLASSES = {
|
||||
1: 'text-base',
|
||||
2: 'text-lg',
|
||||
3: 'text-xl',
|
||||
4: 'text-2xl',
|
||||
5: 'text-3xl'
|
||||
} as const;
|
||||
|
||||
const FONT_FAMILY_CLASSES = {
|
||||
'lora': 'Lora',
|
||||
'serif': 'font-serif',
|
||||
'sans-serif': 'font-sans',
|
||||
'monospace': 'font-mono'
|
||||
} as const;
|
||||
|
||||
const LINE_HEIGHT_CLASSES = {
|
||||
1.2: 'leading-tight',
|
||||
1.5: 'leading-normal',
|
||||
1.75: 'leading-relaxed',
|
||||
2: 'leading-loose'
|
||||
} as const;
|
||||
|
||||
const MAX_WIDTH_CLASSES = {
|
||||
600: 'max-w-xl',
|
||||
650: 'max-w-2xl',
|
||||
700: 'max-w-3xl',
|
||||
750: 'max-w-4xl',
|
||||
800: 'max-w-5xl',
|
||||
850: 'max-w-6xl',
|
||||
900: 'max-w-7xl',
|
||||
950: 'max-w-full',
|
||||
1000: 'max-w-full',
|
||||
1050: 'max-w-full',
|
||||
1100: 'max-w-full',
|
||||
1150: 'max-w-full',
|
||||
1200: 'max-w-full'
|
||||
} as const;
|
||||
|
||||
function getClosestKey<T extends Record<number, any>>(value: number, obj: T): keyof T {
|
||||
const keys: number[] = Object.keys(obj).map(Number).sort((a: number, b: number): number => a - b);
|
||||
return keys.reduce((prev: number, curr: number): number =>
|
||||
Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev
|
||||
);
|
||||
}
|
||||
|
||||
export default function TextEditor() {
|
||||
const t = useTranslations();
|
||||
const {lang} = useContext<LangContextProps>(LangContext)
|
||||
const {editor} = useContext(EditorContext);
|
||||
const {chapter} = useContext(ChapterContext);
|
||||
const {errorMessage, successMessage} = useContext(AlertContext);
|
||||
const {session} = useContext(SessionContext);
|
||||
|
||||
const [mainTimer, setMainTimer] = useState<number>(0);
|
||||
const [showDraftCompanion, setShowDraftCompanion] = useState<boolean>(false);
|
||||
const [showGhostWriter, setShowGhostWriter] = useState<boolean>(false);
|
||||
const [showUserSettings, setShowUserSettings] = useState<boolean>(false);
|
||||
const [isSaving, setIsSaving] = useState<boolean>(false);
|
||||
const [editorSettings, setEditorSettings] = useState<EditorDisplaySettings>(DEFAULT_EDITOR_SETTINGS);
|
||||
const [editorClasses, setEditorClasses] = useState<EditorClasses>({
|
||||
base: 'text-lg font-serif leading-normal',
|
||||
h1: 'text-3xl font-bold',
|
||||
h2: 'text-2xl font-bold',
|
||||
h3: 'text-xl font-bold',
|
||||
container: 'max-w-3xl',
|
||||
theme: 'bg-tertiary/90 backdrop-blur-sm bg-opacity-25 text-text-primary',
|
||||
paragraph: 'indent-6',
|
||||
lists: 'pl-10',
|
||||
listItems: 'text-lg'
|
||||
});
|
||||
|
||||
const timerRef: React.RefObject<number | null> = useRef<number | null>(null);
|
||||
const timeoutRef: React.RefObject<number | null> = useRef<number | null>(null);
|
||||
|
||||
const updateEditorClasses: (settings: EditorDisplaySettings) => void = useCallback((settings: EditorDisplaySettings): void => {
|
||||
const fontSizeKey = settings.zoomLevel as keyof typeof FONT_SIZE_CLASSES;
|
||||
const h1SizeKey = settings.zoomLevel as keyof typeof H1_SIZE_CLASSES;
|
||||
const h2SizeKey = settings.zoomLevel as keyof typeof H2_SIZE_CLASSES;
|
||||
const h3SizeKey = settings.zoomLevel as keyof typeof H3_SIZE_CLASSES;
|
||||
const fontFamilyKey = settings.fontFamily as keyof typeof FONT_FAMILY_CLASSES;
|
||||
const lineHeightKey = settings.lineHeight as keyof typeof LINE_HEIGHT_CLASSES;
|
||||
const maxWidthKey: number = getClosestKey(settings.maxWidth, MAX_WIDTH_CLASSES);
|
||||
|
||||
const indentClass = `indent-${Math.round(settings.indent / 4)}`;
|
||||
|
||||
const baseClass = `${FONT_SIZE_CLASSES[fontSizeKey]} ${FONT_FAMILY_CLASSES[fontFamilyKey]} ${LINE_HEIGHT_CLASSES[lineHeightKey]}`;
|
||||
const h1Class = `${H1_SIZE_CLASSES[h1SizeKey]} font-bold ${FONT_FAMILY_CLASSES[fontFamilyKey]} ${LINE_HEIGHT_CLASSES[lineHeightKey]}`;
|
||||
const h2Class = `${H2_SIZE_CLASSES[h2SizeKey]} font-bold ${FONT_FAMILY_CLASSES[fontFamilyKey]} ${LINE_HEIGHT_CLASSES[lineHeightKey]}`;
|
||||
const h3Class = `${H3_SIZE_CLASSES[h3SizeKey]} font-bold ${FONT_FAMILY_CLASSES[fontFamilyKey]} ${LINE_HEIGHT_CLASSES[lineHeightKey]}`;
|
||||
const containerClass = MAX_WIDTH_CLASSES[maxWidthKey as keyof typeof MAX_WIDTH_CLASSES];
|
||||
const listsClass = `pl-${Math.round((settings.indent + 20) / 4)}`;
|
||||
|
||||
let themeClass: string = '';
|
||||
switch (settings.theme) {
|
||||
case 'clair':
|
||||
themeClass = 'bg-white text-black';
|
||||
break;
|
||||
case 'sépia':
|
||||
themeClass = 'text-amber-900';
|
||||
break;
|
||||
default:
|
||||
themeClass = 'bg-tertiary/90 backdrop-blur-sm bg-opacity-25 text-text-primary';
|
||||
}
|
||||
|
||||
setEditorClasses({
|
||||
base: baseClass,
|
||||
h1: h1Class,
|
||||
h2: h2Class,
|
||||
h3: h3Class,
|
||||
container: containerClass,
|
||||
theme: themeClass,
|
||||
paragraph: indentClass,
|
||||
lists: listsClass,
|
||||
listItems: baseClass
|
||||
});
|
||||
}, []);
|
||||
|
||||
const containerStyle = useMemo(() => {
|
||||
if (editorSettings.theme === 'sépia') {
|
||||
return {backgroundColor: '#f4f1e8'};
|
||||
}
|
||||
return {};
|
||||
}, [editorSettings.theme]);
|
||||
|
||||
const toolbarButtons: ToolbarButton[] = (() => {
|
||||
if (!editor) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
action: (): boolean => editor.chain().focus().setParagraph().run(),
|
||||
icon: faParagraph,
|
||||
isActive: editor.isActive('paragraph')
|
||||
},
|
||||
{
|
||||
action: (): boolean => editor.chain().focus().toggleBold().run(),
|
||||
icon: faBold,
|
||||
isActive: editor.isActive('bold')
|
||||
},
|
||||
{
|
||||
action: (): boolean => editor.chain().focus().toggleUnderline().run(),
|
||||
icon: faUnderline,
|
||||
isActive: editor.isActive('underline')
|
||||
},
|
||||
{
|
||||
action: (): boolean => editor.chain().focus().setTextAlign('left').run(),
|
||||
icon: faAlignLeft,
|
||||
isActive: editor.isActive({textAlign: 'left'})
|
||||
},
|
||||
{
|
||||
action: (): boolean => editor.chain().focus().setTextAlign('center').run(),
|
||||
icon: faAlignCenter,
|
||||
isActive: editor.isActive({textAlign: 'center'})
|
||||
},
|
||||
{
|
||||
action: (): boolean => editor.chain().focus().setTextAlign('right').run(),
|
||||
icon: faAlignRight,
|
||||
isActive: editor.isActive({textAlign: 'right'})
|
||||
},
|
||||
{
|
||||
action: (): boolean => editor.chain().focus().toggleBulletList().run(),
|
||||
icon: faListUl,
|
||||
isActive: editor.isActive('bulletList')
|
||||
},
|
||||
{
|
||||
action: (): boolean => editor.chain().focus().toggleOrderedList().run(),
|
||||
icon: faListOl,
|
||||
isActive: editor.isActive('orderedList')
|
||||
},
|
||||
{
|
||||
action: (): boolean => editor.chain().focus().toggleHeading({level: 1}).run(),
|
||||
icon: faHeading,
|
||||
isActive: editor.isActive('heading', {level: 1}),
|
||||
label: '1'
|
||||
},
|
||||
{
|
||||
action: (): boolean => editor.chain().focus().toggleHeading({level: 2}).run(),
|
||||
icon: faHeading,
|
||||
isActive: editor.isActive('heading', {level: 2}),
|
||||
label: '2'
|
||||
},
|
||||
{
|
||||
action: (): boolean => editor.chain().focus().toggleHeading({level: 3}).run(),
|
||||
icon: faHeading,
|
||||
isActive: editor.isActive('heading', {level: 3}),
|
||||
label: '3'
|
||||
},
|
||||
];
|
||||
})();
|
||||
|
||||
const saveContent: () => Promise<void> = useCallback(async (): Promise<void> => {
|
||||
if (!editor || !chapter) return;
|
||||
|
||||
setIsSaving(true);
|
||||
const content = editor.state.doc.toJSON();
|
||||
const chapterId: string = chapter.chapterId || '';
|
||||
const version: number = chapter.chapterContent.version || 0;
|
||||
|
||||
try {
|
||||
const response: boolean = await System.authPostToServer<boolean>(`chapter/content`, {
|
||||
chapterId,
|
||||
version,
|
||||
content,
|
||||
totalWordCount: editor.getText().length,
|
||||
currentTime: mainTimer
|
||||
}, session?.accessToken ?? '');
|
||||
if (!response) {
|
||||
errorMessage(t('editor.error.savedFailed'));
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
setMainTimer(0);
|
||||
successMessage(t('editor.success.saved'));
|
||||
setIsSaving(false);
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
} else {
|
||||
errorMessage(t('editor.error.unknownError'));
|
||||
}
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [editor, chapter, mainTimer, session?.accessToken, successMessage, errorMessage]);
|
||||
|
||||
const handleShowDraftCompanion: () => void = useCallback((): void => {
|
||||
setShowDraftCompanion((prev: boolean): boolean => !prev);
|
||||
setShowGhostWriter(false);
|
||||
setShowUserSettings(false);
|
||||
}, []);
|
||||
|
||||
const handleShowGhostWriter: () => void = useCallback((): void => {
|
||||
if (chapter?.chapterContent.version === 2) {
|
||||
setShowGhostWriter((prev: boolean): boolean => !prev);
|
||||
setShowDraftCompanion(false);
|
||||
setShowUserSettings(false);
|
||||
}
|
||||
}, [chapter?.chapterContent.version]);
|
||||
|
||||
const handleShowUserSettings: () => void = useCallback((): void => {
|
||||
setShowUserSettings((prev: boolean): boolean => !prev);
|
||||
setShowDraftCompanion(false);
|
||||
setShowGhostWriter(false);
|
||||
}, []);
|
||||
|
||||
useEffect((): void => {
|
||||
if (!editor) return;
|
||||
|
||||
const editorElement: HTMLElement = editor.view.dom;
|
||||
if (editorElement) {
|
||||
const indentClasses: string[] = Array.from({length: 21}, (_, i) => `indent-${i}`);
|
||||
editorElement.classList.remove(...indentClasses);
|
||||
|
||||
if (editorClasses.paragraph) {
|
||||
editorElement.classList.add(editorClasses.paragraph);
|
||||
}
|
||||
}
|
||||
}, [editor, editorClasses.paragraph]);
|
||||
|
||||
useEffect((): void => {
|
||||
updateEditorClasses(editorSettings);
|
||||
}, [editorSettings, updateEditorClasses]);
|
||||
|
||||
useEffect((): () => void => {
|
||||
function startTimer(): void {
|
||||
if (timerRef.current === null) {
|
||||
timerRef.current = window.setInterval(() => {
|
||||
setMainTimer(prevTimer => prevTimer + 1);
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
function stopTimer(): void {
|
||||
if (timerRef.current !== null) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
function resetTimeout(): void {
|
||||
if (timeoutRef.current !== null) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
timeoutRef.current = window.setTimeout(stopTimer, 5000);
|
||||
}
|
||||
|
||||
function handleKeyDown(): void {
|
||||
startTimer();
|
||||
resetTimeout();
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown, {passive: true});
|
||||
|
||||
return (): void => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
if (timerRef.current !== null) {
|
||||
clearInterval(timerRef.current);
|
||||
}
|
||||
if (timeoutRef.current !== null) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect((): () => void => {
|
||||
document.addEventListener('keydown', handleKeyDown, {passive: false});
|
||||
return (): void => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [saveContent]);
|
||||
|
||||
useEffect((): void => {
|
||||
if (!editor) return;
|
||||
if (chapter?.chapterContent.content) {
|
||||
try {
|
||||
const parsedContent = JSON.parse(chapter.chapterContent.content);
|
||||
editor.commands.setContent(parsedContent);
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du parsing du contenu:', error);
|
||||
editor.commands.setContent({
|
||||
type: "doc",
|
||||
content: [{type: "paragraph", content: []}]
|
||||
});
|
||||
}
|
||||
} else {
|
||||
editor.commands.setContent({
|
||||
type: "doc",
|
||||
content: [{type: "paragraph", content: []}]
|
||||
});
|
||||
}
|
||||
|
||||
if (chapter?.chapterContent.version !== 2) {
|
||||
setShowGhostWriter(false);
|
||||
}
|
||||
}, [editor, chapter?.chapterContent.content, chapter?.chapterContent.version]);
|
||||
|
||||
async function handleKeyDown(event: KeyboardEvent): Promise<void> {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
|
||||
event.preventDefault();
|
||||
await saveContent();
|
||||
}
|
||||
}
|
||||
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 w-full h-full">
|
||||
<div
|
||||
className={`flex justify-between gap-3 border-b border-secondary/30 px-4 py-3 bg-gradient-to-b from-dark-background/80 to-dark-background/50 backdrop-blur-sm transition-opacity duration-300 shadow-md ${editorSettings.focusMode ? 'opacity-70 hover:opacity-100' : ''}`}>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{toolbarButtons.map((button: ToolbarButton, index: number) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={button.action}
|
||||
className={`group flex items-center px-3 py-2 rounded-lg transition-all duration-200 ${button.isActive ? 'bg-primary text-text-primary shadow-md shadow-primary/30 scale-105' : 'text-muted hover:text-text-primary hover:bg-secondary/50 hover:shadow-sm hover:scale-105'}`}
|
||||
>
|
||||
<FontAwesomeIcon icon={button.icon}
|
||||
className={'w-4 h-4 transition-transform duration-200 group-hover:scale-110'}/>
|
||||
{
|
||||
button.label &&
|
||||
<span className="ml-2 text-sm font-medium">
|
||||
{t(`textEditor.toolbar.${button.label}`)}
|
||||
</span>
|
||||
}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CollapsableButton
|
||||
showCollapsable={showUserSettings}
|
||||
text={t("textEditor.preferences")}
|
||||
onClick={handleShowUserSettings}
|
||||
icon={faCog}
|
||||
/>
|
||||
{chapter?.chapterContent.version === 2 && (
|
||||
<CollapsableButton
|
||||
showCollapsable={showGhostWriter}
|
||||
text={t("textEditor.ghostWriter")}
|
||||
onClick={handleShowGhostWriter}
|
||||
icon={faGhost}
|
||||
/>
|
||||
)}
|
||||
{chapter?.chapterContent.version && chapter.chapterContent.version > 2 && (
|
||||
<CollapsableButton
|
||||
showCollapsable={showDraftCompanion}
|
||||
text={t("textEditor.draftCompanion")}
|
||||
onClick={handleShowDraftCompanion}
|
||||
icon={faLayerGroup}
|
||||
/>
|
||||
)}
|
||||
<SubmitButtonWLoading
|
||||
callBackAction={saveContent}
|
||||
isLoading={isSaving}
|
||||
text={t("textEditor.save")}
|
||||
loadingText={t("textEditor.saving")}
|
||||
icon={faFloppyDisk}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between w-full h-full overflow-auto">
|
||||
<div
|
||||
className={`flex-1 p-8 overflow-auto transition-all duration-300 ${editorSettings.focusMode ? 'bg-black/20' : ''}`}>
|
||||
<div
|
||||
className={`editor-container mx-auto p-6 rounded-2xl shadow-2xl min-h-[80%] border border-secondary/50 ${editorClasses.container} ${editorClasses.theme} relative`}
|
||||
style={containerStyle}>
|
||||
<div
|
||||
className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-primary/30 to-transparent"></div>
|
||||
<div
|
||||
className="absolute bottom-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-primary/30 to-transparent"></div>
|
||||
<EditorContent className={`w-full h-full ${editorClasses.base} editor-content`}
|
||||
editor={editor}/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(showDraftCompanion || showGhostWriter || showUserSettings) && (
|
||||
<div
|
||||
className={`w-4/12 transition-opacity duration-300 ${editorSettings.focusMode ? 'opacity-50 hover:opacity-100' : ''}`}>
|
||||
{showDraftCompanion && <DraftCompanion/>}
|
||||
{showGhostWriter && <GhostWriter/>}
|
||||
{showUserSettings && (
|
||||
<UserEditorSettings
|
||||
settings={editorSettings}
|
||||
onSettingsChange={setEditorSettings}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
240
components/editor/UserEditorSetting.tsx
Normal file
240
components/editor/UserEditorSetting.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
'use client'
|
||||
import React, {ChangeEvent, useCallback, useEffect, useMemo} from 'react';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {faEye, faFont, faIndent, faPalette, faTextHeight, faTextWidth} from '@fortawesome/free-solid-svg-icons';
|
||||
import {useTranslations} from "next-intl";
|
||||
import SelectBox from "@/components/form/SelectBox";
|
||||
|
||||
interface UserEditorSettingsProps {
|
||||
settings: EditorDisplaySettings;
|
||||
onSettingsChange: (settings: EditorDisplaySettings) => void;
|
||||
}
|
||||
|
||||
export interface EditorDisplaySettings {
|
||||
zoomLevel: number;
|
||||
indent: number;
|
||||
lineHeight: number;
|
||||
theme: 'clair' | 'sombre' | 'sépia';
|
||||
fontFamily: 'lora' | 'serif' | 'sans-serif' | 'monospace';
|
||||
maxWidth: number;
|
||||
focusMode: boolean;
|
||||
}
|
||||
|
||||
const ZOOM_LABELS = ['Très petit', 'Petit', 'Normal', 'Grand', 'Très grand'] as const;
|
||||
const FONT_SIZES = [14, 16, 18, 20, 22] as const;
|
||||
const THEMES = ['clair', 'sombre', 'sépia'] as const;
|
||||
|
||||
const DEFAULT_SETTINGS: EditorDisplaySettings = {
|
||||
zoomLevel: 3,
|
||||
indent: 30,
|
||||
lineHeight: 1.5,
|
||||
theme: 'sombre',
|
||||
fontFamily: 'lora',
|
||||
maxWidth: 768,
|
||||
focusMode: false
|
||||
};
|
||||
|
||||
export default function UserEditorSettings({settings, onSettingsChange}: UserEditorSettingsProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
const handleSettingChange = useCallback(<K extends keyof EditorDisplaySettings>(
|
||||
key: K,
|
||||
value: EditorDisplaySettings[K]
|
||||
) => {
|
||||
onSettingsChange({...settings, [key]: value});
|
||||
}, [settings, onSettingsChange]);
|
||||
|
||||
const resetToDefaults = useCallback(() => {
|
||||
onSettingsChange(DEFAULT_SETTINGS);
|
||||
}, [onSettingsChange]);
|
||||
|
||||
const zoomOptions = useMemo(() =>
|
||||
ZOOM_LABELS.map((label, index) => ({
|
||||
value: (index + 1).toString(),
|
||||
label: `${t(`userEditorSettings.zoom.${label}`)} (${FONT_SIZES[index]}px)`
|
||||
}))
|
||||
, [t]);
|
||||
|
||||
const themeButtons = useMemo(() =>
|
||||
THEMES.map(theme => ({
|
||||
key: theme,
|
||||
isActive: settings.theme === theme,
|
||||
className: `p-2.5 rounded-xl border capitalize transition-all duration-200 font-medium ${
|
||||
settings.theme === theme
|
||||
? 'bg-primary text-text-primary border-primary shadow-md scale-105'
|
||||
: 'bg-secondary/50 border-secondary/50 text-muted hover:text-text-primary hover:border-secondary hover:bg-secondary hover:scale-102'
|
||||
}`
|
||||
}))
|
||||
, [settings.theme]);
|
||||
|
||||
useEffect((): void => {
|
||||
try {
|
||||
const savedSettings: string | null = localStorage.getItem('userEditorSettings');
|
||||
if (savedSettings) {
|
||||
const parsed = JSON.parse(savedSettings);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
onSettingsChange({...DEFAULT_SETTINGS, ...parsed});
|
||||
}
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
onSettingsChange(DEFAULT_SETTINGS);
|
||||
}
|
||||
}, [onSettingsChange]);
|
||||
|
||||
useEffect((): () => void => {
|
||||
const timeoutId = setTimeout((): void => {
|
||||
try {
|
||||
localStorage.setItem('userEditorSettings', JSON.stringify(settings));
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la sauvegarde des settings:', error);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return (): void => clearTimeout(timeoutId);
|
||||
}, [settings]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="p-5 bg-secondary/30 backdrop-blur-sm border-l border-secondary/50 h-full overflow-y-auto shadow-inner">
|
||||
<div className="flex items-center gap-3 mb-8 pb-4 border-b border-secondary/50">
|
||||
<FontAwesomeIcon icon={faEye} className="text-primary w-6 h-6"/>
|
||||
<h3 className="text-xl font-['ADLaM_Display'] text-text-primary">{t("userEditorSettings.displayPreferences")}</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="flex items-center gap-2 mb-2 text-text-primary">
|
||||
<FontAwesomeIcon icon={faTextHeight} className="text-muted w-5 h-5"/>
|
||||
{t("userEditorSettings.textSize")}
|
||||
</label>
|
||||
<SelectBox
|
||||
defaultValue={settings.zoomLevel.toString()}
|
||||
onChangeCallBack={(e: ChangeEvent<HTMLSelectElement>) => {
|
||||
handleSettingChange('zoomLevel', Number(e.target.value))
|
||||
}}
|
||||
data={zoomOptions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2 mb-2 text-text-primary">
|
||||
<FontAwesomeIcon icon={faIndent} className="text-muted w-5 h-5"/>
|
||||
{t("userEditorSettings.indent")}
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={50}
|
||||
step={5}
|
||||
value={settings.indent}
|
||||
onChange={(e) => handleSettingChange('indent', Number(e.target.value))}
|
||||
className="w-full accent-primary"
|
||||
/>
|
||||
<div className="flex justify-between text-sm text-muted">
|
||||
<span>{t("userEditorSettings.indentNone")}</span>
|
||||
<span className="text-text-primary font-medium">{settings.indent}px</span>
|
||||
<span>{t("userEditorSettings.indentMax")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2 mb-2 text-text-primary">
|
||||
<FontAwesomeIcon icon={faTextWidth} className="text-muted w-5 h-5"/>
|
||||
{t("userEditorSettings.lineHeight")}
|
||||
</label>
|
||||
<SelectBox
|
||||
defaultValue={settings.lineHeight.toString()}
|
||||
onChangeCallBack={(e: ChangeEvent<HTMLSelectElement>) => handleSettingChange('lineHeight', Number(e.target.value))}
|
||||
data={[
|
||||
{value: "1.2", label: t("userEditorSettings.lineHeightCompact")},
|
||||
{value: "1.5", label: t("userEditorSettings.lineHeightNormal")},
|
||||
{value: "1.75", label: t("userEditorSettings.lineHeightSpaced")},
|
||||
{value: "2", label: t("userEditorSettings.lineHeightDouble")}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2 mb-2 text-text-primary">
|
||||
<FontAwesomeIcon icon={faFont} className="text-muted w-5 h-5"/>
|
||||
{t("userEditorSettings.fontFamily")}
|
||||
</label>
|
||||
<SelectBox
|
||||
defaultValue={settings.fontFamily}
|
||||
onChangeCallBack={(e: ChangeEvent<HTMLSelectElement>) => handleSettingChange('fontFamily', e.target.value as EditorDisplaySettings['fontFamily'])}
|
||||
data={[
|
||||
{value: "lora", label: t("userEditorSettings.fontLora")},
|
||||
{value: "serif", label: t("userEditorSettings.fontSerif")},
|
||||
{value: "sans-serif", label: t("userEditorSettings.fontSansSerif")},
|
||||
{value: "monospace", label: t("userEditorSettings.fontMonospace")}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2 mb-2 text-text-primary">
|
||||
<FontAwesomeIcon icon={faTextWidth} className="text-muted w-5 h-5"/>
|
||||
{t("userEditorSettings.maxWidth")}
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="range"
|
||||
min={600}
|
||||
max={1200}
|
||||
step={50}
|
||||
value={settings.maxWidth}
|
||||
onChange={(e) => handleSettingChange('maxWidth', Number(e.target.value))}
|
||||
className="w-full accent-primary"
|
||||
/>
|
||||
<div className="flex justify-between text-sm text-muted">
|
||||
<span>{t("userEditorSettings.maxWidthNarrow")}</span>
|
||||
<span className="text-text-primary font-medium">{settings.maxWidth}px</span>
|
||||
<span>{t("userEditorSettings.maxWidthWide")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2 mb-2 text-text-primary">
|
||||
<FontAwesomeIcon icon={faPalette} className="text-muted w-5 h-5"/>
|
||||
{t("userEditorSettings.theme")}
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{themeButtons.map((themeBtn) => (
|
||||
<button
|
||||
key={themeBtn.key}
|
||||
onClick={() => handleSettingChange('theme', themeBtn.key)}
|
||||
className={themeBtn.className}
|
||||
>
|
||||
{t(`userEditorSettings.themeOption.${themeBtn.key}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.focusMode}
|
||||
onChange={(e) => handleSettingChange('focusMode', e.target.checked)}
|
||||
className="w-4 h-4 accent-primary"
|
||||
/>
|
||||
<span className="text-text-primary">{t("userEditorSettings.focusMode")}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="pt-6 border-t border-secondary/50">
|
||||
<button
|
||||
onClick={resetToDefaults}
|
||||
className="w-full py-2.5 bg-secondary/50 border border-secondary/50 rounded-xl text-muted hover:text-text-primary hover:border-secondary hover:bg-secondary transition-all duration-200 hover:scale-105 shadow-sm hover:shadow-md font-medium"
|
||||
>
|
||||
{t("userEditorSettings.reset")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
components/form/AddActionButton.tsx
Normal file
20
components/form/AddActionButton.tsx
Normal file
@@ -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<void>;
|
||||
}
|
||||
|
||||
export default function AddActionButton(
|
||||
{
|
||||
callBackAction
|
||||
}: AddActionButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={`group p-2 rounded-lg text-muted hover:text-primary hover:bg-primary/10 transition-colors`}
|
||||
onClick={callBackAction}>
|
||||
<FontAwesomeIcon icon={faPlus} className={'w-5 h-5 transition-transform group-hover:rotate-90'}/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
21
components/form/CancelButton.tsx
Normal file
21
components/form/CancelButton.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from "react";
|
||||
|
||||
interface CancelButtonProps {
|
||||
callBackFunction: () => void;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export default function CancelButton(
|
||||
{
|
||||
callBackFunction,
|
||||
text = "Annuler"
|
||||
}: CancelButtonProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={callBackFunction}
|
||||
className="px-5 py-2.5 rounded-lg bg-secondary/50 text-text-primary border border-secondary/50 hover:bg-secondary hover:border-secondary hover:shadow-md transition-all duration-200 hover:scale-105 font-medium"
|
||||
>
|
||||
{text}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
53
components/form/CheckBox.tsx
Normal file
53
components/form/CheckBox.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React, {Dispatch, SetStateAction} from "react";
|
||||
|
||||
interface CheckBoxProps {
|
||||
isChecked: boolean;
|
||||
setIsChecked: Dispatch<SetStateAction<boolean>>;
|
||||
label: string;
|
||||
description: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export default function CheckBox(
|
||||
{
|
||||
isChecked,
|
||||
setIsChecked,
|
||||
label,
|
||||
description,
|
||||
id,
|
||||
}: CheckBoxProps) {
|
||||
return (
|
||||
<div className="flex items-center group">
|
||||
<div className="relative inline-block w-12 mr-3 align-middle select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={id}
|
||||
checked={isChecked}
|
||||
onChange={() => setIsChecked(!isChecked)}
|
||||
className="hidden"
|
||||
/>
|
||||
<label htmlFor={id}
|
||||
className={`block overflow-hidden h-6 rounded-full cursor-pointer transition-all duration-200 border-2 shadow-sm hover:shadow-md ${
|
||||
isChecked
|
||||
? 'bg-primary border-primary shadow-primary/30'
|
||||
: 'bg-secondary/50 border-secondary hover:bg-secondary'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute block h-5 w-5 rounded-full bg-white shadow-md transform transition-all duration-200 top-0.5 ${
|
||||
isChecked ? 'right-0.5 scale-110' : 'left-0.5'
|
||||
}`}/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label htmlFor={id}
|
||||
className="text-text-primary text-sm font-medium cursor-pointer group-hover:text-primary transition-colors">
|
||||
{label}
|
||||
</label>
|
||||
<p className="text-text-secondary text-xs mt-0.5 hidden md:block">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
40
components/form/ConfirmButton.tsx
Normal file
40
components/form/ConfirmButton.tsx
Normal file
@@ -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 (
|
||||
<button
|
||||
onClick={callBackFunction}
|
||||
className={`rounded-lg ${applyType} px-5 py-2.5 text-white font-semibold shadow-md hover:shadow-lg transition-all duration-200 hover:scale-105 focus:outline-none focus:ring-4 focus:ring-primary/20`}
|
||||
>
|
||||
{text}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
21
components/form/DatePicker.tsx
Normal file
21
components/form/DatePicker.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React, {ChangeEvent} from "react";
|
||||
|
||||
interface DatePickerProps {
|
||||
date: string;
|
||||
setDate: (e: ChangeEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
export default function DatePicker(
|
||||
{
|
||||
setDate,
|
||||
date
|
||||
}: DatePickerProps) {
|
||||
return (
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={setDate}
|
||||
className="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 outline-none transition-all duration-200"
|
||||
/>
|
||||
)
|
||||
}
|
||||
71
components/form/InlineAddInput.tsx
Normal file
71
components/form/InlineAddInput.tsx
Normal file
@@ -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<void>;
|
||||
showNumericalInput?: boolean;
|
||||
}
|
||||
|
||||
export default function InlineAddInput(
|
||||
{
|
||||
value,
|
||||
setValue,
|
||||
numericalValue,
|
||||
setNumericalValue,
|
||||
placeholder,
|
||||
onAdd,
|
||||
showNumericalInput = false
|
||||
}: InlineAddInputProps) {
|
||||
const [isAdding, setIsAdding] = useState<boolean>(false);
|
||||
const listItemRef = useRef<HTMLLIElement>(null);
|
||||
|
||||
async function handleAdd(): Promise<void> {
|
||||
await onAdd();
|
||||
setIsAdding(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
ref={listItemRef}
|
||||
onBlur={(e: React.FocusEvent<HTMLLIElement, Element>): 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 && (
|
||||
<input
|
||||
className={`bg-secondary/50 text-primary text-sm px-1.5 py-0.5 rounded border border-secondary/50 transition-all duration-200 !outline-none !ring-0 !shadow-none focus-visible:!outline-none focus-visible:!ring-0 focus-visible:!shadow-none ${isAdding ? 'w-10 opacity-100' : 'w-0 opacity-0 px-0 border-0'}`}
|
||||
type="number"
|
||||
value={numericalValue}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setNumericalValue(parseInt(e.target.value))}
|
||||
tabIndex={isAdding ? 0 : -1}
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
onFocus={(): void => setIsAdding(true)}
|
||||
onKeyUp={async (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
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<HTMLInputElement>) => setValue(e.target.value)}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
{isAdding && (
|
||||
<div className="absolute right-1 opacity-100">
|
||||
<AddActionButton callBackAction={handleAdd}/>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
92
components/form/InputField.tsx
Normal file
92
components/form/InputField.tsx
Normal file
@@ -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<void>
|
||||
removeButtonCallBack?: () => Promise<void>
|
||||
isAddButtonDisabled?: boolean
|
||||
action?: () => Promise<void>
|
||||
actionLabel?: string
|
||||
actionIcon?: IconDefinition
|
||||
hint?: string,
|
||||
}
|
||||
|
||||
export default function InputField(
|
||||
{
|
||||
fieldName,
|
||||
icon,
|
||||
input,
|
||||
addButtonCallBack,
|
||||
removeButtonCallBack,
|
||||
isAddButtonDisabled,
|
||||
action,
|
||||
actionLabel,
|
||||
actionIcon,
|
||||
hint
|
||||
}: InputFieldProps) {
|
||||
return (
|
||||
<div className={'flex flex-col'}>
|
||||
<div className={'flex justify-between items-center mb-2 lg:mb-3 flex-wrap gap-2'}>
|
||||
{
|
||||
fieldName && (
|
||||
<h3 className="text-text-primary text-xl font-[ADLaM Display] font-medium mb-2 flex items-center gap-2">
|
||||
{
|
||||
icon && <FontAwesomeIcon icon={icon} className="text-primary w-5 h-5"/>
|
||||
}
|
||||
{fieldName}
|
||||
</h3>
|
||||
)
|
||||
}
|
||||
{
|
||||
action && (
|
||||
<button
|
||||
onClick={action}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-secondary/50 rounded-lg text-primary hover:bg-secondary hover:shadow-md hover:scale-105 transition-all duration-200 border border-secondary/50 font-medium"
|
||||
>
|
||||
{
|
||||
actionIcon && <FontAwesomeIcon icon={actionIcon} className={'w-3.5 h-3.5'}/>
|
||||
}
|
||||
{
|
||||
actionLabel && <span>{actionLabel}</span>
|
||||
}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
{hint && (
|
||||
<span
|
||||
className="text-xs text-muted bg-secondary/30 px-3 py-1.5 rounded-lg border border-secondary/30">
|
||||
{hint}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-between items-center gap-2">
|
||||
{input}
|
||||
{
|
||||
addButtonCallBack && (
|
||||
<button
|
||||
className="bg-primary text-text-primary w-9 h-9 rounded-full flex items-center justify-center disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 hover:bg-primary-dark hover:shadow-lg hover:scale-110 shadow-md"
|
||||
onClick={addButtonCallBack}
|
||||
disabled={isAddButtonDisabled}>
|
||||
<FontAwesomeIcon icon={faPlus} className="w-4 h-4"/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
{
|
||||
removeButtonCallBack && (
|
||||
<button
|
||||
className="bg-error/90 hover:bg-error text-text-primary w-9 h-9 rounded-full flex items-center justify-center transition-all duration-200 hover:shadow-lg hover:scale-110 shadow-md"
|
||||
onClick={removeButtonCallBack}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} className={'w-4 h-4'}/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
44
components/form/NumberInput.tsx
Normal file
44
components/form/NumberInput.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React, {ChangeEvent, Dispatch} from "react";
|
||||
|
||||
interface NumberInputProps {
|
||||
value: number;
|
||||
setValue: Dispatch<React.SetStateAction<number>>;
|
||||
placeholder?: string;
|
||||
readOnly?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function NumberInput(
|
||||
{
|
||||
value,
|
||||
setValue,
|
||||
placeholder,
|
||||
readOnly = false,
|
||||
disabled = false
|
||||
}: NumberInputProps
|
||||
) {
|
||||
function handleChange(e: ChangeEvent<HTMLInputElement>) {
|
||||
const newValue: number = parseInt(e.target.value);
|
||||
if (!isNaN(newValue)) {
|
||||
setValue(newValue);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
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
|
||||
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
|
||||
${readOnly ? 'cursor-default' : ''}`}
|
||||
placeholder={placeholder}
|
||||
readOnly={readOnly}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)
|
||||
}
|
||||
62
components/form/RadioBox.tsx
Normal file
62
components/form/RadioBox.tsx
Normal file
@@ -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<SetStateAction<number>>;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export default function RadioBox(
|
||||
{
|
||||
selected,
|
||||
setSelected,
|
||||
name
|
||||
}: RadioBoxProps) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{storyStates.map((option: RadioBoxValue) => (
|
||||
<div key={option.value} className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
id={option.label}
|
||||
name={name}
|
||||
value={option.value}
|
||||
checked={selected === option.value}
|
||||
onChange={() => setSelected(option.value)}
|
||||
className="hidden"
|
||||
/>
|
||||
<label
|
||||
htmlFor={option.label}
|
||||
className={`px-3 lg:px-4 py-2 lg:py-2.5 text-xs lg:text-sm font-medium rounded-xl cursor-pointer transition-all duration-200 flex items-center gap-2 ${
|
||||
selected === option.value
|
||||
? 'bg-primary text-text-primary shadow-lg shadow-primary/30 scale-105 border border-primary-dark'
|
||||
: 'bg-secondary/50 text-muted hover:bg-secondary hover:text-text-primary hover:scale-105 border border-secondary/50 hover:border-secondary'
|
||||
}`}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={
|
||||
[
|
||||
faPenNib,
|
||||
faKeyboard,
|
||||
faPalette,
|
||||
faBookOpen,
|
||||
faMagicWandSparkles
|
||||
][option.value]
|
||||
}
|
||||
className={selected === option.value ? "text-text-primary w-5 h-5" : "text-muted w-5 h-5"}
|
||||
/>
|
||||
{option.label}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
51
components/form/RadioGroup.tsx
Normal file
51
components/form/RadioGroup.tsx
Normal file
@@ -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 (
|
||||
<div className={`flex flex-wrap gap-3 ${className}`}>
|
||||
{options.map((option: RadioOption) => (
|
||||
<div key={option.value} className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
id={`${name}-${option.value}`}
|
||||
name={name}
|
||||
value={option.value}
|
||||
checked={value === option.value}
|
||||
onChange={() => onChange(option.value)}
|
||||
className="hidden"
|
||||
/>
|
||||
<label
|
||||
htmlFor={`${name}-${option.value}`}
|
||||
className={`px-4 py-2 rounded-lg cursor-pointer transition-all duration-200 text-sm font-medium border ${
|
||||
value === option.value
|
||||
? 'bg-primary/20 text-primary border-primary/40 shadow-md'
|
||||
: 'bg-secondary/30 text-text-primary border-secondary/50 hover:bg-secondary hover:border-secondary hover:scale-105'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
components/form/SearchInputWithSelect.tsx
Normal file
62
components/form/SearchInputWithSelect.tsx
Normal file
@@ -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<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
export default function SearchInputWithSelect(
|
||||
{
|
||||
selectValue,
|
||||
setSelectValue,
|
||||
selectOptions,
|
||||
inputValue,
|
||||
setInputValue,
|
||||
inputPlaceholder,
|
||||
searchIcon,
|
||||
onSearch,
|
||||
onKeyDown
|
||||
}: SearchInputWithSelectProps) {
|
||||
return (
|
||||
<div className="flex items-center space-x-3">
|
||||
<select
|
||||
value={selectValue}
|
||||
onChange={(e: ChangeEvent<HTMLSelectElement>) => setSelectValue(e.target.value)}
|
||||
className="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 outline-none transition-all duration-200 font-medium"
|
||||
>
|
||||
{selectOptions.map((option: SelectBoxProps) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div className="flex-1 relative">
|
||||
<input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => 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}
|
||||
/>
|
||||
<button
|
||||
onClick={onSearch}
|
||||
className="absolute right-0 top-0 h-full px-4 text-primary hover:text-primary-light hover:scale-110 transition-all duration-200"
|
||||
>
|
||||
<FontAwesomeIcon icon={searchIcon} className="w-5 h-5"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
components/form/SelectBox.tsx
Normal file
43
components/form/SelectBox.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React, {ChangeEvent} from "react";
|
||||
import {SelectBoxProps} from "@/shared/interface";
|
||||
|
||||
export interface SelectBoxFormProps {
|
||||
onChangeCallBack: (event: ChangeEvent<HTMLSelectElement>) => void,
|
||||
data: SelectBoxProps[],
|
||||
defaultValue: string | null | undefined,
|
||||
placeholder?: string,
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export default function SelectBox(
|
||||
{
|
||||
onChangeCallBack,
|
||||
data,
|
||||
defaultValue,
|
||||
placeholder,
|
||||
disabled
|
||||
}: SelectBoxFormProps) {
|
||||
return (
|
||||
<select
|
||||
onChange={onChangeCallBack}
|
||||
disabled={disabled}
|
||||
key={defaultValue || 'placeholder'}
|
||||
defaultValue={defaultValue || '0'}
|
||||
className={`w-full text-text-primary bg-secondary/50 hover:bg-secondary 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:border-secondary
|
||||
outline-none transition-all duration-200 cursor-pointer
|
||||
${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
{placeholder && <option value={'0'}>{placeholder}</option>}
|
||||
{
|
||||
data.map((item: SelectBoxProps) => (
|
||||
<option key={item.value} value={item.value} className="bg-tertiary text-text-primary">
|
||||
{item.label}
|
||||
</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
55
components/form/SubmitButtonWLoading.tsx
Normal file
55
components/form/SubmitButtonWLoading.tsx
Normal file
@@ -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> | void;
|
||||
isLoading: boolean;
|
||||
text: string;
|
||||
loadingText: string;
|
||||
icon?: IconDefinition;
|
||||
}
|
||||
|
||||
export default function SubmitButtonWLoading(
|
||||
{
|
||||
callBackAction,
|
||||
isLoading,
|
||||
icon,
|
||||
text,
|
||||
loadingText
|
||||
}: SubmitButtonWLoadingProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={callBackAction}
|
||||
disabled={isLoading}
|
||||
className={`group py-2.5 px-5 rounded-lg font-semibold transition-all flex items-center justify-center gap-2 relative overflow-hidden ${
|
||||
isLoading
|
||||
? 'bg-secondary cursor-not-allowed opacity-75'
|
||||
: 'bg-secondary/80 hover:bg-secondary shadow-md hover:shadow-lg hover:shadow-primary/20 hover:scale-105 border border-secondary/50 hover:border-primary/30'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`flex items-center gap-2 transition-all duration-200 ${isLoading ? 'opacity-0' : 'opacity-100'} text-primary`}>
|
||||
{
|
||||
icon &&
|
||||
<FontAwesomeIcon icon={icon} className={'w-4 h-4 transition-transform group-hover:scale-110'}/>
|
||||
}
|
||||
<span className="text-sm">{text}</span>
|
||||
</span>
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-secondary/50 backdrop-blur-sm">
|
||||
<FontAwesomeIcon icon={faSpinner} className="w-4 h-4 text-primary animate-spin"/>
|
||||
<span className="ml-3 text-primary text-sm font-medium">
|
||||
<span className="hidden sm:inline">
|
||||
{loadingText}
|
||||
</span>
|
||||
<span className="sm:hidden">
|
||||
{loadingText}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
79
components/form/SuggestFieldInput.tsx
Normal file
79
components/form/SuggestFieldInput.tsx
Normal file
@@ -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<HTMLInputElement>) => void;
|
||||
handleAddTag: (characterId: string) => void;
|
||||
handleRemoveTag: (characterId: string) => void;
|
||||
filteredTags: () => SelectBoxProps[];
|
||||
showTagSuggestions: boolean;
|
||||
setShowTagSuggestions: Dispatch<SetStateAction<boolean>>;
|
||||
getTagLabel: (id: string) => string;
|
||||
}
|
||||
|
||||
export default function SuggestFieldInput(
|
||||
{
|
||||
inputFieldName,
|
||||
inputFieldIcon,
|
||||
searchTags,
|
||||
tagued,
|
||||
handleTagSearch,
|
||||
handleAddTag,
|
||||
handleRemoveTag,
|
||||
filteredTags,
|
||||
showTagSuggestions,
|
||||
setShowTagSuggestions,
|
||||
getTagLabel
|
||||
}: SuggestFieldInputProps) {
|
||||
return (
|
||||
<div className="bg-secondary/20 rounded-xl p-5 shadow-inner border border-secondary/30">
|
||||
<InputField fieldName={inputFieldName} icon={inputFieldIcon} input={
|
||||
<div className="w-full mb-3 relative">
|
||||
<TextInput value={searchTags} setValue={handleTagSearch}
|
||||
onFocus={() => setShowTagSuggestions(searchTags.trim().length > 0)}
|
||||
placeholder="Rechercher et ajouter..."/>
|
||||
{showTagSuggestions && filteredTags().length > 0 && (
|
||||
<div
|
||||
className="absolute top-full left-0 right-0 z-10 mt-2 bg-tertiary border border-secondary/50 rounded-xl shadow-2xl max-h-48 overflow-y-auto backdrop-blur-sm">
|
||||
{filteredTags().map((character: SelectBoxProps) => (
|
||||
<button
|
||||
key={character.value}
|
||||
className="w-full text-left px-4 py-2.5 hover:bg-secondary/70 text-text-primary transition-all hover:pl-5 first:rounded-t-xl last:rounded-b-xl font-medium"
|
||||
onClick={() => handleAddTag(character.value)}
|
||||
>
|
||||
{character.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tagued.length === 0 ? (
|
||||
<p className="text-text-secondary text-sm italic">Aucun élément ajouté</p>
|
||||
) : (
|
||||
tagued.map((tag: string) => (
|
||||
<div
|
||||
key={tag}
|
||||
className="group bg-primary/90 text-white rounded-full px-4 py-1.5 text-sm font-medium flex items-center gap-2 shadow-md hover:shadow-lg hover:scale-105 transition-all border border-primary-dark"
|
||||
>
|
||||
<span>{getTagLabel(tag)}</span>
|
||||
<button
|
||||
onClick={() => handleRemoveTag(tag)}
|
||||
className="w-5 h-5 flex items-center justify-center rounded-full hover:bg-white/20 transition-all group-hover:scale-110 text-base font-bold"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
39
components/form/TextInput.tsx
Normal file
39
components/form/TextInput.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React, {ChangeEvent} from "react";
|
||||
|
||||
interface TextInputProps {
|
||||
value: string;
|
||||
setValue?: (e: ChangeEvent<HTMLInputElement>) => void;
|
||||
placeholder?: string;
|
||||
readOnly?: boolean;
|
||||
disabled?: boolean;
|
||||
onFocus?: () => void;
|
||||
}
|
||||
|
||||
export default function TextInput(
|
||||
{
|
||||
value,
|
||||
setValue,
|
||||
placeholder,
|
||||
readOnly = false,
|
||||
disabled = false,
|
||||
onFocus
|
||||
}: TextInputProps) {
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
readOnly={readOnly}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
onFocus={onFocus}
|
||||
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
|
||||
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
|
||||
${readOnly ? 'cursor-default' : ''}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
117
components/form/TexteAreaInput.tsx
Normal file
117
components/form/TexteAreaInput.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import React, {ChangeEvent, useEffect, useState} from "react";
|
||||
|
||||
interface TextAreaInputProps {
|
||||
value: string;
|
||||
setValue: (e: ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
placeholder: string;
|
||||
maxLength?: number;
|
||||
}
|
||||
|
||||
export default function TextAreaInput(
|
||||
{
|
||||
value,
|
||||
setValue,
|
||||
placeholder,
|
||||
maxLength
|
||||
}: TextAreaInputProps) {
|
||||
const [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 (
|
||||
<div className="flex-grow flex-col flex h-full">
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
placeholder={placeholder}
|
||||
rows={3}
|
||||
className={`w-full flex-grow text-text-primary p-3 lg:p-4 rounded-xl border-2 outline-none resize-none transition-all duration-300 placeholder:text-muted/60 ${
|
||||
maxLength && value.length >= maxLength
|
||||
? 'border-error focus:ring-4 focus:ring-error/20 bg-error/10 hover:bg-error/15'
|
||||
: 'bg-secondary/50 border-secondary/50 focus:ring-4 focus:ring-primary/20 focus:border-primary focus:bg-secondary hover:bg-secondary hover:border-secondary'
|
||||
}`}
|
||||
style={{height: '100%', minHeight: '200px'}}
|
||||
/>
|
||||
|
||||
{maxLength && (
|
||||
<div className="flex items-center justify-end gap-3 mt-3">
|
||||
{/* Compteur avec effet de croissance */}
|
||||
<div className={`flex items-center gap-3 px-4 py-2 rounded-lg border transition-all duration-300 ${
|
||||
isGrowing ? 'scale-110 shadow-lg' : 'scale-100'
|
||||
} ${styles.bgColor} ${styles.borderColor}`}>
|
||||
|
||||
{/* Progress bar visible */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted font-medium">Progression</span>
|
||||
<div className="w-20 h-2 bg-secondary/50 rounded-full overflow-hidden shadow-inner">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ease-out ${styles.progressColor} shadow-md`}
|
||||
style={{width: `${getProgressPercentage()}%`}}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Compteur de caractères */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`text-sm font-semibold transition-all duration-200 ${
|
||||
isGrowing ? 'scale-125' : 'scale-100'
|
||||
} ${styles.textColor}`}>
|
||||
{value.length}
|
||||
<span className="text-muted mx-1">/</span>
|
||||
<span className="text-text-secondary">{maxLength}</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted font-medium">caractères</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
426
components/ghostwriter/GhostWriter.tsx
Normal file
426
components/ghostwriter/GhostWriter.tsx
Normal file
@@ -0,0 +1,426 @@
|
||||
import React, {ChangeEvent, useContext, useState} from 'react';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faBookOpen,
|
||||
faFileImport,
|
||||
faFloppyDisk,
|
||||
faGear,
|
||||
faGhost,
|
||||
faHashtag,
|
||||
faMagicWandSparkles,
|
||||
faPalette,
|
||||
faTags,
|
||||
faX
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import {SessionContext} from "@/context/SessionContext";
|
||||
import {AlertContext} from "@/context/AlertContext";
|
||||
import {EditorContext} from "@/context/EditorContext";
|
||||
import System from "@/lib/models/System";
|
||||
import {BookContext} from "@/context/BookContext";
|
||||
import {ChapterContext} from "@/context/ChapterContext";
|
||||
import QSTextGeneratedPreview from "@/components/QSTextGeneratedPreview";
|
||||
import TextInput from "@/components/form/TextInput";
|
||||
import InputField from "@/components/form/InputField";
|
||||
import RadioBox from "@/components/form/RadioBox";
|
||||
import TexteAreaInput from "@/components/form/TexteAreaInput";
|
||||
import SubmitButtonWLoading from "@/components/form/SubmitButtonWLoading";
|
||||
import NumberInput from "@/components/form/NumberInput";
|
||||
import PanelHeader from "@/components/PanelHeader";
|
||||
import GhostWriterTags from "@/components/ghostwriter/GhostWriterTags";
|
||||
import Chapter, {TiptapNode} from "@/lib/models/Chapter";
|
||||
import GhostWriterSettings from "@/components/ghostwriter/GhostWriterSettings";
|
||||
import {useTranslations} from "next-intl";
|
||||
import QuillSense, {AIGeneratedText} from "@/lib/models/QuillSense";
|
||||
import {AIUsageContext, AIUsageContextProps} from "@/context/AIUsageContext";
|
||||
import {LangContext} from "@/context/LangContext";
|
||||
import {configs} from "@/lib/configs";
|
||||
|
||||
export default function GhostWriter() {
|
||||
const t = useTranslations();
|
||||
const {lang} = useContext(LangContext)
|
||||
const {session} = useContext(SessionContext);
|
||||
const {errorMessage, successMessage, infoMessage} = useContext(AlertContext);
|
||||
const {editor} = useContext(EditorContext);
|
||||
const {book} = useContext(BookContext);
|
||||
const {chapter} = useContext(ChapterContext);
|
||||
const {setTotalPrice, setTotalCredits} = useContext<AIUsageContextProps>(AIUsageContext);
|
||||
|
||||
const [minWords, setMinWords] = useState<number>(500);
|
||||
const [maxWords, setMaxWords] = useState<number>(1000);
|
||||
const [toneAtmosphere, setToneAtmosphere] = useState<string>('');
|
||||
const [directive, setDirective] = useState<string>('');
|
||||
const [type, setType] = useState<number>(0);
|
||||
const [isGenerating, setIsGenerating] = useState<boolean>(false);
|
||||
const [textGenerated, setTextGenerated] = useState<string>('');
|
||||
const [isTextGenerated, setIsTextGenerated] = useState<boolean>(false);
|
||||
const [advanceSettings, setAdvanceSettings] = useState<boolean>(false);
|
||||
const [advancedPrompt, setAdvancedPrompt] = useState<string>('');
|
||||
const [showTags, setShowTags] = useState<boolean>(false);
|
||||
const [taguedCharacters, setTaguedCharacters] = useState<string[]>([]);
|
||||
const [taguedLocations, setTaguedLocations] = useState<string[]>([]);
|
||||
const [taguedObjects, setTaguedObjects] = useState<string[]>([]);
|
||||
const [taguedWorldElements, setTaguedWorldElements] = useState<string[]>([]);
|
||||
const [abortController, setAbortController] = useState<ReadableStreamDefaultReader<Uint8Array> | null>(null);
|
||||
|
||||
const isGPTEnabled: boolean = QuillSense.isOpenAIEnabled(session);
|
||||
const isSubTierTree: boolean = QuillSense.getSubLevel(session) === 3;
|
||||
const hasAccess: boolean = isGPTEnabled || isSubTierTree;
|
||||
|
||||
async function showAdvanceSetting(): Promise<void> {
|
||||
if (advanceSettings) {
|
||||
await handleSaveAdvancedSettings();
|
||||
setAdvanceSettings(false);
|
||||
} else {
|
||||
setAdvanceSettings(true);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveAdvancedSettings(): Promise<void> {
|
||||
try {
|
||||
if (advancedPrompt.trim() === '') {
|
||||
errorMessage(t('ghostWriter.promptEmpty'));
|
||||
return;
|
||||
}
|
||||
const response: boolean = await System.authPostToServer<boolean>(`quillsense/ghostwriter/advanced-settings`, {
|
||||
bookId: book?.bookId,
|
||||
advancedPrompt: advancedPrompt
|
||||
}, session.accessToken, lang);
|
||||
if (!response) {
|
||||
errorMessage(t('ghostWriter.errorSaveAdvanced'));
|
||||
return;
|
||||
}
|
||||
successMessage(t('ghostWriter.successSaveAdvanced'));
|
||||
setAdvanceSettings(false);
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(t('ghostWriter.errorSave'));
|
||||
} else {
|
||||
errorMessage(t('ghostWriter.errorUnknown'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStopGeneration(): Promise<void> {
|
||||
if (abortController) {
|
||||
await abortController.cancel();
|
||||
setAbortController(null);
|
||||
infoMessage(t("ghostWriter.abortSuccess"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGenerateGhostWriter(): Promise<void> {
|
||||
setIsGenerating(true);
|
||||
setIsTextGenerated(false);
|
||||
setTextGenerated('');
|
||||
|
||||
try {
|
||||
let content: string = '';
|
||||
if (editor?.getText()) {
|
||||
try {
|
||||
content = editor?.getText();
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(t('ghostWriter.errorRetrieveContent'));
|
||||
} else {
|
||||
errorMessage(t('ghostWriter.errorUnknownRetrieveContent'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response: Response = await fetch(`${configs.apiUrl}quillsense/ghostwriter/generate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${session.accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
bookId: book?.bookId,
|
||||
minWords: minWords,
|
||||
maxWords: maxWords,
|
||||
toneAtmosphere: toneAtmosphere,
|
||||
directive: directive,
|
||||
positionType: type,
|
||||
content: content,
|
||||
tags: {
|
||||
characters: taguedCharacters,
|
||||
locations: taguedLocations,
|
||||
objects: taguedObjects,
|
||||
worldElements: taguedWorldElements,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error: { message?: string } = await response.json();
|
||||
errorMessage(error.message || t('ghostWriter.errorGenerate'));
|
||||
setIsGenerating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const reader: ReadableStreamDefaultReader<Uint8Array> | undefined = response.body?.getReader();
|
||||
const decoder: TextDecoder = new TextDecoder();
|
||||
let accumulatedText: string = '';
|
||||
|
||||
if (!reader) {
|
||||
errorMessage(t('ghostWriter.errorGenerate'));
|
||||
setIsGenerating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setAbortController(reader);
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const {done, value}: ReadableStreamReadResult<Uint8Array> = await reader.read();
|
||||
|
||||
if (done) break;
|
||||
|
||||
const chunk: string = decoder.decode(value, {stream: true});
|
||||
const lines: string[] = chunk.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const dataStr: string = line.slice(6);
|
||||
const data: {
|
||||
content?: string;
|
||||
totalCost?: number;
|
||||
totalPrice?: number;
|
||||
useYourKey?: boolean;
|
||||
aborted?: boolean;
|
||||
} = JSON.parse(dataStr);
|
||||
|
||||
// Si c'est le message final avec les totaux
|
||||
if ('totalCost' in data && 'useYourKey' in data && 'totalPrice' in data) {
|
||||
console.log(data)
|
||||
if (data.useYourKey) {
|
||||
setTotalPrice((prev: number): number => prev + data.totalPrice!);
|
||||
} else {
|
||||
setTotalCredits(data.totalPrice!);
|
||||
}
|
||||
} else if ('content' in data && data.content && data.content !== 'starting') {
|
||||
accumulatedText += data.content;
|
||||
setTextGenerated(accumulatedText);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
console.error('Error parsing SSE data:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setIsGenerating(false);
|
||||
setIsTextGenerated(true);
|
||||
setAbortController(null);
|
||||
} catch (e: unknown) {
|
||||
setIsGenerating(false);
|
||||
setAbortController(null);
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
} else {
|
||||
errorMessage(t('ghostWriter.errorUnknown'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function importPrompt(): Promise<void> {
|
||||
try {
|
||||
const response: TiptapNode = await System.authGetQueryToServer<TiptapNode>(
|
||||
`chapter/content`,
|
||||
session.accessToken,
|
||||
lang,
|
||||
{
|
||||
chapterid: chapter?.chapterId,
|
||||
version: 1
|
||||
},
|
||||
)
|
||||
if (!response) {
|
||||
errorMessage(t('ghostWriter.noContentFound'));
|
||||
return;
|
||||
}
|
||||
const content: string = System.htmlToText(Chapter.convertTiptapToHTML(response));
|
||||
setDirective(content);
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
} else {
|
||||
errorMessage(t('ghostWriter.errorUnknownImport'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function insertText(): void {
|
||||
if (editor && textGenerated) {
|
||||
editor.commands.focus('end');
|
||||
if (editor.getText().length > 0) {
|
||||
editor.commands.insertContent('\n\n');
|
||||
}
|
||||
editor.commands.insertContent(System.textContentToHtml(textGenerated));
|
||||
setIsTextGenerated(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasAccess) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div
|
||||
className="bg-tertiary/90 backdrop-blur-sm p-10 rounded-2xl shadow-2xl text-center border border-secondary/50 max-w-md">
|
||||
<h2 className="text-2xl font-['ADLaM_Display'] text-text-primary mb-4">{t("ghostWriter.title")}</h2>
|
||||
<p className="text-muted mb-6 text-lg leading-relaxed">{t("ghostWriter.subscriptionRequired")}</p>
|
||||
<button
|
||||
onClick={(): string => window.location.href = '/pricing'}
|
||||
className="px-6 py-3 bg-primary text-text-primary rounded-xl hover:bg-primary-dark transition-all duration-200 hover:scale-105 shadow-md hover:shadow-lg font-semibold"
|
||||
>
|
||||
{t("ghostWriter.subscribe")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-secondary/20 backdrop-blur-sm flex flex-col h-full overflow-hidden">
|
||||
<PanelHeader
|
||||
title={t("ghostWriter.title")}
|
||||
description={t("ghostWriter.description")}
|
||||
badge="AI"
|
||||
icon={faGhost}
|
||||
/>
|
||||
|
||||
{
|
||||
showTags ? (
|
||||
<GhostWriterTags taguedCharacters={taguedCharacters} setTaguedCharacters={setTaguedCharacters}
|
||||
taguedLocations={taguedLocations} setTaguedLocations={setTaguedLocations}
|
||||
taguedObjects={taguedObjects} setTaguedObjects={setTaguedObjects}
|
||||
taguedWorldElements={taguedWorldElements}
|
||||
setTaguedWorldElements={setTaguedWorldElements}/>
|
||||
) : !showTags && !advanceSettings ? (
|
||||
<div className="p-4 lg:p-5 space-y-5 overflow-y-auto flex-grow custom-scrollbar">
|
||||
<div className="bg-secondary/20 rounded-xl p-5 shadow-inner border border-secondary/30">
|
||||
<h3 className="text-text-primary text-lg font-medium mb-4 flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faHashtag} className="text-primary w-5 h-5"/>
|
||||
{t("ghostWriter.length")}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<InputField
|
||||
fieldName={t("ghostWriter.minimum")}
|
||||
input={
|
||||
<NumberInput
|
||||
value={minWords}
|
||||
setValue={setMinWords}
|
||||
placeholder={t("ghostWriter.words")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InputField
|
||||
fieldName={t("ghostWriter.maximum")}
|
||||
input={
|
||||
<NumberInput
|
||||
value={maxWords}
|
||||
setValue={setMaxWords}
|
||||
placeholder={t("ghostWriter.words")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-secondary/20 rounded-xl p-5 shadow-inner border border-secondary/30">
|
||||
<div className="mb-1">
|
||||
<InputField
|
||||
icon={faBookOpen}
|
||||
fieldName={t("ghostWriter.type")}
|
||||
input={<RadioBox selected={type} setSelected={setType} name={'sectionType'}/>}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-secondary/20 rounded-xl p-5 shadow-inner border border-secondary/30">
|
||||
<InputField
|
||||
icon={faPalette}
|
||||
fieldName={t("ghostWriter.toneAtmosphere")}
|
||||
input={
|
||||
<TextInput
|
||||
value={toneAtmosphere}
|
||||
setValue={(e: ChangeEvent<HTMLInputElement>) => setToneAtmosphere(e.target.value)}
|
||||
placeholder={t("ghostWriter.tonePlaceholder")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-secondary/20 rounded-lg p-5 shadow-inner flex-1">
|
||||
<InputField
|
||||
icon={faMagicWandSparkles}
|
||||
fieldName={t("ghostWriter.directive")}
|
||||
action={importPrompt}
|
||||
actionIcon={faFileImport}
|
||||
actionLabel={t("ghostWriter.importPrompt")}
|
||||
input={
|
||||
<TexteAreaInput
|
||||
value={directive}
|
||||
setValue={(e: ChangeEvent<HTMLTextAreaElement>) => setDirective(e.target.value)}
|
||||
placeholder={t("ghostWriter.directivePlaceholder")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : advanceSettings && (
|
||||
<GhostWriterSettings advancedPrompt={advancedPrompt} setAdvancedPrompt={setAdvancedPrompt}/>
|
||||
)
|
||||
}
|
||||
|
||||
<div className="p-5 border-t border-secondary/50 bg-secondary/30 backdrop-blur-sm shrink-0 shadow-inner">
|
||||
<div className="flex justify-center gap-6">
|
||||
<button
|
||||
onClick={showAdvanceSetting}
|
||||
className="px-6 py-3 rounded-xl font-medium text-base bg-secondary/50 hover:bg-secondary text-primary transition-all duration-200 flex items-center gap-2 shadow-md hover:shadow-lg hover:scale-105 border border-secondary/50"
|
||||
>
|
||||
<FontAwesomeIcon icon={advanceSettings ? faFloppyDisk : faGear} className={'w-5 h-5'}/>
|
||||
<span>{advanceSettings ? t("ghostWriter.save") : t("ghostWriter.advanced")}</span>
|
||||
</button>
|
||||
{
|
||||
advanceSettings && (
|
||||
<button
|
||||
onClick={(): void => setAdvanceSettings(false)}
|
||||
className="px-6 py-3 rounded-xl font-medium text-base bg-secondary/50 hover:bg-secondary text-error transition-all duration-200 flex items-center gap-2 shadow-md hover:shadow-lg hover:scale-105 border border-secondary/50"
|
||||
>
|
||||
<FontAwesomeIcon icon={faX} className={'w-5 h-5'}/>
|
||||
<span>{t("ghostWriter.cancel")}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
<button
|
||||
onClick={(): void => setShowTags(!showTags)}
|
||||
className="px-6 py-3 rounded-xl font-medium text-base bg-secondary/50 hover:bg-secondary text-primary transition-all duration-200 flex items-center gap-2 shadow-md hover:shadow-lg hover:scale-105 border border-secondary/50"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTags} className={'w-5 h-5'}/>
|
||||
<span>{t("ghostWriter.tags.addTagPlaceholder")}</span>
|
||||
</button>
|
||||
|
||||
<SubmitButtonWLoading
|
||||
callBackAction={handleGenerateGhostWriter}
|
||||
isLoading={isGenerating}
|
||||
text={t("ghostWriter.generate")}
|
||||
loadingText={t("ghostWriter.generating")}
|
||||
icon={faMagicWandSparkles}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{(isTextGenerated || isGenerating) && (
|
||||
<QSTextGeneratedPreview
|
||||
onClose={(): void => setIsTextGenerated(false)}
|
||||
onRefresh={(): Promise<void> => handleGenerateGhostWriter()}
|
||||
value={textGenerated}
|
||||
onInsert={insertText}
|
||||
isGenerating={isGenerating}
|
||||
onStop={handleStopGeneration}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
components/ghostwriter/GhostWriterSettings.tsx
Normal file
58
components/ghostwriter/GhostWriterSettings.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import InputField from "@/components/form/InputField";
|
||||
import TexteAreaInput from "@/components/form/TexteAreaInput";
|
||||
import React, {ChangeEvent, Dispatch, SetStateAction, useContext, useEffect} from "react";
|
||||
import {faGuilded} from "@fortawesome/free-brands-svg-icons";
|
||||
import {AlertContext} from "@/context/AlertContext";
|
||||
import System from "@/lib/models/System";
|
||||
import {SessionContext} from "@/context/SessionContext";
|
||||
import {BookContext} from "@/context/BookContext";
|
||||
import {LangContext, LangContextProps} from "@/context/LangContext";
|
||||
import {useTranslations} from "next-intl";
|
||||
|
||||
interface GhostWriterSettingsProps {
|
||||
advancedPrompt: string;
|
||||
setAdvancedPrompt: Dispatch<SetStateAction<string>>;
|
||||
}
|
||||
|
||||
export default function GhostWriterSettings(
|
||||
{
|
||||
advancedPrompt,
|
||||
setAdvancedPrompt
|
||||
}: GhostWriterSettingsProps) {
|
||||
const {errorMessage} = useContext(AlertContext);
|
||||
const {session} = useContext(SessionContext);
|
||||
const t = useTranslations();
|
||||
const {lang} = useContext<LangContextProps>(LangContext)
|
||||
const {book} = useContext(BookContext);
|
||||
|
||||
useEffect((): void => {
|
||||
getAdvancedSettings().catch();
|
||||
}, []);
|
||||
|
||||
async function getAdvancedSettings(): Promise<void> {
|
||||
try {
|
||||
const setting: string = await System.authGetQueryToServer<string>(`quillsense/ghostwriter/advanced-settings`, session.accessToken, lang, {
|
||||
bookId: book?.bookId
|
||||
});
|
||||
if (setting) {
|
||||
setAdvancedPrompt(setting);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
} else {
|
||||
errorMessage(t('ghostwriter.settings.unknownError'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`p-4 lg:p-5 space-y-5 overflow-y-auto flex-grow custom-scrollbar`}>
|
||||
<InputField input={<TexteAreaInput value={advancedPrompt}
|
||||
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setAdvancedPrompt(e.target.value)}
|
||||
placeholder={`Information complémentaire pour la génération...`}
|
||||
maxLength={600}/>}
|
||||
fieldName={`Prompt additionnel`} icon={faGuilded}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
265
components/ghostwriter/GhostWriterTags.tsx
Normal file
265
components/ghostwriter/GhostWriterTags.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import React, {ChangeEvent, Dispatch, SetStateAction, useContext, useEffect, useState} from "react";
|
||||
import {SelectBoxProps} from "@/shared/interface";
|
||||
import {faCubes, faGlobe, faMapPin, faUser} from "@fortawesome/free-solid-svg-icons";
|
||||
import System from "@/lib/models/System";
|
||||
import {SessionContext} from "@/context/SessionContext";
|
||||
import {AlertContext} from "@/context/AlertContext";
|
||||
import {BookContext} from "@/context/BookContext";
|
||||
import {BookTags} from "@/lib/models/Book";
|
||||
import SuggestFieldInput from "@/components/form/SuggestFieldInput";
|
||||
import {LangContext, LangContextProps} from "@/context/LangContext";
|
||||
import {useTranslations} from "next-intl";
|
||||
|
||||
interface GhostWriterTagsProps {
|
||||
taguedCharacters: string[];
|
||||
setTaguedCharacters: Dispatch<SetStateAction<string[]>>;
|
||||
taguedLocations: string[];
|
||||
setTaguedLocations: Dispatch<SetStateAction<string[]>>;
|
||||
taguedObjects: string[];
|
||||
setTaguedObjects: Dispatch<SetStateAction<string[]>>;
|
||||
taguedWorldElements: string[];
|
||||
setTaguedWorldElements: Dispatch<SetStateAction<string[]>>;
|
||||
}
|
||||
|
||||
export default function GhostWriterTags(
|
||||
{
|
||||
taguedCharacters,
|
||||
setTaguedCharacters,
|
||||
taguedLocations,
|
||||
setTaguedLocations,
|
||||
taguedObjects,
|
||||
setTaguedObjects,
|
||||
taguedWorldElements,
|
||||
setTaguedWorldElements
|
||||
}: GhostWriterTagsProps) {
|
||||
const {session} = useContext(SessionContext);
|
||||
const {errorMessage} = useContext(AlertContext);
|
||||
const t = useTranslations();
|
||||
const {lang} = useContext<LangContextProps>(LangContext)
|
||||
const {book} = useContext(BookContext);
|
||||
|
||||
const [characters, setCharacters] = useState<SelectBoxProps[]>([]);
|
||||
const [locations, setLocations] = useState<SelectBoxProps[]>([]);
|
||||
const [objects, setObjects] = useState<SelectBoxProps[]>([]);
|
||||
const [worldElements, setWorldElements] = useState<SelectBoxProps[]>([]);
|
||||
|
||||
const [searchCharacters, setSearchCharacters] = useState<string>('');
|
||||
const [searchLocations, setSearchLocations] = useState<string>('');
|
||||
const [searchObjects, setSearchObjects] = useState<string>('');
|
||||
const [searchWorldElements, setSearchWorldElements] = useState<string>('');
|
||||
|
||||
const [showCharacterSuggestions, setShowCharacterSuggestions] = useState<boolean>(false);
|
||||
const [showLocationSuggestions, setShowLocationSuggestions] = useState<boolean>(false);
|
||||
const [showObjectSuggestions, setShowObjectSuggestions] = useState<boolean>(false);
|
||||
const [showWorldElementSuggestions, setShowWorldElementSuggestions] = useState<boolean>(false);
|
||||
|
||||
useEffect((): void => {
|
||||
fetchData().then();
|
||||
}, []);
|
||||
|
||||
async function fetchData(): Promise<void> {
|
||||
try {
|
||||
const tagsResponse: BookTags = await System.authGetQueryToServer<BookTags>(`book/tags`, session.accessToken, lang, {bookId: book?.bookId});
|
||||
if (tagsResponse) {
|
||||
setCharacters(tagsResponse.characters);
|
||||
setLocations(tagsResponse.locations);
|
||||
setObjects(tagsResponse.objects);
|
||||
setWorldElements(tagsResponse.worldElements);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
} else {
|
||||
errorMessage(t('ghostwriter.tags.unknownError'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 lg:p-5 space-y-4 overflow-y-auto flex-grow custom-scrollbar">
|
||||
<SuggestFieldInput inputFieldName={`Personnages`}
|
||||
inputFieldIcon={faUser}
|
||||
searchTags={searchCharacters}
|
||||
tagued={taguedCharacters}
|
||||
handleTagSearch={(e: ChangeEvent<HTMLInputElement>): void => handleCharacterSearch(e.target.value)}
|
||||
handleAddTag={handleAddCharacter}
|
||||
handleRemoveTag={handleRemoveCharacter}
|
||||
filteredTags={filteredCharacters}
|
||||
showTagSuggestions={showCharacterSuggestions}
|
||||
setShowTagSuggestions={setShowCharacterSuggestions}
|
||||
getTagLabel={getCharacterLabel}
|
||||
/>
|
||||
|
||||
<SuggestFieldInput inputFieldName={`Lieux`}
|
||||
inputFieldIcon={faMapPin}
|
||||
searchTags={searchLocations}
|
||||
tagued={taguedLocations}
|
||||
handleTagSearch={(e) => handleLocationSearch(e.target.value)}
|
||||
handleAddTag={handleAddLocation}
|
||||
handleRemoveTag={handleRemoveLocation}
|
||||
filteredTags={filteredLocations}
|
||||
showTagSuggestions={showLocationSuggestions}
|
||||
setShowTagSuggestions={setShowLocationSuggestions}
|
||||
getTagLabel={getLocationLabel}
|
||||
/>
|
||||
|
||||
<SuggestFieldInput inputFieldName={`Objets`}
|
||||
inputFieldIcon={faCubes}
|
||||
searchTags={searchObjects}
|
||||
tagued={taguedObjects}
|
||||
handleTagSearch={(e) => handleObjectSearch(e.target.value)}
|
||||
handleAddTag={handleAddObject}
|
||||
handleRemoveTag={handleRemoveObject}
|
||||
filteredTags={filteredObjects}
|
||||
showTagSuggestions={showObjectSuggestions}
|
||||
setShowTagSuggestions={setShowObjectSuggestions}
|
||||
getTagLabel={getObjectLabel}
|
||||
/>
|
||||
|
||||
<SuggestFieldInput inputFieldName={`Éléments mondiaux`}
|
||||
inputFieldIcon={faGlobe}
|
||||
searchTags={searchWorldElements}
|
||||
tagued={taguedWorldElements}
|
||||
handleTagSearch={(e) => handleWorldElementSearch(e.target.value)}
|
||||
handleAddTag={handleAddWorldElement}
|
||||
handleRemoveTag={handleRemoveWorldElement}
|
||||
filteredTags={filteredWorldElements}
|
||||
showTagSuggestions={showWorldElementSuggestions}
|
||||
setShowTagSuggestions={setShowWorldElementSuggestions}
|
||||
getTagLabel={getWorldElementLabel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
components/input/BackButton.tsx
Normal file
28
components/input/BackButton.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from "react";
|
||||
|
||||
export default function BackButton(
|
||||
{
|
||||
text,
|
||||
callBackFunction
|
||||
}:{
|
||||
text:string,
|
||||
callBackFunction: Function;
|
||||
}){
|
||||
|
||||
function callBackButton(){
|
||||
callBackFunction();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-center mt-4">
|
||||
<button
|
||||
onClick={callBackButton}
|
||||
className="text-muted hover:text-primary hover:scale-105 transition-all duration-200 font-medium px-3 py-1.5 rounded-lg hover:bg-primary/10"
|
||||
>
|
||||
{
|
||||
text
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
48
components/input/SelectOptionField.tsx
Normal file
48
components/input/SelectOptionField.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React, {Dispatch, SetStateAction} from "react";
|
||||
import {IconDefinition} from "@fortawesome/fontawesome-svg-core";
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
|
||||
interface SelectOption {
|
||||
label: string,
|
||||
value: string,
|
||||
}
|
||||
|
||||
interface SelectOptionFieldProps {
|
||||
label: string,
|
||||
options: SelectOption[],
|
||||
setOptionValue: Dispatch<SetStateAction<string>>,
|
||||
isRequired?: boolean,
|
||||
isDisabled?: boolean,
|
||||
icon?: IconDefinition,
|
||||
}
|
||||
|
||||
export default function SelectOptionField(
|
||||
{
|
||||
label,
|
||||
options,
|
||||
setOptionValue,
|
||||
isRequired = false,
|
||||
isDisabled = false,
|
||||
icon
|
||||
}: SelectOptionFieldProps
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
className={'flex justify-start items-center gap-2 px-4 py-2.5 rounded-xl bg-secondary/50 text-text-primary border border-secondary/50 outline-none w-full hover:bg-secondary hover:border-secondary focus-within:border-primary focus-within:ring-4 focus-within:ring-primary/20 focus-within:bg-secondary transition-all duration-200'}>
|
||||
{
|
||||
icon && <FontAwesomeIcon icon={icon} className={'text-primary w-4 h-4'}/>
|
||||
}
|
||||
<select onChange={(e) => setOptionValue(e.target.value)}
|
||||
className={'w-full bg-transparent text-text-primary border-none outline-none font-medium disabled:opacity-50 disabled:cursor-not-allowed'}
|
||||
required={isRequired}
|
||||
disabled={isDisabled}>
|
||||
<option value={''} defaultChecked={true} hidden={true}
|
||||
className="bg-tertiary">{label}{isRequired ? ' *' : ''}</option>
|
||||
{options.map((option: SelectOption) => (
|
||||
<option key={option.value} value={option.value}
|
||||
className="bg-tertiary text-text-primary">{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
68
components/input/TextInputField.tsx
Normal file
68
components/input/TextInputField.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import {IconDefinition} from "@fortawesome/fontawesome-svg-core";
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import React, {Dispatch, SetStateAction, useState} from "react";
|
||||
|
||||
export default function TextInputField(
|
||||
{
|
||||
label,
|
||||
inputValue,
|
||||
inputType,
|
||||
setInputValue,
|
||||
inputId,
|
||||
isRequired = false,
|
||||
hintMessage,
|
||||
isDisabled = false,
|
||||
icon
|
||||
}:{
|
||||
label: string,
|
||||
inputValue: string,
|
||||
inputType: "text" | "password" | "email" | "url" | "date" | "number",
|
||||
setInputValue: Dispatch<SetStateAction<string>>,
|
||||
inputId: string,
|
||||
isRequired?: boolean,
|
||||
hintMessage?: string,
|
||||
isDisabled?: boolean,
|
||||
icon?: IconDefinition,
|
||||
}
|
||||
){
|
||||
|
||||
const [hintShown, setHintShown] = useState<boolean>(false);
|
||||
|
||||
function showHint(){
|
||||
if (hintMessage){
|
||||
setHintShown(true);
|
||||
}
|
||||
}
|
||||
function hideHint(){
|
||||
if (hintShown){
|
||||
setHintShown(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div
|
||||
className={'flex justify-start items-center gap-2 px-4 py-2.5 rounded-xl bg-secondary/50 text-text-primary border border-secondary/50 outline-none w-full hover:bg-secondary hover:border-secondary focus-within:border-primary focus-within:ring-4 focus-within:ring-primary/20 focus-within:bg-secondary transition-all duration-200'}>
|
||||
{
|
||||
icon && <FontAwesomeIcon icon={icon} className={'text-primary w-4 h-4'}/>
|
||||
}
|
||||
<input
|
||||
type={inputType}
|
||||
id={inputId}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
required={isRequired}
|
||||
placeholder={`${label}${isRequired ? ' *' : ''}`}
|
||||
onFocus={showHint}
|
||||
onBlur={hideHint}
|
||||
className={'w-full bg-transparent text-text-primary border-none outline-none placeholder:text-muted/60 disabled:opacity-50 disabled:cursor-not-allowed'}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</div>
|
||||
{
|
||||
hintShown && hintMessage &&
|
||||
<small className="text-muted text-xs mt-1 ml-1 block">{hintMessage}</small>
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
309
components/leftbar/ScribeChapterComponent.tsx
Normal file
309
components/leftbar/ScribeChapterComponent.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
import {ChapterListProps, ChapterProps} from "@/lib/models/Chapter";
|
||||
import React, {useContext, useEffect, useRef, useState} from "react";
|
||||
import System from "@/lib/models/System";
|
||||
import {BookContext} from "@/context/BookContext";
|
||||
import {AlertContext} from "@/context/AlertContext";
|
||||
import {ChapterContext} from "@/context/ChapterContext";
|
||||
import {SessionContext} from "@/context/SessionContext";
|
||||
import {faSheetPlastic} from "@fortawesome/free-solid-svg-icons";
|
||||
import ListItem from "@/components/ListItem";
|
||||
import AlertBox from "@/components/AlertBox";
|
||||
import {useTranslations} from "next-intl";
|
||||
import InlineAddInput from "@/components/form/InlineAddInput";
|
||||
import {LangContext} from "@/context/LangContext";
|
||||
|
||||
export default function ScribeChapterComponent() {
|
||||
const t = useTranslations();
|
||||
const {lang} = useContext(LangContext)
|
||||
|
||||
const {book} = useContext(BookContext);
|
||||
const {chapter, setChapter} = useContext(ChapterContext);
|
||||
const {errorMessage, successMessage} = useContext(AlertContext);
|
||||
const {session} = useContext(SessionContext);
|
||||
const userToken: string = session?.accessToken ? session?.accessToken : '';
|
||||
|
||||
const [chapters, setChapters] = useState<ChapterListProps[]>([])
|
||||
|
||||
const [newChapterName, setNewChapterName] = useState<string>('');
|
||||
const [newChapterOrder, setNewChapterOrder] = useState<number>(1);
|
||||
|
||||
const [deleteConfirmationMessage, setDeleteConfirmationMessage] = useState<boolean>(false);
|
||||
const [removeChapterId, setRemoveChapterId] = useState<string>('');
|
||||
|
||||
const chapterRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const scrollContainerRef = useRef<HTMLUListElement>(null);
|
||||
|
||||
useEffect((): void => {
|
||||
getChapterList().then();
|
||||
}, [book]);
|
||||
|
||||
useEffect((): void => {
|
||||
setNewChapterOrder(getNextChapterOrder());
|
||||
}, [chapters]);
|
||||
|
||||
useEffect((): void => {
|
||||
if (chapter?.chapterId && scrollContainerRef.current) {
|
||||
// Small delay to ensure DOM is ready
|
||||
setTimeout(() => {
|
||||
const element = chapterRefs.current.get(chapter.chapterId);
|
||||
const container = scrollContainerRef.current;
|
||||
|
||||
if (element && container) {
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const elementRect = element.getBoundingClientRect();
|
||||
|
||||
// Calculate relative position
|
||||
const relativeTop = elementRect.top - containerRect.top + container.scrollTop;
|
||||
const scrollPosition = relativeTop - (containerRect.height / 2) + (elementRect.height / 2);
|
||||
|
||||
container.scrollTo({
|
||||
top: Math.max(0, scrollPosition),
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}, [chapter?.chapterId]);
|
||||
|
||||
function getNextChapterOrder(): number {
|
||||
const maxOrder: number = Math.max(0, ...chapters.map((chap: ChapterListProps) => chap.chapterOrder ?? 0));
|
||||
return maxOrder + 1;
|
||||
}
|
||||
|
||||
async function getChapterList(): Promise<void> {
|
||||
try {
|
||||
const response: ChapterListProps[] = await System.authGetQueryToServer<ChapterListProps[]>(`book/chapters?id=${book?.bookId}`, userToken, lang);
|
||||
if (response) {
|
||||
setChapters(response);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
} else {
|
||||
errorMessage(t("scribeChapterComponent.errorFetchChapters"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getChapter(chapterId: string): Promise<void> {
|
||||
const version: number = chapter?.chapterContent.version ? chapter?.chapterContent.version : 2;
|
||||
try {
|
||||
const response: ChapterProps = await System.authGetQueryToServer<ChapterProps>(`chapter/whole`, userToken, lang, {
|
||||
bookid: book?.bookId,
|
||||
id: chapterId,
|
||||
version: version,
|
||||
});
|
||||
if (!response) {
|
||||
errorMessage(t("scribeChapterComponent.errorFetchChapter"));
|
||||
return;
|
||||
}
|
||||
setChapter(response);
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
} else {
|
||||
errorMessage(t("scribeChapterComponent.errorFetchChapter"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleChapterUpdate(chapterId: string, title: string, chapterOrder: number): Promise<void> {
|
||||
try {
|
||||
const response: boolean = await System.authPostToServer<boolean>('chapter/update', {
|
||||
chapterId: chapterId,
|
||||
chapterOrder: chapterOrder,
|
||||
title: title,
|
||||
}, userToken, lang);
|
||||
if (!response) {
|
||||
errorMessage(t("scribeChapterComponent.errorChapterUpdate"));
|
||||
return;
|
||||
}
|
||||
successMessage(t("scribeChapterComponent.successUpdate"));
|
||||
setChapters((prevState: ChapterListProps[]): ChapterListProps[] => {
|
||||
return prevState.map((chapter: ChapterListProps): ChapterListProps => {
|
||||
if (chapter.chapterId === chapterId) {
|
||||
chapter.chapterOrder = chapterOrder;
|
||||
chapter.title = title;
|
||||
}
|
||||
return chapter;
|
||||
});
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(t("scribeChapterComponent.errorChapterUpdateFr"));
|
||||
} else {
|
||||
errorMessage(t("scribeChapterComponent.errorChapterUpdateEn"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteConfirmation(chapterId: string): Promise<void> {
|
||||
setDeleteConfirmationMessage(true);
|
||||
setRemoveChapterId(chapterId);
|
||||
}
|
||||
|
||||
async function handleDeleteChapter(): Promise<void> {
|
||||
try {
|
||||
setDeleteConfirmationMessage(false);
|
||||
const response: boolean = await System.authDeleteToServer<boolean>('chapter/remove', {
|
||||
bookId: book?.bookId,
|
||||
chapterId: removeChapterId,
|
||||
}, userToken, lang);
|
||||
if (!response) {
|
||||
errorMessage(t("scribeChapterComponent.errorChapterDelete"));
|
||||
return;
|
||||
}
|
||||
const updatedChapters: ChapterListProps[] = chapters.filter(
|
||||
(chapter: ChapterListProps): boolean => chapter.chapterId !== removeChapterId,
|
||||
);
|
||||
setChapters(updatedChapters);
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
} else {
|
||||
errorMessage(t("scribeChapterComponent.unknownErrorChapterDelete"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddChapter(chapterOrder: number): Promise<void> {
|
||||
if (!newChapterName && chapterOrder >= 0) {
|
||||
errorMessage(t("scribeChapterComponent.errorChapterNameRequired"));
|
||||
return;
|
||||
}
|
||||
const chapterTitle: string = chapterOrder >= 0 ? newChapterName : book?.title as string;
|
||||
try {
|
||||
const chapterId: string = await System.authPostToServer<string>('chapter/add', {
|
||||
bookId: book?.bookId,
|
||||
chapterOrder: chapterOrder,
|
||||
title: chapterTitle
|
||||
}, userToken, lang);
|
||||
if (!chapterId) {
|
||||
errorMessage(t("scribeChapterComponent.errorChapterSubmit", {chapterName: newChapterName}));
|
||||
return;
|
||||
}
|
||||
const newChapter: ChapterListProps = {
|
||||
chapterId: chapterId,
|
||||
title: chapterTitle,
|
||||
chapterOrder: chapterOrder
|
||||
}
|
||||
setChapters((prevState: ChapterListProps[]): ChapterListProps[] => {
|
||||
return [newChapter, ...prevState]
|
||||
})
|
||||
await getChapter(chapterId);
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
} else {
|
||||
errorMessage(t("scribeChapterComponent.errorChapterSubmit", {chapterName: newChapterName}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col p-4 min-h-0">
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="h-6 w-1 bg-primary rounded-full"></div>
|
||||
<h3 className="text-lg font-bold text-primary tracking-wide">{t("scribeChapterComponent.sheetHeading")}</h3>
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{
|
||||
chapters.filter((chap: ChapterListProps): boolean => {
|
||||
return chap.chapterOrder !== undefined && chap.chapterOrder < 0;
|
||||
})
|
||||
.sort((a: ChapterListProps, b: ChapterListProps): number => {
|
||||
const aOrder: number = a.chapterOrder ?? 0;
|
||||
const bOrder: number = b.chapterOrder ?? 0;
|
||||
return aOrder - bOrder;
|
||||
}).map((chap: ChapterListProps) => (
|
||||
<div key={chap.chapterId}
|
||||
ref={(el): void => {
|
||||
if (el) {
|
||||
chapterRefs.current.set(chap.chapterId, el);
|
||||
} else {
|
||||
chapterRefs.current.delete(chap.chapterId);
|
||||
}
|
||||
}}>
|
||||
<ListItem icon={faSheetPlastic}
|
||||
onClick={(): Promise<void> => getChapter(chap.chapterId)}
|
||||
selectedId={chapter?.chapterId ?? ''}
|
||||
id={chap.chapterId}
|
||||
text={chap.title}/>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
{
|
||||
chapters.filter((chap: ChapterListProps): boolean => {
|
||||
return chap.chapterOrder !== undefined && chap.chapterOrder < 0;
|
||||
}).length === 0 &&
|
||||
<li onClick={(): Promise<void> => handleAddChapter(-1)}
|
||||
className="group p-3 bg-secondary/30 rounded-xl hover:bg-secondary cursor-pointer transition-all hover:shadow-md border border-secondary/30 hover:border-primary/30">
|
||||
<span
|
||||
className="text-sm font-medium text-muted group-hover:text-text-primary transition-colors">
|
||||
{t("scribeChapterComponent.createSheet")}
|
||||
</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col mt-6 min-h-0">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="h-6 w-1 bg-primary rounded-full"></div>
|
||||
<h3 className="text-lg font-bold text-primary tracking-wide">{t("scribeChapterComponent.chaptersHeading")}</h3>
|
||||
</div>
|
||||
<ul ref={scrollContainerRef} className="flex-1 space-y-2 overflow-y-auto pr-2 min-h-0">
|
||||
{
|
||||
chapters.filter((chap: ChapterListProps): boolean => {
|
||||
return !(chap.chapterOrder && chap.chapterOrder < 0);
|
||||
})
|
||||
.sort((a: ChapterListProps, b: ChapterListProps): number => {
|
||||
const aOrder: number = a.chapterOrder ?? 0;
|
||||
const bOrder: number = b.chapterOrder ?? 0;
|
||||
return aOrder - bOrder;
|
||||
}).map((chap: ChapterListProps) => (
|
||||
<div key={chap.chapterId}
|
||||
ref={(el): void => {
|
||||
if (el) {
|
||||
chapterRefs.current.set(chap.chapterId, el);
|
||||
} else {
|
||||
chapterRefs.current.delete(chap.chapterId);
|
||||
}
|
||||
}}>
|
||||
<ListItem onClick={(): Promise<void> => getChapter(chap.chapterId)}
|
||||
isEditable={true}
|
||||
handleUpdate={handleChapterUpdate}
|
||||
handleDelete={handleDeleteConfirmation}
|
||||
selectedId={chapter?.chapterId ?? ''}
|
||||
id={chap.chapterId} text={chap.title}
|
||||
numericalIdentifier={chap.chapterOrder}/>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
<div className="mt-2 shrink-0">
|
||||
<InlineAddInput
|
||||
value={newChapterName}
|
||||
setValue={setNewChapterName}
|
||||
numericalValue={newChapterOrder}
|
||||
setNumericalValue={setNewChapterOrder}
|
||||
placeholder={t("scribeChapterComponent.addChapterPlaceholder")}
|
||||
onAdd={async (): Promise<void> => {
|
||||
await handleAddChapter(newChapterOrder);
|
||||
setNewChapterName("");
|
||||
}}
|
||||
showNumericalInput={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
deleteConfirmationMessage &&
|
||||
<AlertBox title={t("scribeChapterComponent.deleteChapterTitle")}
|
||||
message={t("scribeChapterComponent.deleteChapterMessage")}
|
||||
type={"danger"} onConfirm={(): Promise<void> => handleDeleteChapter()}
|
||||
onCancel={(): void => setDeleteConfirmationMessage(false)}/>
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
137
components/leftbar/ScribeLeftBar.tsx
Normal file
137
components/leftbar/ScribeLeftBar.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {faBookMedical, faBookOpen, faFeather} from "@fortawesome/free-solid-svg-icons";
|
||||
import React, {useContext, useState} from "react";
|
||||
import {BookContext} from "@/context/BookContext";
|
||||
import ScribeChapterComponent from "@/components/leftbar/ScribeChapterComponent";
|
||||
import PanelHeader from "@/components/PanelHeader";
|
||||
import {PanelComponent} from "@/lib/models/Editor";
|
||||
import AddNewBookForm from "@/components/book/AddNewBookForm";
|
||||
import ShortStoryGenerator from "@/components/ShortStoryGenerator";
|
||||
import {SessionContext} from "@/context/SessionContext";
|
||||
import {useTranslations} from "next-intl";
|
||||
|
||||
export default function ScribeLeftBar() {
|
||||
const {book} = useContext(BookContext);
|
||||
const {session} = useContext(SessionContext);
|
||||
const t = useTranslations();
|
||||
|
||||
const [panelHidden, setPanelHidden] = useState<boolean>(false);
|
||||
|
||||
const [currentPanel, setCurrentPanel] = useState<PanelComponent>();
|
||||
|
||||
const [showAddNewBook, setShowAddNewBook] = useState<boolean>(false);
|
||||
const [showGenerateShortModal, setShowGenerateShortModal] = useState<boolean>(false)
|
||||
|
||||
const editorComponents: PanelComponent[] = [
|
||||
{
|
||||
id: 1,
|
||||
title: t("scribeLeftBar.editorComponents.structure.title"),
|
||||
description: t("scribeLeftBar.editorComponents.structure.description"),
|
||||
badge: t("scribeLeftBar.editorComponents.structure.badge"),
|
||||
icon: faBookOpen
|
||||
}
|
||||
/*
|
||||
{
|
||||
id: 2,
|
||||
title: 'Ligne directive',
|
||||
icon: faBookmark,
|
||||
badge: 'LD',
|
||||
description: 'Ligne directrice pour ce chapitre.'
|
||||
}, {
|
||||
id: 3,
|
||||
title: 'Statistique',
|
||||
icon: faChartLine,
|
||||
badge: 'STATS',
|
||||
description: 'Vérification des verbes'
|
||||
}*/
|
||||
]
|
||||
|
||||
const homeComponents: PanelComponent[] = [
|
||||
{
|
||||
id: 1,
|
||||
title: t("scribeLeftBar.homeComponents.addBook.title"),
|
||||
description: t("scribeLeftBar.homeComponents.addBook.description"),
|
||||
badge: t("scribeLeftBar.homeComponents.addBook.badge"),
|
||||
icon: faBookMedical
|
||||
}, {
|
||||
id: 2,
|
||||
title: t("scribeLeftBar.homeComponents.generateStory.title"),
|
||||
icon: faFeather,
|
||||
badge: t("scribeLeftBar.homeComponents.generateStory.badge"),
|
||||
description: t("scribeLeftBar.homeComponents.generateStory.description")
|
||||
},
|
||||
]
|
||||
|
||||
function togglePanel(component: PanelComponent): void {
|
||||
if (panelHidden) {
|
||||
if (currentPanel?.id === component.id) {
|
||||
setPanelHidden(!panelHidden);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
setPanelHidden(true);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="left-panel-container" data-guide={"left-panel-container"} className="flex transition-all duration-300">
|
||||
<div className="bg-tertiary border-r border-secondary/50 p-3 flex flex-col space-y-3 shadow-xl">
|
||||
{book ? editorComponents.map(component => (
|
||||
<button
|
||||
key={component.id}
|
||||
onClick={(): void => {
|
||||
togglePanel(component);
|
||||
setCurrentPanel(component);
|
||||
}}
|
||||
title={component.title}
|
||||
className={`group relative p-3 rounded-xl transition-all duration-200 ${panelHidden && currentPanel?.id === component.id
|
||||
? 'bg-primary text-text-primary shadow-lg shadow-primary/30'
|
||||
: 'bg-secondary/30 text-muted hover:text-text-primary hover:bg-secondary hover:shadow-md'}`}
|
||||
>
|
||||
<FontAwesomeIcon icon={component.icon}
|
||||
className={'w-5 h-5 transition-transform duration-200'}/>
|
||||
{panelHidden && currentPanel?.id === component.id && (
|
||||
<div
|
||||
className="absolute -right-1 top-1/2 -translate-y-1/2 w-1 h-8 bg-primary rounded-l-full"></div>
|
||||
)}
|
||||
</button>
|
||||
)) : (
|
||||
homeComponents
|
||||
.map((component: PanelComponent) => (
|
||||
<button
|
||||
key={component.id}
|
||||
onClick={() => component.id === 1 ? setShowAddNewBook(true) : component.id === 2 ? setShowGenerateShortModal(true) : session.user?.groupId && session.user?.groupId === 1}
|
||||
title={component.title}
|
||||
className={`group relative p-3 rounded-xl transition-all duration-200 ${panelHidden && currentPanel?.id === component.id
|
||||
? 'bg-primary text-text-primary shadow-lg shadow-primary/30'
|
||||
: 'bg-secondary/30 text-muted hover:text-text-primary hover:bg-secondary hover:shadow-md'}`}
|
||||
>
|
||||
<FontAwesomeIcon icon={component.icon}
|
||||
className={'w-5 h-5 transition-transform duration-200'}/>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{panelHidden && (
|
||||
<div id="left-panel"
|
||||
className="bg-tertiary/95 backdrop-blur-sm border-r border-secondary/50 h-full min-w-[320px] transition-all duration-300 overflow-y-auto shadow-2xl flex flex-col">
|
||||
<PanelHeader title={currentPanel?.title ?? ''} description={``} badge={``}
|
||||
icon={currentPanel?.icon}
|
||||
callBackAction={async () => setPanelHidden(!panelHidden)}/>
|
||||
{currentPanel?.id === 1 && (
|
||||
<ScribeChapterComponent/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{
|
||||
showAddNewBook &&
|
||||
<AddNewBookForm setCloseForm={setShowAddNewBook}/>
|
||||
}
|
||||
{
|
||||
showGenerateShortModal &&
|
||||
<ShortStoryGenerator onClose={() => setShowGenerateShortModal(false)}/>
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
131
components/quillsense/QuillSenseComponent.tsx
Normal file
131
components/quillsense/QuillSenseComponent.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import React, {useContext, useState} from 'react';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faBars,
|
||||
faComments,
|
||||
faExchangeAlt,
|
||||
faLanguage,
|
||||
faLightbulb,
|
||||
faSpellCheck,
|
||||
IconDefinition
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import QuillSense, {QSView,} from "@/lib/models/QuillSense";
|
||||
|
||||
import QuillList from "@/components/quillsense/modes/QuillList";
|
||||
import QuillConversation from "./modes/QuillConversation";
|
||||
import Dictionary from "@/components/quillsense/modes/Dictionary";
|
||||
import Synonyms from "@/components/quillsense/modes/Synonyms";
|
||||
import InspireMe from "@/components/quillsense/modes/InspireMe";
|
||||
import {SessionContext} from "@/context/SessionContext";
|
||||
import {useTranslations} from "next-intl";
|
||||
import Conjugator from "@/components/quillsense/modes/Conjugator";
|
||||
|
||||
interface QSOption {
|
||||
view: QSView;
|
||||
icon: IconDefinition;
|
||||
}
|
||||
|
||||
export default function QuillSenseComponent() {
|
||||
const [view, setView] = useState<QSView>('chat');
|
||||
const t = useTranslations();
|
||||
const [selectedConversation, setSelectedConversation] = useState<string>('');
|
||||
const {session} = useContext(SessionContext);
|
||||
|
||||
const isBringYourKeys: boolean = QuillSense.isBringYourKeys(session);
|
||||
const subLevel: number = QuillSense.getSubLevel(session)
|
||||
|
||||
const isGPTEnabled: boolean = QuillSense.isOpenAIEnabled(session);
|
||||
const isSubTierTwo: boolean = QuillSense.getSubLevel(session) >= 1;
|
||||
const hasAccess: boolean = isGPTEnabled || isSubTierTwo;
|
||||
|
||||
const qsOptions: QSOption[] = [
|
||||
{view: 'dictionary', icon: faSpellCheck},
|
||||
{view: 'conjugator', icon: faLanguage},
|
||||
{view: 'synonyms', icon: faExchangeAlt},
|
||||
{view: 'inspiration', icon: faLightbulb},
|
||||
{view: 'chat', icon: faComments},
|
||||
];
|
||||
|
||||
function handleSetView(view: QSView): void {
|
||||
setView(view);
|
||||
}
|
||||
|
||||
function handleSelectConversation(conversationId: string) {
|
||||
setSelectedConversation(conversationId);
|
||||
setView('chat');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full bg-secondary/20 backdrop-blur-sm overflow-hidden">
|
||||
<div
|
||||
className="px-3 py-3 flex items-center justify-between border-b border-secondary/50 bg-secondary/30 shadow-sm">
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
onClick={() => handleSetView(view === 'chat' ? 'list' : 'chat')}
|
||||
className="group text-text-primary mr-3 hover:text-primary p-2 rounded-lg hover:bg-secondary/50 transition-all hover:scale-110"
|
||||
aria-label={t('quillSense.toggleList')}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBars}
|
||||
className={'w-5 h-5 transition-transform group-hover:scale-110'}/>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{
|
||||
qsOptions.map((option: QSOption) => (
|
||||
<button
|
||||
key={option.view}
|
||||
disabled={!isBringYourKeys && subLevel < 2 && option.view !== 'chat'}
|
||||
onClick={(): void => handleSetView(option.view)}
|
||||
className={`group p-2.5 rounded-lg transition-all duration-200 ${
|
||||
view === option.view
|
||||
? 'bg-primary text-white shadow-md shadow-primary/30 scale-105'
|
||||
: !isBringYourKeys && subLevel < 2 && option.view !== 'chat'
|
||||
? 'text-muted/40 cursor-not-allowed'
|
||||
: 'text-text-primary hover:text-primary hover:bg-secondary/50 hover:scale-110'
|
||||
}`}
|
||||
aria-label={t(`quillSense.options.${option.view}`)}
|
||||
>
|
||||
<FontAwesomeIcon icon={option.icon}
|
||||
className={'w-4 h-4 transition-transform group-hover:scale-110'}/>
|
||||
</button>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
isBringYourKeys || subLevel >= 1 ? (
|
||||
<>
|
||||
{view === 'list' ? (
|
||||
<QuillList handleSelectConversation={handleSelectConversation}/>
|
||||
) : view === 'chat' ? (
|
||||
<QuillConversation
|
||||
disabled={!isBringYourKeys && subLevel < 2}
|
||||
selectedConversation={selectedConversation}
|
||||
setSelectConversation={setSelectedConversation}
|
||||
/>
|
||||
) : view === 'dictionary' ? (
|
||||
<Dictionary hasKey={hasAccess}/>
|
||||
) : view === 'synonyms' ? (
|
||||
<Synonyms hasKey={hasAccess}/>
|
||||
) : view === 'conjugator' ? (
|
||||
<Conjugator hasKey={hasAccess}/>
|
||||
) : view === 'inspiration' ? (
|
||||
<InspireMe hasKey={hasAccess}/>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full text-text-primary p-8">
|
||||
<div
|
||||
className="w-20 h-20 rounded-2xl bg-primary/10 flex items-center justify-center mb-6 shadow-md">
|
||||
<FontAwesomeIcon icon={faLightbulb} className="w-10 h-10 text-primary"/>
|
||||
</div>
|
||||
<p className="text-xl font-['ADLaM_Display'] text-center mb-3">{t('quillSense.needSubscription')}</p>
|
||||
<p className="text-lg text-muted text-center max-w-md leading-relaxed">{t('quillSense.subscriptionDescription')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
262
components/quillsense/modes/Conjugator.tsx
Normal file
262
components/quillsense/modes/Conjugator.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import {AlertContext} from "@/context/AlertContext";
|
||||
import {SessionContext} from "@/context/SessionContext";
|
||||
import System from "@/lib/models/System";
|
||||
import {ChangeEvent, JSX, useContext, useState} from "react";
|
||||
import InputField from "@/components/form/InputField";
|
||||
import {faLanguage, faLock, faMagnifyingGlass} from "@fortawesome/free-solid-svg-icons";
|
||||
import TextInput from "@/components/form/TextInput";
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {LangContext, LangContextProps} from "@/context/LangContext";
|
||||
import {useTranslations} from "next-intl";
|
||||
import {AIVerbConjugation} from "@/lib/models/QuillSense";
|
||||
import {AIUsageContext, AIUsageContextProps} from "@/context/AIUsageContext";
|
||||
|
||||
interface ConjugationTenses {
|
||||
[tense: string]: {
|
||||
firstPersonSingular?: string;
|
||||
secondPersonSingular?: string;
|
||||
thirdPersonSingular?: string;
|
||||
firstPersonPlural?: string;
|
||||
secondPersonPlural?: string;
|
||||
thirdPersonPlural?: string;
|
||||
présent?: string;
|
||||
passé?: string;
|
||||
} | string;
|
||||
}
|
||||
|
||||
interface ConjugationResponse {
|
||||
conjugations: {
|
||||
[mode: string]: ConjugationTenses;
|
||||
};
|
||||
}
|
||||
|
||||
export default function Conjugator({hasKey}: { hasKey: boolean }): JSX.Element {
|
||||
const {session} = useContext(SessionContext);
|
||||
const {errorMessage} = useContext(AlertContext);
|
||||
const {lang} = useContext<LangContextProps>(LangContext);
|
||||
const t = useTranslations();
|
||||
const {setTotalCredits, setTotalPrice} = useContext<AIUsageContextProps>(AIUsageContext);
|
||||
const [verbToConjugate, setVerbToConjugate] = useState<string>('');
|
||||
const [inProgress, setInProgress] = useState<boolean>(false);
|
||||
const [conjugationResponse, setConjugationResponse] = useState<ConjugationResponse | null>(null);
|
||||
|
||||
async function handleConjugation(): Promise<void> {
|
||||
if (verbToConjugate.trim() === '') {
|
||||
return;
|
||||
}
|
||||
setInProgress(true);
|
||||
try {
|
||||
const response: AIVerbConjugation = await System.authPostToServer<AIVerbConjugation>(
|
||||
`quillsense/verb-conjugation`,
|
||||
{verb: verbToConjugate},
|
||||
session.accessToken,
|
||||
lang
|
||||
);
|
||||
if (!response) {
|
||||
errorMessage(t("conjugator.error.noResponse"));
|
||||
return;
|
||||
}
|
||||
if (response.useYourKey) {
|
||||
setTotalPrice((prevState: number): number => prevState + response.totalPrice)
|
||||
} else {
|
||||
setTotalCredits(response.totalPrice)
|
||||
}
|
||||
setConjugationResponse(response.data as ConjugationResponse);
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
} else {
|
||||
errorMessage(t("conjugator.error.unknown"));
|
||||
}
|
||||
} finally {
|
||||
setInProgress(false);
|
||||
}
|
||||
}
|
||||
|
||||
function renderConjugationTable(tense: string, conjugations: ConjugationTenses[string]): JSX.Element {
|
||||
if (typeof conjugations === 'string') {
|
||||
return (
|
||||
<div key={tense} className="mb-4">
|
||||
<div className="bg-secondary/20 rounded-xl p-4 border border-secondary/30">
|
||||
<span className="text-text-primary">{conjugations}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (typeof conjugations === 'object' && conjugations !== null) {
|
||||
const hasPersonConjugations = conjugations.firstPersonSingular || conjugations.secondPersonSingular ||
|
||||
conjugations.thirdPersonSingular || conjugations.firstPersonPlural ||
|
||||
conjugations.secondPersonPlural || conjugations.thirdPersonPlural;
|
||||
if (hasPersonConjugations) {
|
||||
return (
|
||||
<div key={tense} className="mb-6">
|
||||
<h4 className="text-primary font-medium mb-3 capitalize">
|
||||
{tense}
|
||||
</h4>
|
||||
<div className="bg-secondary/20 rounded-xl p-4 border border-secondary/30">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{conjugations.firstPersonSingular && (
|
||||
<div className="flex">
|
||||
<span className="text-text-secondary w-20">{t('conjugator.persons.je')}</span>
|
||||
<span className="text-text-primary">{conjugations.firstPersonSingular}</span>
|
||||
</div>
|
||||
)}
|
||||
{conjugations.firstPersonPlural && (
|
||||
<div className="flex">
|
||||
<span className="text-text-secondary w-20">{t('conjugator.persons.nous')}</span>
|
||||
<span className="text-text-primary">{conjugations.firstPersonPlural}</span>
|
||||
</div>
|
||||
)}
|
||||
{conjugations.secondPersonSingular && (
|
||||
<div className="flex">
|
||||
<span className="text-text-secondary w-20">{t('conjugator.persons.tu')}</span>
|
||||
<span className="text-text-primary">{conjugations.secondPersonSingular}</span>
|
||||
</div>
|
||||
)}
|
||||
{conjugations.secondPersonPlural && (
|
||||
<div className="flex">
|
||||
<span className="text-text-secondary w-20">{t('conjugator.persons.vous')}</span>
|
||||
<span className="text-text-primary">{conjugations.secondPersonPlural}</span>
|
||||
</div>
|
||||
)}
|
||||
{conjugations.thirdPersonSingular && (
|
||||
<div className="flex">
|
||||
<span className="text-text-secondary w-20">{t('conjugator.persons.il')}</span>
|
||||
<span className="text-text-primary">{conjugations.thirdPersonSingular}</span>
|
||||
</div>
|
||||
)}
|
||||
{conjugations.thirdPersonPlural && (
|
||||
<div className="flex">
|
||||
<span className="text-text-secondary w-20">{t('conjugator.persons.ils')}</span>
|
||||
<span className="text-text-primary">{conjugations.thirdPersonPlural}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
return <div key={tense}></div>;
|
||||
}
|
||||
|
||||
function renderMode(mode: string, tenses: ConjugationTenses): JSX.Element {
|
||||
if (mode === 'infinitif' || mode === 'participe') {
|
||||
return (
|
||||
<div key={mode} className="mb-8">
|
||||
<h3 className="text-lg font-['ADLaM_Display'] text-primary mb-4 capitalize border-b border-primary/20 pb-2">
|
||||
{mode}
|
||||
</h3>
|
||||
<div className="ml-4 space-y-4">
|
||||
{Object.entries(tenses).map(([tense, conjugation]: [string, string | ConjugationTenses[string]]) => (
|
||||
<div key={tense} className="mb-4">
|
||||
<h4 className="text-primary font-medium mb-2 capitalize">
|
||||
{tense}
|
||||
</h4>
|
||||
<div className="bg-secondary/20 rounded-xl p-4 border border-secondary/30">
|
||||
<span className="text-text-primary">{conjugation as string}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={mode} className="mb-8">
|
||||
<h3 className="text-xl font-semibold text-primary mb-4 capitalize border-b border-primary/20 pb-2">
|
||||
{mode}
|
||||
</h3>
|
||||
<div className="ml-4">
|
||||
{Object.entries(tenses).map(([tense, conjugations]) =>
|
||||
renderConjugationTable(tense, conjugations)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasKey) {
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-secondary/20 backdrop-blur-sm">
|
||||
<div className="flex-1 p-6 overflow-y-auto flex items-center justify-center">
|
||||
<div className="max-w-md mx-auto">
|
||||
<div
|
||||
className="bg-tertiary/90 backdrop-blur-sm border border-secondary/50 rounded-2xl p-8 text-center shadow-2xl">
|
||||
<div
|
||||
className="w-20 h-20 mx-auto mb-6 bg-primary rounded-2xl flex items-center justify-center shadow-lg">
|
||||
<FontAwesomeIcon icon={faLock} className="w-10 h-10 text-text-primary"/>
|
||||
</div>
|
||||
<h3 className="text-xl font-['ADLaM_Display'] text-text-primary mb-4">
|
||||
{t('conjugator.locked.title')}
|
||||
</h3>
|
||||
<p className="text-muted leading-relaxed text-lg">
|
||||
{t('conjugator.locked.description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-secondary/20 backdrop-blur-sm overflow-hidden">
|
||||
<div className="p-5 border-b border-secondary/50 bg-secondary/30 shadow-sm">
|
||||
<InputField
|
||||
input={
|
||||
<TextInput
|
||||
value={verbToConjugate}
|
||||
setValue={(e: ChangeEvent<HTMLInputElement>) => setVerbToConjugate(e.target.value)}
|
||||
placeholder={t('conjugator.input.placeholder')}
|
||||
/>
|
||||
}
|
||||
icon={faLanguage}
|
||||
fieldName={t('conjugator.input.label')}
|
||||
actionLabel={t('conjugator.input.action')}
|
||||
actionIcon={faMagnifyingGlass}
|
||||
action={async (): Promise<void> => handleConjugation()}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{inProgress && (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="animate-pulse flex flex-col items-center">
|
||||
<div
|
||||
className="w-12 h-12 border-4 border-primary border-t-transparent rounded-full animate-spin mb-3"></div>
|
||||
<p className="text-text-secondary">{t('conjugator.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!inProgress && conjugationResponse && (
|
||||
<div
|
||||
className="rounded-xl bg-tertiary/90 backdrop-blur-sm shadow-lg overflow-hidden border border-secondary/50">
|
||||
<div className="bg-primary/10 p-4 border-b border-secondary/30">
|
||||
<h3 className="text-xl font-['ADLaM_Display'] text-text-primary flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faLanguage} className="text-primary w-6 h-6"/>
|
||||
<span>{verbToConjugate}</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
{Object.entries(conjugationResponse.conjugations).map(([mode, tenses]: [string, ConjugationTenses]) =>
|
||||
renderMode(mode, tenses)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!inProgress && !conjugationResponse && (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center p-8">
|
||||
<div
|
||||
className="w-20 h-20 rounded-2xl bg-primary/10 flex items-center justify-center mb-6 shadow-md">
|
||||
<FontAwesomeIcon icon={faLanguage} className="text-primary w-10 h-10"/>
|
||||
</div>
|
||||
<h3 className="text-xl font-['ADLaM_Display'] text-text-primary mb-3">{t('conjugator.welcome.title')}</h3>
|
||||
<p className="text-muted max-w-md text-lg leading-relaxed">
|
||||
{t('conjugator.welcome.description')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
158
components/quillsense/modes/Dictionary.tsx
Normal file
158
components/quillsense/modes/Dictionary.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import {AlertContext} from "@/context/AlertContext";
|
||||
import {SessionContext} from "@/context/SessionContext";
|
||||
import System from "@/lib/models/System";
|
||||
import {ChangeEvent, JSX, useContext, useState} from "react";
|
||||
import {AIDictionary, DictionaryAIResponse} from "@/lib/models/QuillSense";
|
||||
import InputField from "@/components/form/InputField";
|
||||
import {faLock, faMagnifyingGlass, faSpellCheck} from "@fortawesome/free-solid-svg-icons";
|
||||
import TextInput from "@/components/form/TextInput";
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {useTranslations} from "next-intl";
|
||||
import {LangContext, LangContextProps} from "@/context/LangContext";
|
||||
import {AIUsageContext, AIUsageContextProps} from "@/context/AIUsageContext";
|
||||
|
||||
export default function Dictionary({hasKey}: { hasKey: boolean }): JSX.Element {
|
||||
const {session} = useContext(SessionContext);
|
||||
const {errorMessage} = useContext(AlertContext);
|
||||
const t = useTranslations();
|
||||
const {lang} = useContext<LangContextProps>(LangContext)
|
||||
const {setTotalCredits,setTotalPrice} = useContext<AIUsageContextProps>(AIUsageContext)
|
||||
const [wordToCheck, setWordToCheck] = useState<string>('');
|
||||
const [inProgress, setInProgress] = useState<boolean>(false);
|
||||
const [aiResponse, setAiResponse] = useState<DictionaryAIResponse | null>(null);
|
||||
|
||||
async function handleSearch(): Promise<void> {
|
||||
if (wordToCheck.trim() === '') {
|
||||
return;
|
||||
}
|
||||
setInProgress(true);
|
||||
try {
|
||||
const response: AIDictionary = await System.authPostToServer<AIDictionary>(
|
||||
`quillsense/dictionary`,
|
||||
{word: wordToCheck},
|
||||
session.accessToken,
|
||||
lang
|
||||
);
|
||||
if (!response) {
|
||||
errorMessage(t("dictionary.errorNoResponse"));
|
||||
return;
|
||||
}
|
||||
if (response.useYourKey){
|
||||
setTotalPrice((prevState:number):number => prevState + response.totalPrice)
|
||||
} else {
|
||||
setTotalCredits(response.totalPrice)
|
||||
}
|
||||
setAiResponse(response.data);
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
} else {
|
||||
errorMessage(t("dictionary.errorUnknown"));
|
||||
}
|
||||
} finally {
|
||||
setInProgress(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasKey) {
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-secondary/20 backdrop-blur-sm">
|
||||
<div className="flex-1 p-6 overflow-y-auto flex items-center justify-center">
|
||||
<div className="max-w-md mx-auto">
|
||||
<div
|
||||
className="bg-tertiary/90 backdrop-blur-sm border border-secondary/50 rounded-2xl p-8 text-center shadow-2xl">
|
||||
<div
|
||||
className="w-20 h-20 mx-auto mb-6 bg-primary rounded-2xl flex items-center justify-center shadow-lg">
|
||||
<FontAwesomeIcon icon={faLock} className="w-10 h-10 text-text-primary"/>
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-['ADLaM_Display'] text-text-primary mb-4">
|
||||
Accès requis
|
||||
</h3>
|
||||
|
||||
<p className="text-muted leading-relaxed text-lg">
|
||||
Un abonnement de niveau de base de QuillSense ou une clé API OpenAI est requis pour
|
||||
activer le dictionnaire intelligent.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-secondary/20 backdrop-blur-sm overflow-hidden">
|
||||
<div className="p-5 border-b border-secondary/50 bg-secondary/30 shadow-sm">
|
||||
<InputField
|
||||
input={
|
||||
<TextInput
|
||||
value={wordToCheck}
|
||||
setValue={(e: ChangeEvent<HTMLInputElement>) => setWordToCheck(e.target.value)}
|
||||
placeholder={t("dictionary.searchPlaceholder")}
|
||||
/>
|
||||
}
|
||||
icon={faSpellCheck}
|
||||
fieldName={t("dictionary.fieldName")}
|
||||
actionLabel={t("dictionary.searchAction")}
|
||||
actionIcon={faMagnifyingGlass}
|
||||
action={async (): Promise<void> => handleSearch()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{inProgress && (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="animate-pulse flex flex-col items-center">
|
||||
<div
|
||||
className="w-12 h-12 border-4 border-primary border-t-transparent rounded-full animate-spin mb-3"></div>
|
||||
<p className="text-text-secondary">{t("dictionary.loading")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!inProgress && aiResponse && (
|
||||
<div
|
||||
className="rounded-xl bg-tertiary/90 backdrop-blur-sm shadow-lg overflow-hidden border border-secondary/50">
|
||||
<div className="bg-primary/10 p-4 border-b border-secondary/30">
|
||||
<h3 className="text-xl font-['ADLaM_Display'] text-text-primary flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faSpellCheck} className="text-primary w-6 h-6"/>
|
||||
<span>{wordToCheck}</span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="p-5 space-y-5">
|
||||
<div className="bg-secondary/20 rounded-xl p-4 border border-secondary/30">
|
||||
<h4 className="text-primary font-semibold mb-2 text-base">{t("dictionary.definitionHeading")}</h4>
|
||||
<p className="text-text-primary leading-relaxed">{aiResponse.definition}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-secondary/20 rounded-xl p-4 border border-secondary/30">
|
||||
<h4 className="text-primary font-semibold mb-2 text-base">{t("dictionary.exampleHeading")}</h4>
|
||||
<p className="text-text-primary italic leading-relaxed">{aiResponse.example}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-secondary/20 rounded-xl p-4 border border-secondary/30">
|
||||
<h4 className="text-primary font-semibold mb-2 text-base">{t("dictionary.literaryUsageHeading")}</h4>
|
||||
<p className="text-text-primary leading-relaxed">{aiResponse.literaryUsage}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!inProgress && !aiResponse && (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center p-8">
|
||||
<div
|
||||
className="w-20 h-20 rounded-2xl bg-primary/10 flex items-center justify-center mb-6 shadow-md">
|
||||
<FontAwesomeIcon icon={faSpellCheck} className="text-primary w-10 h-10"/>
|
||||
</div>
|
||||
<h3 className="text-xl font-['ADLaM_Display'] text-text-primary mb-3">{t("dictionary.fieldName")}</h3>
|
||||
<p className="text-muted max-w-md text-lg leading-relaxed">
|
||||
{t("dictionary.description")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
223
components/quillsense/modes/InspireMe.tsx
Normal file
223
components/quillsense/modes/InspireMe.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import React, {ChangeEvent, useContext, useEffect, useState} from "react";
|
||||
import {SessionContext} from "@/context/SessionContext";
|
||||
import {BookContext} from "@/context/BookContext";
|
||||
import {ChapterContext} from "@/context/ChapterContext";
|
||||
import {AlertContext} from "@/context/AlertContext";
|
||||
import {AIInspire, InspirationAIIdea} from "@/lib/models/QuillSense";
|
||||
import System from "@/lib/models/System";
|
||||
import InputField from "@/components/form/InputField";
|
||||
import TextInput from "@/components/form/TextInput";
|
||||
import {faArrowRight, faLightbulb, faLink, faLock} from "@fortawesome/free-solid-svg-icons";
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {useTranslations} from "next-intl";
|
||||
import {LangContext, LangContextProps} from "@/context/LangContext";
|
||||
import {EditorContext} from "@/context/EditorContext";
|
||||
import {AIUsageContext, AIUsageContextProps} from "@/context/AIUsageContext";
|
||||
|
||||
export default function InspireMe({hasKey}: { hasKey: boolean }) {
|
||||
const t = useTranslations();
|
||||
const {session} = useContext(SessionContext);
|
||||
const {editor} = useContext(EditorContext);
|
||||
const {book} = useContext(BookContext);
|
||||
const {chapter} = useContext(ChapterContext);
|
||||
const {errorMessage} = useContext(AlertContext);
|
||||
const {lang} = useContext<LangContextProps>(LangContext);
|
||||
const {setTotalCredits, setTotalPrice} = useContext<AIUsageContextProps>(AIUsageContext);
|
||||
const [prompt, setPrompt] = useState<string>('');
|
||||
|
||||
const [hideHelp, setHideHelp] = useState<boolean>(true);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [inspirations, setInspirations] = useState<InspirationAIIdea[]>([]);
|
||||
|
||||
useEffect((): void => {
|
||||
if (prompt.trim().length > 0) {
|
||||
setHideHelp(true);
|
||||
} else {
|
||||
setHideHelp(false);
|
||||
}
|
||||
}, [prompt]);
|
||||
|
||||
async function handleInspireMe(): Promise<void> {
|
||||
if (prompt.trim() === '') {
|
||||
errorMessage(t("inspireMe.emptyPromptError"));
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setInspirations([]);
|
||||
|
||||
try {
|
||||
let content: string = '';
|
||||
if (editor) {
|
||||
try {
|
||||
content = editor.getHTML();
|
||||
content = System.htmlToText(content);
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage('Erreur lors de la récupération du contenu.');
|
||||
console.error('Erreur lors de la récupération du contenu.');
|
||||
} else {
|
||||
errorMessage('Erreur inconnue lors de la récupération du contenu.')
|
||||
console.error('Erreur inconnue lors de la récupération du contenu.');
|
||||
}
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!book?.bookId) {
|
||||
errorMessage('Aucun livre sélectionné.');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (chapter?.chapterOrder === undefined) {
|
||||
errorMessage('Aucun chapitre sélectionné.');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const inspire: AIInspire = await System.authPostToServer<AIInspire>(
|
||||
`quillsense/inspire`,
|
||||
{
|
||||
prompt: prompt,
|
||||
bookId: book.bookId,
|
||||
chapterOrder: chapter.chapterOrder,
|
||||
currentContent: content,
|
||||
},
|
||||
session.accessToken,
|
||||
lang
|
||||
)
|
||||
if (inspire.useYourKey) {
|
||||
setTotalPrice((prevState: number): number => prevState + inspire.totalPrice)
|
||||
} else {
|
||||
setTotalCredits(inspire.totalPrice)
|
||||
}
|
||||
setInspirations(inspire.data.ideas);
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
} else {
|
||||
errorMessage(`Une erreur inconnue est survenue lors de la génération.`);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasKey) {
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-secondary/20 backdrop-blur-sm">
|
||||
<div className="flex-1 p-6 overflow-y-auto flex items-center justify-center">
|
||||
<div className="max-w-md mx-auto">
|
||||
<div
|
||||
className="bg-tertiary/90 backdrop-blur-sm border border-secondary/50 rounded-2xl p-8 text-center shadow-2xl">
|
||||
<div
|
||||
className="w-20 h-20 mx-auto mb-6 bg-primary rounded-2xl flex items-center justify-center shadow-lg">
|
||||
<FontAwesomeIcon icon={faLock} className="w-10 h-10 text-text-primary"/>
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-['ADLaM_Display'] text-text-primary mb-4">
|
||||
Accès requis
|
||||
</h3>
|
||||
|
||||
<p className="text-muted leading-relaxed text-lg">
|
||||
Un abonnement de niveau de base de QuillSense ou une clé API OpenAI est requis pour
|
||||
activer le mode "Inspire-moi".
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-secondary/20 backdrop-blur-sm overflow-hidden">
|
||||
<div className="p-5 border-b border-secondary/50 bg-secondary/30 shadow-sm">
|
||||
<InputField
|
||||
input={
|
||||
<TextInput
|
||||
value={prompt}
|
||||
setValue={(e: ChangeEvent<HTMLInputElement>) => setPrompt(e.target.value)}
|
||||
placeholder={t("inspireMe.inputPlaceholder")}
|
||||
/>
|
||||
}
|
||||
icon={faLightbulb}
|
||||
fieldName={t("inspireMe.fieldName")}
|
||||
actionLabel={t("inspireMe.actionLabel")}
|
||||
actionIcon={faLightbulb}
|
||||
action={async () => handleInspireMe()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="animate-pulse flex flex-col items-center">
|
||||
<div
|
||||
className="w-12 h-12 border-4 border-primary border-t-transparent rounded-full animate-spin mb-3"></div>
|
||||
<p className="text-text-secondary">{t("inspireMe.loading")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && inspirations.length > 0 && (
|
||||
<div
|
||||
className="rounded-xl bg-tertiary/90 backdrop-blur-sm shadow-lg overflow-hidden border border-secondary/50">
|
||||
<div className="bg-primary/10 p-4 border-b border-secondary/30">
|
||||
<h3 className="text-xl font-['ADLaM_Display'] text-text-primary flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faLightbulb} className="text-primary w-6 h-6"/>
|
||||
<span>{t("inspireMe.resultHeading")}</span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="p-5 space-y-6">
|
||||
{inspirations.map((idea, index) => (
|
||||
<div key={index}
|
||||
className="bg-secondary/20 rounded-xl shadow-md border border-secondary/30 hover:border-primary/50 hover:shadow-lg hover:scale-102 transition-all duration-200 overflow-hidden">
|
||||
<div className="p-4 bg-primary/10 border-b border-secondary/30">
|
||||
<h4 className="text-lg font-semibold text-primary">{idea.idea}</h4>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center mb-1.5 text-sm text-text-secondary">
|
||||
<FontAwesomeIcon icon={faArrowRight}
|
||||
className="mr-1.5 text-primary w-5 h-5"/>
|
||||
<span>{t("inspireMe.justificationHeading")}</span>
|
||||
</div>
|
||||
<p className="text-text-primary pl-5">{idea.reason}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center mb-1.5 text-sm text-text-secondary">
|
||||
<FontAwesomeIcon icon={faLink} className="mr-1.5 text-primary w-5 h-5"/>
|
||||
<span>{t("inspireMe.linkHeading")}</span>
|
||||
</div>
|
||||
<div className="pl-5">
|
||||
<span
|
||||
className="text-xs bg-secondary/50 text-text-secondary px-2.5 py-1 rounded-lg inline-block border border-secondary/50">
|
||||
{idea.relatedTo}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && inspirations.length === 0 && (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center p-8">
|
||||
<div
|
||||
className="w-20 h-20 rounded-2xl bg-primary/10 flex items-center justify-center mb-6 shadow-md">
|
||||
<FontAwesomeIcon icon={faLightbulb} className="text-primary w-10 h-10"/>
|
||||
</div>
|
||||
<h3 className="text-xl font-['ADLaM_Display'] text-text-primary mb-3">{t("inspireMe.emptyHeading")}</h3>
|
||||
<p className="text-muted max-w-md text-lg leading-relaxed">
|
||||
{t("inspireMe.emptyDescription")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
452
components/quillsense/modes/QuillConversation.tsx
Normal file
452
components/quillsense/modes/QuillConversation.tsx
Normal file
@@ -0,0 +1,452 @@
|
||||
import {
|
||||
faBook,
|
||||
faBookOpen,
|
||||
faExclamationTriangle,
|
||||
faLock,
|
||||
faPaperPlane,
|
||||
faRobot,
|
||||
faUser
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import React, {Dispatch, RefObject, SetStateAction, useContext, useEffect, useRef, useState,} from 'react';
|
||||
import QuillSense, {Conversation, ConversationType, Message} from "@/lib/models/QuillSense";
|
||||
import {ChapterContext} from "@/context/ChapterContext";
|
||||
import {BookContext} from "@/context/BookContext";
|
||||
import {AlertContext} from "@/context/AlertContext";
|
||||
import {SessionContext} from '@/context/SessionContext';
|
||||
import System from "@/lib/models/System";
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {useTranslations} from "next-intl";
|
||||
import {LangContext, LangContextProps} from "@/context/LangContext";
|
||||
import {AIUsageContext, AIUsageContextProps} from "@/context/AIUsageContext";
|
||||
|
||||
interface QuillConversationProps {
|
||||
disabled: boolean;
|
||||
selectedConversation: string;
|
||||
setSelectConversation: Dispatch<SetStateAction<string>>;
|
||||
}
|
||||
|
||||
type ContextType = 'none' | 'chapter' | 'book';
|
||||
|
||||
export default function QuillConversation(
|
||||
{
|
||||
disabled,
|
||||
selectedConversation,
|
||||
setSelectConversation,
|
||||
}: QuillConversationProps) {
|
||||
const t = useTranslations();
|
||||
const {lang} = useContext<LangContextProps>(LangContext);
|
||||
const {session} = useContext(SessionContext);
|
||||
const {errorMessage} = useContext(AlertContext);
|
||||
const {book} = useContext(BookContext);
|
||||
const {chapter} = useContext(ChapterContext);
|
||||
const {setTotalPrice} = useContext<AIUsageContextProps>(AIUsageContext)
|
||||
|
||||
const [inputText, setInputText] = useState<string>('');
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [contextType, setContextType] = useState<ContextType>('none');
|
||||
const [showContextAlert, setShowContextAlert] = useState<boolean>(false);
|
||||
const [pendingContextType, setPendingContextType] = useState<ContextType>('none');
|
||||
const messageContainerRef: RefObject<HTMLDivElement | null> = useRef<HTMLDivElement>(null);
|
||||
const textareaRef: RefObject<HTMLTextAreaElement | null> = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const [mode, setMode] = useState<ConversationType>('chatbot');
|
||||
|
||||
const isGeminiEnabled: boolean = QuillSense.isGeminiEnabled(session);
|
||||
const isSubTierTwo: boolean = QuillSense.getSubLevel(session) >= 2;
|
||||
const hasAccess: boolean = isGeminiEnabled || isSubTierTwo;
|
||||
|
||||
function adjustTextareaHeight(): void {
|
||||
const textarea: HTMLTextAreaElement | null = textareaRef.current;
|
||||
if (textarea) {
|
||||
textarea.style.height = 'auto';
|
||||
const newHeight: number = Math.min(Math.max(textarea.scrollHeight, 42), 120);
|
||||
textarea.style.height = `${newHeight}px`;
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToBottom(): void {
|
||||
const messageContainer: HTMLDivElement | null = messageContainerRef.current;
|
||||
if (messageContainer) {
|
||||
messageContainer.scrollTop = messageContainer.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function LoadingMessage() {
|
||||
return (
|
||||
<div className="flex mb-6 justify-start">
|
||||
<div
|
||||
className="w-10 h-10 rounded-full bg-gradient-to-br from-primary to-primary-dark flex items-center justify-center text-text-primary mr-3 shadow-lg">
|
||||
<FontAwesomeIcon icon={faRobot} className={'w-5 h-5'}/>
|
||||
</div>
|
||||
<div
|
||||
className="max-w-[75%] p-4 rounded-2xl bg-secondary/80 text-text-primary rounded-bl-md backdrop-blur-sm border border-secondary/50">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-text-secondary text-sm">{t('quillConversation.loadingMessage')}</span>
|
||||
<div className="flex space-x-1">
|
||||
<div className="w-2 h-2 bg-primary rounded-full animate-pulse"
|
||||
style={{animationDelay: '0ms', animationDuration: '1.5s'}}></div>
|
||||
<div className="w-2 h-2 bg-primary rounded-full animate-pulse"
|
||||
style={{animationDelay: '0.3s', animationDuration: '1.5s'}}></div>
|
||||
<div className="w-2 h-2 bg-primary rounded-full animate-pulse"
|
||||
style={{animationDelay: '0.6s', animationDuration: '1.5s'}}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WelcomeMessage() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full p-8">
|
||||
<div
|
||||
className="w-20 h-20 rounded-2xl bg-gradient-to-br from-primary to-primary-dark flex items-center justify-center text-text-primary mb-6 shadow-2xl">
|
||||
<FontAwesomeIcon icon={faRobot} className={'w-10 h-10'}/>
|
||||
</div>
|
||||
<h2 className="text-2xl font-['ADLaM_Display'] text-text-primary mb-3">{t('quillConversation.welcomeTitle')}</h2>
|
||||
<p className="text-muted text-center leading-relaxed text-lg max-w-md mb-6">
|
||||
{t('quillConversation.welcomeDescription')}
|
||||
</p>
|
||||
<div className="bg-secondary/30 rounded-xl p-4 border border-secondary/50 backdrop-blur-sm shadow-md">
|
||||
<p className="text-sm text-text-secondary text-center">
|
||||
{t('quillConversation.welcomeTip')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextAlert() {
|
||||
const contextDescription: string = pendingContextType === 'chapter'
|
||||
? t('quillConversation.contextAlert.chapter')
|
||||
: t('quillConversation.contextAlert.book');
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-overlay flex items-center justify-center z-50">
|
||||
<div
|
||||
className="bg-tertiary/90 backdrop-blur-sm border border-secondary/50 rounded-2xl p-6 max-w-md mx-4 shadow-2xl">
|
||||
<div className="flex items-center mb-4">
|
||||
<div
|
||||
className="w-12 h-12 rounded-xl bg-warning/20 flex items-center justify-center mr-3 shadow-sm">
|
||||
<FontAwesomeIcon icon={faExclamationTriangle} className="w-6 h-6 text-warning"/>
|
||||
</div>
|
||||
<h3 className="text-xl font-['ADLaM_Display'] text-text-primary">{t('quillConversation.contextAlert.title')}</h3>
|
||||
</div>
|
||||
<p className="text-muted mb-6 leading-relaxed text-lg">
|
||||
{contextDescription}
|
||||
</p>
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={(): void => {
|
||||
setShowContextAlert(false);
|
||||
setPendingContextType('none');
|
||||
}}
|
||||
className="flex-1 px-4 py-2.5 bg-secondary/50 text-text-secondary rounded-xl hover:bg-secondary hover:text-text-primary transition-all duration-200 hover:scale-105 shadow-sm hover:shadow-md border border-secondary/50 font-medium"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={(): void => {
|
||||
setContextType(pendingContextType);
|
||||
setShowContextAlert(false);
|
||||
setPendingContextType('none');
|
||||
}}
|
||||
className="flex-1 px-4 py-2.5 bg-primary text-text-primary rounded-xl hover:bg-primary-dark transition-all duration-200 hover:scale-105 shadow-md hover:shadow-lg font-medium"
|
||||
>
|
||||
{t('common.confirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function handleContextChange(type: ContextType): void {
|
||||
if (type === 'none') {
|
||||
setContextType('none');
|
||||
} else {
|
||||
setPendingContextType(type);
|
||||
setShowContextAlert(true);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect((): void => {
|
||||
if (selectedConversation !== '' && hasAccess) {
|
||||
getMessages().then();
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect((): void => {
|
||||
scrollToBottom();
|
||||
}, [messages, isLoading]);
|
||||
|
||||
useEffect((): void => {
|
||||
adjustTextareaHeight();
|
||||
}, [inputText]);
|
||||
|
||||
|
||||
async function getMessages(): Promise<void> {
|
||||
try {
|
||||
const response: Conversation =
|
||||
await System.authGetQueryToServer<Conversation>(
|
||||
`quillsense/conversation`,
|
||||
session.accessToken,
|
||||
"fr",
|
||||
{id: selectedConversation},
|
||||
);
|
||||
if (response) {
|
||||
setMessages(response.messages);
|
||||
setMode((response.type as ConversationType) ?? 'chatbot');
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
} else {
|
||||
errorMessage(t('quillConversation.genericError'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentTime(): string {
|
||||
const now: Date = new Date();
|
||||
return now.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'});
|
||||
}
|
||||
|
||||
async function handleSend(): Promise<void> {
|
||||
if (!inputText.trim()) {
|
||||
errorMessage(t('quillConversation.emptyMessageError'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const tempId: number = Date.now();
|
||||
const newMessage: Message = {
|
||||
id: tempId,
|
||||
message: inputText,
|
||||
type: 'user',
|
||||
date: getCurrentTime(),
|
||||
};
|
||||
setMessages((prevMessages: Message[]): Message[] => [...prevMessages, newMessage]);
|
||||
setInputText('');
|
||||
setIsLoading(true);
|
||||
|
||||
const response: Conversation = await System.authPostToServer<Conversation>('quillsense/chatbot/send', {
|
||||
message: inputText,
|
||||
bookId: book?.bookId || null,
|
||||
chapterId: chapter?.chapterId || null,
|
||||
conversationId: selectedConversation ?? '',
|
||||
mode: mode,
|
||||
contextType: contextType,
|
||||
version: chapter?.chapterContent.version || null,
|
||||
}, session.accessToken, lang);
|
||||
console.log(response);
|
||||
|
||||
setIsLoading(false);
|
||||
if (response) {
|
||||
setMessages((prevMessages: Message[]): Message[] => {
|
||||
const userMessageFromServer: Message | undefined =
|
||||
response.messages.find(
|
||||
(msg: Message): boolean => msg.type === 'user',
|
||||
);
|
||||
const aiMessageFromServer: Message | undefined =
|
||||
response.messages.find(
|
||||
(msg: Message): boolean => msg.type === 'model',
|
||||
);
|
||||
|
||||
const updatedMessages: Message[] = prevMessages.map(
|
||||
(msg: Message): Message =>
|
||||
msg.id === tempId && userMessageFromServer
|
||||
? {
|
||||
...msg,
|
||||
id: userMessageFromServer.id,
|
||||
date: userMessageFromServer.date,
|
||||
}
|
||||
: msg,
|
||||
);
|
||||
|
||||
return aiMessageFromServer
|
||||
? [...updatedMessages, aiMessageFromServer]
|
||||
: updatedMessages;
|
||||
});
|
||||
|
||||
setTotalPrice((prevTotal: number): number => prevTotal + (response.totalPrice || 0));
|
||||
|
||||
if (selectedConversation === '') {
|
||||
setSelectConversation(response.id);
|
||||
}
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(t('quillConversation.sendError'));
|
||||
} else {
|
||||
errorMessage(t('quillConversation.genericError'));
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasAccess) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 p-6 overflow-y-auto flex items-center justify-center">
|
||||
<div className="max-w-md mx-auto">
|
||||
<div
|
||||
className="bg-tertiary/90 backdrop-blur-sm border border-secondary/50 rounded-2xl p-8 text-center shadow-2xl">
|
||||
<div
|
||||
className="w-20 h-20 mx-auto mb-6 bg-primary rounded-2xl flex items-center justify-center shadow-lg">
|
||||
<FontAwesomeIcon icon={faLock} className="w-10 h-10 text-text-primary"/>
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-['ADLaM_Display'] text-text-primary mb-4">
|
||||
{t('quillConversation.accessRequired.title')}
|
||||
</h3>
|
||||
|
||||
<p className="text-muted leading-relaxed text-lg">
|
||||
{t('quillConversation.accessRequired.description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-secondary/30 backdrop-blur-sm border-t border-secondary/50 p-4 shadow-inner">
|
||||
<div
|
||||
className="flex items-center rounded-2xl bg-tertiary/30 p-3 border border-secondary/50 opacity-50">
|
||||
<textarea
|
||||
disabled={true}
|
||||
placeholder={t('quillConversation.inputPlaceholder')}
|
||||
rows={1}
|
||||
className="flex-1 bg-transparent border-0 outline-none px-4 py-2 text-text-primary placeholder-text-secondary resize-none overflow-hidden min-h-[42px] max-h-[120px] cursor-not-allowed"
|
||||
/>
|
||||
<button
|
||||
disabled={true}
|
||||
className="p-3 rounded-xl text-text-secondary cursor-not-allowed ml-2"
|
||||
>
|
||||
<FontAwesomeIcon icon={faPaperPlane} className="w-5 h-5"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={messageContainerRef} className="flex-1 p-6 overflow-y-auto">
|
||||
{messages.length === 0 && !isLoading ? (
|
||||
<WelcomeMessage/>
|
||||
) : (
|
||||
messages.map((message: Message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`flex mb-6 ${message.type === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
{message.type === 'model' && (
|
||||
<div
|
||||
className="w-10 h-10 rounded-full bg-gradient-to-br from-primary to-primary-dark flex items-center justify-center text-text-primary mr-3 shadow-lg">
|
||||
<FontAwesomeIcon icon={faRobot} className={'w-5 h-5'}/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`max-w-[75%] p-4 rounded-2xl shadow-sm ${
|
||||
message.type === 'user'
|
||||
? 'bg-gradient-to-br from-primary to-primary-dark text-text-primary rounded-br-md'
|
||||
: 'bg-secondary/80 text-text-primary rounded-bl-md backdrop-blur-sm border border-secondary/50'
|
||||
}`}
|
||||
>
|
||||
<p className="leading-relaxed whitespace-pre-wrap">{message.message}</p>
|
||||
<p className={`text-xs mt-2 ${
|
||||
message.type === 'user'
|
||||
? 'text-text-primary/70'
|
||||
: 'text-text-secondary'
|
||||
}`}>
|
||||
{message.date}
|
||||
</p>
|
||||
</div>
|
||||
{message.type === 'user' && (
|
||||
<div
|
||||
className="w-10 h-10 rounded-full bg-gradient-to-br from-primary-dark to-tertiary flex items-center justify-center text-text-primary ml-3 shadow-lg">
|
||||
<FontAwesomeIcon icon={faUser} className={'w-5 h-5'}/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{isLoading && <LoadingMessage/>}
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<div className="flex items-center space-x-4 mb-3 px-2">
|
||||
<span
|
||||
className="text-sm text-text-secondary font-medium">{t('quillConversation.contextLabel')}</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={(): void => handleContextChange('none')}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||||
contextType === 'none'
|
||||
? 'bg-primary text-text-primary'
|
||||
: 'bg-secondary/50 text-text-secondary hover:bg-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
{t('quillConversation.context.none')}
|
||||
</button>
|
||||
{chapter && (
|
||||
<button
|
||||
onClick={(): void => handleContextChange('chapter')}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors flex items-center space-x-1 ${
|
||||
contextType === 'chapter'
|
||||
? 'bg-primary text-text-primary'
|
||||
: 'bg-secondary/50 text-text-secondary hover:bg-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBookOpen} className="w-3 h-3"/>
|
||||
<span>{t('quillConversation.context.chapter')}</span>
|
||||
</button>
|
||||
)}
|
||||
{book && (
|
||||
<button
|
||||
onClick={(): void => handleContextChange('book')}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors flex items-center space-x-1 ${
|
||||
contextType === 'book'
|
||||
? 'bg-primary text-text-primary'
|
||||
: 'bg-secondary/50 text-text-secondary hover:bg-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBook} className="w-3 h-3"/>
|
||||
<span>{t('quillConversation.context.book')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-end rounded-2xl bg-tertiary border border-secondary/50 shadow-inner">
|
||||
<textarea
|
||||
disabled={disabled || isLoading}
|
||||
ref={textareaRef}
|
||||
value={inputText}
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
await handleSend();
|
||||
}
|
||||
}}
|
||||
placeholder={t('quillConversation.inputPlaceholder')}
|
||||
rows={1}
|
||||
className="flex-1 bg-transparent border-0 outline-none px-4 text-text-primary placeholder-text-secondary resize-none overflow-hidden min-h-[42px] max-h-[120px] leading-relaxed"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={inputText.trim() === '' || isLoading}
|
||||
className={`m-2 p-3 rounded-xl transition-all duration-200 ${
|
||||
inputText.trim() === '' || isLoading
|
||||
? 'text-text-secondary bg-secondary/50 cursor-not-allowed'
|
||||
: 'text-text-primary bg-gradient-to-br from-primary to-primary-dark hover:from-primary-dark hover:to-primary shadow-lg hover:shadow-xl transform hover:scale-105'
|
||||
}`}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPaperPlane} className={'w-5 h-5'}/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showContextAlert && <ContextAlert/>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
84
components/quillsense/modes/QuillList.tsx
Normal file
84
components/quillsense/modes/QuillList.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import {faRobot} from '@fortawesome/free-solid-svg-icons';
|
||||
import React, {useContext, useEffect, useState} from 'react';
|
||||
import {SessionContext} from "@/context/SessionContext";
|
||||
import {ConversationProps} from "@/lib/models/QuillSense";
|
||||
import System from "@/lib/models/System";
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {BookContext, BookContextProps} from "@/context/BookContext";
|
||||
import {LangContext} from "@/context/LangContext";
|
||||
|
||||
interface QuillListProps {
|
||||
handleSelectConversation: (itemId: string) => void;
|
||||
}
|
||||
|
||||
export default function QuillList({handleSelectConversation}: QuillListProps) {
|
||||
const {session} = useContext(SessionContext);
|
||||
const {book} = useContext<BookContextProps>(BookContext);
|
||||
const {lang} = useContext(LangContext);
|
||||
|
||||
const [conversations, setConversations] = useState<ConversationProps[]>([]);
|
||||
|
||||
useEffect((): void => {
|
||||
getConversations().then();
|
||||
}, []);
|
||||
|
||||
async function getConversations(): Promise<void> {
|
||||
try {
|
||||
const response: ConversationProps[] = await System.authGetQueryToServer<ConversationProps[]>(
|
||||
`quillsense/conversations`,
|
||||
session.accessToken,
|
||||
lang,
|
||||
{
|
||||
id: book?.bookId,
|
||||
}
|
||||
);
|
||||
if (response.length > 0) {
|
||||
setConversations(response);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusColorClass(status: number): string {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return 'bg-muted';
|
||||
case 2:
|
||||
return 'bg-blue-500';
|
||||
case 3:
|
||||
return 'bg-primary';
|
||||
case 4:
|
||||
return 'bg-error';
|
||||
default:
|
||||
return 'bg-muted';
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{conversations.map((conversation: ConversationProps) => (
|
||||
<div key={conversation.id}
|
||||
className="flex items-center justify-between p-3 mb-2 rounded-xl bg-secondary/30 hover:bg-secondary hover:shadow-md cursor-pointer transition-all duration-200 border border-secondary/50 hover:border-secondary hover:scale-102"
|
||||
onClick={(): void => handleSelectConversation(conversation.id)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`w-2.5 h-2.5 rounded-full ${getStatusColorClass(conversation.status)} shadow-sm`}></div>
|
||||
<FontAwesomeIcon icon={faRobot} className="text-primary w-5 h-5"/>
|
||||
<div>
|
||||
<span className="text-text-primary font-medium">{conversation.title || "Sans titre"}</span>
|
||||
{conversation.startDate && (
|
||||
<p className="text-xs text-muted mt-0.5">{conversation.startDate}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{conversation.mode && (
|
||||
<span
|
||||
className="text-xs bg-primary/20 text-primary px-2.5 py-1 rounded-lg font-medium border border-primary/30">{conversation.mode}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
178
components/quillsense/modes/Synonyms.tsx
Normal file
178
components/quillsense/modes/Synonyms.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import React, {JSX, useContext, useState} from "react";
|
||||
import {SessionContext} from "@/context/SessionContext";
|
||||
import {AlertContext} from "@/context/AlertContext";
|
||||
import {AISynonyms, SynonymAI, SynonymsAIResponse} from "@/lib/models/QuillSense";
|
||||
import System from "@/lib/models/System";
|
||||
import {faExchangeAlt, faLock, faSearch} from "@fortawesome/free-solid-svg-icons";
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {useTranslations} from "next-intl";
|
||||
import {LangContext, LangContextProps} from "@/context/LangContext";
|
||||
import SearchInputWithSelect from "@/components/form/SearchInputWithSelect";
|
||||
import {AIUsageContext, AIUsageContextProps} from "@/context/AIUsageContext";
|
||||
|
||||
export default function Synonyms({hasKey}: { hasKey: boolean }): JSX.Element {
|
||||
const t = useTranslations();
|
||||
const {session} = useContext(SessionContext);
|
||||
const {lang} = useContext<LangContextProps>(LangContext);
|
||||
const {errorMessage} = useContext(AlertContext);
|
||||
const {setTotalCredits, setTotalPrice} = useContext<AIUsageContextProps>(AIUsageContext);
|
||||
const [type, setType] = useState<string>('synonymes')
|
||||
const [wordToCheck, setWordToCheck] = useState<string>('')
|
||||
const [inProgress, setInProgress] = useState<boolean>(false);
|
||||
const [aiResponse, setAiResponse] = useState<SynonymsAIResponse | null>(null)
|
||||
|
||||
async function handleSearch(): Promise<void> {
|
||||
if (wordToCheck.trim() === '') {
|
||||
errorMessage(t("synonyms.enterWordError"));
|
||||
return;
|
||||
}
|
||||
setInProgress(true);
|
||||
try {
|
||||
const response: AISynonyms = await System.authPostToServer<AISynonyms>(`quillsense/synonyms`, {
|
||||
word: wordToCheck,
|
||||
type: type
|
||||
}, session.accessToken, lang);
|
||||
if (!response) {
|
||||
errorMessage(t("synonyms.errorNoResponse"));
|
||||
return;
|
||||
}
|
||||
if (response.useYourKey) {
|
||||
setTotalPrice((prevState: number): number => prevState + response.totalPrice)
|
||||
} else {
|
||||
setTotalCredits(response.totalPrice)
|
||||
}
|
||||
setAiResponse(response.data);
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
} else {
|
||||
errorMessage(t("synonyms.errorUnknown"));
|
||||
}
|
||||
} finally {
|
||||
setInProgress(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasKey) {
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-secondary/20 backdrop-blur-sm">
|
||||
<div className="flex-1 p-6 overflow-y-auto flex items-center justify-center">
|
||||
<div className="max-w-md mx-auto">
|
||||
<div
|
||||
className="bg-tertiary/90 backdrop-blur-sm border border-secondary/50 rounded-2xl p-8 text-center shadow-2xl">
|
||||
<div
|
||||
className="w-20 h-20 mx-auto mb-6 bg-primary rounded-2xl flex items-center justify-center shadow-lg">
|
||||
<FontAwesomeIcon icon={faLock} className="w-10 h-10 text-text-primary"/>
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-['ADLaM_Display'] text-text-primary mb-4">
|
||||
Accès requis
|
||||
</h3>
|
||||
|
||||
<p className="text-muted leading-relaxed text-lg">
|
||||
Un abonnement de niveau de base de QuillSense ou une clé API OpenAI est requis pour
|
||||
activer le dictionnaire intelligent.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-secondary/20 backdrop-blur-sm overflow-hidden">
|
||||
<div className="p-5 border-b border-secondary/50 bg-secondary/30 shadow-sm">
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center shadow-sm">
|
||||
<FontAwesomeIcon icon={faExchangeAlt} className="text-primary w-6 h-6"/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-['ADLaM_Display'] text-text-primary">{t("synonyms.heading")}</h3>
|
||||
<p className="text-sm text-muted">{t("synonyms.subheading")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<SearchInputWithSelect
|
||||
selectValue={type}
|
||||
setSelectValue={setType}
|
||||
selectOptions={[
|
||||
{value: "synonymes", label: t("synonyms.optionSynonyms")},
|
||||
{value: "antonymes", label: t("synonyms.optionAntonyms")}
|
||||
]}
|
||||
inputValue={wordToCheck}
|
||||
setInputValue={setWordToCheck}
|
||||
inputPlaceholder={t("synonyms.inputPlaceholder")}
|
||||
searchIcon={faSearch}
|
||||
onSearch={handleSearch}
|
||||
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{inProgress && (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="animate-pulse flex flex-col items-center">
|
||||
<div
|
||||
className="w-12 h-12 border-4 border-primary border-t-transparent rounded-full animate-spin mb-3"></div>
|
||||
<p className="text-text-secondary">{t("synonyms.loading")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!inProgress && aiResponse && aiResponse.words.length > 0 && (
|
||||
<div
|
||||
className="rounded-xl bg-tertiary/90 backdrop-blur-sm shadow-lg overflow-hidden border border-secondary/50">
|
||||
<div className="bg-primary/10 p-4 border-b border-secondary/30">
|
||||
<h3 className="text-xl font-['ADLaM_Display'] text-text-primary flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faExchangeAlt} className="text-primary w-6 h-6"/>
|
||||
<span>
|
||||
{type === 'synonymes'
|
||||
? t("synonyms.resultSynonyms", {word: wordToCheck})
|
||||
: t("synonyms.resultAntonyms", {word: wordToCheck})}
|
||||
</span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="p-5">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{aiResponse.words.map((item: SynonymAI, index: number) => (
|
||||
<div key={index}
|
||||
className="bg-secondary/20 rounded-xl p-4 border border-secondary/30 hover:border-primary/50 hover:shadow-md hover:scale-102 transition-all duration-200">
|
||||
<div className="font-semibold text-primary mb-1.5">{item.word}</div>
|
||||
<div className="text-sm text-muted leading-relaxed">{item.context}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!inProgress && (!aiResponse || aiResponse.words.length === 0) && (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center p-8">
|
||||
<div
|
||||
className="w-20 h-20 rounded-2xl bg-primary/10 flex items-center justify-center mb-6 shadow-md">
|
||||
<FontAwesomeIcon icon={faExchangeAlt} className="w-10 h-10 text-primary"/>
|
||||
</div>
|
||||
<h3 className="text-xl font-['ADLaM_Display'] text-text-primary mb-3">
|
||||
{type === 'synonymes' ? t("synonyms.emptySynonymsTitle") : t("synonyms.emptyAntonymsTitle")}
|
||||
</h3>
|
||||
<p className="text-muted max-w-md text-lg leading-relaxed">
|
||||
{type === 'synonymes'
|
||||
? t("synonyms.emptySynonymsDescription")
|
||||
: t("synonyms.emptyAntonymsDescription")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
components/rightbar/AboutERitors.tsx
Normal file
112
components/rightbar/AboutERitors.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
'use client';
|
||||
import React, {useEffect, useRef} from "react";
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {faCode, faCopyright, faInfo, faLaptopCode, faTag, faX} from "@fortawesome/free-solid-svg-icons";
|
||||
import {configs} from "@/lib/configs";
|
||||
import {useTranslations} from "next-intl";
|
||||
|
||||
interface AboutEditorsProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AboutEditors({onClose}: AboutEditorsProps) {
|
||||
const t = useTranslations();
|
||||
const modalRef: React.RefObject<HTMLDivElement | null> = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect((): () => void => {
|
||||
document.body.style.overflow = 'hidden';
|
||||
return (): void => {
|
||||
document.body.style.overflow = 'auto';
|
||||
};
|
||||
}, []);
|
||||
|
||||
const appInfo = {
|
||||
name: configs.appName,
|
||||
version: configs.appVersion,
|
||||
copyright: t("aboutEditors.copyright"),
|
||||
description: t("aboutEditors.description"),
|
||||
developers: [t("aboutEditors.teamMember")],
|
||||
technologies: [
|
||||
"TypeScript", "NextJS", "NodeJS", "Fastify", "TailwindCSS", "TipTap"
|
||||
]
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-overlay z-50 backdrop-blur-sm">
|
||||
<div ref={modalRef}
|
||||
className="bg-tertiary/90 backdrop-blur-sm text-text-primary rounded-2xl border border-secondary/50 shadow-2xl md:w-3/4 xl:w-1/4 lg:w-2/4 sm:w-11/12 max-h-[85vh] flex flex-col">
|
||||
<div className="flex justify-between items-center bg-primary px-5 py-4 rounded-t-2xl shadow-md">
|
||||
<h2 className="font-['ADLaM_Display'] text-xl text-text-primary">
|
||||
{t("aboutEditors.title")}
|
||||
</h2>
|
||||
<button
|
||||
className="text-text-primary hover:text-text-primary p-2 rounded-xl hover:bg-text-primary/10 transition-all duration-200 hover:scale-110"
|
||||
onClick={onClose}>
|
||||
<FontAwesomeIcon icon={faX} className={'w-5 h-5'}/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-5 overflow-y-auto flex-grow custom-scrollbar">
|
||||
<div className="flex flex-col items-center mb-6">
|
||||
<h3 className="text-2xl font-['ADLaM_Display'] text-primary">{appInfo.name}</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="bg-secondary/20 rounded-xl p-4 border border-secondary/30 shadow-sm">
|
||||
<div className="flex items-center mb-2">
|
||||
<FontAwesomeIcon icon={faTag} className="text-primary mr-2 w-5 h-5"/>
|
||||
<h4 className="text-lg font-semibold text-text-primary">{t("aboutEditors.version")}</h4>
|
||||
</div>
|
||||
<p className="text-muted ml-7">{appInfo.version}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-secondary/20 rounded-xl p-4 border border-secondary/30 shadow-sm">
|
||||
<div className="flex items-center mb-2">
|
||||
<FontAwesomeIcon icon={faCopyright} className="text-primary mr-2 w-5 h-5"/>
|
||||
<h4 className="text-lg font-semibold text-text-primary">{t("aboutEditors.copyrightLabel")}</h4>
|
||||
</div>
|
||||
<p className="text-muted ml-7">{appInfo.copyright}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-secondary/20 rounded-xl p-4 border border-secondary/30 shadow-sm">
|
||||
<div className="flex items-center mb-2">
|
||||
<FontAwesomeIcon icon={faInfo} className="text-primary mr-2 w-5 h-5"/>
|
||||
<h4 className="text-lg font-semibold text-text-primary">{t("aboutEditors.descriptionLabel")}</h4>
|
||||
</div>
|
||||
<p className="text-muted ml-7 leading-relaxed">{appInfo.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-secondary/20 rounded-xl p-4 border border-secondary/30 shadow-sm">
|
||||
<div className="flex items-center mb-2">
|
||||
<FontAwesomeIcon icon={faLaptopCode} className="text-primary mr-2 w-5 h-5"/>
|
||||
<h4 className="text-lg font-semibold text-text-primary">{t("aboutEditors.teamLabel")}</h4>
|
||||
</div>
|
||||
<ul className="text-muted ml-7">
|
||||
{appInfo.developers.map((dev: string, index: number) => (
|
||||
<li key={index}>{dev}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-secondary/20 rounded-xl p-4 border border-secondary/30 shadow-sm">
|
||||
<div className="flex items-center mb-2">
|
||||
<FontAwesomeIcon icon={faCode} className="text-primary mr-2 w-5 h-5"/>
|
||||
<h4 className="text-lg font-semibold text-text-primary">{t("aboutEditors.techLabel")}</h4>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 ml-7">
|
||||
{appInfo.technologies.map((tech, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="bg-primary/20 text-primary px-2.5 py-1 rounded-lg text-sm font-medium border border-primary/30"
|
||||
>
|
||||
{tech}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
213
components/rightbar/ComposerRightBar.tsx
Normal file
213
components/rightbar/ComposerRightBar.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {faFeather, faGlobe, faInfoCircle, faMapMarkerAlt, faUsers} from "@fortawesome/free-solid-svg-icons";
|
||||
import React, {RefObject, useContext, useRef, useState} from "react";
|
||||
import {BookContext} from "@/context/BookContext";
|
||||
import {ChapterContext} from "@/context/ChapterContext";
|
||||
import {PanelComponent} from "@/lib/models/Editor";
|
||||
import PanelHeader from "@/components/PanelHeader";
|
||||
import AboutEditors from "@/components/rightbar/AboutERitors";
|
||||
import {faDiscord, faFacebook} from "@fortawesome/free-brands-svg-icons";
|
||||
import WorldSetting from "@/components/book/settings/world/WorldSetting";
|
||||
import LocationComponent from "@/components/book/settings/locations/LocationComponent";
|
||||
import CharacterComponent from "@/components/book/settings/characters/CharacterComponent";
|
||||
import QuillSense from "@/components/quillsense/QuillSenseComponent";
|
||||
import {useTranslations} from "next-intl";
|
||||
|
||||
export default function ComposerRightBar() {
|
||||
const {book} = useContext(BookContext);
|
||||
const {chapter} = useContext(ChapterContext);
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
const [panelHidden, setPanelHidden] = useState<boolean>(false);
|
||||
const [currentPanel, setCurrentPanel] = useState<PanelComponent | undefined>()
|
||||
|
||||
const [showAbout, setShowAbout] = useState<boolean>(false);
|
||||
|
||||
const worldRef: RefObject<{ handleSave: () => Promise<void> } | null> = useRef<{
|
||||
handleSave: () => Promise<void>
|
||||
}>(null);
|
||||
const locationRef: RefObject<{ handleSave: () => Promise<void> } | null> = useRef<{
|
||||
handleSave: () => Promise<void>
|
||||
}>(null);
|
||||
const characterRef: RefObject<{ handleSave: () => Promise<void> } | null> = useRef<{
|
||||
handleSave: () => Promise<void>
|
||||
}>(null);
|
||||
|
||||
async function handleSaveClick(): Promise<void> {
|
||||
switch (currentPanel?.id) {
|
||||
case 2:
|
||||
worldRef.current?.handleSave();
|
||||
break;
|
||||
case 3:
|
||||
locationRef.current?.handleSave();
|
||||
break;
|
||||
case 4:
|
||||
characterRef.current?.handleSave();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function togglePanel(component: PanelComponent): void {
|
||||
if (panelHidden) {
|
||||
if (currentPanel?.id === component.id) {
|
||||
setPanelHidden(!panelHidden);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
setPanelHidden(true);
|
||||
}
|
||||
}
|
||||
|
||||
const editorComponents: PanelComponent[] = [
|
||||
{
|
||||
id: 1,
|
||||
title: t("composerRightBar.editorComponents.quillSense.title"),
|
||||
description: t("composerRightBar.editorComponents.quillSense.description"),
|
||||
badge: t("composerRightBar.editorComponents.quillSense.badge"),
|
||||
icon: faFeather
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: t("composerRightBar.editorComponents.worlds.title"),
|
||||
description: t("composerRightBar.editorComponents.worlds.description"),
|
||||
badge: t("composerRightBar.editorComponents.worlds.badge"),
|
||||
icon: faGlobe
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: t("composerRightBar.editorComponents.locations.title"),
|
||||
description: t("composerRightBar.editorComponents.locations.description"),
|
||||
badge: t("composerRightBar.editorComponents.locations.badge"),
|
||||
icon: faMapMarkerAlt
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: t("composerRightBar.editorComponents.characters.title"),
|
||||
description: t("composerRightBar.editorComponents.characters.description"),
|
||||
badge: t("composerRightBar.editorComponents.characters.badge"),
|
||||
icon: faUsers
|
||||
},
|
||||
/*{
|
||||
id: 5,
|
||||
title: t("composerRightBar.editorComponents.items.title"),
|
||||
description: t("composerRightBar.editorComponents.items.description"),
|
||||
badge: t("composerRightBar.editorComponents.items.badge"),
|
||||
icon: faCube,
|
||||
}*/
|
||||
]
|
||||
|
||||
const homeComponents: PanelComponent[] = [
|
||||
{
|
||||
id: 1,
|
||||
title: t("composerRightBar.homeComponents.about.title"),
|
||||
description: t("composerRightBar.homeComponents.about.description"),
|
||||
badge: t("composerRightBar.homeComponents.about.badge"),
|
||||
icon: faInfoCircle,
|
||||
action: () => setShowAbout(true)
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: t("composerRightBar.homeComponents.facebook.title"),
|
||||
description: t("composerRightBar.homeComponents.facebook.description"),
|
||||
badge: t("composerRightBar.homeComponents.facebook.badge"),
|
||||
icon: faFacebook,
|
||||
action: () => window.open('https://www.facebook.com/profile.php?id=61562628720878', '_blank')
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: t("composerRightBar.homeComponents.discord.title"),
|
||||
description: t("composerRightBar.homeComponents.discord.description"),
|
||||
badge: t("composerRightBar.homeComponents.discord.badge"),
|
||||
icon: faDiscord,
|
||||
action: () => window.open('https://discord.gg/CHXRPvmaXm', '_blank')
|
||||
}
|
||||
]
|
||||
|
||||
function disabled(componentId: number): boolean {
|
||||
switch (componentId) {
|
||||
case 1:
|
||||
return book === null;
|
||||
default:
|
||||
return book === null;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="right-panel-container" className="flex transition-all duration-300">
|
||||
{panelHidden && (
|
||||
<div id="right-panel"
|
||||
className="bg-tertiary/95 backdrop-blur-sm border-l border-secondary/50 min-w-[450px] max-w-[450px] h-full transition-all duration-300 overflow-hidden shadow-2xl">
|
||||
<div className="flex flex-col h-full">
|
||||
<PanelHeader title={currentPanel?.title ?? ''}
|
||||
description={currentPanel?.description ?? ''}
|
||||
badge={currentPanel?.badge ?? ''}
|
||||
icon={currentPanel?.icon}
|
||||
secondActionCallback={currentPanel?.id === 2 || currentPanel?.id === 3 || currentPanel?.id === 4 ? handleSaveClick : undefined}
|
||||
callBackAction={async () => setPanelHidden(!panelHidden)}
|
||||
/>
|
||||
<div className="flex-grow overflow-auto">
|
||||
{currentPanel?.id === 1 && (
|
||||
<QuillSense/>
|
||||
)}
|
||||
{currentPanel?.id === 2 && (
|
||||
<WorldSetting ref={worldRef}/>
|
||||
)}
|
||||
{currentPanel?.id === 3 && (
|
||||
<LocationComponent ref={locationRef}/>
|
||||
)}
|
||||
{currentPanel?.id === 4 && (
|
||||
<CharacterComponent ref={characterRef}/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-tertiary border-l border-secondary/50 p-3 flex flex-col space-y-3 shadow-xl">
|
||||
{book ? editorComponents.map((component: PanelComponent) => (
|
||||
<button
|
||||
key={component.id}
|
||||
disabled={disabled(component.id)}
|
||||
onClick={() => {
|
||||
togglePanel(component);
|
||||
setCurrentPanel(component);
|
||||
}}
|
||||
className={`group relative p-3 rounded-xl transition-all duration-200 ${
|
||||
disabled(component.id)
|
||||
? 'bg-secondary/10 text-muted cursor-not-allowed opacity-40'
|
||||
: panelHidden && currentPanel?.id === component.id
|
||||
? 'bg-primary text-text-primary shadow-lg shadow-primary/30 scale-105'
|
||||
: 'bg-secondary/30 text-muted hover:text-text-primary hover:bg-secondary hover:shadow-md hover:scale-105'
|
||||
}`}
|
||||
title={component.title}
|
||||
>
|
||||
<FontAwesomeIcon icon={component.icon}
|
||||
className={'w-5 h-5 transition-transform duration-200 group-hover:scale-110'}/>
|
||||
{panelHidden && currentPanel?.id === component.id && (
|
||||
<div
|
||||
className="absolute -left-1 top-1/2 -translate-y-1/2 w-1 h-8 bg-primary rounded-r-full"></div>
|
||||
)}
|
||||
</button>
|
||||
)) : homeComponents.map((component: PanelComponent) => (
|
||||
<button
|
||||
key={component.id}
|
||||
onClick={component.action ?? (() => {
|
||||
})}
|
||||
className={`group relative p-3 rounded-xl transition-all duration-200 ${panelHidden && currentPanel?.id === component.id
|
||||
? 'bg-primary text-text-primary shadow-lg shadow-primary/30 scale-105'
|
||||
: 'bg-secondary/30 text-muted hover:text-text-primary hover:bg-secondary hover:shadow-md hover:scale-105'}`}
|
||||
title={component.title}
|
||||
>
|
||||
<FontAwesomeIcon icon={component.icon}
|
||||
className={'w-5 h-5 transition-transform duration-200 group-hover:scale-110'}/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{
|
||||
showAbout && <AboutEditors onClose={() => setShowAbout(false)}/>
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
10274
package-lock.json
generated
Normal file
10274
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
105
package.json
105
package.json
@@ -1,12 +1,111 @@
|
||||
{
|
||||
"name": "eritorsscribe",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"main": "dist/electron/main.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"dev:next": "next dev",
|
||||
"dev:electron": "NODE_ENV=development tsx --watch electron/main.ts",
|
||||
"dev": "concurrently \"npm run dev:next\" \"npm run dev:electron\"",
|
||||
"build:next": "next build",
|
||||
"build:electron": "tsc --project tsconfig.electron.json",
|
||||
"build": "npm run build:next && npm run build:electron",
|
||||
"start": "electron .",
|
||||
"package": "npm run build && electron-builder build --mac --win --linux",
|
||||
"package:mac": "npm run build && electron-builder build --mac",
|
||||
"package:win": "npm run build && electron-builder build --win",
|
||||
"package:linux": "npm run build && electron-builder build --linux"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": ""
|
||||
"description": "",
|
||||
"devDependencies": {
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"concurrently": "^9.2.1",
|
||||
"electron": "^39.2.1",
|
||||
"electron-builder": "^26.0.12",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/css": "^11.13.5",
|
||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||
"@fortawesome/free-brands-svg-icons": "^7.1.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
||||
"@fortawesome/react-fontawesome": "^3.1.0",
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
"@tiptap/extension-color": "^3.10.7",
|
||||
"@tiptap/extension-gapcursor": "^3.10.7",
|
||||
"@tiptap/extension-highlight": "^3.10.7",
|
||||
"@tiptap/extension-text-align": "^3.10.7",
|
||||
"@tiptap/extension-underline": "^3.10.7",
|
||||
"@tiptap/react": "^3.10.7",
|
||||
"@tiptap/starter-kit": "^3.10.7",
|
||||
"antd": "^5.28.1",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"axios": "^1.13.2",
|
||||
"i18next": "^25.6.2",
|
||||
"js-cookie": "^3.0.5",
|
||||
"next": "^16.0.3",
|
||||
"next-export-i18n": "^2.4.3",
|
||||
"next-intl": "^4.5.3",
|
||||
"postcss": "^8.5.6",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-i18next": "^16.3.3",
|
||||
"react-slick": "^0.31.0",
|
||||
"tailwindcss": "^4.1.17"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.eritorsscribe.app",
|
||||
"productName": "EritorsScribe",
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"out/**/*",
|
||||
"package.json"
|
||||
],
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
"mac": {
|
||||
"target": [
|
||||
"dmg",
|
||||
"zip"
|
||||
],
|
||||
"category": "public.app-category.productivity",
|
||||
"hardenedRuntime": true,
|
||||
"gatekeeperAssess": false,
|
||||
"entitlements": "build/entitlements.mac.plist",
|
||||
"entitlementsInherit": "build/entitlements.mac.plist"
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": [
|
||||
"x64",
|
||||
"ia32"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
"AppImage",
|
||||
"deb"
|
||||
],
|
||||
"category": "Utility"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
22
tsconfig.electron.json
Normal file
22
tsconfig.electron.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"target": "ES2022",
|
||||
"outDir": "dist/electron",
|
||||
"rootDir": "electron",
|
||||
"lib": ["ES2022"],
|
||||
"jsx": "react"
|
||||
},
|
||||
"include": [
|
||||
"electron/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"src",
|
||||
".next",
|
||||
"out"
|
||||
]
|
||||
}
|
||||
41
tsconfig.json
Normal file
41
tsconfig.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"allowJs": true,
|
||||
"checkJs": false,
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src/**/*",
|
||||
"electron/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
".next",
|
||||
"out"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user