Add components for Act management and integrate Electron setup

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

38
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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);
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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' : ''}`}
/>
)
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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
View 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"
]
}