Remove unused components and models for improved maintainability
- Deleted redundant components (`AddActionButton`, `AlertBox`, `AlertStack`, `BackButton`, `CancelButton`, and `CollapsableArea`) and related files. - Removed unused models (`Book`, `BookSerie`, `BookTables`, `Character`, and `Chapter`) to reduce codebase clutter. - Updated project structure and references to reflect these removals.
This commit is contained in:
313
app/globals.css
313
app/globals.css
@@ -1,30 +1,178 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/*
|
||||
* === ERitors Scribe Design Tokens ===
|
||||
*
|
||||
* Theme values are defined in :root and overridable via [data-theme].
|
||||
* The @theme block references these variables so Tailwind utilities
|
||||
* (bg-primary, text-muted, etc.) automatically follow the active theme.
|
||||
*
|
||||
* COLORS:
|
||||
* Brand ........... primary, primary-dark, primary-light
|
||||
* Surfaces ........ background, dark-background, darkest-background, secondary, tertiary
|
||||
* Semantic surfaces surface-primary, surface-secondary, surface-elevated
|
||||
* Text ............ text-primary, text-secondary, muted, text-dimmed
|
||||
* Semantic ........ success, error, warning, info
|
||||
* Editor .......... editor-text, editor-bold, editor-heading, editor-page-sepia
|
||||
* Scrollbar ....... scrollbar-track, scrollbar-thumb, scrollbar-thumb-hover
|
||||
* Utility ......... glass-bg, surface-gradient-dark, surface-gradient-light
|
||||
* Accents ......... accent-blue, accent-green, accent-orange, accent-purple, accent-red
|
||||
* Interactive ..... interactive, interactive-hover
|
||||
*
|
||||
* SHADOWS: shadow-feature-hover, shadow-focus-ring
|
||||
* FONTS: font-family-lora, font-family-lora-italic, font-family-adlam
|
||||
*/
|
||||
|
||||
:root, :root[data-theme="dark"] {
|
||||
/* Brand */
|
||||
--theme-primary: #51AE84;
|
||||
--theme-primary-dark: #3A8B69;
|
||||
--theme-primary-light: #74C9A0;
|
||||
|
||||
/* Surfaces */
|
||||
--theme-secondary: #393B40;
|
||||
--theme-tertiary: #26272B;
|
||||
--theme-background: #1E1E22;
|
||||
--theme-dark-background: #1B1C1F;
|
||||
--theme-darkest-background: #191A1C;
|
||||
--theme-surface-gradient-dark: #313337;
|
||||
--theme-surface-gradient-light: #45474D;
|
||||
--theme-glass-bg: rgba(43, 45, 48, 0.7);
|
||||
|
||||
/* Text */
|
||||
--theme-text-primary: #F0F0F4;
|
||||
--theme-text-secondary: #BCBEC4;
|
||||
--theme-muted: #B0B0B0;
|
||||
--theme-text-dimmed: #6F737A;
|
||||
|
||||
/* Semantic */
|
||||
--theme-success: #28A745;
|
||||
--theme-error: #DC3545;
|
||||
--theme-warning: #FFC107;
|
||||
--theme-info: #17A2B8;
|
||||
|
||||
/* Gray scale */
|
||||
--theme-gray: #808388;
|
||||
--theme-gray-light: #A0A3A8;
|
||||
--theme-gray-dark: #45474D;
|
||||
|
||||
/* Scrollbar */
|
||||
--theme-scrollbar-track: #1E1F22;
|
||||
--theme-scrollbar-thumb: #51AE84;
|
||||
--theme-scrollbar-thumb-hover: #3A8B69;
|
||||
|
||||
/* Editor content */
|
||||
--theme-editor-text: #E0E0E4;
|
||||
--theme-editor-bold: #F0F0F4;
|
||||
--theme-editor-heading: #34acd0;
|
||||
--theme-editor-page-sepia: #f4f1e8;
|
||||
|
||||
/* Accents */
|
||||
--theme-accent-blue: #5C9CE6;
|
||||
--theme-accent-green: #5FB87B;
|
||||
--theme-accent-orange: #D4923A;
|
||||
--theme-accent-purple: #B07CD8;
|
||||
--theme-accent-red: #E06C75;
|
||||
|
||||
/* Shadows */
|
||||
--theme-shadow-feature-hover: 0 10px 25px -5px rgba(81, 174, 132, 0.2);
|
||||
--theme-shadow-focus-ring: 0 0 0 4px rgba(81, 174, 132, 0.1);
|
||||
}
|
||||
|
||||
/* Light theme — à remplir quand le design sera prêt */
|
||||
:root[data-theme="light"] {
|
||||
--theme-background: #F5F5F5;
|
||||
--theme-secondary: #E0E0E0;
|
||||
--theme-tertiary: #EBEBEB;
|
||||
--theme-dark-background: #D5D5D5;
|
||||
--theme-darkest-background: #C0C0C0;
|
||||
--theme-surface-gradient-dark: #D0D0D0;
|
||||
--theme-surface-gradient-light: #E8E8E8;
|
||||
--theme-glass-bg: rgba(240, 240, 240, 0.7);
|
||||
--theme-text-primary: #1A1A1A;
|
||||
--theme-text-secondary: #555555;
|
||||
--theme-muted: #888888;
|
||||
--theme-text-dimmed: #999999;
|
||||
--theme-scrollbar-track: #E0E0E0;
|
||||
--theme-scrollbar-thumb: #51AE84;
|
||||
--theme-scrollbar-thumb-hover: #3A8B69;
|
||||
--theme-editor-text: #333333;
|
||||
--theme-editor-bold: #1A1A1A;
|
||||
--theme-editor-heading: #2196B8;
|
||||
--theme-editor-page-sepia: #f4f1e8;
|
||||
--theme-gray: #999999;
|
||||
--theme-gray-light: #BBBBBB;
|
||||
--theme-gray-dark: #666666;
|
||||
--theme-accent-blue: #3A7BD5;
|
||||
--theme-accent-green: #2E8B57;
|
||||
--theme-accent-orange: #C47F17;
|
||||
--theme-accent-purple: #8B5CC4;
|
||||
--theme-accent-red: #C94F5A;
|
||||
}
|
||||
|
||||
@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;
|
||||
/* Brand */
|
||||
--color-primary: var(--theme-primary);
|
||||
--color-primary-dark: var(--theme-primary-dark);
|
||||
--color-primary-light: var(--theme-primary-light);
|
||||
|
||||
--color-gray: #808080;
|
||||
--color-gray-light: #A0A0A0;
|
||||
--color-gray-dark: #404040;
|
||||
/* Surfaces */
|
||||
--color-secondary: var(--theme-secondary);
|
||||
--color-tertiary: var(--theme-tertiary);
|
||||
--color-background: var(--theme-background);
|
||||
--color-dark-background: var(--theme-dark-background);
|
||||
--color-darkest-background: var(--theme-darkest-background);
|
||||
--color-surface-gradient-dark: var(--theme-surface-gradient-dark);
|
||||
--color-surface-gradient-light: var(--theme-surface-gradient-light);
|
||||
--color-glass-bg: var(--theme-glass-bg);
|
||||
|
||||
/* Aliases pour compatibilité */
|
||||
--color-textPrimary: #FFFFFF;
|
||||
--color-textSecondary: #B0B0B0;
|
||||
/* Semantic surfaces */
|
||||
--color-surface-primary: var(--theme-background);
|
||||
--color-surface-secondary: var(--theme-secondary);
|
||||
--color-surface-elevated: var(--theme-darkest-background);
|
||||
|
||||
/* Text */
|
||||
--color-text-primary: var(--theme-text-primary);
|
||||
--color-text-secondary: var(--theme-text-secondary);
|
||||
--color-muted: var(--theme-muted);
|
||||
--color-text-dimmed: var(--theme-text-dimmed);
|
||||
|
||||
/* Semantic */
|
||||
--color-success: var(--theme-success);
|
||||
--color-error: var(--theme-error);
|
||||
--color-warning: var(--theme-warning);
|
||||
--color-info: var(--theme-info);
|
||||
|
||||
/* Gray scale */
|
||||
--color-gray: var(--theme-gray);
|
||||
--color-gray-light: var(--theme-gray-light);
|
||||
--color-gray-dark: var(--theme-gray-dark);
|
||||
|
||||
/* Scrollbar */
|
||||
--color-scrollbar-track: var(--theme-scrollbar-track);
|
||||
--color-scrollbar-thumb: var(--theme-scrollbar-thumb);
|
||||
--color-scrollbar-thumb-hover: var(--theme-scrollbar-thumb-hover);
|
||||
|
||||
/* Editor content */
|
||||
--color-editor-text: var(--theme-editor-text);
|
||||
--color-editor-bold: var(--theme-editor-bold);
|
||||
--color-editor-heading: var(--theme-editor-heading);
|
||||
--color-editor-page-sepia: var(--theme-editor-page-sepia);
|
||||
|
||||
/* Accents */
|
||||
--color-accent-blue: var(--theme-accent-blue);
|
||||
--color-accent-green: var(--theme-accent-green);
|
||||
--color-accent-orange: var(--theme-accent-orange);
|
||||
--color-accent-purple: var(--theme-accent-purple);
|
||||
--color-accent-red: var(--theme-accent-red);
|
||||
|
||||
/* Interactive */
|
||||
--color-interactive: var(--theme-primary);
|
||||
--color-interactive-hover: var(--theme-primary-dark);
|
||||
|
||||
/* Shadows */
|
||||
--shadow-feature-hover: var(--theme-shadow-feature-hover);
|
||||
--shadow-focus-ring: var(--theme-shadow-focus-ring);
|
||||
|
||||
/* Font Family */
|
||||
--font-family-lora: 'Lora', Georgia, serif;
|
||||
@@ -53,15 +201,8 @@
|
||||
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))
|
||||
background-color: var(--color-background);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
@@ -70,23 +211,23 @@ body {
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #2d2d2d;
|
||||
background: var(--color-scrollbar-track);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #51AE84;
|
||||
background: var(--color-scrollbar-thumb);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #3a8b69;
|
||||
background: var(--color-scrollbar-thumb-hover);
|
||||
}
|
||||
|
||||
/* Scrollbar Styles for Firefox */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #51AE84 #2d2d2d;
|
||||
scrollbar-color: var(--color-scrollbar-thumb) var(--color-scrollbar-track);
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
@@ -200,8 +341,15 @@ body {
|
||||
}
|
||||
|
||||
/* Styles pour l'éditeur principal avec classes dynamiques */
|
||||
.editor-content .tiptap > p:first-child,
|
||||
.editor-content .tiptap > h1:first-child,
|
||||
.editor-content .tiptap > h2:first-child,
|
||||
.editor-content .tiptap > h3:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.editor-content .tiptap p {
|
||||
color: #dedede;
|
||||
color: var(--color-editor-text);
|
||||
margin-top: 0.7em;
|
||||
margin-bottom: 0.7em;
|
||||
}
|
||||
@@ -212,11 +360,11 @@ body {
|
||||
|
||||
.editor-content .tiptap p strong {
|
||||
font-weight: 900;
|
||||
color: #f9f9f9;
|
||||
color: var(--color-editor-bold);
|
||||
}
|
||||
|
||||
.editor-content .tiptap h1, .editor-content .tiptap h2, .editor-content .tiptap h3 {
|
||||
color: #34acd0;
|
||||
color: var(--color-editor-heading);
|
||||
margin-top: 1em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
@@ -251,6 +399,7 @@ body {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
|
||||
.composer-panel-h {
|
||||
height: calc(100vh - 8rem);
|
||||
}
|
||||
@@ -286,13 +435,13 @@ body {
|
||||
position: relative;
|
||||
transition: all 0.3s ease;
|
||||
overflow: hidden;
|
||||
background-color: #3E3E3E;
|
||||
background-color: var(--color-secondary);
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 10px 25px -5px rgba(81, 174, 132, 0.2);
|
||||
box-shadow: var(--shadow-feature-hover);
|
||||
}
|
||||
|
||||
.feature-card-bg {
|
||||
@@ -310,7 +459,7 @@ body {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #313131, #4A4A4A);
|
||||
background: linear-gradient(135deg, var(--color-surface-gradient-dark), var(--color-surface-gradient-light));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -331,7 +480,7 @@ body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.feature-title-container {
|
||||
@@ -356,7 +505,7 @@ body {
|
||||
.feature-shine-line {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: #4A4A4A;
|
||||
background-color: var(--color-surface-gradient-light);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -421,7 +570,7 @@ body {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
background-color: var(--color-glass-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -446,7 +595,7 @@ body {
|
||||
}
|
||||
|
||||
.last-updated {
|
||||
color: #777777;
|
||||
color: var(--color-text-dimmed);
|
||||
font-size: 0.75rem;
|
||||
text-align: center;
|
||||
margin-top: 1rem;
|
||||
@@ -484,19 +633,42 @@ body {
|
||||
margin-bottom: 0.7em;
|
||||
}
|
||||
|
||||
/* Form input base */
|
||||
.input-base {
|
||||
@apply w-full text-text-primary bg-dark-background px-4 py-2.5 rounded-xl
|
||||
border border-secondary
|
||||
focus:border-primary focus:ring-4 focus:ring-primary/20
|
||||
placeholder:text-muted
|
||||
outline-none transition-all duration-200;
|
||||
}
|
||||
|
||||
.input-base:disabled {
|
||||
@apply opacity-50 cursor-not-allowed;
|
||||
}
|
||||
|
||||
.input-base[readonly] {
|
||||
@apply cursor-default;
|
||||
}
|
||||
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
box-shadow: 0 0 0 4px rgba(81, 174, 132, 0.1);
|
||||
box-shadow: var(--shadow-focus-ring);
|
||||
}
|
||||
|
||||
/* Smooth hover scale for interactive elements */
|
||||
@@ -509,7 +681,7 @@ select:focus-visible {
|
||||
.literary-ornament::before,
|
||||
.literary-ornament::after {
|
||||
content: "❖";
|
||||
color: #51AE84;
|
||||
color: var(--color-primary);
|
||||
opacity: 0.3;
|
||||
font-size: 0.8em;
|
||||
margin: 0 0.5em;
|
||||
@@ -531,9 +703,9 @@ select:focus-visible {
|
||||
|
||||
/* Glass morphism effect */
|
||||
.glass-effect {
|
||||
background: rgba(62, 62, 62, 0.7);
|
||||
background: var(--color-glass-bg);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid var(--color-surface-gradient-light);
|
||||
}
|
||||
|
||||
/* Fade in pour le texte qui stream */
|
||||
@@ -549,3 +721,38 @@ select:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Alert animations */
|
||||
@keyframes shrink {
|
||||
from {
|
||||
width: 100%;
|
||||
}
|
||||
to {
|
||||
width: 0%;
|
||||
}
|
||||
}
|
||||
|
||||
.alert-progress-shrink {
|
||||
animation: shrink 5s linear forwards;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.alert-slide-in {
|
||||
animation: slideInFromRight 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Pulse dots for loading indicators */
|
||||
.pulse-dot-1 {
|
||||
animation-delay: 0ms;
|
||||
animation-duration: 1.5s;
|
||||
}
|
||||
|
||||
.pulse-dot-2 {
|
||||
animation-delay: 300ms;
|
||||
animation-duration: 1.5s;
|
||||
}
|
||||
|
||||
.pulse-dot-3 {
|
||||
animation-delay: 600ms;
|
||||
animation-duration: 1.5s;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
import type {Metadata} from "next";
|
||||
import "./globals.css";
|
||||
import {ReactNode} from "react";
|
||||
import ScribeShell from "@/components/layout/ScribeShell";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "ERitors Scribe",
|
||||
description: "Les meilleurs livres sont ceux qui ont le meilleur plan.",
|
||||
icons: {
|
||||
icon: "/eritors-favicon-white.png"
|
||||
},
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout(
|
||||
{
|
||||
@@ -9,13 +23,8 @@ export default function RootLayout(
|
||||
}>) {
|
||||
return (
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<title>ERitors Scribe</title>
|
||||
<meta name="description" content="Les meilleurs livres sont ceux qui ont le meilleur plan." />
|
||||
<link rel="icon" href="/eritors-favicon-white.png" />
|
||||
</head>
|
||||
<body>
|
||||
{children}
|
||||
<ScribeShell>{children}</ScribeShell>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,29 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import frMessages from '@/lib/locales/fr.json';
|
||||
import enMessages from '@/lib/locales/en.json';
|
||||
import {useEffect, useState} from "react";
|
||||
import {LangContext} from "@/context/LangContext";
|
||||
import {NextIntlClientProvider} from "next-intl";
|
||||
import StaticAlert from "@/components/StaticAlert";
|
||||
import {SessionProps} from "@/lib/models/Session";
|
||||
import System from "@/lib/models/System";
|
||||
import StaticAlert from "@/components/ui/StaticAlert";
|
||||
import {SessionProps} from "@/lib/types/session";
|
||||
import {getCookie} from "@/lib/utils/cookies";
|
||||
import {SessionContext} from "@/context/SessionContext";
|
||||
import {AlertContext} from "@/context/AlertContext";
|
||||
import {changeLanguage} from "@/lib/i18n";
|
||||
import * as tauri from '@/lib/tauri';
|
||||
|
||||
const messagesMap = {
|
||||
fr: frMessages,
|
||||
en: enMessages
|
||||
};
|
||||
|
||||
export default function LoginWrapper({children}: { children: React.ReactNode }) {
|
||||
const [locale, setLocale] = useState<'fr' | 'en'>('fr');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [successMessage, setSuccessMessage] = useState('');
|
||||
const [infoMessage, setInfoMessage] = useState('');
|
||||
const [warningMessage, setWarningMessage] = useState('');
|
||||
const messages = messagesMap[locale];
|
||||
|
||||
const [session, setSession] = useState<SessionProps>({
|
||||
isConnected: false,
|
||||
@@ -35,6 +25,10 @@ export default function LoginWrapper({children}: { children: React.ReactNode })
|
||||
checkAuthentification().then()
|
||||
}, []);
|
||||
|
||||
useEffect((): void => {
|
||||
changeLanguage(locale);
|
||||
}, [locale]);
|
||||
|
||||
useEffect((): void => {
|
||||
if (session.isConnected) {
|
||||
tauri.loginSuccess();
|
||||
@@ -42,20 +36,21 @@ export default function LoginWrapper({children}: { children: React.ReactNode })
|
||||
}, [session]);
|
||||
|
||||
async function checkAuthentification(): Promise<void> {
|
||||
const language: "fr" | "en" | null = System.getCookie('lang') as "fr" | "en" | null;
|
||||
const language: "fr" | "en" | null = getCookie('lang') as "fr" | "en" | null;
|
||||
if (language) {
|
||||
setLocale(language);
|
||||
}
|
||||
|
||||
// Pas besoin de vérifier le token ici dans Electron
|
||||
// Le main process gère quelle fenêtre ouvrir
|
||||
}
|
||||
|
||||
return (
|
||||
<SessionContext.Provider value={{session: session, setSession: setSession}}>
|
||||
<LangContext.Provider value={{lang: locale, setLang: setLocale}}>
|
||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||
<AlertContext.Provider value={{errorMessage: setErrorMessage, successMessage: setSuccessMessage, infoMessage: setInfoMessage, warningMessage: setWarningMessage}}>
|
||||
<AlertContext.Provider value={{
|
||||
errorMessage: setErrorMessage,
|
||||
successMessage: setSuccessMessage,
|
||||
infoMessage: setInfoMessage,
|
||||
warningMessage: setWarningMessage
|
||||
}}>
|
||||
{children}
|
||||
<div className="fixed top-4 right-4 z-[9999] flex flex-col gap-3">
|
||||
{
|
||||
@@ -70,7 +65,6 @@ export default function LoginWrapper({children}: { children: React.ReactNode })
|
||||
}
|
||||
</div>
|
||||
</AlertContext.Provider>
|
||||
</NextIntlClientProvider>
|
||||
</LangContext.Provider>
|
||||
</SessionContext.Provider>
|
||||
)
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import {useContext, useState} from "react";
|
||||
import System from "@/lib/models/System";
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {faEnvelope, faLock} from "@fortawesome/free-solid-svg-icons";
|
||||
import {Mail, Lock} from "lucide-react";
|
||||
import {AlertContext} from "@/context/AlertContext";
|
||||
import {useTranslations} from "next-intl";
|
||||
import {useTranslations} from "@/lib/i18n";
|
||||
import {LangContext, LangContextProps} from "@/context/LangContext";
|
||||
import * as tauri from '@/lib/tauri';
|
||||
import {verifyInput} from "@/lib/utils/validation";
|
||||
import {apiPostPublic} from "@/lib/api/client";
|
||||
import Button from "@/components/ui/Button";
|
||||
import InputField from "@/components/form/InputField";
|
||||
import TextInput from "@/components/form/TextInput";
|
||||
|
||||
export default function LoginForm() {
|
||||
const {errorMessage} = useContext(AlertContext);
|
||||
@@ -22,23 +25,27 @@ export default function LoginForm() {
|
||||
|
||||
if (email.length === 0) {
|
||||
errorMessage(t('loginForm.error.emailRequired'));
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
if (password.length === 0) {
|
||||
errorMessage(t('loginForm.error.passwordRequired'));
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
if (email.length < 3) {
|
||||
errorMessage(t('loginForm.error.emailLength'));
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (System.verifyInput(email) || System.verifyInput(password)) {
|
||||
if (verifyInput(email) || verifyInput(password)) {
|
||||
errorMessage(t('loginForm.error.emailInvalidChars'));
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response: string = await System.postToServer<string>('login', {
|
||||
const response: string = await apiPostPublic<string>('login', {
|
||||
email: email,
|
||||
password: password,
|
||||
}, lang)
|
||||
@@ -61,57 +68,39 @@ export default function LoginForm() {
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-5">
|
||||
<label htmlFor="email" className="block text-sm font-medium mb-1 text-muted">
|
||||
{t('loginForm.fields.email.label')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<FontAwesomeIcon icon={faEnvelope}
|
||||
className="absolute left-4 top-1/2 transform -translate-y-1/2 text-muted w-5 h-5"/>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
placeholder={t('loginForm.fields.email.placeholder')}
|
||||
className="w-full pl-12 pr-4 py-4 bg-secondary border border-gray-dark rounded-xl focus:outline-none focus:border-primary transition-colors"
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<InputField icon={Mail} fieldName={t('loginForm.fields.email.label')} input={
|
||||
<TextInput
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
setValue={(e) => setEmail(e.target.value)}
|
||||
placeholder={t('loginForm.fields.email.placeholder')}
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}/>
|
||||
|
||||
<div className="mb-5">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<label htmlFor="password" className="block text-sm font-medium text-muted">
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h3 className="text-text-secondary text-sm font-medium flex items-center gap-2">
|
||||
<Lock className="text-primary w-5 h-5" strokeWidth={1.75}/>
|
||||
{t('loginForm.fields.password.label')}
|
||||
</label>
|
||||
</h3>
|
||||
<a href="/login/reset-password" className="text-xs text-primary hover:underline">
|
||||
{t('loginForm.fields.password.forgot')}
|
||||
</a>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<FontAwesomeIcon icon={faLock}
|
||||
className="absolute left-4 top-1/2 transform -translate-y-1/2 text-muted w-5 h-5"/>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
placeholder={t('loginForm.fields.password.placeholder')}
|
||||
className="w-full pl-12 pr-4 py-4 bg-secondary border border-gray-dark rounded-xl focus:outline-none focus:border-primary transition-colors"
|
||||
className="input-base px-5 py-3 text-base rounded-xl"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className={`w-full py-4 mb-4 bg-gradient-to-r from-primary to-info text-textPrimary font-semibold rounded-xl hover:shadow-lg hover:shadow-primary/20 transition-all ${isLoading ? 'opacity-75 cursor-not-allowed' : ''}`}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? t('loginForm.loading') : t('loginForm.submit')}
|
||||
</button>
|
||||
<Button variant="primary" fullWidth size="lg" type="submit" isLoading={isLoading}>
|
||||
{t('loginForm.submit')}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,28 @@
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {faApple, faFacebookF, faGoogle} from "@fortawesome/free-brands-svg-icons";
|
||||
import React, {useContext, useEffect} from "react";
|
||||
import System from "@/lib/models/System";
|
||||
import {AlertContext} from "@/context/AlertContext";
|
||||
import {configs} from "@/lib/configs";
|
||||
import {useTranslations} from "next-intl";
|
||||
import {useTranslations} from "@/lib/i18n";
|
||||
import {LangContext, LangContextProps} from "@/context/LangContext";
|
||||
import * as tauri from '@/lib/tauri';
|
||||
import {apiPostPublic} from "@/lib/api/client";
|
||||
|
||||
const FacebookIcon = () => (
|
||||
<svg className="w-6 h-6" viewBox="0 0 320 512" fill="currentColor">
|
||||
<path d="M80 299.3V512H196V299.3h86.5l18-97.8H196V166.9c0-51.7 20.3-71.5 72.7-71.5c16.3 0 29.4.4 37 1.2V7.9C291.4 4 256.4 0 236.2 0C129.3 0 80 50.5 80 159.4v42.1H14v97.8H80z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const GoogleIcon = () => (
|
||||
<svg className="w-6 h-6" viewBox="0 0 488 512" fill="currentColor">
|
||||
<path d="M488 261.8C488 403.3 391.1 504 248 504 110.8 504 0 393.2 0 256S110.8 8 248 8c66.8 0 123 24.5 166.3 64.9l-67.5 64.9C258.5 52.6 94.3 116.6 94.3 256c0 86.5 69.1 156.6 153.7 156.6 98.2 0 135-70.4 140.8-106.9H248v-85.3h236.1c2.3 12.7 3.9 24.9 3.9 41.4z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const AppleIcon = () => (
|
||||
<svg className="w-6 h-6" viewBox="0 0 384 512" fill="currentColor">
|
||||
<path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function SocialForm() {
|
||||
const {errorMessage} = useContext(AlertContext);
|
||||
@@ -46,7 +62,7 @@ export default function SocialForm() {
|
||||
|
||||
async function handleFacebookLogin(code: string, state: string): Promise<void> {
|
||||
if (code && state) {
|
||||
const response: string = await System.postToServer<string>(`auth/facebook`, {
|
||||
const response: string = await apiPostPublic<string>(`auth/facebook`, {
|
||||
code: code,
|
||||
state: state,
|
||||
}, lang);
|
||||
@@ -60,7 +76,7 @@ export default function SocialForm() {
|
||||
|
||||
async function handleGoogleLogin(code: string): Promise<void> {
|
||||
if (code) {
|
||||
const response: string = await System.postToServer<string>(`auth/google`, {
|
||||
const response: string = await apiPostPublic<string>(`auth/google`, {
|
||||
code: code,
|
||||
}, lang);
|
||||
if (!response) {
|
||||
@@ -73,7 +89,7 @@ export default function SocialForm() {
|
||||
|
||||
async function handleAppleLogin(code: string, state: string): Promise<void> {
|
||||
if (code && state) {
|
||||
const response: string = await System.postToServer<string>(`auth/apple`, {
|
||||
const response: string = await apiPostPublic<string>(`auth/apple`, {
|
||||
code: code,
|
||||
state: state,
|
||||
}, lang);
|
||||
@@ -98,10 +114,10 @@ export default function SocialForm() {
|
||||
<div className="flex justify-center gap-3">
|
||||
<button
|
||||
onClick={() => handleOAuthClick('facebook')}
|
||||
className="flex items-center justify-center w-14 h-14 bg-[#1877F2] hover:bg-opacity-90 text-textPrimary rounded-xl transition-colors cursor-pointer"
|
||||
className="flex items-center justify-center w-14 h-14 bg-[#1877F2] hover:bg-opacity-90 text-text-primary rounded-xl transition-colors cursor-pointer"
|
||||
aria-label="Login with Facebook"
|
||||
>
|
||||
<FontAwesomeIcon icon={faFacebookF} className="w-6 h-6 text-textPrimary"/>
|
||||
<FacebookIcon/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -109,7 +125,7 @@ export default function SocialForm() {
|
||||
className="flex items-center justify-center w-14 h-14 bg-white hover:bg-opacity-90 text-[#4285F4] rounded-xl transition-colors cursor-pointer"
|
||||
aria-label="Login with Google"
|
||||
>
|
||||
<FontAwesomeIcon icon={faGoogle} className="w-6 h-6 text-[#4285F4]"/>
|
||||
<GoogleIcon/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -117,7 +133,7 @@ export default function SocialForm() {
|
||||
className="flex items-center justify-center w-14 h-14 bg-black hover:bg-opacity-90 text-white rounded-xl transition-colors cursor-pointer"
|
||||
aria-label="Login with Apple"
|
||||
>
|
||||
<FontAwesomeIcon icon={faApple} className="w-6 h-6 text-white"/>
|
||||
<AppleIcon/>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,47 +1,37 @@
|
||||
'use client'
|
||||
import {useContext, useEffect, useState} from 'react';
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {faEnvelope, faWifi, faCloudArrowUp} from "@fortawesome/free-solid-svg-icons";
|
||||
import LoginForm from "@/app/login/login/LoginForm";
|
||||
import {Mail, Lock, Wifi, CloudUpload, Globe} from "lucide-react";
|
||||
import SocialForm from "@/app/login/login/SocialForm";
|
||||
import {useTranslations} from "next-intl";
|
||||
import {LangContext} from "@/context/LangContext";
|
||||
import System from "@/lib/models/System";
|
||||
import {useTranslations} from "@/lib/i18n";
|
||||
import {AlertContext} from "@/context/AlertContext";
|
||||
import {LangContext, LangContextProps} from "@/context/LangContext";
|
||||
import {setCookie} from "@/lib/utils/cookies";
|
||||
import {verifyInput} from "@/lib/utils/validation";
|
||||
import {apiPostPublic} from "@/lib/api/client";
|
||||
import * as tauri from '@/lib/tauri';
|
||||
import Button from "@/components/ui/Button";
|
||||
import InputField from "@/components/form/InputField";
|
||||
import TextInput from "@/components/form/TextInput";
|
||||
import ToggleGroup from "@/components/ui/ToggleGroup";
|
||||
|
||||
export default function LoginPage() {
|
||||
const t = useTranslations();
|
||||
const {lang, setLang} = useContext(LangContext);
|
||||
const {errorMessage} = useContext(AlertContext);
|
||||
const {lang, setLang} = useContext<LangContextProps>(LangContext);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showOfflineWarning, setShowOfflineWarning] = useState(false);
|
||||
const [isOnline, setIsOnline] = useState(true);
|
||||
const [resetDone, setResetDone] = useState(false);
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
|
||||
const handleDevReset = async () => {
|
||||
try {
|
||||
await tauri.devResetAll();
|
||||
setResetDone(true);
|
||||
} catch (error) {
|
||||
console.error('[DevReset]', error);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleLanguage = () => {
|
||||
const newLang = lang === 'fr' ? 'en' : 'fr';
|
||||
setLang(newLang);
|
||||
System.setCookie('lang', newLang, 365);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
async function checkFirstConnectionAndNetwork() {
|
||||
try {
|
||||
const token = await tauri.getToken();
|
||||
const hasToken = !!token;
|
||||
const online = navigator.onLine;
|
||||
setIsOnline(online);
|
||||
|
||||
if (!hasToken && !online) {
|
||||
if (!token && !online) {
|
||||
setShowOfflineWarning(true);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -51,17 +41,11 @@ export default function LoginPage() {
|
||||
|
||||
checkFirstConnectionAndNetwork();
|
||||
|
||||
const handleOnline = () => {
|
||||
setIsOnline(true);
|
||||
setShowOfflineWarning(false);
|
||||
};
|
||||
const handleOnline = () => setShowOfflineWarning(false);
|
||||
const handleOffline = async () => {
|
||||
setIsOnline(false);
|
||||
try {
|
||||
const token = await tauri.getToken();
|
||||
if (!token) {
|
||||
setShowOfflineWarning(true);
|
||||
}
|
||||
if (!token) setShowOfflineWarning(true);
|
||||
} catch {
|
||||
setShowOfflineWarning(true);
|
||||
}
|
||||
@@ -69,43 +53,78 @@ export default function LoginPage() {
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent): Promise<void> {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
errorMessage('');
|
||||
|
||||
if (email.length === 0) {
|
||||
errorMessage(t('loginForm.error.emailRequired'));
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
if (password.length === 0) {
|
||||
errorMessage(t('loginForm.error.passwordRequired'));
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
if (email.length < 3) {
|
||||
errorMessage(t('loginForm.error.emailLength'));
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
if (verifyInput(email) || verifyInput(password)) {
|
||||
errorMessage(t('loginForm.error.emailInvalidChars'));
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response: string = await apiPostPublic<string>('login', {
|
||||
email: email,
|
||||
password: password,
|
||||
}, lang);
|
||||
if (!response) {
|
||||
errorMessage(t('loginForm.error.connection'));
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
await tauri.setToken(response);
|
||||
await tauri.loginSuccess();
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(t('loginForm.error.server'));
|
||||
} else {
|
||||
errorMessage(t('loginForm.error.unknown'));
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center bg-background text-textPrimary p-4">
|
||||
<main className="flex min-h-screen flex-col items-center justify-center bg-tertiary text-text-primary p-4 font-['Lora']">
|
||||
<div className="w-full max-w-md px-4 py-10">
|
||||
<div className="flex justify-center mb-8">
|
||||
<div className="w-[90%] max-w-[320px] relative">
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="ERitors"
|
||||
className="object-contain"
|
||||
style={{width: 320, height: 107}}
|
||||
/>
|
||||
</div>
|
||||
<img src="/logo.png" alt="ERitors" className="object-contain" style={{width: 320, height: 107}}/>
|
||||
</div>
|
||||
|
||||
{/* Offline warning notification */}
|
||||
{showOfflineWarning && (
|
||||
<div className="mb-6 bg-gradient-to-r from-orange-500/20 to-amber-500/20 border border-orange-500/40 rounded-xl p-4 shadow-lg shadow-orange-500/10">
|
||||
<div className="mb-6 bg-warning/10 border border-warning/30 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
<div className="relative">
|
||||
<FontAwesomeIcon icon={faWifi} className="w-5 h-5 text-orange-400" />
|
||||
<div className="absolute inset-0 w-6 h-0.5 bg-orange-400 rotate-45 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-orange-200 mb-1 flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faCloudArrowUp} className="w-4 h-4" />
|
||||
<Wifi className="w-5 h-5 text-warning mt-0.5" strokeWidth={1.75}/>
|
||||
<div>
|
||||
<h3 className="font-semibold text-warning mb-1 flex items-center gap-2">
|
||||
<CloudUpload className="w-4 h-4" strokeWidth={1.75}/>
|
||||
{t('loginPage.offlineWarning.title')}
|
||||
</h3>
|
||||
<p className="text-sm text-orange-300/90 leading-relaxed">
|
||||
<p className="text-sm text-muted leading-relaxed">
|
||||
{t('loginPage.offlineWarning.message')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -113,57 +132,80 @@ export default function LoginPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="bg-tertiary rounded-2xl overflow-hidden shadow-xl shadow-primary/10 w-full relative">
|
||||
<button
|
||||
onClick={toggleLanguage}
|
||||
className="absolute right-4 top-4 z-10 flex items-center gap-2 px-3 py-2 bg-secondary border border-gray-dark rounded-lg hover:bg-tertiary transition-colors"
|
||||
aria-label="Toggle language"
|
||||
>
|
||||
<span
|
||||
className={`text-sm font-medium ${lang === 'fr' ? 'text-primary' : 'text-muted'}`}>FR</span>
|
||||
<span className="text-muted">/</span>
|
||||
<span
|
||||
className={`text-sm font-medium ${lang === 'en' ? 'text-primary' : 'text-muted'}`}>EN</span>
|
||||
</button>
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-2 bg-gradient-to-r from-primary to-info pointer-events-none"></div>
|
||||
<div
|
||||
className="absolute -top-10 -left-10 w-40 h-40 bg-gradient-to-br from-primary/20 to-transparent rounded-full blur-xl pointer-events-none"></div>
|
||||
<div
|
||||
className="absolute -bottom-10 -right-10 w-40 h-40 bg-gradient-to-br from-info/20 to-transparent rounded-full blur-xl pointer-events-none"></div>
|
||||
|
||||
<div className="p-6 sm:p-8">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl sm:text-3xl font-bold mb-2 font-['ADLaM Display']">{t('loginPage.title')}</h1>
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-2xl sm:text-3xl font-bold mb-2 font-['ADLaM_Display']">{t('loginPage.title')}</h1>
|
||||
<p className="text-muted">{t('loginPage.welcome')}</p>
|
||||
</div>
|
||||
<LoginForm/>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="bg-darkest-background rounded-xl p-5 space-y-5">
|
||||
<div className="flex justify-end">
|
||||
<ToggleGroup
|
||||
options={[{value: 'fr', label: 'FR'}, {value: 'en', label: 'EN'}]}
|
||||
value={lang}
|
||||
onChange={(value: string) => {
|
||||
setLang(value as 'fr' | 'en');
|
||||
setCookie('lang', value, 365);
|
||||
}}
|
||||
icon={Globe}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<InputField icon={Mail} fieldName={t('loginForm.fields.email.label')} input={
|
||||
<TextInput value={email} setValue={(e) => setEmail(e.target.value)}
|
||||
placeholder={t('loginForm.fields.email.placeholder')} size="lg"/>
|
||||
}/>
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h3 className="text-text-secondary text-sm font-medium flex items-center gap-2">
|
||||
<Lock className="text-primary w-5 h-5" strokeWidth={1.75}/>
|
||||
{t('loginForm.fields.password.label')}
|
||||
</h3>
|
||||
<a href="/login/reset-password" className="text-xs text-primary hover:underline">
|
||||
{t('loginForm.fields.password.forgot')}
|
||||
</a>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
placeholder={t('loginForm.fields.password.placeholder')}
|
||||
className="input-base px-5 py-3 text-base rounded-xl"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Button variant="primary" fullWidth size="lg" type="submit" isLoading={isLoading}>
|
||||
{t('loginForm.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="relative my-6">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-dark"></div>
|
||||
<div className="w-full border-t border-secondary"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<span className="px-3 bg-tertiary text-muted text-sm">{t('loginPage.orSocial')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center gap-3 mb-6">
|
||||
<a
|
||||
href="/login/register"
|
||||
className="flex items-center justify-center w-14 h-14 bg-gradient-to-r from-primary to-info hover:shadow-lg hover:shadow-primary/20 text-textPrimary rounded-xl transition-all"
|
||||
aria-label="Register with email"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEnvelope} className="w-6 h-6 text-textPrimary"/>
|
||||
<a href="/login/register"
|
||||
className="flex items-center justify-center w-14 h-14 bg-primary-dark hover:bg-primary text-text-primary rounded-xl transition-colors duration-200">
|
||||
<Mail className="w-6 h-6" strokeWidth={1.75}/>
|
||||
</a>
|
||||
<SocialForm/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isDev && (
|
||||
resetDone
|
||||
? <span className="mt-6 text-xs text-green-400">Reset OK</span>
|
||||
: <button onClick={handleDevReset} className="mt-6 px-4 py-2 text-xs text-red-400 border border-red-400/30 rounded-lg hover:bg-red-400/10 transition-colors">
|
||||
? <span className="mt-6 text-xs text-success">Reset OK</span>
|
||||
: <button onClick={async () => { await tauri.devResetAll(); setResetDone(true); }}
|
||||
className="mt-6 px-4 py-2 text-xs text-error border border-error/30 rounded-lg hover:bg-error/10 transition-colors">
|
||||
DEV: Reset All Data
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import {useEffect} from 'react';
|
||||
import OfflinePinVerify from '@/components/offline/OfflinePinVerify';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faWifi, faArrowLeft } from '@fortawesome/free-solid-svg-icons';
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
import {Wifi, ArrowLeft} from 'lucide-react';
|
||||
import Button from '@/components/ui/Button';
|
||||
import * as tauri from '@/lib/tauri';
|
||||
|
||||
export default function OfflineLoginPage() {
|
||||
@@ -38,41 +38,29 @@ export default function OfflineLoginPage() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center bg-background text-textPrimary">
|
||||
<div className="w-full max-w-md px-4">
|
||||
{/* Offline indicator */}
|
||||
<div className="mb-6 flex items-center justify-center">
|
||||
<div className="px-4 py-2 bg-warning/10 border border-warning/30 rounded-full flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faWifi} className="w-4 h-4 text-warning" />
|
||||
<main className="flex min-h-screen flex-col items-center justify-center bg-tertiary text-text-primary p-4 font-['Lora']">
|
||||
<div className="w-full max-w-md px-4 py-10">
|
||||
<div className="flex justify-center mb-8">
|
||||
<img src="/logo.png" alt="ERitors" className="object-contain" style={{width: 320, height: 107}}/>
|
||||
</div>
|
||||
|
||||
<div className="text-center mb-6">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1.5 bg-warning/10 border border-warning/30 rounded-full mb-3">
|
||||
<Wifi className="w-4 h-4 text-warning" strokeWidth={1.75}/>
|
||||
<span className="text-sm text-warning font-medium">{t('offline.mode.title')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logo */}
|
||||
<div className="flex justify-center mb-8">
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="ERitors"
|
||||
className="object-contain w-full max-w-[240px] h-[80px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* PIN Verify Component */}
|
||||
<div className="bg-darkest-background rounded-xl p-5 mb-4">
|
||||
<OfflinePinVerify
|
||||
onSuccess={handlePinSuccess}
|
||||
onCancel={handleBackToOnline}
|
||||
/>
|
||||
|
||||
{/* Back to online link */}
|
||||
<div className="mt-6 text-center">
|
||||
<button
|
||||
onClick={handleBackToOnline}
|
||||
className="text-sm text-muted hover:text-textPrimary transition-colors inline-flex items-center gap-2"
|
||||
>
|
||||
<FontAwesomeIcon icon={faArrowLeft} className="w-3 h-3" />
|
||||
{t('offline.mode.backToOnline')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Button variant="ghost" fullWidth icon={ArrowLeft} onClick={handleBackToOnline}>
|
||||
{t('offline.mode.backToOnline')}
|
||||
</Button>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import React, {Dispatch, SetStateAction, useContext, useState} from "react";
|
||||
import System from "@/lib/models/System";
|
||||
import {AlertContext} from "@/context/AlertContext";
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {faEnvelope, faLock, faSignature, faUser} from '@fortawesome/free-solid-svg-icons';
|
||||
import {useTranslations} from "next-intl";
|
||||
import {Mail, Lock, PenLine, User} from 'lucide-react';
|
||||
import {useTranslations} from "@/lib/i18n";
|
||||
import {LangContext, LangContextProps} from "@/context/LangContext";
|
||||
import {verifyInput} from "@/lib/utils/validation";
|
||||
import {apiPostPublic} from "@/lib/api/client";
|
||||
import Button from "@/components/ui/Button";
|
||||
import InputField from "@/components/form/InputField";
|
||||
import TextInput from "@/components/form/TextInput";
|
||||
|
||||
export default function StepOne(
|
||||
{
|
||||
@@ -47,7 +50,7 @@ export default function StepOne(
|
||||
errorMessage(t('registerStepOne.error.usernameLength'));
|
||||
return;
|
||||
}
|
||||
if (System.verifyInput(firstName) || System.verifyInput(lastName) || System.verifyInput(username) || System.verifyInput(password) || System.verifyInput(repeatPassword) || System.verifyInput(email)) {
|
||||
if (verifyInput(firstName) || verifyInput(lastName) || verifyInput(username) || verifyInput(password) || verifyInput(repeatPassword) || verifyInput(email)) {
|
||||
errorMessage(t('registerStepOne.error.invalidInput'));
|
||||
return;
|
||||
}
|
||||
@@ -57,7 +60,7 @@ export default function StepOne(
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response: string = await System.postToServer<string>(`register/pre`, {
|
||||
const response: string = await apiPostPublic<string>(`register/pre`, {
|
||||
firstName,
|
||||
lastName,
|
||||
username,
|
||||
@@ -83,123 +86,62 @@ export default function StepOne(
|
||||
|
||||
return (
|
||||
<form className="space-y-4">
|
||||
<div className="mb-4">
|
||||
<label htmlFor="firstName" className="block text-sm font-medium mb-1 text-muted">
|
||||
{t('registerStepOne.fields.firstName.label')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<FontAwesomeIcon icon={faSignature}
|
||||
className="absolute left-4 top-1/2 transform -translate-y-1/2 text-muted w-5 h-5"/>
|
||||
<input
|
||||
type="text"
|
||||
id="firstName"
|
||||
placeholder={t('registerStepOne.fields.firstName.placeholder')}
|
||||
className="w-full pl-12 pr-4 py-4 bg-secondary border border-gray-dark rounded-xl focus:outline-none focus:border-primary transition-colors"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="lastName" className="block text-sm font-medium mb-1 text-muted">
|
||||
{t('registerStepOne.fields.lastName.label')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<FontAwesomeIcon icon={faSignature}
|
||||
className="absolute left-4 top-1/2 transform -translate-y-1/2 text-muted w-5 h-5"/>
|
||||
<input
|
||||
type="text"
|
||||
id="lastName"
|
||||
placeholder={t('registerStepOne.fields.lastName.placeholder')}
|
||||
className="w-full pl-12 pr-4 py-4 bg-secondary border border-gray-dark rounded-xl focus:outline-none focus:border-primary transition-colors"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="username" className="block text-sm font-medium mb-1 text-muted">
|
||||
{t('registerStepOne.fields.username.label')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<FontAwesomeIcon icon={faUser}
|
||||
className="absolute left-4 top-1/2 transform -translate-y-1/2 text-muted w-5 h-5"/>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
placeholder={t('registerStepOne.fields.username.placeholder')}
|
||||
className="w-full pl-12 pr-4 py-4 bg-secondary border border-gray-dark rounded-xl focus:outline-none focus:border-primary transition-colors"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<InputField icon={PenLine} fieldName={t('registerStepOne.fields.firstName.label')} input={
|
||||
<TextInput value={firstName} setValue={(e) => setFirstName(e.target.value)}
|
||||
placeholder={t('registerStepOne.fields.firstName.placeholder')}/>
|
||||
}/>
|
||||
|
||||
<InputField icon={PenLine} fieldName={t('registerStepOne.fields.lastName.label')} input={
|
||||
<TextInput value={lastName} setValue={(e) => setLastName(e.target.value)}
|
||||
placeholder={t('registerStepOne.fields.lastName.placeholder')}/>
|
||||
}/>
|
||||
|
||||
<div>
|
||||
<InputField icon={User} fieldName={t('registerStepOne.fields.username.label')} input={
|
||||
<TextInput value={username} setValue={(e) => setUsername(e.target.value)}
|
||||
placeholder={t('registerStepOne.fields.username.placeholder')}/>
|
||||
}/>
|
||||
<p className="text-xs text-muted mt-1">{t('registerStepOne.fields.username.note')}</p>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="email" className="block text-sm font-medium mb-1 text-muted">
|
||||
{t('registerStepOne.fields.email.label')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<FontAwesomeIcon icon={faEnvelope}
|
||||
className="absolute left-4 top-1/2 transform -translate-y-1/2 text-muted w-5 h-5"/>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
placeholder={t('registerStepOne.fields.email.placeholder')}
|
||||
className="w-full pl-12 pr-4 py-4 bg-secondary border border-gray-dark rounded-xl focus:outline-none focus:border-primary transition-colors"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label htmlFor="password" className="block text-sm font-medium mb-1 text-muted">
|
||||
<InputField icon={Mail} fieldName={t('registerStepOne.fields.email.label')} input={
|
||||
<TextInput value={email} setValue={(e) => setEmail(e.target.value)}
|
||||
placeholder={t('registerStepOne.fields.email.placeholder')}/>
|
||||
}/>
|
||||
|
||||
<div>
|
||||
<h3 className="text-text-secondary text-sm font-medium flex items-center gap-2 mb-2">
|
||||
<Lock className="text-primary w-5 h-5" strokeWidth={1.75}/>
|
||||
{t('registerStepOne.fields.password.label')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<FontAwesomeIcon icon={faLock}
|
||||
className="absolute left-4 top-1/2 transform -translate-y-1/2 text-muted w-5 h-5"/>
|
||||
</h3>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
placeholder={t('registerStepOne.fields.password.placeholder')}
|
||||
className="w-full pl-12 pr-4 py-4 bg-secondary border border-gray-dark rounded-xl focus:outline-none focus:border-primary transition-colors"
|
||||
className="input-base"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="repeatPassword" className="block text-sm font-medium mb-1 text-muted">
|
||||
|
||||
<div>
|
||||
<h3 className="text-text-secondary text-sm font-medium flex items-center gap-2 mb-2">
|
||||
<Lock className="text-primary w-5 h-5" strokeWidth={1.75}/>
|
||||
{t('registerStepOne.fields.repeatPassword.label')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<FontAwesomeIcon icon={faLock}
|
||||
className="absolute left-4 top-1/2 transform -translate-y-1/2 text-muted w-5 h-5"/>
|
||||
</h3>
|
||||
<input
|
||||
type="password"
|
||||
id="repeatPassword"
|
||||
placeholder={t('registerStepOne.fields.repeatPassword.placeholder')}
|
||||
className="w-full pl-12 pr-4 py-4 bg-secondary border border-gray-dark rounded-xl focus:outline-none focus:border-primary transition-colors"
|
||||
className="input-base"
|
||||
value={repeatPassword}
|
||||
onChange={(e) => setRepeatPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleStep()}
|
||||
className="w-full py-4 mt-6 bg-gradient-to-r from-primary to-info text-textPrimary font-semibold rounded-xl hover:shadow-lg hover:shadow-primary/20 transition-all"
|
||||
>
|
||||
|
||||
<Button variant="primary" fullWidth size="lg" onClick={() => handleStep()}>
|
||||
{t('registerStepOne.next')}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import React, {useContext, useState} from "react";
|
||||
import Link from "next/link";
|
||||
import System from "@/lib/models/System";
|
||||
import {Link} from "@/lib/navigation";
|
||||
import {AlertContext} from "@/context/AlertContext";
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {faKey} from '@fortawesome/free-solid-svg-icons';
|
||||
import {useTranslations} from "next-intl";
|
||||
import {KeyRound} from 'lucide-react';
|
||||
import {useTranslations} from "@/lib/i18n";
|
||||
import {LangContext, LangContextProps} from "@/context/LangContext";
|
||||
import {apiPostPublic} from "@/lib/api/client";
|
||||
import Button from "@/components/ui/Button";
|
||||
import InputField from "@/components/form/InputField";
|
||||
import TextInput from "@/components/form/TextInput";
|
||||
|
||||
export default function StepTree(
|
||||
{
|
||||
@@ -29,7 +31,7 @@ export default function StepTree(
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response: boolean = await System.postToServer<boolean>('register/verify-code', {
|
||||
const response: boolean = await apiPostPublic<boolean>('register/verify-code', {
|
||||
verifyCode,
|
||||
email,
|
||||
}, lang);
|
||||
@@ -56,56 +58,31 @@ export default function StepTree(
|
||||
<p className="text-muted text-sm mt-2">{t('registerStepTwo.instructions.checkInbox')}</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label htmlFor="verifyCode" className="block text-sm font-medium mb-1 text-muted">
|
||||
{t('registerStepTwo.fields.code.label')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<FontAwesomeIcon icon={faKey}
|
||||
className="absolute left-4 top-1/2 transform -translate-y-1/2 text-muted w-5 h-5"/>
|
||||
<input
|
||||
type="text"
|
||||
id="verifyCode"
|
||||
placeholder={t('registerStepTwo.fields.code.placeholder')}
|
||||
className="w-full pl-12 pr-4 py-4 bg-secondary border border-gray-dark rounded-xl focus:outline-none focus:border-primary transition-colors"
|
||||
value={verifyCode}
|
||||
onChange={(e) => setVerifyCode(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<InputField icon={KeyRound} fieldName={t('registerStepTwo.fields.code.label')} input={
|
||||
<TextInput value={verifyCode} setValue={(e) => setVerifyCode(e.target.value)}
|
||||
placeholder={t('registerStepTwo.fields.code.placeholder')}/>
|
||||
}/>
|
||||
|
||||
<div className="flex flex-col space-y-3 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleVerifyCode()}
|
||||
className="w-full py-4 bg-gradient-to-r from-primary to-info text-textPrimary font-semibold rounded-xl hover:shadow-lg hover:shadow-primary/20 transition-all"
|
||||
>
|
||||
<Button variant="primary" fullWidth size="lg" onClick={() => handleVerifyCode()}>
|
||||
{t('registerStepTwo.verify')}
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => prevStep()}
|
||||
className="w-full py-4 bg-secondary text-textPrimary font-semibold rounded-xl hover:bg-gray-dark transition-colors"
|
||||
>
|
||||
<Button variant="secondary" fullWidth size="lg" onClick={() => prevStep()}>
|
||||
{t('registerStepTwo.back')}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="p-4 bg-success/20 border border-success rounded-xl">
|
||||
<p className="text-center text-text">{t('registerStepTwo.confirmed')}</p>
|
||||
<p className="text-center text-text-primary">{t('registerStepTwo.confirmed')}</p>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<Link href="/login" className="block w-full">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full py-4 bg-gradient-to-r from-primary to-info text-textPrimary font-semibold rounded-xl hover:shadow-lg hover:shadow-primary/20 transition-all"
|
||||
>
|
||||
<Button variant="primary" fullWidth size="lg">
|
||||
{t('registerStepTwo.start')}
|
||||
</button>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,94 +1,201 @@
|
||||
'use client'
|
||||
import {useState} from 'react';
|
||||
import StepOne from "./StepOne";
|
||||
import StepTree from "@/app/login/register/StepTree";
|
||||
import SocialForm from "@/app/login/login/SocialForm";
|
||||
import {useTranslations} from "next-intl";
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {faArrowLeft} from '@fortawesome/free-solid-svg-icons';
|
||||
import {useContext, useState} from 'react';
|
||||
import {Mail, Lock, User, KeyRound, ArrowLeft} from 'lucide-react';
|
||||
import {useTranslations} from "@/lib/i18n";
|
||||
import {AlertContext} from "@/context/AlertContext";
|
||||
import {LangContext, LangContextProps} from "@/context/LangContext";
|
||||
import {verifyInput} from "@/lib/utils/validation";
|
||||
import {apiPostPublic} from "@/lib/api/client";
|
||||
import {Link} from "@/lib/navigation";
|
||||
import Button from "@/components/ui/Button";
|
||||
import InputField from "@/components/form/InputField";
|
||||
import TextInput from "@/components/form/TextInput";
|
||||
|
||||
export default function Register() {
|
||||
const t = useTranslations();
|
||||
const {errorMessage, successMessage} = useContext(AlertContext);
|
||||
const {lang} = useContext<LangContextProps>(LangContext);
|
||||
|
||||
const [step, setStep] = useState<number>(1);
|
||||
const [username, setUsername] = useState<string>('');
|
||||
const [email, setEmail] = useState<string>('');
|
||||
const [step, setStep] = useState<number>(1);
|
||||
const [password, setPassword] = useState<string>('');
|
||||
const [repeatPassword, setRepeatPassword] = useState<string>('');
|
||||
const [verifyCode, setVerifyCode] = useState<string>('');
|
||||
const [isConfirmed, setIsConfirmed] = useState<boolean>(false);
|
||||
|
||||
function handleNextStep(): void {
|
||||
setStep(step + 1);
|
||||
async function handleStepOne(): Promise<void> {
|
||||
if (!username || !password || !repeatPassword || !email) {
|
||||
errorMessage(t('registerStepOne.error.requiredFields'));
|
||||
return;
|
||||
}
|
||||
if (username.length < 3 || username.length > 50) {
|
||||
errorMessage(t('registerStepOne.error.usernameLength'));
|
||||
return;
|
||||
}
|
||||
if (verifyInput(username) || verifyInput(password) || verifyInput(repeatPassword) || verifyInput(email)) {
|
||||
errorMessage(t('registerStepOne.error.invalidInput'));
|
||||
return;
|
||||
}
|
||||
if (password !== repeatPassword) {
|
||||
errorMessage(t('registerStepOne.error.passwordMismatch'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response: string = await apiPostPublic<string>('register/pre', {
|
||||
username, email, password, retypePass: repeatPassword
|
||||
}, lang);
|
||||
if (!response) {
|
||||
errorMessage(t('registerStepOne.error.preRegister'));
|
||||
return;
|
||||
}
|
||||
successMessage(t('registerStepOne.success.preRegister'));
|
||||
setStep(2);
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
} else {
|
||||
errorMessage(t('registerStepOne.error.unknown'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handlePrevStep(): void {
|
||||
setStep(step - 1);
|
||||
async function handleVerifyCode(): Promise<void> {
|
||||
if (verifyCode === '') {
|
||||
errorMessage(t('registerStepTwo.error.codeIncorrect'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response: boolean = await apiPostPublic<boolean>('register/verify-code', {
|
||||
verifyCode, email,
|
||||
}, lang);
|
||||
if (!response) {
|
||||
errorMessage(t('registerStepTwo.error.codeIncorrect'));
|
||||
return;
|
||||
}
|
||||
setIsConfirmed(true);
|
||||
successMessage(t('registerStepTwo.success.verified'));
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
} else {
|
||||
errorMessage(t('registerStepTwo.error.unknown'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center bg-background text-textPrimary p-4">
|
||||
<main className="flex min-h-screen flex-col items-center justify-center bg-tertiary text-text-primary p-4 font-['Lora']">
|
||||
<div className="w-full max-w-md px-4 py-10">
|
||||
<div className="flex justify-center mb-8">
|
||||
<div className="w-[90%] max-w-[320px]">
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="ERitors"
|
||||
className="w-full h-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
<img src="/logo.png" alt="ERitors" className="object-contain" style={{width: 320, height: 107}}/>
|
||||
</div>
|
||||
|
||||
<div className="bg-tertiary rounded-2xl overflow-hidden shadow-xl shadow-primary/10 w-full relative">
|
||||
<div className="absolute top-0 left-0 right-0 h-2 bg-gradient-to-r from-primary to-info"></div>
|
||||
<div
|
||||
className="absolute -top-10 -left-10 w-40 h-40 bg-gradient-to-br from-primary/20 to-transparent rounded-full blur-xl"></div>
|
||||
<div
|
||||
className="absolute -bottom-10 -right-10 w-40 h-40 bg-gradient-to-br from-info/20 to-transparent rounded-full blur-xl"></div>
|
||||
|
||||
<div className="p-6 sm:p-8">
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-2xl sm:text-3xl font-bold mb-2 font-['ADLaM Display']">{t('registerPage.title')}</h1>
|
||||
<p className="text-muted">{t('registerPage.subtitle')}</p>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold mb-2 font-['ADLaM_Display'] tracking-wide">{t('registerPage.title')}</h1>
|
||||
<p className="text-muted text-sm">{t('registerPage.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className={`flex-grow h-2 rounded-full ${step >= 1 ? 'bg-gradient-to-r from-primary to-info' : 'bg-secondary'}`}></div>
|
||||
<div className="w-4"></div>
|
||||
<div
|
||||
className={`flex-grow h-2 rounded-full ${step >= 2 ? 'bg-gradient-to-r from-primary to-info' : 'bg-secondary'}`}></div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mt-2 text-xs text-muted">
|
||||
<span>{t('registerPage.progress.infos')}</span>
|
||||
<span>{t('registerPage.progress.verif')}</span>
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
step === 1 && <SocialForm/>
|
||||
}
|
||||
{
|
||||
step === 1 && (
|
||||
{step === 1 && !isConfirmed && (
|
||||
<>
|
||||
<StepOne username={username}
|
||||
email={email}
|
||||
setUsername={setUsername}
|
||||
setEmail={setEmail}
|
||||
handleNextStep={handleNextStep}
|
||||
/>
|
||||
<a
|
||||
href="/login/login"
|
||||
className="w-full py-4 mt-3 bg-secondary text-textPrimary font-semibold rounded-xl hover:bg-gray-dark transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<FontAwesomeIcon icon={faArrowLeft} className="w-4 h-4" />
|
||||
<div className="overflow-hidden rounded-xl mb-4">
|
||||
<div className="flex items-center">
|
||||
<div className={`flex-grow h-2 ${step >= 1 ? 'bg-primary' : 'bg-secondary'}`}></div>
|
||||
<div className="w-1 bg-tertiary"></div>
|
||||
<div className={`flex-grow h-2 ${step >= 2 ? 'bg-primary' : 'bg-secondary'}`}></div>
|
||||
</div>
|
||||
<div className="bg-darkest-background p-5 space-y-4">
|
||||
<div>
|
||||
<InputField icon={User} fieldName={t('registerStepOne.fields.username.label')} input={
|
||||
<TextInput value={username} setValue={(e) => setUsername(e.target.value)}
|
||||
placeholder={t('registerStepOne.fields.username.placeholder')}/>
|
||||
}/>
|
||||
<p className="text-xs text-muted mt-1">{t('registerStepOne.fields.username.note')}</p>
|
||||
</div>
|
||||
<InputField icon={Mail} fieldName={t('registerStepOne.fields.email.label')} input={
|
||||
<TextInput value={email} setValue={(e) => setEmail(e.target.value)}
|
||||
placeholder={t('registerStepOne.fields.email.placeholder')}/>
|
||||
}/>
|
||||
<div>
|
||||
<h3 className="text-text-secondary text-sm font-medium flex items-center gap-2 mb-2">
|
||||
<Lock className="text-primary w-5 h-5" strokeWidth={1.75}/>
|
||||
{t('registerStepOne.fields.password.label')}
|
||||
</h3>
|
||||
<input type="password" placeholder={t('registerStepOne.fields.password.placeholder')}
|
||||
className="input-base" value={password}
|
||||
onChange={(e) => setPassword(e.target.value)} required/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-text-secondary text-sm font-medium flex items-center gap-2 mb-2">
|
||||
<Lock className="text-primary w-5 h-5" strokeWidth={1.75}/>
|
||||
{t('registerStepOne.fields.repeatPassword.label')}
|
||||
</h3>
|
||||
<input type="password" placeholder={t('registerStepOne.fields.repeatPassword.placeholder')}
|
||||
className="input-base" value={repeatPassword}
|
||||
onChange={(e) => setRepeatPassword(e.target.value)} required/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button variant="primary" fullWidth size="lg" onClick={handleStepOne}>
|
||||
{t('registerStepOne.next')}
|
||||
</Button>
|
||||
<a href="/login/login" className="block w-full">
|
||||
<Button variant="secondary" fullWidth icon={ArrowLeft}>
|
||||
{t('registerPage.backToLogin')}
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
step === 2 && (
|
||||
<StepTree email={email} prevStep={handlePrevStep}/>
|
||||
)
|
||||
}
|
||||
)}
|
||||
|
||||
{step === 2 && !isConfirmed && (
|
||||
<>
|
||||
<div className="overflow-hidden rounded-xl mb-4">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-grow h-2 bg-primary"></div>
|
||||
<div className="w-1 bg-tertiary"></div>
|
||||
<div className="flex-grow h-2 bg-primary"></div>
|
||||
</div>
|
||||
<div className="bg-darkest-background p-5 space-y-4">
|
||||
<div className="text-center">
|
||||
<p className="text-muted">{t('registerStepTwo.instructions.sent')}</p>
|
||||
<p className="text-muted text-sm mt-2">{t('registerStepTwo.instructions.checkInbox')}</p>
|
||||
</div>
|
||||
<InputField icon={KeyRound} fieldName={t('registerStepTwo.fields.code.label')} input={
|
||||
<TextInput value={verifyCode} setValue={(e) => setVerifyCode(e.target.value)}
|
||||
placeholder={t('registerStepTwo.fields.code.placeholder')}/>
|
||||
}/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button variant="primary" fullWidth size="lg" onClick={handleVerifyCode}>
|
||||
{t('registerStepTwo.verify')}
|
||||
</Button>
|
||||
<Button variant="secondary" fullWidth onClick={() => setStep(1)}>
|
||||
{t('registerStepTwo.back')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isConfirmed && (
|
||||
<>
|
||||
<div className="bg-darkest-background rounded-xl p-5 mb-4">
|
||||
<div className="p-4 bg-success/20 border border-success rounded-xl">
|
||||
<p className="text-center text-text-primary">{t('registerStepTwo.confirmed')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link href="/login" className="block w-full">
|
||||
<Button variant="primary" fullWidth size="lg">
|
||||
{t('registerStepTwo.start')}
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
'use client'
|
||||
import {useContext, useState} from "react";
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {faEnvelope, faKey, faLock, faArrowLeft} from '@fortawesome/free-solid-svg-icons';
|
||||
import System from "@/lib/models/System";
|
||||
import {Mail, KeyRound, Lock, ArrowLeft} from 'lucide-react';
|
||||
import {QueryDataResponse} from "@/shared/interface";
|
||||
import {AlertContext} from "@/context/AlertContext";
|
||||
import {useTranslations} from "next-intl";
|
||||
import {useTranslations} from "@/lib/i18n";
|
||||
import {LangContext, LangContextProps} from "@/context/LangContext";
|
||||
import {apiPostPublic} from "@/lib/api/client";
|
||||
import Button from "@/components/ui/Button";
|
||||
import InputField from "@/components/form/InputField";
|
||||
import TextInput from "@/components/form/TextInput";
|
||||
|
||||
export default function ForgetPasswordPage() {
|
||||
const [step, setStep] = useState(1);
|
||||
@@ -18,19 +20,37 @@ export default function ForgetPasswordPage() {
|
||||
const t = useTranslations();
|
||||
const {lang} = useContext<LangContextProps>(LangContext)
|
||||
|
||||
function handleNextStep(): void {
|
||||
setStep(step + 1);
|
||||
async function handleEmailCheck(): Promise<void> {
|
||||
if (!email) {
|
||||
errorMessage(t('resetPassword.error.emailInvalid'));
|
||||
return;
|
||||
}
|
||||
|
||||
function handlePrevStep(): void {
|
||||
setStep(step - 1);
|
||||
const emailRegEx = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegEx.test(email)) {
|
||||
errorMessage(t('resetPassword.error.emailFormat'));
|
||||
return;
|
||||
}
|
||||
|
||||
async function handleConfirm() {
|
||||
try {
|
||||
const response: QueryDataResponse<null> = await System.postToServer<QueryDataResponse<null>>('user/verify-code', {
|
||||
verifyCode: verificationCode,
|
||||
email,
|
||||
const response: QueryDataResponse<null> = await apiPostPublic<QueryDataResponse<null>>('user/email-check', {email}, lang);
|
||||
if (response.valid) {
|
||||
successMessage(response.message ?? '');
|
||||
setStep(2);
|
||||
} else {
|
||||
errorMessage(response.message ?? '');
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(t('resetPassword.error.emailServer'));
|
||||
} else {
|
||||
errorMessage(t('resetPassword.error.emailUnknown'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfirm(): Promise<void> {
|
||||
try {
|
||||
const response: QueryDataResponse<null> = await apiPostPublic<QueryDataResponse<null>>('user/verify-code', {
|
||||
verifyCode: verificationCode, email,
|
||||
}, lang);
|
||||
if (response.valid) {
|
||||
successMessage(response.message ?? '');
|
||||
@@ -47,48 +67,14 @@ export default function ForgetPasswordPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEmailCheck(): Promise<void> {
|
||||
if (email == null || email == "") {
|
||||
errorMessage(t('resetPassword.error.emailInvalid'));
|
||||
return;
|
||||
}
|
||||
|
||||
const emailRegEx = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
if (!emailRegEx.test(email)) {
|
||||
errorMessage(t('resetPassword.error.emailFormat'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response: QueryDataResponse<null> = await System.postToServer<QueryDataResponse<null>>('user/email-check', {
|
||||
email: email
|
||||
}, lang);
|
||||
if (response.valid) {
|
||||
successMessage(response.message ?? '');
|
||||
handleNextStep();
|
||||
} else {
|
||||
errorMessage(response.message ?? '');
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(t('resetPassword.error.emailServer'));
|
||||
} else {
|
||||
errorMessage(t('resetPassword.error.emailUnknown'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNewPassword(): Promise<void> {
|
||||
try {
|
||||
const response: QueryDataResponse<null> = await System.postToServer('password/reset', {
|
||||
email: email,
|
||||
newPassword: newPassword,
|
||||
code: verificationCode
|
||||
const response: QueryDataResponse<null> = await apiPostPublic<QueryDataResponse<null>>('password/reset', {
|
||||
email, newPassword, code: verificationCode
|
||||
}, lang);
|
||||
if (response.valid) {
|
||||
successMessage(response.message ?? '');
|
||||
handleNextStep();
|
||||
setStep(3);
|
||||
} else {
|
||||
errorMessage(response.message ?? '');
|
||||
}
|
||||
@@ -102,176 +88,103 @@ export default function ForgetPasswordPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center bg-background text-textPrimary p-4">
|
||||
<main className="flex min-h-screen flex-col items-center justify-center bg-tertiary text-text-primary p-4 font-['Lora']">
|
||||
<div className="w-full max-w-md px-4 py-10">
|
||||
<div className="flex justify-center mb-8">
|
||||
<div className="w-[90%] max-w-[320px]">
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="ERitors"
|
||||
className="w-full h-auto object-contain"
|
||||
/>
|
||||
<img src="/logo.png" alt="ERitors" className="object-contain" style={{width: 320, height: 107}}/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-tertiary rounded-2xl overflow-hidden shadow-xl shadow-primary/10 w-full relative">
|
||||
<div className="absolute top-0 left-0 right-0 h-2 bg-gradient-to-r from-primary to-info"></div>
|
||||
<div
|
||||
className="absolute -top-10 -left-10 w-40 h-40 bg-gradient-to-br from-primary/20 to-transparent rounded-full blur-xl"></div>
|
||||
<div
|
||||
className="absolute -bottom-10 -right-10 w-40 h-40 bg-gradient-to-br from-info/20 to-transparent rounded-full blur-xl"></div>
|
||||
|
||||
<div className="p-6 sm:p-8">
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-2xl sm:text-3xl font-bold mb-2 font-['ADLaM Display']">{t('resetPassword.title')}</h1>
|
||||
<p className="text-muted">{t('resetPassword.subtitle')}</p>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold mb-2 font-['ADLaM_Display'] tracking-wide">{t('resetPassword.title')}</h1>
|
||||
<p className="text-muted text-sm">{t('resetPassword.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
{step < 3 && (
|
||||
<>
|
||||
<div className="overflow-hidden rounded-xl mb-4">
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className={`flex-grow h-2 rounded-full ${step >= 1 ? 'bg-gradient-to-r from-primary to-info' : 'bg-secondary'}`}></div>
|
||||
<div className="w-4"></div>
|
||||
<div
|
||||
className={`flex-grow h-2 rounded-full ${step >= 2 ? 'bg-gradient-to-r from-primary to-info' : 'bg-secondary'}`}></div>
|
||||
<div className="w-4"></div>
|
||||
<div
|
||||
className={`flex-grow h-2 rounded-full ${step >= 3 ? 'bg-gradient-to-r from-primary to-info' : 'bg-secondary'}`}></div>
|
||||
<div className={`flex-grow h-2 ${step >= 1 ? 'bg-primary' : 'bg-secondary'}`}></div>
|
||||
<div className="w-1 bg-tertiary"></div>
|
||||
<div className={`flex-grow h-2 ${step >= 2 ? 'bg-primary' : 'bg-secondary'}`}></div>
|
||||
<div className="w-1 bg-tertiary"></div>
|
||||
<div className={`flex-grow h-2 ${step >= 3 ? 'bg-primary' : 'bg-secondary'}`}></div>
|
||||
</div>
|
||||
<div className="flex justify-between mt-2 text-xs text-muted">
|
||||
<span>{t('resetPassword.progress.email')}</span>
|
||||
<span>{t('resetPassword.progress.verification')}</span>
|
||||
<span>{t('resetPassword.progress.final')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-darkest-background p-5 space-y-4">
|
||||
{step === 1 && (
|
||||
<form className="space-y-4">
|
||||
<div className="mb-5">
|
||||
<label htmlFor="email" className="block text-sm font-medium mb-1 text-muted">
|
||||
{t('resetPassword.fields.email.label')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<FontAwesomeIcon icon={faEnvelope}
|
||||
className="absolute left-4 top-1/2 transform -translate-y-1/2 text-muted w-5 h-5"/>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
placeholder={t('resetPassword.fields.email.placeholder')}
|
||||
className="w-full pl-12 pr-4 py-4 bg-secondary border border-gray-dark rounded-xl focus:outline-none focus:border-primary transition-colors"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleEmailCheck}
|
||||
className="w-full py-4 bg-gradient-to-r from-primary to-info text-textPrimary font-semibold rounded-xl hover:shadow-lg hover:shadow-primary/20 transition-all"
|
||||
>
|
||||
{t('resetPassword.verify')}
|
||||
</button>
|
||||
|
||||
<a
|
||||
href="/login/login"
|
||||
className="w-full py-4 bg-secondary text-textPrimary font-semibold rounded-xl hover:bg-gray-dark transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<FontAwesomeIcon icon={faArrowLeft} className="w-4 h-4" />
|
||||
{t('resetPassword.backToLogin')}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
<InputField icon={Mail} fieldName={t('resetPassword.fields.email.label')} input={
|
||||
<TextInput value={email} setValue={(e) => setEmail(e.target.value)}
|
||||
placeholder={t('resetPassword.fields.email.placeholder')}/>
|
||||
}/>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<form className="space-y-4">
|
||||
<div className="mb-5">
|
||||
<label htmlFor="verificationCode"
|
||||
className="block text-sm font-medium mb-1 text-muted">
|
||||
{t('resetPassword.fields.code.label')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<FontAwesomeIcon icon={faKey}
|
||||
className="absolute left-4 top-1/2 transform -translate-y-1/2 text-muted w-5 h-5"/>
|
||||
<input
|
||||
type="text"
|
||||
id="verificationCode"
|
||||
<>
|
||||
<InputField icon={KeyRound} fieldName={t('resetPassword.fields.code.label')} input={
|
||||
<TextInput value={verificationCode}
|
||||
setValue={(e) => setVerificationCode(e.target.value)}
|
||||
placeholder={t('resetPassword.fields.code.placeholder')}
|
||||
className="w-full pl-12 pr-4 py-4 bg-secondary border border-gray-dark rounded-xl focus:outline-none focus:border-primary transition-colors"
|
||||
value={verificationCode}
|
||||
onChange={(e) => setVerificationCode(e.target.value)}
|
||||
disabled={isConfirmed}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
disabled={isConfirmed}/>
|
||||
}/>
|
||||
{isConfirmed && (
|
||||
<div className="mb-5">
|
||||
<label htmlFor="newPassword"
|
||||
className="block text-sm font-medium mb-1 text-muted">
|
||||
<div>
|
||||
<h3 className="text-text-secondary text-sm font-medium flex items-center gap-2 mb-2">
|
||||
<Lock className="text-primary w-5 h-5" strokeWidth={1.75}/>
|
||||
{t('resetPassword.fields.newPassword.label')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<FontAwesomeIcon icon={faLock}
|
||||
className="absolute left-4 top-1/2 transform -translate-y-1/2 text-muted w-5 h-5"/>
|
||||
<input
|
||||
type="password"
|
||||
id="newPassword"
|
||||
</h3>
|
||||
<input type="password"
|
||||
placeholder={t('resetPassword.fields.newPassword.placeholder')}
|
||||
className="w-full pl-12 pr-4 py-4 bg-secondary border border-gray-dark rounded-xl focus:outline-none focus:border-primary transition-colors"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
className="input-base" value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)} required/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col space-y-3 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={isConfirmed ? handleNewPassword : handleConfirm}
|
||||
className="w-full py-4 bg-gradient-to-r from-primary to-info text-textPrimary font-semibold rounded-xl hover:shadow-lg hover:shadow-primary/20 transition-all"
|
||||
>
|
||||
{isConfirmed ? t('resetPassword.changePassword') : t('resetPassword.confirm')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePrevStep}
|
||||
className="w-full py-4 bg-secondary text-textPrimary font-semibold rounded-xl hover:bg-gray-dark transition-colors"
|
||||
>
|
||||
{t('resetPassword.back')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{step === 1 && (
|
||||
<>
|
||||
<Button variant="primary" fullWidth size="lg" onClick={handleEmailCheck}>
|
||||
{t('resetPassword.verify')}
|
||||
</Button>
|
||||
<a href="/login/login" className="block w-full">
|
||||
<Button variant="secondary" fullWidth icon={ArrowLeft}>
|
||||
{t('resetPassword.backToLogin')}
|
||||
</Button>
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<>
|
||||
<Button variant="primary" fullWidth size="lg"
|
||||
onClick={isConfirmed ? handleNewPassword : handleConfirm}>
|
||||
{isConfirmed ? t('resetPassword.changePassword') : t('resetPassword.confirm')}
|
||||
</Button>
|
||||
<Button variant="secondary" fullWidth onClick={() => setStep(1)}>
|
||||
{t('resetPassword.back')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="space-y-6">
|
||||
<>
|
||||
<div className="bg-darkest-background rounded-xl p-5 mb-4">
|
||||
<div className="p-4 bg-success/20 border border-success rounded-xl">
|
||||
<p className="text-center text-text">
|
||||
{t('resetPassword.success')}
|
||||
</p>
|
||||
<p className="text-center text-text-primary">{t('resetPassword.success')}</p>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
</div>
|
||||
|
||||
<a href="/login/login" className="block w-full">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full py-4 bg-gradient-to-r from-primary to-info text-textPrimary font-semibold rounded-xl hover:shadow-lg hover:shadow-primary/20 transition-all"
|
||||
>
|
||||
<Button variant="primary" fullWidth size="lg">
|
||||
{t('resetPassword.goToLogin')}
|
||||
</button>
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
797
app/page.tsx
797
app/page.tsx
@@ -1,797 +1,6 @@
|
||||
'use client';
|
||||
import {useCallback, useContext, useEffect, useRef, 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 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 {NextIntlClientProvider, useTranslations} from "next-intl";
|
||||
import {LangContext} from "@/context/LangContext";
|
||||
import {AIUsageContext} from "@/context/AIUsageContext";
|
||||
import OfflineProvider from "@/context/OfflineProvider";
|
||||
import OfflineContext, {OfflineMode} from "@/context/OfflineContext";
|
||||
import OfflinePinSetup from "@/components/offline/OfflinePinSetup";
|
||||
import OfflinePinVerify from "@/components/offline/OfflinePinVerify";
|
||||
import {SyncedBook, BookSyncCompare, compareBookSyncs} from "@/lib/models/SyncedBook";
|
||||
import {SyncedSeries, SeriesSyncCompare, compareSeriesSyncs} from "@/lib/models/SyncedSeries";
|
||||
import {BooksSyncContext} from "@/context/BooksSyncContext";
|
||||
import {SeriesSyncContext} from "@/context/SeriesSyncContext";
|
||||
import useSyncBooks from "@/hooks/useSyncBooks";
|
||||
import useSyncSeries from "@/hooks/useSyncSeries";
|
||||
import {LocalSyncQueueContext, LocalSyncOperation} from "@/context/SyncQueueContext";
|
||||
import * as tauri from '@/lib/tauri';
|
||||
import BookList from '@/components/book/BookList';
|
||||
|
||||
interface RemovedItemRecord {
|
||||
removal_id: string;
|
||||
table_name: string;
|
||||
entity_id: string;
|
||||
book_id: string | null;
|
||||
user_id: string;
|
||||
deleted_at: number;
|
||||
}
|
||||
|
||||
interface SyncedBooksResponse {
|
||||
books: SyncedBook[];
|
||||
tombstones: RemovedItemRecord[];
|
||||
}
|
||||
|
||||
interface SyncedSeriesResponse {
|
||||
series: SyncedSeries[];
|
||||
tombstones: RemovedItemRecord[];
|
||||
}
|
||||
|
||||
const messagesMap = {
|
||||
fr: frMessages,
|
||||
en: enMessages
|
||||
};
|
||||
|
||||
function AutoSyncOnReconnect() {
|
||||
const {offlineMode} = useContext(OfflineContext);
|
||||
const {session} = useContext(SessionContext);
|
||||
const {syncAllToServer: syncAllBooksToServer, syncAllFromServer: syncAllBooksFromServer, refreshBooks, booksToSyncToServer, booksToSyncFromServer} = useSyncBooks();
|
||||
const {syncAllToServer: syncAllSeriesToServer, syncAllFromServer: syncAllSeriesFromServer, refreshSeries, seriesToSyncToServer, seriesToSyncFromServer} = useSyncSeries();
|
||||
const isSyncingRef = useRef<boolean>(false);
|
||||
const hasRefreshedRef = useRef<boolean>(false);
|
||||
|
||||
const saveLastOnlineTimestamp = useCallback((): void => {
|
||||
const timestamp: number = Math.floor(Date.now() / 1000);
|
||||
localStorage.setItem('lastOnlineTimestamp', timestamp.toString());
|
||||
}, []);
|
||||
|
||||
// Refresh sync data when online + authenticated + DB ready
|
||||
useEffect((): void => {
|
||||
if (!offlineMode.isOffline && session.isConnected && offlineMode.isDatabaseInitialized) {
|
||||
hasRefreshedRef.current = true;
|
||||
Promise.all([refreshBooks(), refreshSeries()]);
|
||||
}
|
||||
}, [offlineMode.isOffline, session.isConnected, offlineMode.isDatabaseInitialized]);
|
||||
|
||||
// Auto-sync when diffs become available (reactive, no flags)
|
||||
useEffect((): void => {
|
||||
if (offlineMode.isOffline || !session.isConnected || isSyncingRef.current || !hasRefreshedRef.current) return;
|
||||
|
||||
const syncPromises: Promise<void>[] = [];
|
||||
|
||||
if (booksToSyncToServer.length > 0) syncPromises.push(syncAllBooksToServer());
|
||||
if (booksToSyncFromServer.length > 0) syncPromises.push(syncAllBooksFromServer());
|
||||
if (seriesToSyncToServer.length > 0) syncPromises.push(syncAllSeriesToServer());
|
||||
if (seriesToSyncFromServer.length > 0) syncPromises.push(syncAllSeriesFromServer());
|
||||
|
||||
if (syncPromises.length > 0) {
|
||||
isSyncingRef.current = true;
|
||||
Promise.all(syncPromises).then((): void => {
|
||||
saveLastOnlineTimestamp();
|
||||
isSyncingRef.current = false;
|
||||
}).catch((): void => {
|
||||
isSyncingRef.current = false;
|
||||
});
|
||||
}
|
||||
}, [booksToSyncToServer, booksToSyncFromServer, seriesToSyncToServer, seriesToSyncFromServer]);
|
||||
|
||||
// Update lastOnlineTimestamp every 5 minutes while online
|
||||
useEffect((): (() => void) | void => {
|
||||
if (!offlineMode.isOffline && session.isConnected) {
|
||||
const intervalId: NodeJS.Timeout = setInterval((): void => {
|
||||
saveLastOnlineTimestamp();
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
return (): void => clearInterval(intervalId);
|
||||
}
|
||||
}, [offlineMode.isOffline, session.isConnected, saveLastOnlineTimestamp]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function ScribeContent() {
|
||||
const t = useTranslations();
|
||||
const {lang: locale} = useContext(LangContext);
|
||||
const {errorMessage} = useContext(AlertContext);
|
||||
const {initializeDatabase, setOfflineMode, isCurrentlyOffline, offlineMode} = useContext(OfflineContext);
|
||||
const editor: Editor | null = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Underline,
|
||||
TextAlign.configure({
|
||||
types: ['heading', 'paragraph'],
|
||||
}),
|
||||
],
|
||||
injectCSS: false,
|
||||
immediatelyRender: false,
|
||||
shouldRerenderOnTransaction: true,
|
||||
});
|
||||
|
||||
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 [serverSyncedBooks, setServerSyncedBooks] = useState<SyncedBook[]>([]);
|
||||
const [localSyncedBooks, setLocalSyncedBooks] = useState<SyncedBook[]>([]);
|
||||
const [bookSyncDiffsFromServer, setBookSyncDiffsFromServer] = useState<BookSyncCompare[]>([]);
|
||||
const [bookSyncDiffsToServer, setBookSyncDiffsToServer] = useState<BookSyncCompare[]>([]);
|
||||
const [serverOnlyBooks, setServerOnlyBooks] = useState<SyncedBook[]>([]);
|
||||
const [localOnlyBooks, setLocalOnlyBooks] = useState<SyncedBook[]>([]);
|
||||
|
||||
const [serverSyncedSeries, setServerSyncedSeries] = useState<SyncedSeries[]>([]);
|
||||
const [localSyncedSeries, setLocalSyncedSeries] = useState<SyncedSeries[]>([]);
|
||||
const [seriesSyncDiffsFromServer, setSeriesSyncDiffsFromServer] = useState<SeriesSyncCompare[]>([]);
|
||||
const [seriesSyncDiffsToServer, setSeriesSyncDiffsToServer] = useState<SeriesSyncCompare[]>([]);
|
||||
const [serverOnlySeries, setServerOnlySeries] = useState<SyncedSeries[]>([]);
|
||||
const [localOnlySeries, setLocalOnlySeries] = useState<SyncedSeries[]>([]);
|
||||
|
||||
const [currentCredits, setCurrentCredits] = useState<number>(160);
|
||||
const [amountSpent, setAmountSpent] = useState<number>(session.user?.aiUsage || 0);
|
||||
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
|
||||
const [isTermsAccepted, setIsTermsAccepted] = useState<boolean>(false);
|
||||
const [homeStepsGuide, setHomeStepsGuide] = useState<boolean>(false);
|
||||
const [showPinSetup, setShowPinSetup] = useState<boolean>(false);
|
||||
const [showPinVerify, setShowPinVerify] = useState<boolean>(false);
|
||||
|
||||
const [localSyncQueue, setLocalSyncQueue] = useState<LocalSyncOperation[]>([]);
|
||||
const [isQueueProcessing, setIsQueueProcessing] = useState<boolean>(false);
|
||||
|
||||
|
||||
function addToLocalSyncQueue(channel: string, data: Record<string, unknown>): void {
|
||||
const operation: LocalSyncOperation = {
|
||||
id: `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`,
|
||||
channel,
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
setLocalSyncQueue((prev: LocalSyncOperation[]): LocalSyncOperation[] => [...prev, operation]);
|
||||
}
|
||||
|
||||
useEffect((): void => {
|
||||
if (localSyncQueue.length === 0 || isQueueProcessing) {
|
||||
return;
|
||||
}
|
||||
|
||||
async function processQueue(): Promise<void> {
|
||||
setIsQueueProcessing(true);
|
||||
|
||||
const queueCopy: LocalSyncOperation[] = [...localSyncQueue];
|
||||
|
||||
for (const operation of queueCopy) {
|
||||
try {
|
||||
await tauri.invoke(operation.channel, operation.data);
|
||||
setLocalSyncQueue((prev: LocalSyncOperation[]): LocalSyncOperation[] =>
|
||||
prev.filter((op: LocalSyncOperation): boolean => op.id !== operation.id)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`[LocalSyncQueue] Failed to process operation ${operation.channel}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
setIsQueueProcessing(false);
|
||||
}
|
||||
|
||||
processQueue().then();
|
||||
}, [localSyncQueue, isQueueProcessing]);
|
||||
|
||||
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();
|
||||
|
||||
let unlisten: (() => void) | undefined;
|
||||
import('@tauri-apps/api/event').then(function ({listen}) {
|
||||
listen('auth-success', function () {
|
||||
checkAuthentification().then();
|
||||
}).then(function (fn) {
|
||||
unlisten = fn;
|
||||
});
|
||||
});
|
||||
|
||||
return (): void => {
|
||||
if (unlisten) unlisten();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect((): void => {
|
||||
if (session.isConnected) {
|
||||
setIsTermsAccepted(session.user?.termsAccepted ?? false);
|
||||
setHomeStepsGuide(User.guideTourDone(session.user?.guideTour ?? [], 'home-basic'));
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
useEffect((): void => {
|
||||
if (session.isConnected) {
|
||||
if (currentBook) {
|
||||
getLastChapter().then();
|
||||
} else {
|
||||
refreshBooks().then();
|
||||
}
|
||||
}
|
||||
}, [currentBook]);
|
||||
|
||||
useEffect((): void => {
|
||||
const diffsFromServer: BookSyncCompare[] = [];
|
||||
const diffsToServer: BookSyncCompare[] = [];
|
||||
|
||||
serverSyncedBooks.forEach((serverBook: SyncedBook): void => {
|
||||
const localBook: SyncedBook | undefined = localSyncedBooks.find((book: SyncedBook): boolean => book.id === serverBook.id);
|
||||
if (!localBook) {
|
||||
return;
|
||||
}
|
||||
|
||||
const diff: BookSyncCompare | null = compareBookSyncs(serverBook, localBook);
|
||||
if (diff) {
|
||||
diffsFromServer.push(diff);
|
||||
}
|
||||
});
|
||||
|
||||
localSyncedBooks.forEach((localBook: SyncedBook): void => {
|
||||
const serverBook: SyncedBook | undefined = serverSyncedBooks.find((book: SyncedBook): boolean => book.id === localBook.id);
|
||||
if (!serverBook) {
|
||||
return;
|
||||
}
|
||||
|
||||
const diff: BookSyncCompare | null = compareBookSyncs(localBook, serverBook);
|
||||
if (diff) {
|
||||
diffsToServer.push(diff);
|
||||
}
|
||||
});
|
||||
|
||||
setBookSyncDiffsFromServer(diffsFromServer);
|
||||
setBookSyncDiffsToServer(diffsToServer);
|
||||
setServerOnlyBooks(serverSyncedBooks.filter((serverBook: SyncedBook):boolean => !localSyncedBooks.find((localBook: SyncedBook):boolean => localBook.id === serverBook.id)))
|
||||
setLocalOnlyBooks(localSyncedBooks.filter((localBook: SyncedBook):boolean => !serverSyncedBooks.find((serverBook: SyncedBook):boolean => serverBook.id === localBook.id)))
|
||||
}, [localSyncedBooks, serverSyncedBooks]);
|
||||
|
||||
useEffect((): void => {
|
||||
const diffsFromServer: SeriesSyncCompare[] = [];
|
||||
const diffsToServer: SeriesSyncCompare[] = [];
|
||||
|
||||
serverSyncedSeries.forEach((serverSeries: SyncedSeries): void => {
|
||||
const localSeries: SyncedSeries | undefined = localSyncedSeries.find((series: SyncedSeries): boolean => series.id === serverSeries.id);
|
||||
if (!localSeries) {
|
||||
return;
|
||||
}
|
||||
|
||||
const diff: SeriesSyncCompare | null = compareSeriesSyncs(serverSeries, localSeries);
|
||||
if (diff) {
|
||||
diffsFromServer.push(diff);
|
||||
}
|
||||
});
|
||||
|
||||
localSyncedSeries.forEach((localSeries: SyncedSeries): void => {
|
||||
const serverSeries: SyncedSeries | undefined = serverSyncedSeries.find((series: SyncedSeries): boolean => series.id === localSeries.id);
|
||||
if (!serverSeries) {
|
||||
return;
|
||||
}
|
||||
|
||||
const diff: SeriesSyncCompare | null = compareSeriesSyncs(localSeries, serverSeries);
|
||||
if (diff) {
|
||||
diffsToServer.push(diff);
|
||||
}
|
||||
});
|
||||
|
||||
setSeriesSyncDiffsFromServer(diffsFromServer);
|
||||
setSeriesSyncDiffsToServer(diffsToServer);
|
||||
setServerOnlySeries(serverSyncedSeries.filter((serverSeries: SyncedSeries): boolean => !localSyncedSeries.find((localSeries: SyncedSeries): boolean => localSeries.id === serverSeries.id)));
|
||||
setLocalOnlySeries(localSyncedSeries.filter((localSeries: SyncedSeries): boolean => !serverSyncedSeries.find((serverSeries: SyncedSeries): boolean => serverSeries.id === localSeries.id)));
|
||||
}, [localSyncedSeries, serverSyncedSeries]);
|
||||
|
||||
async function refreshBooks(): Promise<void> {
|
||||
try {
|
||||
let localBooksResponse: SyncedBook[] = [];
|
||||
let serverBooksResponse: SyncedBook[] = [];
|
||||
|
||||
if (!isCurrentlyOffline()) {
|
||||
if (offlineMode.isDatabaseInitialized) {
|
||||
localBooksResponse = await tauri.getSyncedBooks() as SyncedBook[];
|
||||
const lastOnlineStr: string | null = localStorage.getItem('lastOnlineTimestamp');
|
||||
const lastOnlineTimestamp: number = lastOnlineStr ? parseInt(lastOnlineStr, 10) : 0;
|
||||
const localTombstones: RemovedItemRecord[] = await tauri.getTombstonesSince(lastOnlineTimestamp) as RemovedItemRecord[];
|
||||
const serverResponse: SyncedBooksResponse = await System.authPostToServer<SyncedBooksResponse>('books/synced', { lastOnlineTimestamp, tombstones: localTombstones }, session.accessToken, locale);
|
||||
serverBooksResponse = serverResponse.books;
|
||||
await tauri.applyBookTombstones(serverResponse.tombstones);
|
||||
} else {
|
||||
const serverResponse: SyncedBooksResponse = await System.authPostToServer<SyncedBooksResponse>('books/synced', { lastOnlineTimestamp: 0, tombstones: [] }, session.accessToken, locale);
|
||||
serverBooksResponse = serverResponse.books;
|
||||
}
|
||||
} else {
|
||||
if (offlineMode.isDatabaseInitialized) {
|
||||
localBooksResponse = await tauri.getSyncedBooks() as SyncedBook[];
|
||||
}
|
||||
}
|
||||
|
||||
setServerSyncedBooks(serverBooksResponse);
|
||||
setLocalSyncedBooks(localBooksResponse);
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
} else {
|
||||
errorMessage(t("homePage.errors.fetchBooksError"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSeries(): Promise<void> {
|
||||
try {
|
||||
let localSeriesResponse: SyncedSeries[] = [];
|
||||
let serverSeriesResponse: SyncedSeries[] = [];
|
||||
|
||||
if (!isCurrentlyOffline()) {
|
||||
if (offlineMode.isDatabaseInitialized) {
|
||||
localSeriesResponse = await tauri.getSyncedSeries() as SyncedSeries[];
|
||||
const lastOnlineStr: string | null = localStorage.getItem('lastOnlineTimestamp');
|
||||
const lastOnlineTimestamp: number = lastOnlineStr ? parseInt(lastOnlineStr, 10) : 0;
|
||||
const localTombstones: RemovedItemRecord[] = await tauri.getTombstonesSince(lastOnlineTimestamp) as RemovedItemRecord[];
|
||||
const serverResponse: SyncedSeriesResponse = await System.authPostToServer<SyncedSeriesResponse>('series/synced', { lastOnlineTimestamp, tombstones: localTombstones }, session.accessToken, locale);
|
||||
serverSeriesResponse = serverResponse.series;
|
||||
await tauri.applySeriesTombstones(serverResponse.tombstones);
|
||||
} else {
|
||||
const serverResponse: SyncedSeriesResponse = await System.authPostToServer<SyncedSeriesResponse>('series/synced', { lastOnlineTimestamp: 0, tombstones: [] }, session.accessToken, locale);
|
||||
serverSeriesResponse = serverResponse.series;
|
||||
}
|
||||
} else {
|
||||
if (offlineMode.isDatabaseInitialized) {
|
||||
localSeriesResponse = await tauri.getSyncedSeries() as SyncedSeries[];
|
||||
}
|
||||
}
|
||||
|
||||
setServerSyncedSeries(serverSeriesResponse);
|
||||
setLocalSyncedSeries(localSeriesResponse);
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
} else {
|
||||
errorMessage(t("homePage.errors.fetchSeriesError"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function handlePinVerifySuccess(userId: string): Promise<void> {
|
||||
try {
|
||||
const storedToken: string | null = await tauri.getToken();
|
||||
const encryptionKey: string | null = await tauri.getUserEncryptionKey(userId);
|
||||
|
||||
if (encryptionKey) {
|
||||
await tauri.dbInitialize(userId, encryptionKey);
|
||||
setOfflineMode(prev => ({...prev, isDatabaseInitialized: true}));
|
||||
|
||||
const localUser: UserProps = await tauri.getUserInfo();
|
||||
if (localUser && localUser.id) {
|
||||
setSession({
|
||||
isConnected: true,
|
||||
user: localUser,
|
||||
accessToken: storedToken || '',
|
||||
});
|
||||
setShowPinVerify(false);
|
||||
setCurrentCredits(localUser.creditsBalance || 0);
|
||||
setAmountSpent(localUser.aiUsage || 0);
|
||||
} else {
|
||||
errorMessage(t("homePage.errors.localDataError"));
|
||||
}
|
||||
} else {
|
||||
errorMessage(t("homePage.errors.encryptionKeyError"));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[OfflinePin] Error initializing offline mode:', error);
|
||||
errorMessage(t("homePage.errors.offlineModeError"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleHomeTour(): Promise<void> {
|
||||
try {
|
||||
if (!isCurrentlyOffline()) {
|
||||
const response: boolean = await System.authPostToServer<boolean>('logs/tour', {
|
||||
plateforme: 'desktop',
|
||||
tour: 'home-basic'
|
||||
},
|
||||
session.accessToken,
|
||||
locale
|
||||
);
|
||||
if (response) {
|
||||
setSession(User.setNewGuideTour(session, 'home-basic'));
|
||||
setHomeStepsGuide(false);
|
||||
}
|
||||
} else {
|
||||
const completedGuides = JSON.parse(localStorage.getItem('completedGuides') || '[]');
|
||||
if (!completedGuides.includes('home-basic')) {
|
||||
completedGuides.push('home-basic');
|
||||
localStorage.setItem('completedGuides', JSON.stringify(completedGuides));
|
||||
}
|
||||
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> {
|
||||
let token: string | null = null;
|
||||
|
||||
try {
|
||||
token = await tauri.getToken();
|
||||
} catch (e) {
|
||||
console.error('Error getting token:', e);
|
||||
}
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
const user: UserProps = await System.authGetQueryToServer<UserProps>('user/infos', token, locale);
|
||||
if (!user) {
|
||||
errorMessage(t("homePage.errors.userNotFound"));
|
||||
await tauri.removeToken();
|
||||
tauri.logout();
|
||||
return;
|
||||
}
|
||||
|
||||
if (user.id) {
|
||||
try {
|
||||
const initResult = await tauri.initUser(user.id);
|
||||
if (!initResult.success) {
|
||||
errorMessage(initResult.error || t("homePage.errors.offlineInitError"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const offlineStatus = await tauri.offlineModeGet();
|
||||
if (!offlineStatus.hasPin) {
|
||||
setTimeout(():void => {
|
||||
setShowPinSetup(true);
|
||||
}, 2000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Page] Error checking offline mode:', error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Page] Error initializing user:', error);
|
||||
}
|
||||
}
|
||||
if (user.id) {
|
||||
try {
|
||||
const dbInitialized: boolean = await initializeDatabase(user.id);
|
||||
if (dbInitialized) {
|
||||
try {
|
||||
await tauri.syncUser({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
email: user.email
|
||||
});
|
||||
} catch (syncError) {
|
||||
console.error('[Page] syncUser failed:', syncError);
|
||||
errorMessage(t("homePage.errors.syncError"));
|
||||
}
|
||||
} else {
|
||||
errorMessage(t("homePage.errors.dbInitError"));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Page] DB init or sync failed:', error);
|
||||
errorMessage(t("homePage.errors.syncError"));
|
||||
}
|
||||
}
|
||||
setSession({
|
||||
isConnected: true,
|
||||
user: user,
|
||||
accessToken: token,
|
||||
});
|
||||
setCurrentCredits(user.creditsBalance)
|
||||
setAmountSpent(user.aiUsage)
|
||||
} catch (e: unknown) {
|
||||
try {
|
||||
const offlineStatus = await tauri.offlineModeGet();
|
||||
|
||||
if (offlineStatus.hasPin && offlineStatus.lastUserId) {
|
||||
setOfflineMode((prev:OfflineMode):OfflineMode => ({...prev, isOffline: true, isManuallyOffline: true, isNetworkOnline: false}));
|
||||
setShowPinVerify(true);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
} else {
|
||||
await tauri.removeToken();
|
||||
tauri.logout();
|
||||
}
|
||||
} catch (offlineError) {
|
||||
errorMessage(t("homePage.errors.offlineError"));
|
||||
}
|
||||
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
} else {
|
||||
errorMessage(t("homePage.errors.authenticationError"));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const offlineStatus = await tauri.offlineModeGet();
|
||||
|
||||
if (offlineStatus.hasPin && offlineStatus.lastUserId) {
|
||||
setOfflineMode(prev => ({...prev, isOffline: true, isManuallyOffline: true, isNetworkOnline: false}));
|
||||
setShowPinVerify(true);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage(t("homePage.errors.authenticationError"));
|
||||
}
|
||||
tauri.logout();
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
let response: ChapterProps | null
|
||||
if (isCurrentlyOffline()){
|
||||
if (!offlineMode.isDatabaseInitialized) {
|
||||
setCurrentChapter(undefined);
|
||||
return;
|
||||
}
|
||||
response = await tauri.getLastChapter(currentBook?.bookId ?? '')
|
||||
} else {
|
||||
if (currentBook?.localBook) {
|
||||
if (!offlineMode.isDatabaseInitialized) {
|
||||
setCurrentChapter(undefined);
|
||||
return;
|
||||
}
|
||||
response = await tauri.getLastChapter(currentBook?.bookId ?? '')
|
||||
} else {
|
||||
response = 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">
|
||||
<img src="/eritors-favicon-white.png" alt="ERitors Logo" style={{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}}>
|
||||
<LocalSyncQueueContext.Provider value={{
|
||||
queue: localSyncQueue,
|
||||
setQueue: setLocalSyncQueue,
|
||||
addToQueue: addToLocalSyncQueue,
|
||||
isProcessing: isQueueProcessing,
|
||||
}}>
|
||||
<BooksSyncContext.Provider value={{serverSyncedBooks, localSyncedBooks, booksToSyncFromServer:bookSyncDiffsFromServer, booksToSyncToServer:bookSyncDiffsToServer, setServerSyncedBooks, setLocalSyncedBooks, setServerOnlyBooks, setLocalOnlyBooks, setBooksToSyncFromServer:setBookSyncDiffsFromServer, setBooksToSyncToServer:setBookSyncDiffsToServer, serverOnlyBooks, localOnlyBooks}}>
|
||||
<SeriesSyncContext.Provider value={{serverSyncedSeries, localSyncedSeries, seriesToSyncFromServer:seriesSyncDiffsFromServer, seriesToSyncToServer:seriesSyncDiffsToServer, setServerSyncedSeries, setLocalSyncedSeries, setServerOnlySeries, setLocalOnlySeries, setSeriesToSyncFromServer:setSeriesSyncDiffsFromServer, setSeriesToSyncToServer:setSeriesSyncDiffsToServer, serverOnlySeries, localOnlySeries}}>
|
||||
<AutoSyncOnReconnect/>
|
||||
<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 && !isCurrentlyOffline() &&
|
||||
<GuideTour stepId={0} steps={homeSteps} onComplete={handleHomeTour}
|
||||
onClose={(): void => setHomeStepsGuide(false)}/>
|
||||
}
|
||||
{
|
||||
!isTermsAccepted && !isCurrentlyOffline() && <TermsOfUse onAccept={handleTermsAcceptance}/>
|
||||
}
|
||||
{
|
||||
showPinSetup && (
|
||||
<OfflinePinSetup
|
||||
showOnFirstLogin={true}
|
||||
onClose={():void => setShowPinSetup(false)}
|
||||
onSuccess={():void => {
|
||||
setShowPinSetup(false);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
showPinVerify && (
|
||||
<OfflinePinVerify
|
||||
onSuccess={handlePinVerifySuccess}
|
||||
onCancel={():void => {}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</AIUsageContext.Provider>
|
||||
</ChapterContext.Provider>
|
||||
</BookContext.Provider>
|
||||
</SeriesSyncContext.Provider>
|
||||
</BooksSyncContext.Provider>
|
||||
</LocalSyncQueueContext.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} timeZone="America/Montreal">
|
||||
<OfflineProvider>
|
||||
<AlertProvider>
|
||||
<ScribeContent/>
|
||||
</AlertProvider>
|
||||
</OfflineProvider>
|
||||
</NextIntlClientProvider>
|
||||
</LangContext.Provider>
|
||||
);
|
||||
export default function HomePage() {
|
||||
return <BookList/>;
|
||||
}
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import {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;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function AlertBox(
|
||||
{
|
||||
title,
|
||||
message,
|
||||
type,
|
||||
confirmText = 'Confirmer',
|
||||
cancelText = 'Annuler',
|
||||
onConfirm,
|
||||
onCancel,
|
||||
children
|
||||
}: 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="text-text-primary whitespace-pre-line leading-relaxed">{message}</p>
|
||||
{children}
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<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);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import {useEffect, useState} 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] = useState(false);
|
||||
|
||||
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>{`
|
||||
@keyframes slideInFromRight {
|
||||
from {
|
||||
transform: translateX(400px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(alertContent, document.body);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import {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>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import {JSX, useEffect, useMemo, useRef, useState} from 'react';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {faXmark} from '@fortawesome/free-solid-svg-icons';
|
||||
import React, {JSX, useEffect, useRef, useState} from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
import {X} from 'lucide-react';
|
||||
import Button from '@/components/ui/Button';
|
||||
import IconButton from '@/components/ui/IconButton';
|
||||
|
||||
export type GuidePosition =
|
||||
'top'
|
||||
@@ -38,23 +40,33 @@ interface GuideTourProps {
|
||||
* position, and properties for spotlight rendering.
|
||||
* @return {string} The CSS background string representing the spotlight effect.
|
||||
*/
|
||||
function getOverlayColor(opacity: number): string {
|
||||
const style: CSSStyleDeclaration = getComputedStyle(document.documentElement);
|
||||
const darkest: string = style.getPropertyValue('--theme-darkest-background').trim() || '#1A1A1A';
|
||||
const hex: string = darkest.replace('#', '');
|
||||
const r: number = parseInt(hex.substring(0, 2), 16);
|
||||
const g: number = parseInt(hex.substring(2, 4), 16);
|
||||
const b: number = parseInt(hex.substring(4, 6), 16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
||||
}
|
||||
|
||||
function getSpotlightBackground(step: GuideStep): string {
|
||||
if (step.x !== undefined && step.y !== undefined) {
|
||||
return 'rgba(0, 0, 0, 0.5)';
|
||||
return getOverlayColor(0.5);
|
||||
}
|
||||
if (!step.targetSelector) {
|
||||
return 'rgba(0, 0, 0, 0.5)';
|
||||
return getOverlayColor(0.5);
|
||||
}
|
||||
const element = document.querySelector(step.targetSelector) as HTMLElement | null;
|
||||
const element: HTMLElement | null = document.querySelector<HTMLElement>(step.targetSelector);
|
||||
if (!element) {
|
||||
return 'rgba(0, 0, 0, 0.5)';
|
||||
return getOverlayColor(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)`;
|
||||
return `radial-gradient(circle at ${centerX}px ${centerY}px, transparent ${radius}px, ${getOverlayColor(0.65)} ${radius + 20}px)`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,7 +75,13 @@ function getSpotlightBackground(step: GuideStep): string {
|
||||
* @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 {
|
||||
interface PopoverPosition {
|
||||
left: string;
|
||||
top: string;
|
||||
transform?: string;
|
||||
}
|
||||
|
||||
function getPopoverPosition(step: GuideStep): PopoverPosition {
|
||||
if (step.x !== undefined && step.y !== undefined) {
|
||||
return {
|
||||
left: `${step.x}%`,
|
||||
@@ -80,7 +98,7 @@ function getPopoverPosition(step: GuideStep): React.CSSProperties {
|
||||
};
|
||||
}
|
||||
|
||||
const element = document.querySelector(step.targetSelector) as HTMLElement | null;
|
||||
const element: HTMLElement | null = document.querySelector<HTMLElement>(step.targetSelector);
|
||||
if (!element) {
|
||||
return {
|
||||
left: '50%',
|
||||
@@ -91,9 +109,9 @@ function getPopoverPosition(step: GuideStep): React.CSSProperties {
|
||||
|
||||
const rect: DOMRect = element.getBoundingClientRect();
|
||||
const {left, top, width, height} = rect;
|
||||
const popoverWidth = 420;
|
||||
const popoverHeight = 300;
|
||||
const margin = 20;
|
||||
const popoverWidth: number = 420;
|
||||
const popoverHeight: number = 300;
|
||||
const margin: number = 20;
|
||||
const position: GuidePosition = step.position || 'auto';
|
||||
|
||||
switch (position) {
|
||||
@@ -188,16 +206,17 @@ export default function GuideTour({stepId, steps, onClose, onComplete}: GuideTou
|
||||
const [currentStep, setCurrentStep] = useState<number>(0);
|
||||
const [isVisible, setIsVisible] = useState<boolean>(false);
|
||||
const [rendered, setRendered] = useState<boolean>(false);
|
||||
const overlayRef: React.RefObject<HTMLDivElement | null> = useRef<HTMLDivElement>(null);
|
||||
|
||||
const filteredSteps: GuideStep[] = useMemo((): GuideStep[] => {
|
||||
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 timeoutRef: React.RefObject<NodeJS.Timeout | null> = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const showStep = (index: number) => {
|
||||
function showStep(index: number): void {
|
||||
setIsVisible(false);
|
||||
|
||||
if (timeoutRef.current) {
|
||||
@@ -210,7 +229,7 @@ export default function GuideTour({stepId, steps, onClose, onComplete}: GuideTou
|
||||
|
||||
const step: GuideStep = filteredSteps[index];
|
||||
if (step?.targetSelector) {
|
||||
const element = document.querySelector(step.targetSelector) as HTMLElement;
|
||||
const element: HTMLElement | null = document.querySelector<HTMLElement>(step.targetSelector);
|
||||
if (element) {
|
||||
element.scrollIntoView({behavior: 'smooth', block: 'center'});
|
||||
}
|
||||
@@ -224,7 +243,7 @@ export default function GuideTour({stepId, steps, onClose, onComplete}: GuideTou
|
||||
}, 50);
|
||||
}, 600);
|
||||
}, 200);
|
||||
};
|
||||
}
|
||||
|
||||
useEffect((): () => void => {
|
||||
showStep(0);
|
||||
@@ -236,30 +255,34 @@ export default function GuideTour({stepId, steps, onClose, onComplete}: GuideTou
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleNext: () => void = (): void => {
|
||||
useEffect((): void => {
|
||||
if (overlayRef.current) {
|
||||
overlayRef.current.style.background = rendered ? getSpotlightBackground(currentStepData) : getOverlayColor(0.5);
|
||||
overlayRef.current.style.opacity = isVisible ? '1' : '0';
|
||||
}
|
||||
}, [rendered, isVisible, currentStepData]);
|
||||
|
||||
function handleNext(): void {
|
||||
if (currentStep < filteredSteps.length - 1) {
|
||||
showStep(currentStep + 1);
|
||||
} else {
|
||||
onComplete();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handlePrevious: () => void = (): void => {
|
||||
function handlePrevious(): void {
|
||||
if (currentStep > 0) {
|
||||
showStep(currentStep - 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
if (!filteredSteps.length || !currentStepData) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 font-['Lora']">
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[60] font-['Lora']">
|
||||
<div
|
||||
ref={overlayRef}
|
||||
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 && (
|
||||
@@ -273,7 +296,8 @@ export default function GuideTour({stepId, steps, onClose, onComplete}: GuideTou
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
@@ -309,18 +333,25 @@ function GuidePopup(
|
||||
onNext: () => void;
|
||||
onClose: () => void;
|
||||
}): JSX.Element {
|
||||
const positionStyle = useMemo(() => {
|
||||
return getPopoverPosition(step);
|
||||
const popupRef: React.RefObject<HTMLDivElement | null> = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect((): void => {
|
||||
if (popupRef.current) {
|
||||
const pos: PopoverPosition = getPopoverPosition(step);
|
||||
popupRef.current.style.left = pos.left;
|
||||
popupRef.current.style.top = pos.top;
|
||||
popupRef.current.style.transform = pos.transform || '';
|
||||
}
|
||||
}, [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'
|
||||
ref={popupRef}
|
||||
className={`absolute bg-tertiary border border-primary/30 rounded-xl w-96 transition-opacity duration-300 ${
|
||||
isVisible ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
style={positionStyle}
|
||||
>
|
||||
<div className="px-8 py-6 border-b border-secondary/40">
|
||||
<div className="px-8 py-6 border-b border-secondary">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 mr-6">
|
||||
<h3 className="text-text-primary font-semibold text-xl mb-3">
|
||||
@@ -331,24 +362,18 @@ function GuidePopup(
|
||||
Étape {currentStep + 1} sur {totalSteps}
|
||||
</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
{Array.from({length: totalSteps}).map((_, index) => (
|
||||
{Array.from({length: totalSteps}).map((_: unknown, index: number) => (
|
||||
<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'
|
||||
index <= currentStep ? 'bg-primary' : 'bg-secondary'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
<IconButton icon={X} variant="ghost" shape="square" onClick={onClose}/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-8 py-8">
|
||||
@@ -356,26 +381,18 @@ function GuidePopup(
|
||||
{step.content}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-8 py-6 bg-secondary/20 border-t border-secondary/30 rounded-b-xl">
|
||||
<div className="px-8 py-6 bg-tertiary border-t border-secondary 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"
|
||||
>
|
||||
<Button variant="ghost" size="sm" onClick={onPrevious}>
|
||||
← Précédent
|
||||
</button>
|
||||
</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"
|
||||
>
|
||||
<Button variant="primary" onClick={onNext}>
|
||||
{currentStep === totalSteps - 1 ? '🎉 Terminer' : 'Continuer →'}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {faArrowDown, faArrowUp, faCheck, faPen, faTrash, faX, IconDefinition} from "@fortawesome/free-solid-svg-icons";
|
||||
import {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>
|
||||
)
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
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;
|
||||
enableOverflow?: boolean;
|
||||
}
|
||||
|
||||
export default function Modal(
|
||||
{
|
||||
title,
|
||||
children,
|
||||
size,
|
||||
onClose,
|
||||
onConfirm,
|
||||
confirmText = 'Confirm',
|
||||
cancelText = 'Cancel',
|
||||
enableFooter = true,
|
||||
enableOverflow = 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 ${enableOverflow ? 'overflow-auto' : 'overflow-hidden'}`}>
|
||||
{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);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import {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>
|
||||
)
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
import {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();
|
||||
|
||||
const filteredValue: string = value.replace(/^starting\.{0,3}\s*/i, '').trim();
|
||||
const hasRealContent: boolean = filteredValue.length > 0;
|
||||
|
||||
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 && !hasRealContent ? (
|
||||
<div className="space-y-3 animate-pulse">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="h-5 bg-primary/20 rounded px-4"></span>
|
||||
<span className="h-5 bg-primary/15 rounded px-6"></span>
|
||||
<span className="h-5 bg-primary/20 rounded px-3"></span>
|
||||
<span className="h-5 bg-primary/10 rounded px-8"></span>
|
||||
<span className="h-5 bg-primary/20 rounded px-5"></span>
|
||||
<span className="h-5 bg-primary/15 rounded px-4"></span>
|
||||
<span className="h-5 bg-primary/20 rounded px-7"></span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="h-5 bg-primary/15 rounded px-5"></span>
|
||||
<span className="h-5 bg-primary/20 rounded px-3"></span>
|
||||
<span className="h-5 bg-primary/10 rounded px-6"></span>
|
||||
<span className="h-5 bg-primary/20 rounded px-4"></span>
|
||||
<span className="h-5 bg-primary/15 rounded px-8"></span>
|
||||
<span className="h-5 bg-primary/20 rounded px-3"></span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="h-5 bg-primary/20 rounded px-6"></span>
|
||||
<span className="h-5 bg-primary/10 rounded px-4"></span>
|
||||
<span className="h-5 bg-primary/20 rounded px-5"></span>
|
||||
<span className="h-5 bg-primary/15 rounded px-7"></span>
|
||||
<span className="h-5 bg-primary/20 rounded px-3"></span>
|
||||
<span className="h-5 bg-primary/10 rounded px-5"></span>
|
||||
<span className="h-5 bg-primary/20 rounded px-4"></span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="h-5 bg-primary/15 rounded px-4"></span>
|
||||
<span className="h-5 bg-primary/20 rounded px-6"></span>
|
||||
<span className="h-5 bg-primary/10 rounded px-3"></span>
|
||||
<span className="h-5 bg-primary/20 rounded px-5"></span>
|
||||
<span className="h-5 bg-primary/15 rounded px-7"></span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="text-justify leading-relaxed whitespace-pre-wrap fade-in-text">
|
||||
{filteredValue}
|
||||
</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);
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
import React, {useContext, useState} from "react";
|
||||
import * as tauri from '@/lib/tauri';
|
||||
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, {BookProps} from "@/lib/models/Book";
|
||||
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";
|
||||
import OfflineContext, {OfflineContextType} from "@/context/OfflineContext";
|
||||
import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext";
|
||||
|
||||
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 {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext)
|
||||
const {serverSyncedBooks, serverOnlyBooks, localOnlyBooks} = useContext<BooksSyncContextProps>(BooksSyncContext);
|
||||
|
||||
const isGPTEnabled: boolean = !isCurrentlyOffline() && QuillSense.isOpenAIEnabled(session);
|
||||
const isGemini: boolean = !isCurrentlyOffline() && QuillSense.isOpenAIEnabled(session);
|
||||
const isAnthropic: boolean = !isCurrentlyOffline() && QuillSense.isOpenAIEnabled(session);
|
||||
const isSubTierTwo: boolean = !isCurrentlyOffline() && QuillSense.getSubLevel(session) >= 2;
|
||||
const hasAccess: boolean = (isGPTEnabled || isAnthropic || isGemini) || isSubTierTwo;
|
||||
|
||||
const [showSettingPanel, setShowSettingPanel] = useState<boolean>(false);
|
||||
|
||||
async function handleChapterVersionChanged(version: number) {
|
||||
try {
|
||||
let response: ChapterProps | null;
|
||||
if (isCurrentlyOffline() || book?.localBook) {
|
||||
response = await tauri.getWholeChapter(chapter?.chapterId ?? '', version, book?.bookId ?? '');
|
||||
} else {
|
||||
response = 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: BookProps = await System.authGetQueryToServer<BookProps>(`book/basic-information`, session.accessToken, lang, {
|
||||
id: bookId,
|
||||
});
|
||||
if (!response) {
|
||||
errorMessage(t("controllerBar.bookNotFound"));
|
||||
return;
|
||||
}
|
||||
setBook!!({
|
||||
bookId: response.bookId,
|
||||
type: response.type,
|
||||
title: response.title,
|
||||
subTitle: response.subTitle,
|
||||
summary: response.summary,
|
||||
publicationDate: response.publicationDate,
|
||||
desiredWordCount: response.desiredWordCount,
|
||||
totalWordCount: response.desiredWordCount,
|
||||
quillsenseEnabled: response.quillsenseEnabled,
|
||||
tools: response?.tools,
|
||||
seriesId: response.seriesId,
|
||||
});
|
||||
} 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([...serverOnlyBooks, ...localOnlyBooks])} 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 || book?.quillsenseEnabled === false));
|
||||
}).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 && book?.quillsenseEnabled !== false &&
|
||||
<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 && <BookSetting onClose={() => setShowSettingPanel(false)}/>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
import {ChapterContext} from "@/context/ChapterContext";
|
||||
import {EditorContext} from "@/context/EditorContext";
|
||||
import {useContext, useEffect, useState} from "react";
|
||||
import {Editor} from "@tiptap/react";
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {faBook, faChartSimple, faHeart, faSheetPlastic, faHardDrive} from "@fortawesome/free-solid-svg-icons";
|
||||
import {useTranslations} from "next-intl";
|
||||
import {AlertContext} from "@/context/AlertContext";
|
||||
import {BookContext} from "@/context/BookContext";
|
||||
import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext";
|
||||
import OfflineContext, {OfflineContextType} from "@/context/OfflineContext";
|
||||
|
||||
export default function ScribeFooterBar() {
|
||||
const t = useTranslations();
|
||||
const {chapter} = useContext(ChapterContext);
|
||||
const {book} = useContext(BookContext);
|
||||
const editor: Editor | null = useContext(EditorContext).editor;
|
||||
const {errorMessage} = useContext(AlertContext)
|
||||
const {offlineMode} = useContext<OfflineContextType>(OfflineContext)
|
||||
const {localOnlyBooks,serverSyncedBooks} = useContext<BooksSyncContextProps>(BooksSyncContext);
|
||||
|
||||
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 || book?.title || (
|
||||
<>
|
||||
<span>{t('scribeFooterBar.madeWith')}</span>
|
||||
<FontAwesomeIcon color={'red'} icon={faHeart} className={'w-4 h-4 animate-pulse'}/>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{
|
||||
chapter || book ? (
|
||||
<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">
|
||||
{
|
||||
!offlineMode.isOffline && <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-text-primary font-bold">{serverSyncedBooks.length}</span>
|
||||
</div>
|
||||
}
|
||||
{(localOnlyBooks.length > 0 || offlineMode.isOffline) && (
|
||||
<div
|
||||
className="flex items-center gap-2 bg-secondary/50 px-4 py-2 rounded-xl border border-secondary shadow-sm">
|
||||
<FontAwesomeIcon icon={faHardDrive} className={'text-primary w-4 h-4'}/>
|
||||
<span className="text-text-primary font-bold">{localOnlyBooks.length}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
// Removed Next.js Image import for Electron
|
||||
import {useContext} from "react";
|
||||
import {BookContext, BookContextProps} from "@/context/BookContext";
|
||||
import {useTranslations} from "next-intl";
|
||||
import OfflineToggle from "@/components/offline/OfflineToggle";
|
||||
|
||||
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">
|
||||
<img src="/eritors-favicon-white.png" alt={t("scribeTopBar.logoAlt")} style={{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">
|
||||
<OfflineToggle />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
'use client'
|
||||
import {ReactNode, useEffect, useState} from "react";
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {faX} from "@fortawesome/free-solid-svg-icons";
|
||||
import {createPortal} from "react-dom";
|
||||
|
||||
interface SettingsPanelProps {
|
||||
title: string;
|
||||
sidebar: ReactNode;
|
||||
children: ReactNode;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function SettingsPanel({title, sidebar, children, onClose}: SettingsPanelProps) {
|
||||
const [mounted, setMounted] = useState<boolean>(false);
|
||||
|
||||
useEffect((): void => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-40 bg-black/60 backdrop-blur-md flex items-center justify-center">
|
||||
<div className="w-3/4 h-[85vh] bg-tertiary rounded-2xl border border-secondary/50 shadow-2xl flex flex-col">
|
||||
|
||||
<div className="bg-primary px-6 py-4 rounded-t-2xl flex justify-between items-center">
|
||||
<h2 className="font-['ADLaM_Display'] text-xl text-text-primary">{title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-white/80 hover:text-white p-2 rounded-lg hover:bg-white/10 transition-all"
|
||||
>
|
||||
<FontAwesomeIcon icon={faX} className="w-5 h-5"/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div className="w-64 bg-secondary/30 border-r border-secondary/50">
|
||||
{sidebar}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -1,28 +1,28 @@
|
||||
import {ChangeEvent, RefObject, useContext, useEffect, useRef, useState} from 'react';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import React, {ChangeEvent, useContext, useEffect, useRef, useState} from 'react';
|
||||
import {
|
||||
BarChart2,
|
||||
BookMarked,
|
||||
BookOpen,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
CloudSun,
|
||||
FileText,
|
||||
GraduationCap,
|
||||
Languages,
|
||||
Loader2,
|
||||
LucideIcon,
|
||||
MessageSquare,
|
||||
Music,
|
||||
Pencil,
|
||||
RotateCw,
|
||||
Square,
|
||||
User,
|
||||
UserPen,
|
||||
Wand2,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
import {writingLevel} from "@/lib/constants/user";
|
||||
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,
|
||||
@@ -34,23 +34,27 @@ import Story, {
|
||||
intermediatePredefinedType,
|
||||
langues,
|
||||
verbalTime
|
||||
} from '@/lib/models/Story';
|
||||
} from '@/lib/constants/story';
|
||||
import {presetStoryType} from '@/lib/utils/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 TextAreaInput from "@/components/form/TextAreaInput";
|
||||
import {SessionContext, SessionContextProps} from "@/context/SessionContext";
|
||||
import {apiPost} from '@/lib/api/client';
|
||||
import {AlertContext, AlertContextProps} from "@/context/AlertContext";
|
||||
import {configs} from "@/lib/configs";
|
||||
import InputField from "@/components/form/InputField";
|
||||
import NumberInput from "@/components/form/NumberInput";
|
||||
import Button from "@/components/ui/Button";
|
||||
import IconButton from "@/components/ui/IconButton";
|
||||
import PulseLoader from "@/components/ui/PulseLoader";
|
||||
import {Editor as TipEditor, EditorContent, useEditor} from "@tiptap/react";
|
||||
import Editor from "@/lib/models/Editor";
|
||||
import {convertToHtml} from "@/lib/utils/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 {getSubLevel, isAnthropicEnabled} from "@/lib/utils/quillsense";
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
import {LangContext, LangContextProps} from "@/context/LangContext";
|
||||
import {AIUsageContext, AIUsageContextProps} from "@/context/AIUsageContext";
|
||||
import AdvancedGenerationOptions from "@/components/form/AdvancedGenerationOptions";
|
||||
@@ -59,33 +63,35 @@ interface ShortStoryGeneratorProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface TabItem {
|
||||
id: number;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
export default function ShortStoryGenerator({onClose}: ShortStoryGeneratorProps) {
|
||||
const {session} = useContext(SessionContext);
|
||||
const {errorMessage, infoMessage} = useContext(AlertContext);
|
||||
const {lang} = useContext<LangContextProps>(LangContext)
|
||||
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
|
||||
const {errorMessage, infoMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
|
||||
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext)
|
||||
const t = useTranslations();
|
||||
const {setTotalPrice, setTotalCredits} = useContext<AIUsageContextProps>(AIUsageContext)
|
||||
const {setTotalPrice, setTotalCredits}: AIUsageContextProps = 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 [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 [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 progressRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [generatedText, setGeneratedText] = useState<string>('');
|
||||
const [generatedStoryTitle, setGeneratedStoryTitle] = useState<string>('');
|
||||
@@ -98,9 +104,15 @@ export default function ShortStoryGenerator({onClose}: ShortStoryGeneratorProps)
|
||||
const [useExplicit, setUseExplicit] = useState<boolean>(false);
|
||||
const [useSmart, setUseSmart] = useState<boolean>(false);
|
||||
|
||||
const isAnthropicEnabled: boolean = QuillSense.isAnthropicEnabled(session);
|
||||
const isSubTierTwo: boolean = QuillSense.getSubLevel(session) >= 2;
|
||||
const hasAccess: boolean = isAnthropicEnabled || isSubTierTwo;
|
||||
useEffect((): void => {
|
||||
if (progressRef.current) {
|
||||
progressRef.current.style.width = `${progress}%`;
|
||||
}
|
||||
}, [progress]);
|
||||
|
||||
const anthropicEnabled: boolean = isAnthropicEnabled(session);
|
||||
const isSubTierTwo: boolean = getSubLevel(session) >= 2;
|
||||
const hasAccess: boolean = anthropicEnabled || isSubTierTwo;
|
||||
|
||||
const editor: TipEditor | null = useEditor({
|
||||
extensions: [
|
||||
@@ -122,7 +134,7 @@ export default function ShortStoryGenerator({onClose}: ShortStoryGeneratorProps)
|
||||
}, []);
|
||||
|
||||
useEffect((): void => {
|
||||
Story.presetStoryType(
|
||||
presetStoryType(
|
||||
presetType,
|
||||
setTone,
|
||||
setAtmosphere,
|
||||
@@ -140,7 +152,7 @@ export default function ShortStoryGenerator({onClose}: ShortStoryGeneratorProps)
|
||||
|
||||
useEffect((): void => {
|
||||
if (editor)
|
||||
editor.commands.setContent(Editor.convertToHtml(generatedText))
|
||||
editor.commands.setContent(convertToHtml(generatedText))
|
||||
getWordCount();
|
||||
}, [editor, generatedText]);
|
||||
|
||||
@@ -225,6 +237,7 @@ export default function ShortStoryGenerator({onClose}: ShortStoryGeneratorProps)
|
||||
setGeneratedText(accumulatedText);
|
||||
}
|
||||
|
||||
// Le message final du endpoint avec title, totalPrice, useYourKey, totalCost
|
||||
if (data.title && data.useYourKey !== undefined && data.totalPrice !== undefined) {
|
||||
setGeneratedStoryTitle(data.title);
|
||||
if (data.useYourKey) {
|
||||
@@ -284,10 +297,10 @@ export default function ShortStoryGenerator({onClose}: ShortStoryGeneratorProps)
|
||||
}
|
||||
|
||||
async function handleSave(): Promise<void> {
|
||||
let content: string = '';
|
||||
if (editor) content = editor?.state?.doc.toJSON();
|
||||
let content: Record<string, unknown> | string = '';
|
||||
if (editor) content = editor.state.doc.toJSON();
|
||||
try {
|
||||
const bookId: string = await System.authPostToServer<string>(
|
||||
const bookId: string = await apiPost<string>(
|
||||
`quillsense/generate/add`,
|
||||
{
|
||||
title: generatedStoryTitle,
|
||||
@@ -324,69 +337,64 @@ export default function ShortStoryGenerator({onClose}: ShortStoryGeneratorProps)
|
||||
if (!hasAccess) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center bg-darkest-background/80 z-50 backdrop-blur-sm">
|
||||
className="fixed inset-0 flex items-center justify-center p-4 bg-darkest-background/60 z-50 backdrop-blur-md animate-fadeIn">
|
||||
<div
|
||||
className="bg-dark-background text-text-primary rounded-lg border border-secondary shadow-xl w-full max-w-md p-6">
|
||||
className="relative bg-tertiary text-text-primary rounded-xl overflow-hidden 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"/>
|
||||
<Wand2 className="mr-3 w-5 h-5" strokeWidth={1.75}/>
|
||||
{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"
|
||||
>
|
||||
<Button variant="primary" onClick={onClose} fullWidth>
|
||||
{t("shortStoryGenerator.accessDenied.close")}
|
||||
</button>
|
||||
</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="fixed inset-0 flex items-center justify-center p-4 bg-darkest-background/60 z-40 backdrop-blur-md animate-fadeIn">
|
||||
<div
|
||||
className="relative bg-tertiary text-text-primary rounded-xl max-h-[90vh] overflow-hidden flex flex-col w-full max-w-4xl">
|
||||
|
||||
<div className="flex justify-between items-center bg-primary px-6 py-4 rounded-t-2xl shadow-md">
|
||||
<div className="flex justify-between items-center px-6 py-4">
|
||||
<h2 className="font-['ADLaM_Display'] text-xl text-text-primary flex items-center">
|
||||
<FontAwesomeIcon icon={faMagicWandSparkles} className="mr-3 w-5 h-5"/>
|
||||
<Wand2 className="mr-3 w-5 h-5" strokeWidth={1.75}/>
|
||||
{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>
|
||||
<IconButton icon={X} variant="light" onClick={onClose} disabled={isGenerating}/>
|
||||
</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="flex-1 min-h-0 bg-darkest-background rounded-xl mx-2 flex flex-col overflow-hidden">
|
||||
<div className="px-4 pt-4 pb-2">
|
||||
<div className="w-full bg-secondary rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary h-2.5 rounded-full transition-all duration-300 shadow-sm"
|
||||
style={{width: `${progress}%`}}
|
||||
ref={progressRef}
|
||||
className="bg-primary h-2 rounded-full transition-all duration-300"
|
||||
/>
|
||||
</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},
|
||||
<div className="flex border-b border-secondary">
|
||||
{([
|
||||
{id: 1, label: t("shortStoryGenerator.tabs.basics"), icon: BookOpen},
|
||||
{id: 2, label: t("shortStoryGenerator.tabs.structure"), icon: UserPen},
|
||||
{id: 3, label: t("shortStoryGenerator.tabs.atmosphere"), icon: CloudSun},
|
||||
...(hasGenerated || isGenerating ? [{
|
||||
id: 4,
|
||||
label: t("shortStoryGenerator.tabs.result"),
|
||||
icon: faFileLines
|
||||
icon: FileText
|
||||
}] : [])
|
||||
].map(tab => (
|
||||
] satisfies TabItem[]).map((tab: TabItem): React.JSX.Element => {
|
||||
const TabIcon: LucideIcon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
onClick={(): void => setActiveTab(tab.id)}
|
||||
disabled={isGenerating}
|
||||
className={`flex items-center px-6 py-3 font-medium transition-colors ${
|
||||
activeTab === tab.id
|
||||
@@ -394,36 +402,37 @@ export default function ShortStoryGenerator({onClose}: ShortStoryGeneratorProps)
|
||||
: 'text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
<FontAwesomeIcon icon={tab.icon} className="mr-2 w-4 h-4"/>
|
||||
<TabIcon className="mr-2 w-4 h-4" strokeWidth={1.75}/>
|
||||
{tab.label}
|
||||
{tab.id === 4 && isGenerating && !generatedText && (
|
||||
<FontAwesomeIcon icon={faSpinner} className="ml-2 animate-spin w-4 h-4"/>
|
||||
<Loader2 className="ml-2 animate-spin w-4 h-4" strokeWidth={1.75}/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex-1 min-h-0 overflow-auto custom-scrollbar">
|
||||
{activeTab === 1 && (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="p-5 space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<InputField
|
||||
icon={faGraduationCap}
|
||||
icon={GraduationCap}
|
||||
fieldName={t("shortStoryGenerator.fields.complexity")}
|
||||
input={
|
||||
<SelectBox
|
||||
onChangeCallBack={(e) => setAuthorLevel(e.target.value)}
|
||||
onChangeCallBack={(e: ChangeEvent<HTMLSelectElement>): void => setAuthorLevel(e.target.value)}
|
||||
data={writingLevel}
|
||||
defaultValue={authorLevel}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InputField
|
||||
icon={faBookOpen}
|
||||
icon={BookOpen}
|
||||
fieldName={t("shortStoryGenerator.fields.preset")}
|
||||
input={
|
||||
<SelectBox
|
||||
onChangeCallBack={(e) => setPresetType(e.target.value)}
|
||||
onChangeCallBack={(e: ChangeEvent<HTMLSelectElement>): void => setPresetType(e.target.value)}
|
||||
data={
|
||||
authorLevel === '1'
|
||||
? beginnerPredefinedType
|
||||
@@ -436,18 +445,18 @@ export default function ShortStoryGenerator({onClose}: ShortStoryGeneratorProps)
|
||||
}
|
||||
/>
|
||||
<InputField
|
||||
icon={faLanguage}
|
||||
icon={Languages}
|
||||
fieldName={t("shortStoryGenerator.fields.language")}
|
||||
input={
|
||||
<SelectBox
|
||||
onChangeCallBack={(e) => setLanguage(e.target.value)}
|
||||
onChangeCallBack={(e: ChangeEvent<HTMLSelectElement>): void => setLanguage(e.target.value)}
|
||||
data={langues}
|
||||
defaultValue={language}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InputField
|
||||
icon={faChartSimple}
|
||||
icon={BarChart2}
|
||||
fieldName={t("shortStoryGenerator.fields.wordCount")}
|
||||
input={
|
||||
<NumberInput
|
||||
@@ -462,25 +471,25 @@ export default function ShortStoryGenerator({onClose}: ShortStoryGeneratorProps)
|
||||
)}
|
||||
|
||||
{activeTab === 2 && (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="p-5 space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<InputField
|
||||
icon={faClock}
|
||||
icon={Clock}
|
||||
fieldName={t("shortStoryGenerator.fields.tense")}
|
||||
input={
|
||||
<SelectBox
|
||||
onChangeCallBack={(e) => setVerbTense(e.target.value)}
|
||||
onChangeCallBack={(e: ChangeEvent<HTMLSelectElement>): void => setVerbTense(e.target.value)}
|
||||
data={verbalTime}
|
||||
defaultValue={verbTense}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InputField
|
||||
icon={faUserEdit}
|
||||
icon={UserPen}
|
||||
fieldName={t("shortStoryGenerator.fields.narrative")}
|
||||
input={
|
||||
<SelectBox
|
||||
onChangeCallBack={(e) => setPerson(e.target.value)}
|
||||
onChangeCallBack={(e: ChangeEvent<HTMLSelectElement>): void => setPerson(e.target.value)}
|
||||
data={
|
||||
authorLevel === '1'
|
||||
? beginnerNarrativePersons
|
||||
@@ -495,11 +504,11 @@ export default function ShortStoryGenerator({onClose}: ShortStoryGeneratorProps)
|
||||
</div>
|
||||
|
||||
<InputField
|
||||
icon={faComments}
|
||||
icon={MessageSquare}
|
||||
fieldName={t("shortStoryGenerator.fields.dialogue")}
|
||||
input={
|
||||
<SelectBox
|
||||
onChangeCallBack={(e) => setDialogueType(e.target.value)}
|
||||
onChangeCallBack={(e: ChangeEvent<HTMLSelectElement>): void => setDialogueType(e.target.value)}
|
||||
data={
|
||||
authorLevel === '1'
|
||||
? beginnerDialogueTypes
|
||||
@@ -513,10 +522,10 @@ export default function ShortStoryGenerator({onClose}: ShortStoryGeneratorProps)
|
||||
/>
|
||||
|
||||
<InputField
|
||||
icon={faPencilAlt}
|
||||
icon={Pencil}
|
||||
fieldName={t("shortStoryGenerator.fields.directives")}
|
||||
input={
|
||||
<TexteAreaInput
|
||||
<TextAreaInput
|
||||
value={directives}
|
||||
setValue={(e: ChangeEvent<HTMLTextAreaElement>) => setDirectives(e.target.value)}
|
||||
placeholder={t("shortStoryGenerator.placeholders.directives")}
|
||||
@@ -527,10 +536,9 @@ export default function ShortStoryGenerator({onClose}: ShortStoryGeneratorProps)
|
||||
)}
|
||||
|
||||
{activeTab === 3 && (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="p-5 space-y-6">
|
||||
<InputField
|
||||
icon={faMusic}
|
||||
icon={Music}
|
||||
fieldName={t("shortStoryGenerator.fields.tone")}
|
||||
input={
|
||||
<TextInput
|
||||
@@ -540,11 +548,9 @@ export default function ShortStoryGenerator({onClose}: ShortStoryGeneratorProps)
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<InputField
|
||||
icon={faCloudSun}
|
||||
icon={CloudSun}
|
||||
fieldName={t("shortStoryGenerator.fields.atmosphere")}
|
||||
input={
|
||||
<TextInput
|
||||
@@ -554,10 +560,9 @@ export default function ShortStoryGenerator({onClose}: ShortStoryGeneratorProps)
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<InputField
|
||||
icon={faUserAstronaut}
|
||||
icon={User}
|
||||
fieldName={t("shortStoryGenerator.fields.character")}
|
||||
input={
|
||||
<TextInput
|
||||
@@ -578,7 +583,7 @@ export default function ShortStoryGenerator({onClose}: ShortStoryGeneratorProps)
|
||||
)}
|
||||
|
||||
{activeTab === 4 && (
|
||||
<div className="p-6">
|
||||
<div className="p-5">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="font-semibold text-lg">
|
||||
{generatedStoryTitle || t("shortStoryGenerator.result.title")}
|
||||
@@ -586,43 +591,24 @@ export default function ShortStoryGenerator({onClose}: ShortStoryGeneratorProps)
|
||||
|
||||
<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>
|
||||
<IconButton icon={Square} variant="danger" onClick={handleStopGeneration}
|
||||
tooltip={t("shortStoryGenerator.actions.stop")}/>
|
||||
) : 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>
|
||||
<IconButton icon={RotateCw} variant="muted" onClick={handleGeneration}
|
||||
tooltip={t("shortStoryGenerator.actions.regenerate")}/>
|
||||
<IconButton icon={BookMarked} variant="primary" onClick={handleSave}
|
||||
tooltip={t("shortStoryGenerator.actions.save")}/>
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
<PulseLoader text={t("shortStoryGenerator.result.generating")} size="lg"/>
|
||||
) : (
|
||||
<div
|
||||
className="bg-darkest-background rounded-lg p-6 overflow-auto max-h-96 fade-in-text">
|
||||
className="rounded-lg p-6 overflow-auto max-h-96 fade-in-text">
|
||||
<EditorContent editor={editor} className="prose prose-invert max-w-none"/>
|
||||
</div>
|
||||
)}
|
||||
@@ -630,7 +616,7 @@ export default function ShortStoryGenerator({onClose}: ShortStoryGeneratorProps)
|
||||
{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"/>
|
||||
<BarChart2 className="mr-2 w-4 h-4" strokeWidth={1.75}/>
|
||||
{totalWordsCount} {t("shortStoryGenerator.result.words")}
|
||||
</div>
|
||||
</div>
|
||||
@@ -638,58 +624,32 @@ export default function ShortStoryGenerator({onClose}: ShortStoryGeneratorProps)
|
||||
</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"/>
|
||||
<div className="flex justify-between items-center px-6 py-4">
|
||||
<Button variant="ghost" onClick={(): void => setActiveTab(Math.max(1, activeTab - 1))}
|
||||
disabled={activeTab === 1 || isGenerating}>
|
||||
<ChevronRight className="rotate-180 w-4 h-4" strokeWidth={1.75}/>
|
||||
{t("shortStoryGenerator.navigation.previous")}
|
||||
</button>
|
||||
</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}
|
||||
>
|
||||
<Button variant="secondary" onClick={onClose} disabled={isGenerating}>
|
||||
{activeTab === 4 && hasGenerated ? t("shortStoryGenerator.navigation.close") : t("shortStoryGenerator.navigation.cancel")}
|
||||
</button>
|
||||
</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"
|
||||
>
|
||||
<Button variant="primary" onClick={(): void => setActiveTab(activeTab + 1)}
|
||||
disabled={isGenerating}>
|
||||
{t("shortStoryGenerator.navigation.next")}
|
||||
<FontAwesomeIcon icon={faChevronRight} className="ml-2 w-4 h-4"/>
|
||||
</button>
|
||||
<ChevronRight className="w-4 h-4" strokeWidth={1.75}/>
|
||||
</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"/>
|
||||
<Button variant="primary" icon={Wand2} onClick={handleGeneration}
|
||||
disabled={isGenerating} isLoading={isGenerating}
|
||||
loadingText={t("shortStoryGenerator.actions.generating")}>
|
||||
{t("shortStoryGenerator.actions.generate")}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
'use client'
|
||||
import {useEffect, useState, useRef} 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 = 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">
|
||||
{typeof message === 'string' ? message : String(message ?? 'Une erreur est survenue')}
|
||||
</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>{`
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shrink {
|
||||
0% {
|
||||
width: 100%;
|
||||
}
|
||||
100% {
|
||||
width: 0%;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {faCloud, faCloudArrowDown, faCloudArrowUp, faSpinner} from "@fortawesome/free-solid-svg-icons";
|
||||
import {useTranslations} from "next-intl";
|
||||
import {Cloud, CloudDownload, CloudUpload, Loader2} from "lucide-react";
|
||||
import {useTranslations} from "@/lib/i18n";
|
||||
import {useState, useEffect, useContext} from "react";
|
||||
import OfflineContext, {OfflineContextType} from "@/context/OfflineContext";
|
||||
import {SyncType} from "@/context/BooksSyncContext";
|
||||
import useSyncBooks from "@/hooks/useSyncBooks";
|
||||
import IconButton from "@/components/ui/IconButton";
|
||||
|
||||
interface SyncBookProps {
|
||||
bookId: string;
|
||||
@@ -59,9 +59,7 @@ export default function SyncBook({bookId, status}: SyncBookProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-primary">
|
||||
<FontAwesomeIcon icon={faSpinner} className="w-4 h-4 animate-spin"/>
|
||||
</span>
|
||||
<Loader2 className="w-4 h-4 text-primary animate-spin"/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -69,57 +67,48 @@ export default function SyncBook({bookId, status}: SyncBookProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{currentStatus === 'synced' && (
|
||||
<span
|
||||
className="text-gray-light"
|
||||
title={t("bookCard.synced")}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCloud} className="w-4 h-4"/>
|
||||
</span>
|
||||
<Cloud className="w-4 h-4 text-gray-light" title={t("bookCard.synced")}/>
|
||||
)}
|
||||
|
||||
{currentStatus === 'local-only' && (
|
||||
<button
|
||||
<IconButton
|
||||
icon={CloudUpload}
|
||||
variant={isOffline ? 'muted' : 'primary'}
|
||||
size="sm"
|
||||
onClick={upload}
|
||||
className={`transition-colors ${isOffline ? 'text-gray-dark cursor-not-allowed' : 'text-gray hover:text-primary cursor-pointer'}`}
|
||||
title={t("bookCard.localOnly")}
|
||||
type="button"
|
||||
disabled={isOffline}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCloudArrowUp} className="w-4 h-4"/>
|
||||
</button>
|
||||
tooltip={t("bookCard.localOnly")}
|
||||
/>
|
||||
)}
|
||||
{currentStatus === 'server-only' && (
|
||||
<button
|
||||
<IconButton
|
||||
icon={CloudDownload}
|
||||
variant={isOffline ? 'muted' : 'primary'}
|
||||
size="sm"
|
||||
onClick={download}
|
||||
className={`transition-colors ${isOffline ? 'text-gray-dark cursor-not-allowed' : 'text-gray hover:text-primary cursor-pointer'}`}
|
||||
title={t("bookCard.serverOnly")}
|
||||
type="button"
|
||||
disabled={isOffline}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCloudArrowDown} className="w-4 h-4"/>
|
||||
</button>
|
||||
tooltip={t("bookCard.serverOnly")}
|
||||
/>
|
||||
)}
|
||||
{currentStatus === 'to-sync-from-server' && (
|
||||
<button
|
||||
<IconButton
|
||||
icon={CloudDownload}
|
||||
variant={isOffline ? 'muted' : 'primary'}
|
||||
size="sm"
|
||||
onClick={syncFromServer}
|
||||
className={`transition-colors ${isOffline ? 'text-gray-dark cursor-not-allowed' : 'text-warning hover:text-primary cursor-pointer'}`}
|
||||
title={t("bookCard.toSyncFromServer")}
|
||||
type="button"
|
||||
disabled={isOffline}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCloudArrowDown} className="w-4 h-4"/>
|
||||
</button>
|
||||
tooltip={t("bookCard.toSyncFromServer")}
|
||||
/>
|
||||
)}
|
||||
{currentStatus === 'to-sync-to-server' && (
|
||||
<button
|
||||
<IconButton
|
||||
icon={CloudUpload}
|
||||
variant={isOffline ? 'muted' : 'primary'}
|
||||
size="sm"
|
||||
onClick={syncToServer}
|
||||
className={`transition-colors ${isOffline ? 'text-gray-dark cursor-not-allowed' : 'text-warning hover:text-primary cursor-pointer'}`}
|
||||
title={t("bookCard.toSyncToServer")}
|
||||
type="button"
|
||||
disabled={isOffline}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCloudArrowUp} className="w-4 h-4"/>
|
||||
</button>
|
||||
tooltip={t("bookCard.toSyncToServer")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCloud, faCloudArrowDown, faCloudArrowUp, faSpinner } from "@fortawesome/free-solid-svg-icons";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {Cloud, CloudDownload, CloudUpload, Loader2} from "lucide-react";
|
||||
import {useTranslations} from "@/lib/i18n";
|
||||
import {useState, useContext, useEffect} from "react";
|
||||
import OfflineContext, {OfflineContextType} from "@/context/OfflineContext";
|
||||
import {SeriesSyncType} from "@/context/SeriesSyncContext";
|
||||
import useSyncSeries from "@/hooks/useSyncSeries";
|
||||
import IconButton from "@/components/ui/IconButton";
|
||||
|
||||
interface SyncSeriesProps {
|
||||
seriesId: string;
|
||||
@@ -18,15 +18,13 @@ export default function SyncSeries({ seriesId, status }: SyncSeriesProps) {
|
||||
const [currentStatus, setCurrentStatus] = useState<SeriesSyncType>(status);
|
||||
const {upload: hookUpload, download: hookDownload, syncFromServer: hookSyncFromServer, syncToServer: hookSyncToServer} = useSyncSeries();
|
||||
|
||||
// Synchroniser le state local avec le prop quand il change (ex: après sync auto)
|
||||
useEffect(() => {
|
||||
setCurrentStatus(status);
|
||||
}, [status]);
|
||||
|
||||
const isOffline: boolean = isCurrentlyOffline();
|
||||
|
||||
async function upload(event: React.MouseEvent): Promise<void> {
|
||||
event.stopPropagation();
|
||||
async function upload(): Promise<void> {
|
||||
if (isOffline) return;
|
||||
setIsLoading(true);
|
||||
const success: boolean = await hookUpload(seriesId);
|
||||
@@ -34,8 +32,7 @@ export default function SyncSeries({ seriesId, status }: SyncSeriesProps) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
async function download(event: React.MouseEvent): Promise<void> {
|
||||
event.stopPropagation();
|
||||
async function download(): Promise<void> {
|
||||
if (isOffline) return;
|
||||
setIsLoading(true);
|
||||
const success = await hookDownload(seriesId);
|
||||
@@ -43,8 +40,7 @@ export default function SyncSeries({ seriesId, status }: SyncSeriesProps) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
async function syncFromServer(event: React.MouseEvent): Promise<void> {
|
||||
event.stopPropagation();
|
||||
async function syncFromServer(): Promise<void> {
|
||||
if (isOffline) return;
|
||||
setIsLoading(true);
|
||||
const success = await hookSyncFromServer(seriesId);
|
||||
@@ -52,8 +48,7 @@ export default function SyncSeries({ seriesId, status }: SyncSeriesProps) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
async function syncToServer(event: React.MouseEvent): Promise<void> {
|
||||
event.stopPropagation();
|
||||
async function syncToServer(): Promise<void> {
|
||||
if (isOffline || isLoading) return;
|
||||
setIsLoading(true);
|
||||
const success = await hookSyncToServer(seriesId);
|
||||
@@ -63,68 +58,57 @@ export default function SyncSeries({ seriesId, status }: SyncSeriesProps) {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-primary">
|
||||
<FontAwesomeIcon icon={faSpinner} className="w-4 h-4 animate-spin"/>
|
||||
</span>
|
||||
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<Loader2 className="w-4 h-4 text-primary animate-spin"/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
{currentStatus === 'synced' && (
|
||||
<span
|
||||
className="text-gray-light"
|
||||
title={t("seriesCard.synced")}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCloud} className="w-4 h-4"/>
|
||||
</span>
|
||||
<Cloud className="w-4 h-4 text-gray-light" title={t("seriesCard.synced")}/>
|
||||
)}
|
||||
|
||||
{currentStatus === 'local-only' && (
|
||||
<button
|
||||
<IconButton
|
||||
icon={CloudUpload}
|
||||
variant={isOffline ? 'muted' : 'primary'}
|
||||
size="sm"
|
||||
onClick={upload}
|
||||
className={`transition-colors ${isOffline ? 'text-gray-dark cursor-not-allowed' : 'text-gray hover:text-primary cursor-pointer'}`}
|
||||
title={t("seriesCard.localOnly")}
|
||||
type="button"
|
||||
disabled={isOffline}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCloudArrowUp} className="w-4 h-4"/>
|
||||
</button>
|
||||
tooltip={t("seriesCard.localOnly")}
|
||||
/>
|
||||
)}
|
||||
{currentStatus === 'server-only' && (
|
||||
<button
|
||||
<IconButton
|
||||
icon={CloudDownload}
|
||||
variant={isOffline ? 'muted' : 'primary'}
|
||||
size="sm"
|
||||
onClick={download}
|
||||
className={`transition-colors ${isOffline ? 'text-gray-dark cursor-not-allowed' : 'text-gray hover:text-primary cursor-pointer'}`}
|
||||
title={t("seriesCard.serverOnly")}
|
||||
type="button"
|
||||
disabled={isOffline}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCloudArrowDown} className="w-4 h-4"/>
|
||||
</button>
|
||||
tooltip={t("seriesCard.serverOnly")}
|
||||
/>
|
||||
)}
|
||||
{currentStatus === 'to-sync-from-server' && (
|
||||
<button
|
||||
<IconButton
|
||||
icon={CloudDownload}
|
||||
variant={isOffline ? 'muted' : 'primary'}
|
||||
size="sm"
|
||||
onClick={syncFromServer}
|
||||
className={`transition-colors ${isOffline ? 'text-gray-dark cursor-not-allowed' : 'text-warning hover:text-primary cursor-pointer'}`}
|
||||
title={t("seriesCard.toSyncFromServer")}
|
||||
type="button"
|
||||
disabled={isOffline}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCloudArrowDown} className="w-4 h-4"/>
|
||||
</button>
|
||||
tooltip={t("seriesCard.toSyncFromServer")}
|
||||
/>
|
||||
)}
|
||||
{currentStatus === 'to-sync-to-server' && (
|
||||
<button
|
||||
<IconButton
|
||||
icon={CloudUpload}
|
||||
variant={isOffline ? 'muted' : 'primary'}
|
||||
size="sm"
|
||||
onClick={syncToServer}
|
||||
className={`transition-colors ${isOffline ? 'text-gray-dark cursor-not-allowed' : 'text-warning hover:text-primary cursor-pointer'}`}
|
||||
title={t("seriesCard.toSyncToServer")}
|
||||
type="button"
|
||||
disabled={isOffline}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCloudArrowUp} className="w-4 h-4"/>
|
||||
</button>
|
||||
tooltip={t("seriesCard.toSyncToServer")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,25 +1,28 @@
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {faExternalLinkAlt, faFileContract} from '@fortawesome/free-solid-svg-icons';
|
||||
// Removed Next.js router and Link imports for Electron
|
||||
import React from 'react';
|
||||
import {ExternalLink, FileText} from 'lucide-react';
|
||||
import Button from '@/components/ui/Button';
|
||||
import {AppRouterInstance, Link, useRouter} from '@/lib/navigation';
|
||||
|
||||
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']">
|
||||
className="fixed inset-0 z-50 bg-darkest-background/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">
|
||||
className="bg-tertiary border border-primary/40 rounded-2xl max-w-2xl w-full max-h-[90vh] overflow-hidden">
|
||||
<div className="px-8 py-6 border-b border-secondary 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"/>
|
||||
<FileText className="text-primary w-6 h-6" strokeWidth={1.75}/>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-text-primary font-bold text-2xl">Termes d'utilisation</h2>
|
||||
@@ -52,7 +55,7 @@ export default function TermsOfUse({onAccept}: TermsOfUseProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-secondary/20 border border-secondary/30 rounded-xl p-6">
|
||||
<div className="bg-tertiary border border-secondary rounded-xl p-6">
|
||||
<h3 className="text-text-primary font-semibold text-lg mb-4">
|
||||
Documentation complète
|
||||
</h3>
|
||||
@@ -67,14 +70,14 @@ export default function TermsOfUse({onAccept}: TermsOfUseProps) {
|
||||
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"/>
|
||||
<ExternalLink className="w-4 h-4" strokeWidth={1.75}/>
|
||||
</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"/>
|
||||
<FileText className="text-warning w-5 h-5" strokeWidth={1.75}/>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-text-primary font-semibold text-base mb-2">
|
||||
@@ -90,28 +93,23 @@ export default function TermsOfUse({onAccept}: TermsOfUseProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-8 py-6 bg-secondary/10 border-t border-secondary/30 rounded-b-2xl">
|
||||
<div className="px-8 py-6 bg-secondary border-t border-secondary 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"/>
|
||||
<FileText className="text-primary w-4 h-4" strokeWidth={1.75}/>
|
||||
<span>Décision requise pour continuer</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<a
|
||||
<Link
|
||||
href="https://eritors.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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"
|
||||
>
|
||||
Refuser et quitter
|
||||
</a>
|
||||
<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"
|
||||
className="text-muted hover:text-text-primary px-6 py-3 rounded-xl hover:bg-secondary transition-colors duration-150 text-sm font-medium"
|
||||
type="button"
|
||||
>
|
||||
Refuser et quitter
|
||||
</Link>
|
||||
<Button variant="primary" size="lg" onClick={handleAcceptTerm}>
|
||||
J'accepte les termes
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
'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 {Check, KeyRound, Smartphone, QrCode} from 'lucide-react';
|
||||
import {apiPost} from "@/lib/api/client";
|
||||
import {AlertContext, AlertContextProps} from "@/context/AlertContext";
|
||||
import {FormResponse} from "@/shared/interface";
|
||||
import {SessionContext} from "@/context/SessionContext";
|
||||
import TextInput from "@/components/form/TextInput";
|
||||
import InputField from "@/components/form/InputField";
|
||||
import Button from "@/components/ui/Button";
|
||||
|
||||
const AppleIcon = () => (
|
||||
<svg className="w-5 h-5" viewBox="0 0 384 512" fill="currentColor">
|
||||
<path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const GooglePlayIcon = () => (
|
||||
<svg className="w-5 h-5" viewBox="0 0 512 512" fill="currentColor">
|
||||
<path d="M325.3 234.3L104.6 13l280.8 161.2-60.1 60.1zM47 0C34 6.8 25.3 19.2 25.3 35.3v441.3c0 16.1 8.7 28.5 21.7 35.3l256.6-256L47 0zm425.2 225.6l-58.9-34.1-65.7 64.5 65.7 64.5 60.1-34.1c18-14.3 18-46.5-1.2-60.8zM104.6 499l280.8-161.2-60.1-60.1L104.6 499z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function TwoFactorSetup({setShowSetup}: { setShowSetup: Dispatch<SetStateAction<boolean>> }) {
|
||||
const {session} = useContext(SessionContext);
|
||||
@@ -21,13 +33,12 @@ export default function TwoFactorSetup({setShowSetup}: { setShowSetup: Dispatch<
|
||||
|
||||
async function getQRCode() {
|
||||
try {
|
||||
const response: { qrCode: string } = await System.authPostToServer('twofactor/setup', {
|
||||
const response: { qrCode: string } = await apiPost('twofactor/setup', {
|
||||
email: session?.user?.email,
|
||||
}, session?.accessToken ?? '');
|
||||
setQrCode(response.qrCode);
|
||||
} catch (e: any) {
|
||||
alert.errorMessage(e.message);
|
||||
console.error(e);
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) alert.errorMessage(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,16 +57,15 @@ export default function TwoFactorSetup({setShowSetup}: { setShowSetup: Dispatch<
|
||||
|
||||
async function validateToken() {
|
||||
try {
|
||||
const response: FormResponse = await System.authPostToServer('twofactor/activate', {
|
||||
const response: FormResponse = await apiPost('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);
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) alert.errorMessage(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,13 +80,11 @@ export default function TwoFactorSetup({setShowSetup}: { setShowSetup: Dispatch<
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl shadow-2xl bg-tertiary/90 backdrop-blur-sm m-auto p-8 w-full border border-secondary/50">
|
||||
<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>
|
||||
@@ -87,7 +95,6 @@ export default function TwoFactorSetup({setShowSetup}: { setShowSetup: Dispatch<
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step Content */}
|
||||
<div className="mb-6">
|
||||
{step === 1 && (
|
||||
<div className="text-muted">
|
||||
@@ -96,15 +103,15 @@ export default function TwoFactorSetup({setShowSetup}: { setShowSetup: Dispatch<
|
||||
</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"/>
|
||||
<Smartphone className="w-5 h-5 text-primary mr-3 mt-0.5 flex-shrink-0"/>
|
||||
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"/>
|
||||
<Check className="w-5 h-5 text-primary mr-3 mt-0.5 flex-shrink-0"/>
|
||||
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"/>
|
||||
<QrCode className="w-5 h-5 text-primary mr-3 mt-0.5 flex-shrink-0"/>
|
||||
Proceed to the next step to scan the QR code provided.
|
||||
</li>
|
||||
</ol>
|
||||
@@ -113,18 +120,18 @@ export default function TwoFactorSetup({setShowSetup}: { setShowSetup: Dispatch<
|
||||
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"
|
||||
className="flex items-center justify-center gap-2 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"/>
|
||||
<GooglePlayIcon/>
|
||||
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"
|
||||
className="flex items-center justify-center gap-2 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"/>
|
||||
<AppleIcon/>
|
||||
Download on App Store
|
||||
</a>
|
||||
</div>
|
||||
@@ -156,40 +163,35 @@ export default function TwoFactorSetup({setShowSetup}: { setShowSetup: Dispatch<
|
||||
<p className="mb-4">
|
||||
Enter the 6-digit code generated by your authentication app to verify the setup.
|
||||
</p>
|
||||
<div className="relative">
|
||||
<InputField
|
||||
icon={KeyRound}
|
||||
fieldName="Verification Code"
|
||||
input={
|
||||
<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
|
||||
<Button
|
||||
variant="secondary"
|
||||
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
|
||||
</Button>
|
||||
<Button
|
||||
variant={step === 3 ? 'success' : 'primary'}
|
||||
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>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import {useContext, useEffect, useRef, useState} from "react";
|
||||
import {SessionContext} from "@/context/SessionContext";
|
||||
import NoPicture from "@/components/NoPicture";
|
||||
import System from "@/lib/models/System";
|
||||
import {useTranslations} from "next-intl";
|
||||
import * as tauri from '@/lib/tauri';
|
||||
|
||||
export default function UserMenu() {
|
||||
const {session} = useContext(SessionContext);
|
||||
const t = useTranslations();
|
||||
|
||||
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]);
|
||||
|
||||
async function handleLogout(): Promise<void> {
|
||||
System.removeCookie("token");
|
||||
await tauri.removeToken();
|
||||
tauri.logout();
|
||||
}
|
||||
|
||||
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">{t('userMenu.settings')}</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">{t('userMenu.logout')}</span>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,36 +1,26 @@
|
||||
'use client'
|
||||
import React, {ChangeEvent, Dispatch, SetStateAction, useContext, useState} from "react";
|
||||
import {AlertContext, AlertContextProps} from "@/context/AlertContext";
|
||||
import {apiPost} from "@/lib/api/client";
|
||||
import {isDesktop} from '@/lib/configs';
|
||||
import * as tauri from '@/lib/tauri';
|
||||
import {ChangeEvent, Dispatch, RefObject, 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 OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
|
||||
import {SessionContext, SessionContextProps} from "@/context/SessionContext";
|
||||
import {Book, BookOpen, Calendar, FileText, Info, Pencil} from "lucide-react";
|
||||
import SelectBox, {SelectBoxProps} from "@/components/form/SelectBox";
|
||||
import {bookTypes} from "@/lib/constants/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 TextAreaInput from "@/components/form/TextAreaInput";
|
||||
import Button from "@/components/ui/Button";
|
||||
import Modal from "@/components/ui/Modal";
|
||||
import GuideTour, {GuideStep} from "@/components/GuideTour";
|
||||
import {UserProps} from "@/lib/models/User";
|
||||
import {useTranslations} from "next-intl";
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
import {LangContext, LangContextProps} from "@/context/LangContext";
|
||||
import OfflineContext, {OfflineContextType} from "@/context/OfflineContext";
|
||||
import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext";
|
||||
import {SyncedBook} from "@/lib/models/SyncedBook";
|
||||
import {SyncedBook} from "@/lib/types/synced-book";
|
||||
|
||||
interface MinMax {
|
||||
min: number;
|
||||
@@ -39,13 +29,11 @@ interface MinMax {
|
||||
|
||||
export default function AddNewBookForm({setCloseForm}: { setCloseForm: Dispatch<SetStateAction<boolean>> }) {
|
||||
const t = useTranslations();
|
||||
const {lang} = useContext<LangContextProps>(LangContext);
|
||||
const {session} = useContext(SessionContext);
|
||||
const {errorMessage} = useContext(AlertContext);
|
||||
const {setServerOnlyBooks, setLocalOnlyBooks} = useContext<BooksSyncContextProps>(BooksSyncContext)
|
||||
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
|
||||
const modalRef: RefObject<HTMLDivElement | null> = useRef<HTMLDivElement>(null);
|
||||
|
||||
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext);
|
||||
const {session, setSession}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
|
||||
const {errorMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
|
||||
const {setServerSyncedBooks}: BooksSyncContextProps = useContext<BooksSyncContextProps>(BooksSyncContext)
|
||||
const {isCurrentlyOffline}: OfflineContextType = useContext<OfflineContextType>(OfflineContext);
|
||||
const [title, setTitle] = useState<string>('');
|
||||
const [subtitle, setSubtitle] = useState<string>('');
|
||||
const [summary, setSummary] = useState<string>('');
|
||||
@@ -66,27 +54,27 @@ export default function AddNewBookForm({setCloseForm}: { setCloseForm: Dispatch<
|
||||
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">
|
||||
<div className="border-l-4 border-primary pl-4 bg-secondary 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">
|
||||
<div className="border-l-4 border-primary pl-4 bg-secondary 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">
|
||||
<div className="border-l-4 border-primary pl-4 bg-secondary 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">
|
||||
<div className="border-l-4 border-primary pl-4 bg-secondary 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">
|
||||
<div className="border-l-4 border-primary pl-4 bg-secondary 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>
|
||||
@@ -101,13 +89,6 @@ export default function AddNewBookForm({setCloseForm}: { setCloseForm: Dispatch<
|
||||
),
|
||||
}]
|
||||
|
||||
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'));
|
||||
@@ -128,85 +109,60 @@ export default function AddNewBookForm({setCloseForm}: { setCloseForm: Dispatch<
|
||||
}
|
||||
setIsAddingBook(true);
|
||||
try {
|
||||
const bookData = {
|
||||
title,
|
||||
let bookId: string;
|
||||
if (isDesktop && isCurrentlyOffline()) {
|
||||
bookId = await tauri.createBook({
|
||||
title: title,
|
||||
subTitle: subtitle,
|
||||
type: selectedBookType,
|
||||
summary,
|
||||
serie: 0,
|
||||
publicationDate,
|
||||
desiredWordCount: wordCount
|
||||
};
|
||||
|
||||
let bookId: string;
|
||||
if (isCurrentlyOffline()) {
|
||||
bookId = await tauri.createBook(bookData);
|
||||
summary: summary,
|
||||
desiredReleaseDate: publicationDate,
|
||||
desiredWordCount: wordCount,
|
||||
});
|
||||
} else {
|
||||
bookId = await System.authPostToServer<string>('book/add', bookData, token, lang);
|
||||
bookId = await apiPost<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'))
|
||||
errorMessage(t('addNewBookForm.error.addingBook'));
|
||||
setIsAddingBook(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const book: BookProps = {
|
||||
bookId: bookId,
|
||||
...bookData
|
||||
const book: SyncedBook = {
|
||||
id: bookId,
|
||||
type: selectedBookType,
|
||||
title: title,
|
||||
subTitle: subtitle,
|
||||
seriesId: null,
|
||||
lastUpdate: new Date().getTime() / 1000,
|
||||
chapters: [],
|
||||
characters: [],
|
||||
locations: [],
|
||||
worlds: [],
|
||||
incidents: [],
|
||||
plotPoints: [],
|
||||
issues: [],
|
||||
actSummaries: [],
|
||||
guideLine: null,
|
||||
aiGuideLine: null
|
||||
};
|
||||
if (isCurrentlyOffline()){
|
||||
setLocalOnlyBooks((prevBooks: SyncedBook[]): SyncedBook[] => [...prevBooks, {
|
||||
id: book.bookId,
|
||||
type: selectedBookType,
|
||||
title: title,
|
||||
subTitle: subtitle,
|
||||
lastUpdate: new Date().getTime()/1000,
|
||||
chapters: [],
|
||||
characters: [],
|
||||
locations: [],
|
||||
worlds: [],
|
||||
incidents: [],
|
||||
plotPoints: [],
|
||||
issues: [],
|
||||
actSummaries: [],
|
||||
guideLine: null,
|
||||
aiGuideLine: null,
|
||||
bookTools: null,
|
||||
seriesId: null,
|
||||
spells: [],
|
||||
spellTags: []
|
||||
}]);
|
||||
}
|
||||
else {
|
||||
setServerOnlyBooks((prevBooks: SyncedBook[]): SyncedBook[] => [...prevBooks, {
|
||||
id: book.bookId,
|
||||
type: selectedBookType,
|
||||
title: title,
|
||||
subTitle: subtitle,
|
||||
lastUpdate: new Date().getTime()/1000,
|
||||
chapters: [],
|
||||
characters: [],
|
||||
locations: [],
|
||||
worlds: [],
|
||||
incidents: [],
|
||||
plotPoints: [],
|
||||
issues: [],
|
||||
actSummaries: [],
|
||||
guideLine: null,
|
||||
aiGuideLine: null,
|
||||
bookTools: null,
|
||||
seriesId: null,
|
||||
spells: [],
|
||||
spellTags: []
|
||||
}]);
|
||||
}
|
||||
setServerSyncedBooks((prev: SyncedBook[]): SyncedBook[] => [...prev, book])
|
||||
|
||||
setIsAddingBook(false);
|
||||
setCloseForm(false)
|
||||
} catch (e: unknown) {
|
||||
console.error('[AddBook] Raw error:', e, typeof e, JSON.stringify(e));
|
||||
const msg = e instanceof Error ? e.message : typeof e === 'object' && e !== null && 'message' in e ? String((e as {message:string}).message) : typeof e === 'string' ? e : t('addNewBookForm.error.addingBook');
|
||||
errorMessage(msg);
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
} else {
|
||||
errorMessage(t('addNewBookForm.error.addingBook'));
|
||||
}
|
||||
setIsAddingBook(false);
|
||||
}
|
||||
}
|
||||
@@ -247,26 +203,26 @@ export default function AddNewBookForm({setCloseForm}: { setCloseForm: Dispatch<
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center bg-black/60 z-40 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)}
|
||||
<>
|
||||
<Modal
|
||||
icon={Book}
|
||||
title={t("addNewBookForm.title")}
|
||||
onClose={(): void => setCloseForm(false)}
|
||||
size="sm"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setCloseForm(false)}>{t("common.cancel")}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleAddBook}
|
||||
isLoading={isAddingBook}
|
||||
loadingText={t("addNewBookForm.adding")}
|
||||
icon={Book}
|
||||
>{t("addNewBookForm.add")}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<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={
|
||||
<InputField icon={BookOpen} fieldName={t("addNewBookForm.type")} input={
|
||||
<SelectBox
|
||||
onChangeCallBack={(e: ChangeEvent<HTMLSelectElement>): void => setSelectedBookType(e.target.value)}
|
||||
data={bookTypes.map((types: SelectBoxProps): SelectBoxProps => {
|
||||
@@ -276,15 +232,15 @@ export default function AddNewBookForm({setCloseForm}: { setCloseForm: Dispatch<
|
||||
}
|
||||
})} defaultValue={selectedBookType}
|
||||
placeholder={t("addNewBookForm.typePlaceholder")}/>
|
||||
} action={async (): Promise<void> => setBookTypeHint(true)} actionIcon={faInfo}/>
|
||||
<InputField icon={faPencilAlt} fieldName={t("addNewBookForm.bookTitle")} input={
|
||||
} action={async (): Promise<void> => setBookTypeHint(true)} actionIcon={Info}/>
|
||||
<InputField icon={Pencil} 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={
|
||||
<InputField icon={Pencil} fieldName={t("addNewBookForm.subtitle")} input={
|
||||
<TextInput value={subtitle}
|
||||
setValue={(e: ChangeEvent<HTMLInputElement>): void => setSubtitle(e.target.value)}
|
||||
placeholder={t("addNewBookForm.subtitlePlaceholder")}/>
|
||||
@@ -292,7 +248,7 @@ export default function AddNewBookForm({setCloseForm}: { setCloseForm: Dispatch<
|
||||
)
|
||||
}
|
||||
|
||||
<InputField icon={faCalendarAlt} fieldName={t("addNewBookForm.publicationDate")} input={
|
||||
<InputField icon={Calendar} fieldName={t("addNewBookForm.publicationDate")} input={
|
||||
<DatePicker date={publicationDate}
|
||||
setDate={(e: React.ChangeEvent<HTMLInputElement>): void => setPublicationDate(e.target.value)}/>
|
||||
}/>
|
||||
@@ -300,7 +256,7 @@ export default function AddNewBookForm({setCloseForm}: { setCloseForm: Dispatch<
|
||||
{
|
||||
selectedBookType !== 'lyric' && (
|
||||
<>
|
||||
<InputField icon={faFileWord} fieldName={t("addNewBookForm.wordGoal")}
|
||||
<InputField icon={FileText} 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}
|
||||
@@ -308,10 +264,10 @@ export default function AddNewBookForm({setCloseForm}: { setCloseForm: Dispatch<
|
||||
}/>
|
||||
|
||||
<InputField
|
||||
icon={faFileWord}
|
||||
icon={FileText}
|
||||
fieldName={t("addNewBookForm.summary")}
|
||||
input={
|
||||
<TexteAreaInput
|
||||
<TextAreaInput
|
||||
value={summary}
|
||||
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setSummary(e.target.value)}
|
||||
placeholder={t("addNewBookForm.summaryPlaceholder")}
|
||||
@@ -321,22 +277,9 @@ export default function AddNewBookForm({setCloseForm}: { setCloseForm: Dispatch<
|
||||
</>
|
||||
)
|
||||
}
|
||||
</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>
|
||||
</Modal>
|
||||
{bookTypeHint && <GuideTour stepId={0} steps={bookTypesHint} onClose={(): void => setBookTypeHint(false)}
|
||||
onComplete={async (): Promise<void> => setBookTypeHint(false)}/>}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,75 +1,58 @@
|
||||
// Removed Next.js Link import for Electron
|
||||
import {BookProps} from "@/lib/models/Book";
|
||||
import {Link} from '@/lib/navigation';
|
||||
import React from "react";
|
||||
import {BookProps} from "@/lib/types/book";
|
||||
import DeleteBook from "@/components/book/settings/DeleteBook";
|
||||
import {useTranslations} from "next-intl";
|
||||
import SyncBook from "@/components/SyncBook";
|
||||
import {SyncType} from "@/context/BooksSyncContext";
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
|
||||
interface BookCardProps {
|
||||
book: BookProps;
|
||||
onClickCallback: (bookId: string) => void;
|
||||
export default function BookCard(
|
||||
{
|
||||
book,
|
||||
onClickCallback,
|
||||
index
|
||||
}: {
|
||||
book: BookProps,
|
||||
onClickCallback: Function;
|
||||
index: number;
|
||||
syncStatus: SyncType;
|
||||
}
|
||||
|
||||
export default function BookCard({book, onClickCallback, index, syncStatus}: BookCardProps) {
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<Link href={`/book/${book.bookId}`}>
|
||||
<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">
|
||||
<div className="relative w-full aspect-[2/3] flex-shrink-0 overflow-hidden rounded-t-2xl">
|
||||
<button onClick={(): void => onClickCallback(book.bookId)} className="w-full h-full text-left block" type="button">
|
||||
className="group relative aspect-[2/3] rounded-xl overflow-hidden cursor-pointer transition-all duration-300 hover:ring-1 hover:ring-text-primary/20">
|
||||
{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"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<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() : ''}
|
||||
<div className="w-full h-full bg-secondary flex items-center justify-center">
|
||||
<span className="text-muted text-5xl font-['ADLaM_Display']">
|
||||
{book.title.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>
|
||||
)}
|
||||
</button>
|
||||
<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">
|
||||
<button onClick={(): void => onClickCallback(book.bookId)} className="w-full text-left" type="button">
|
||||
<h3 className="text-text-primary text-center font-bold text-base mb-2 truncate group-hover:text-primary transition-colors tracking-wide">
|
||||
<div className="absolute inset-x-0 bottom-0 bg-darkest-background/70 p-3">
|
||||
<h3 className="text-text-primary font-bold text-sm truncate">
|
||||
{book.title}
|
||||
</h3>
|
||||
</button>
|
||||
<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 className="text-text-secondary text-xs truncate mt-0.5">
|
||||
{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">
|
||||
<SyncBook status={syncStatus} bookId={book.bookId}/>
|
||||
<div className="flex items-center gap-1" {...index === 0 && {'data-guide': 'bottom-book-card'}}>
|
||||
|
||||
<div
|
||||
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200"
|
||||
onClick={(e: React.MouseEvent): void => e.preventDefault()}
|
||||
{...(index === 0 && {'data-guide': 'bottom-book-card'})}
|
||||
>
|
||||
<DeleteBook bookId={book.bookId}/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -1,23 +1,22 @@
|
||||
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="bg-tertiary rounded-xl h-full 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="w-full h-full bg-secondary 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 className="h-4 bg-secondary rounded-lg mb-2 w-3/4 mx-auto"></div>
|
||||
<div className="h-3 bg-tertiary 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="h-6 bg-secondary 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 className="h-8 w-8 bg-secondary rounded-lg"></div>
|
||||
<div className="h-8 w-8 bg-secondary rounded-lg"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,25 +1,24 @@
|
||||
import React, {useContext, useEffect, useRef, useState} from "react";
|
||||
import {apiGet, apiPost} from "@/lib/api/client";
|
||||
import {isDesktop} from '@/lib/configs';
|
||||
import * as tauri from '@/lib/tauri';
|
||||
import {useContext, useEffect, useRef, useState} from "react";
|
||||
import System from "@/lib/models/System";
|
||||
import {AlertContext} from "@/context/AlertContext";
|
||||
import {BookContext} from "@/context/BookContext";
|
||||
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
|
||||
import {AlertContext, AlertContextProps} from "@/context/AlertContext";
|
||||
import SearchBook from "./SearchBook";
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {faBook, faChevronLeft, faChevronRight, faDownload, faGear, faTrash} from "@fortawesome/free-solid-svg-icons";
|
||||
import {SessionContext} from "@/context/SessionContext";
|
||||
import Book, {BookProps} from "@/lib/models/Book";
|
||||
import {useRouter} from "@/lib/navigation";
|
||||
import {Book, ChevronLeft, ChevronRight, Download, Settings, Trash2} from 'lucide-react';
|
||||
import Badge from "@/components/ui/Badge";
|
||||
import {SessionContext, SessionContextProps} from "@/context/SessionContext";
|
||||
import {BookProps} from "@/lib/types/book";
|
||||
import {getBookTypeLabel} from "@/lib/utils/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 {guideTourDone, setNewGuideTour} from "@/lib/utils/user";
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
import {LangContext, LangContextProps} from "@/context/LangContext";
|
||||
import OfflineContext, {OfflineContextType} from "@/context/OfflineContext";
|
||||
import {BooksSyncContext, BooksSyncContextProps, SyncType} from "@/context/BooksSyncContext";
|
||||
import {SeriesSyncContext, SeriesSyncContextProps, SeriesSyncType} from "@/context/SeriesSyncContext";
|
||||
import {BookSyncCompare, SyncedBook} from "@/lib/models/SyncedBook";
|
||||
import {SeriesSyncCompare, SyncedSeries} from "@/lib/models/SyncedSeries";
|
||||
import {SeriesListItemProps} from "@/lib/models/Series";
|
||||
import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext";
|
||||
import {SeriesListItemProps} from "@/lib/types/series";
|
||||
import SeriesCard, {SeriesCardProps} from "@/components/series/SeriesCard";
|
||||
import SeriesSetting from "@/components/series/SeriesSetting";
|
||||
|
||||
@@ -30,32 +29,19 @@ interface CategoryItem {
|
||||
}
|
||||
|
||||
export default function BookList() {
|
||||
const {session, setSession} = useContext(SessionContext);
|
||||
const {session, setSession}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
|
||||
const accessToken: string = session?.accessToken || '';
|
||||
const {errorMessage} = useContext(AlertContext);
|
||||
const {setBook} = useContext(BookContext);
|
||||
const {errorMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
const {lang} = useContext<LangContextProps>(LangContext);
|
||||
const {isCurrentlyOffline, offlineMode} = useContext<OfflineContextType>(OfflineContext);
|
||||
const {
|
||||
booksToSyncFromServer,
|
||||
booksToSyncToServer,
|
||||
serverOnlyBooks,
|
||||
localOnlyBooks,
|
||||
serverSyncedBooks
|
||||
} = useContext<BooksSyncContextProps>(BooksSyncContext);
|
||||
const {
|
||||
seriesToSyncFromServer,
|
||||
seriesToSyncToServer,
|
||||
serverOnlySeries,
|
||||
localOnlySeries
|
||||
} = useContext<SeriesSyncContextProps>(SeriesSyncContext);
|
||||
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext)
|
||||
const {serverSyncedBooks}: BooksSyncContextProps = useContext<BooksSyncContextProps>(BooksSyncContext)
|
||||
const {isCurrentlyOffline}: OfflineContextType = useContext<OfflineContextType>(OfflineContext);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
const [groupedItems, setGroupedItems] = useState<Record<string, CategoryItem[]>>({});
|
||||
const [isLoadingBooks, setIsLoadingBooks] = useState<boolean>(true);
|
||||
const [showSeriesSettingId, setShowSeriesSettingId] = useState<string | null>(null);
|
||||
const [isLocalSeries, setIsLocalSeries] = useState<boolean>(false);
|
||||
const carouselRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
|
||||
const [bookGuide, setBookGuide] = useState<boolean>(false);
|
||||
@@ -92,72 +78,50 @@ export default function BookList() {
|
||||
content: (
|
||||
<div>
|
||||
<p>
|
||||
<FontAwesomeIcon icon={faGear} className="mr-2 text-primary w-5 h-5"/>
|
||||
<Settings strokeWidth={1.75} className="mr-2 text-primary w-5 h-5 inline"/>
|
||||
{t("bookList.guideStep2ContentGear")}
|
||||
</p>
|
||||
<p>
|
||||
<FontAwesomeIcon icon={faDownload} className="mr-2 text-primary w-5 h-5"/>
|
||||
<Download strokeWidth={1.75} className="mr-2 text-primary w-5 h-5 inline"/>
|
||||
{t("bookList.guideStep2ContentDownload")}
|
||||
</p>
|
||||
<p>
|
||||
<FontAwesomeIcon icon={faTrash} className="mr-2 text-primary w-5 h-5"/>
|
||||
<Trash2 strokeWidth={1.75} className="mr-2 text-primary w-5 h-5 inline"/>
|
||||
{t("bookList.guideStep2ContentTrash")}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
]
|
||||
|
||||
useEffect((): void => {
|
||||
if (groupedItems && Object.keys(groupedItems).length > 0 && User.guideTourDone(session.user?.guideTour || [], 'new-first-book')) {
|
||||
if (groupedItems && Object.keys(groupedItems).length > 0 && guideTourDone(session.user?.guideTour || [], 'new-first-book')) {
|
||||
setBookGuide(true);
|
||||
}
|
||||
}, [groupedItems]);
|
||||
|
||||
useEffect((): void => {
|
||||
const shouldFetch: boolean | "" =
|
||||
(session.isConnected || accessToken) &&
|
||||
(!isCurrentlyOffline() || offlineMode.isDatabaseInitialized);
|
||||
loadBooksAndSeries().then()
|
||||
}, [serverSyncedBooks]);
|
||||
|
||||
if (shouldFetch) {
|
||||
loadBooksAndSeries().then();
|
||||
}
|
||||
}, [
|
||||
session.isConnected,
|
||||
accessToken,
|
||||
offlineMode.isDatabaseInitialized,
|
||||
offlineMode.isOffline,
|
||||
booksToSyncFromServer,
|
||||
booksToSyncToServer,
|
||||
serverOnlyBooks,
|
||||
localOnlyBooks,
|
||||
serverSyncedBooks
|
||||
]);
|
||||
useEffect((): void => {
|
||||
if (accessToken) loadBooksAndSeries().then();
|
||||
}, [accessToken]);
|
||||
|
||||
async function handleFirstBookGuide(): Promise<void> {
|
||||
try {
|
||||
if (!isCurrentlyOffline()) {
|
||||
const response: boolean = await System.authPostToServer<boolean>(
|
||||
const response: boolean = await apiPost<boolean>(
|
||||
'logs/tour',
|
||||
{plateforme: 'desktop', tour: 'new-first-book'},
|
||||
{plateforme: 'web', tour: 'new-first-book'},
|
||||
session.accessToken, lang
|
||||
);
|
||||
if (response) {
|
||||
setSession(User.setNewGuideTour(session, 'new-first-book'));
|
||||
setSession(setNewGuideTour(session, 'new-first-book'));
|
||||
setBookGuide(false);
|
||||
}
|
||||
} else {
|
||||
const completedGuides = JSON.parse(localStorage.getItem('completedGuides') || '[]');
|
||||
if (!completedGuides.includes('new-first-book')) {
|
||||
completedGuides.push('new-first-book');
|
||||
localStorage.setItem('completedGuides', JSON.stringify(completedGuides));
|
||||
}
|
||||
setSession(User.setNewGuideTour(session, 'new-first-book'));
|
||||
setBookGuide(false);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
errorMessage(error.message);
|
||||
} else {
|
||||
errorMessage(t("bookList.errorBookCreate"));
|
||||
}
|
||||
@@ -167,88 +131,35 @@ export default function BookList() {
|
||||
async function loadBooksAndSeries(): Promise<void> {
|
||||
setIsLoadingBooks(true);
|
||||
try {
|
||||
let booksResponse: (BookProps & { itIsLocal?: boolean })[] = [];
|
||||
let seriesResponse: SeriesListItemProps[] = [];
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// PARTIE 1 : FETCH DES DONNÉES (dual logic)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
if (!isCurrentlyOffline()) {
|
||||
// ONLINE : fetch serveur + local en parallèle
|
||||
const [onlineBooks, localBooks, onlineSeries, localSeries] = await Promise.all([
|
||||
System.authGetQueryToServer<BookProps[]>('books', accessToken, lang),
|
||||
offlineMode.isDatabaseInitialized
|
||||
? tauri.getBooks()
|
||||
: Promise.resolve([]),
|
||||
System.authGetQueryToServer<SeriesListItemProps[]>('series/list', accessToken, lang),
|
||||
offlineMode.isDatabaseInitialized
|
||||
? tauri.getSeriesList() as Promise<SeriesListItemProps[]>
|
||||
: Promise.resolve([])
|
||||
]);
|
||||
|
||||
// Merge des livres (serveur + locaux uniques)
|
||||
const onlineBookIds = new Set(onlineBooks.map(b => b.bookId));
|
||||
const uniqueLocalBooks = localBooks.filter(b => !onlineBookIds.has(b.bookId));
|
||||
booksResponse = [
|
||||
...onlineBooks.map(b => ({...b, itIsLocal: false})),
|
||||
...uniqueLocalBooks.map(b => ({...b, itIsLocal: true}))
|
||||
];
|
||||
|
||||
// Merge des séries (serveur + locales uniques)
|
||||
// Pour les séries synced, on merge les bookIds (serveur + local-only)
|
||||
const localSeriesMap = new Map(localSeries.map(s => [s.id, s]));
|
||||
const mergedOnlineSeries = onlineSeries.map(serverSeries => {
|
||||
const localVersion = localSeriesMap.get(serverSeries.id);
|
||||
if (localVersion) {
|
||||
// Merger les bookIds : serveur + ceux du local qui ne sont pas sur le serveur
|
||||
const serverBookIds = new Set(serverSeries.bookIds);
|
||||
const localOnlyBookIds = localVersion.bookIds.filter(id => !serverBookIds.has(id));
|
||||
return {
|
||||
...serverSeries,
|
||||
bookIds: [...serverSeries.bookIds, ...localOnlyBookIds]
|
||||
};
|
||||
}
|
||||
return serverSeries;
|
||||
});
|
||||
const onlineSeriesIds = new Set(onlineSeries.map(s => s.id));
|
||||
const uniqueLocalSeries = localSeries.filter(s => !onlineSeriesIds.has(s.id));
|
||||
seriesResponse = [...mergedOnlineSeries, ...uniqueLocalSeries];
|
||||
|
||||
let booksResponse: BookProps[];
|
||||
let seriesResponse: SeriesListItemProps[];
|
||||
if (isDesktop && isCurrentlyOffline()) {
|
||||
booksResponse = await tauri.getBooks() as BookProps[];
|
||||
seriesResponse = await tauri.getSeriesList() as SeriesListItemProps[];
|
||||
} else {
|
||||
// OFFLINE : local seulement
|
||||
if (!offlineMode.isDatabaseInitialized) {
|
||||
setIsLoadingBooks(false);
|
||||
return;
|
||||
}
|
||||
const [localBooks, localSeries] = await Promise.all([
|
||||
tauri.getBooks(),
|
||||
tauri.getSeriesList() as Promise<SeriesListItemProps[]>
|
||||
[booksResponse, seriesResponse] = await Promise.all([
|
||||
apiGet<BookProps[]>('books', accessToken, lang),
|
||||
apiGet<SeriesListItemProps[]>('series/list', accessToken, lang)
|
||||
]);
|
||||
booksResponse = localBooks.map(b => ({...b, itIsLocal: true}));
|
||||
seriesResponse = localSeries;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// PARTIE 2 : CRÉATION DU MAPPING BOOK → SERIES
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
if (booksResponse) {
|
||||
const seriesList: SeriesListItemProps[] = seriesResponse || [];
|
||||
|
||||
// Créer un mapping bookId -> seriesInfo
|
||||
const bookToSeriesMap: Map<string, SeriesListItemProps> = new Map();
|
||||
seriesResponse.forEach((series: SeriesListItemProps): void => {
|
||||
seriesList.forEach((series: SeriesListItemProps): void => {
|
||||
series.bookIds.forEach((bookId: string): void => {
|
||||
bookToSeriesMap.set(bookId, series);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// PARTIE 3 : TRANSFORMATION DES LIVRES
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const transformedBooks: (BookProps & { itIsLocal?: boolean })[] = booksResponse.map(book => {
|
||||
const imageDataUrl = book.coverImage ? 'data:image/jpeg;base64,' + book.coverImage : '';
|
||||
// Transformer les livres avec leur image
|
||||
const transformedBooks: BookProps[] = booksResponse.map((book: BookProps): BookProps => {
|
||||
const imageDataUrl: string = book.coverImage ? 'data:image/jpeg;base64,' + book.coverImage : '';
|
||||
return {
|
||||
bookId: book.bookId,
|
||||
type: Book.getBookTypeLabel(book.type),
|
||||
type: getBookTypeLabel(book.type),
|
||||
title: book.title,
|
||||
subTitle: book.subTitle,
|
||||
summary: book.summary,
|
||||
@@ -257,32 +168,28 @@ export default function BookList() {
|
||||
desiredWordCount: book.desiredWordCount,
|
||||
totalWordCount: 0,
|
||||
coverImage: imageDataUrl,
|
||||
itIsLocal: book.itIsLocal
|
||||
};
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// PARTIE 4 : GROUPEMENT PAR CATÉGORIE AVEC SÉRIES
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// Grouper par catégorie avec séries
|
||||
const itemsByCategory: Record<string, CategoryItem[]> = {};
|
||||
const processedSeriesIds: Set<string> = new Set();
|
||||
|
||||
transformedBooks.forEach((book): void => {
|
||||
transformedBooks.forEach((book: BookProps): void => {
|
||||
const categoryLabel: string = t(book.type);
|
||||
if (!itemsByCategory[categoryLabel]) {
|
||||
itemsByCategory[categoryLabel] = [];
|
||||
}
|
||||
|
||||
const seriesInfo = bookToSeriesMap.get(book.bookId);
|
||||
const seriesInfo: SeriesListItemProps | undefined = bookToSeriesMap.get(book.bookId);
|
||||
|
||||
if (seriesInfo && !processedSeriesIds.has(seriesInfo.id)) {
|
||||
// Livre fait partie d'une série non encore traitée
|
||||
// Ce livre fait partie d'une série non encore traitée
|
||||
processedSeriesIds.add(seriesInfo.id);
|
||||
|
||||
// Récupérer tous les livres de cette série
|
||||
// Récupérer tous les livres de cette série dans cette catégorie
|
||||
const seriesBooks: BookProps[] = transformedBooks.filter(
|
||||
b => seriesInfo.bookIds.includes(b.bookId)
|
||||
(bookItem: BookProps): boolean => seriesInfo.bookIds.includes(bookItem.bookId)
|
||||
);
|
||||
|
||||
const seriesCard: SeriesCardProps = {
|
||||
@@ -303,29 +210,33 @@ export default function BookList() {
|
||||
book: book
|
||||
});
|
||||
}
|
||||
// Si le livre fait partie d'une série déjà traitée, on l'ignore car il est déjà dans le SeriesCard
|
||||
});
|
||||
|
||||
// Ajouter les séries vides (orphelines)
|
||||
seriesResponse.forEach((series): void => {
|
||||
// Ajouter les séries sans livres (orphelines)
|
||||
seriesList.forEach((series: SeriesListItemProps): void => {
|
||||
if (series.bookIds.length === 0) {
|
||||
const emptySeriesCategory = t('bookList.emptySeries');
|
||||
const emptySeriesCategory: string = t('bookList.emptySeries');
|
||||
if (!itemsByCategory[emptySeriesCategory]) {
|
||||
itemsByCategory[emptySeriesCategory] = [];
|
||||
}
|
||||
itemsByCategory[emptySeriesCategory].push({
|
||||
type: 'series',
|
||||
series: {
|
||||
|
||||
const emptySeriesCard: SeriesCardProps = {
|
||||
id: series.id,
|
||||
name: series.name,
|
||||
coverImage: series.coverImage,
|
||||
books: []
|
||||
}
|
||||
};
|
||||
|
||||
itemsByCategory[emptySeriesCategory].push({
|
||||
type: 'series',
|
||||
series: emptySeriesCard
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
setGroupedItems(itemsByCategory);
|
||||
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
errorMessage(error.message);
|
||||
@@ -344,15 +255,16 @@ export default function BookList() {
|
||||
|
||||
const filtered: Record<string, CategoryItem[]> = {};
|
||||
|
||||
Object.entries(groupedItems).forEach(([category, items]) => {
|
||||
const filteredItems = items.filter((item): boolean => {
|
||||
Object.entries(groupedItems).forEach(([category, items]: [string, CategoryItem[]]): void => {
|
||||
const filteredItems: CategoryItem[] = items.filter((item: CategoryItem): boolean => {
|
||||
if (item.type === 'book' && item.book) {
|
||||
return item.book.title.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
}
|
||||
if (item.type === 'series' && item.series) {
|
||||
const matchesSeriesName = item.series.name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesBookTitle = item.series.books.some(
|
||||
book => book.title.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
// Recherche dans le nom de la série ou dans les titres des livres
|
||||
const matchesSeriesName: boolean = item.series.name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesBookTitle: boolean = item.series.books.some(
|
||||
(book: BookProps): boolean => book.title.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
return matchesSeriesName || matchesBookTitle;
|
||||
}
|
||||
@@ -368,80 +280,23 @@ export default function BookList() {
|
||||
}
|
||||
|
||||
function getTotalItemsCount(items: CategoryItem[]): number {
|
||||
return items.reduce((count, item) => {
|
||||
if (item.type === 'book') return count + 1;
|
||||
if (item.type === 'series' && item.series) return count + item.series.books.length;
|
||||
return items.reduce((count: number, item: CategoryItem): number => {
|
||||
if (item.type === 'book') {
|
||||
return count + 1;
|
||||
}
|
||||
if (item.type === 'series' && item.series) {
|
||||
return count + item.series.books.length;
|
||||
}
|
||||
return count;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function detectBookSyncStatus(bookId: string): SyncType {
|
||||
if (serverOnlyBooks.find((book: SyncedBook): boolean => book.id === bookId)) return 'server-only';
|
||||
if (localOnlyBooks.find((book: SyncedBook): boolean => book.id === bookId)) return 'local-only';
|
||||
if (booksToSyncFromServer.find((book: BookSyncCompare): boolean => book.id === bookId)) return 'to-sync-from-server';
|
||||
if (booksToSyncToServer.find((book: BookSyncCompare): boolean => book.id === bookId)) return 'to-sync-to-server';
|
||||
return 'synced';
|
||||
function handleBookClick(bookId: string): void {
|
||||
router.push(`/book/${bookId}`);
|
||||
}
|
||||
|
||||
function detectSeriesSyncStatus(seriesId: string): SeriesSyncType {
|
||||
if (serverOnlySeries.find((series: SyncedSeries): boolean => series.id === seriesId)) return 'server-only';
|
||||
if (localOnlySeries.find((series: SyncedSeries): boolean => series.id === seriesId)) return 'local-only';
|
||||
if (seriesToSyncFromServer.find((series: SeriesSyncCompare): boolean => series.id === seriesId)) return 'to-sync-from-server';
|
||||
if (seriesToSyncToServer.find((series: SeriesSyncCompare): boolean => series.id === seriesId)) return 'to-sync-to-server';
|
||||
return 'synced';
|
||||
}
|
||||
|
||||
async function handleBookClick(bookId: string): Promise<void> {
|
||||
try {
|
||||
let localBookOnly: boolean = false;
|
||||
let bookResponse: BookProps | null = null;
|
||||
|
||||
// DUAL LOGIC
|
||||
const isOfflineBook = localOnlyBooks.find((book: SyncedBook): boolean => book.id === bookId);
|
||||
if (isCurrentlyOffline() || isOfflineBook) {
|
||||
if (isCurrentlyOffline() && !offlineMode.isDatabaseInitialized) {
|
||||
errorMessage(t("bookList.errorBookDetails"));
|
||||
return;
|
||||
}
|
||||
bookResponse = await tauri.getBookBasicInformation(bookId);
|
||||
if (bookResponse) localBookOnly = true;
|
||||
}
|
||||
if (!bookResponse) {
|
||||
bookResponse = await System.authGetQueryToServer<BookProps>(
|
||||
'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.serie,
|
||||
seriesId: bookResponse.seriesId,
|
||||
publicationDate: bookResponse.publicationDate || '',
|
||||
desiredWordCount: bookResponse.desiredWordCount || 0,
|
||||
totalWordCount: 0,
|
||||
localBook: localBookOnly,
|
||||
coverImage: bookResponse.coverImage ? 'data:image/jpeg;base64,' + bookResponse.coverImage : '',
|
||||
quillsenseEnabled: bookResponse.quillsenseEnabled,
|
||||
tools: bookResponse.tools,
|
||||
});
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
errorMessage(error.message);
|
||||
} else {
|
||||
errorMessage(t("bookList.errorUnknown"));
|
||||
}
|
||||
}
|
||||
function handleSeriesSettingsClick(seriesId: string): void {
|
||||
setShowSeriesSettingId(seriesId);
|
||||
}
|
||||
|
||||
function scrollCarousel(category: string, direction: 'left' | 'right'): void {
|
||||
@@ -455,24 +310,17 @@ export default function BookList() {
|
||||
});
|
||||
}
|
||||
|
||||
function handleSeriesSettingsClick(seriesId: string): void {
|
||||
const isLocal: boolean = isCurrentlyOffline() ||
|
||||
Boolean(localOnlySeries.find((s: SyncedSeries): boolean => s.id === seriesId));
|
||||
setIsLocalSeries(isLocal);
|
||||
setShowSeriesSettingId(seriesId);
|
||||
}
|
||||
|
||||
const filteredItems = getFilteredGroupedItems();
|
||||
const filteredItems: Record<string, CategoryItem[]> = getFilteredGroupedItems();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center h-full overflow-hidden w-full text-text-primary font-['Lora']">
|
||||
className="flex flex-col h-full overflow-hidden w-full text-text-primary font-['Lora']">
|
||||
<div className="flex flex-col w-full overflow-y-auto overflow-x-hidden h-full min-h-0 flex-grow">
|
||||
{session?.user && (
|
||||
<div data-guide="search-bar" className="w-full max-w-3xl px-4 pt-6 pb-4">
|
||||
<div data-guide="search-bar" className="sticky top-0 z-10 w-full flex justify-center px-4 py-3">
|
||||
<SearchBook searchQuery={searchQuery} setSearchQuery={setSearchQuery}/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col w-full overflow-y-auto overflow-x-hidden h-full min-h-0 flex-grow">
|
||||
{
|
||||
isLoadingBooks ? (
|
||||
<>
|
||||
@@ -483,8 +331,8 @@ export default function BookList() {
|
||||
|
||||
<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 className="h-8 bg-secondary rounded-xl w-32 animate-pulse"></div>
|
||||
<div className="h-6 bg-tertiary rounded-lg w-24 animate-pulse"></div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap justify-center items-start w-full px-4">
|
||||
@@ -504,7 +352,7 @@ export default function BookList() {
|
||||
<p className="text-muted italic text-lg">{t("bookList.booksAreMirrors")}</p>
|
||||
</div>
|
||||
|
||||
{Object.entries(filteredItems).map(([category, items], index) => (
|
||||
{Object.entries(filteredItems).map(([category, items]: [string, CategoryItem[]], index: number) => (
|
||||
<div key={category} {...(index === 0 && {'data-guide': 'book-category'})}
|
||||
className="w-full mb-10">
|
||||
<div
|
||||
@@ -513,33 +361,36 @@ export default function BookList() {
|
||||
<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">
|
||||
<Badge variant="muted" size="md">
|
||||
{getTotalItemsCount(items)} {t("bookList.works")}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="group relative w-full">
|
||||
<div className="group/carousel relative w-full">
|
||||
<div
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 z-10 opacity-0 group-hover/carousel:opacity-100">
|
||||
<button
|
||||
onClick={() => scrollCarousel(category, 'left')}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 z-10 bg-primary/80 backdrop-blur-sm hover:bg-primary text-white rounded-2xl w-12 h-12 flex items-center justify-center shadow-xl border border-primary-light/30 transition-all duration-200 opacity-0 group-hover:opacity-100 hover:scale-110"
|
||||
onClick={(): void => scrollCarousel(category, 'left')}
|
||||
className="flex items-center justify-center w-11 h-11 rounded-xl bg-primary-dark hover:bg-primary text-text-primary transition-all duration-200"
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronLeft} className="w-5 h-5"/>
|
||||
<ChevronLeft strokeWidth={1.75} className="w-5 h-5"/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={(el: HTMLDivElement | null) => { carouselRefs.current[category] = el; }}
|
||||
className="flex items-start w-full overflow-hidden px-4 gap-2 scroll-smooth"
|
||||
ref={(el: HTMLDivElement | null) => {
|
||||
carouselRefs.current[category] = el;
|
||||
}}
|
||||
className="flex items-start w-full overflow-x-auto px-4 gap-2 scroll-smooth scrollbar-hide"
|
||||
>
|
||||
{items.map((item, idx) => {
|
||||
{items.map((item: CategoryItem, idx: number) => {
|
||||
if (item.type === 'book' && item.book) {
|
||||
return (
|
||||
<div key={item.book.bookId}
|
||||
{...(idx === 0 && {'data-guide': 'book-card'})}
|
||||
className={`flex-shrink-0 w-64 sm:w-52 md:w-48 lg:w-56 xl:w-64 p-2 box-border ${User.guideTourDone(session.user?.guideTour || [], 'new-first-book') && 'mb-[200px]'}`}>
|
||||
className={`flex-shrink-0 w-64 sm:w-52 md:w-48 lg:w-56 xl:w-64 p-2 box-border ${guideTourDone(session.user?.guideTour || [], 'new-first-book') && 'mb-[200px]'}`}>
|
||||
<BookCard
|
||||
book={item.book}
|
||||
syncStatus={detectBookSyncStatus(item.book.bookId)}
|
||||
onClickCallback={handleBookClick}
|
||||
index={idx}
|
||||
/>
|
||||
@@ -553,8 +404,6 @@ export default function BookList() {
|
||||
series={item.series}
|
||||
onBookClick={handleBookClick}
|
||||
onSettingsClick={handleSeriesSettingsClick}
|
||||
getSyncStatus={detectBookSyncStatus}
|
||||
seriesSyncStatus={detectSeriesSyncStatus(item.series.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -562,22 +411,27 @@ export default function BookList() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 z-10 opacity-0 group-hover/carousel:opacity-100">
|
||||
<button
|
||||
onClick={() => scrollCarousel(category, 'right')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 z-10 bg-primary/80 backdrop-blur-sm hover:bg-primary text-white rounded-2xl w-12 h-12 flex items-center justify-center shadow-xl border border-primary-light/30 transition-all duration-200 opacity-0 group-hover:opacity-100 hover:scale-110"
|
||||
onClick={(): void => scrollCarousel(category, 'right')}
|
||||
className="flex items-center justify-center w-11 h-11 rounded-xl bg-primary-dark hover:bg-primary text-text-primary transition-all duration-200"
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronRight} className="w-5 h-5"/>
|
||||
<ChevronRight strokeWidth={1.75} className="w-5 h-5"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center p-8 max-w-lg">
|
||||
<div className="mx-auto mb-6 w-fit">
|
||||
<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'}/>
|
||||
className="bg-primary/10 flex items-center justify-center w-16 h-16 rounded-2xl">
|
||||
<Book strokeWidth={1.75} className="text-primary w-8 h-8"/>
|
||||
</div>
|
||||
</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">
|
||||
@@ -591,16 +445,8 @@ export default function BookList() {
|
||||
bookGuide && <GuideTour stepId={0} steps={bookGuideSteps} onComplete={handleFirstBookGuide}
|
||||
onClose={() => setBookGuide(false)}/>
|
||||
}
|
||||
{showSeriesSettingId && (
|
||||
<SeriesSetting
|
||||
seriesId={showSeriesSettingId}
|
||||
localSeries={isLocalSeries}
|
||||
onClose={() => {
|
||||
setShowSeriesSettingId(null);
|
||||
setIsLocalSeries(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showSeriesSettingId &&
|
||||
<SeriesSetting seriesId={showSeriesSettingId} onClose={() => setShowSeriesSettingId(null)}/>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,47 +1,33 @@
|
||||
'use client'
|
||||
import {ChangeEvent, Dispatch, RefObject, SetStateAction, useCallback, 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,
|
||||
faBookmark,
|
||||
faFileImport,
|
||||
faFileWord,
|
||||
faLayerGroup,
|
||||
faSpinner,
|
||||
faSquare,
|
||||
faSquareCheck,
|
||||
faX
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import {SelectBoxProps} from "@/shared/interface";
|
||||
import {bookTypes} from "@/lib/models/Book";
|
||||
import {chapterVersions} from "@/lib/models/Chapter";
|
||||
import {ParsedDocxResponse, ImportChapterSelection} from "@/lib/models/Import";
|
||||
import {SyncedBook} from "@/lib/models/SyncedBook";
|
||||
import React, {ChangeEvent, Dispatch, SetStateAction, useContext, useRef, useState} from "react";
|
||||
import {AlertContext, AlertContextProps} from "@/context/AlertContext";
|
||||
import {apiPost, apiUpload} from "@/lib/api/client";
|
||||
import {SessionContext, SessionContextProps} from "@/context/SessionContext";
|
||||
import {Book, BookOpen, FileInput, FileText, Layers, Pencil, Square, SquareCheck} from 'lucide-react';
|
||||
import SelectBox, {SelectBoxProps} from "@/components/form/SelectBox";
|
||||
import {bookTypes} from "@/lib/constants/book";
|
||||
import {chapterVersions} from "@/lib/constants/chapter";
|
||||
import {ImportChapterSelection, ImportConfirmBody, ParsedChapterPreview, ParsedDocxResponse} from "@/lib/types/import";
|
||||
import InputField from "@/components/form/InputField";
|
||||
import TextInput from "@/components/form/TextInput";
|
||||
import TexteAreaInput from "@/components/form/TexteAreaInput";
|
||||
import SelectBox from "@/components/form/SelectBox";
|
||||
import CancelButton from "@/components/form/CancelButton";
|
||||
import SubmitButtonWLoading from "@/components/form/SubmitButtonWLoading";
|
||||
import {useTranslations} from "next-intl";
|
||||
import TextAreaInput from "@/components/form/TextAreaInput";
|
||||
import Button from "@/components/ui/Button";
|
||||
import Badge from "@/components/ui/Badge";
|
||||
import Modal from "@/components/ui/Modal";
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
import {LangContext, LangContextProps} from "@/context/LangContext";
|
||||
import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext";
|
||||
import {SyncedBook} from "@/lib/types/synced-book";
|
||||
|
||||
const DOCX_ACCEPT: string = '.docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
||||
const docxAccept: string = '.docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
||||
|
||||
export default function ImportBookForm({setCloseForm}: { setCloseForm: Dispatch<SetStateAction<boolean>> }) {
|
||||
const t = useTranslations();
|
||||
const {lang} = useContext<LangContextProps>(LangContext);
|
||||
const {session} = useContext(SessionContext);
|
||||
const {errorMessage, successMessage} = useContext(AlertContext);
|
||||
const {setServerOnlyBooks} = useContext<BooksSyncContextProps>(BooksSyncContext);
|
||||
const modalRef: RefObject<HTMLDivElement | null> = useRef<HTMLDivElement>(null);
|
||||
|
||||
const token: string = session?.accessToken ?? '';
|
||||
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext);
|
||||
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
|
||||
const {errorMessage, successMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
|
||||
const {setServerSyncedBooks}: BooksSyncContextProps = useContext<BooksSyncContextProps>(BooksSyncContext);
|
||||
const fileInputRef: React.RefObject<HTMLInputElement | null> = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [isParsing, setIsParsing] = useState<boolean>(false);
|
||||
const [isImporting, setIsImporting] = useState<boolean>(false);
|
||||
@@ -53,19 +39,12 @@ export default function ImportBookForm({setCloseForm}: { setCloseForm: Dispatch<
|
||||
const [selectedBookType, setSelectedBookType] = useState<string>('short');
|
||||
const [selectedVersion, setSelectedVersion] = useState<string>('2');
|
||||
|
||||
const token: string = session?.accessToken ?? '';
|
||||
const hasParsedFile: boolean = importId.length > 0 && chapters.length > 0;
|
||||
const selectedCount: number = chapters.filter((chapter: ImportChapterSelection): boolean => chapter.selected).length;
|
||||
const canImport: boolean = !isImporting && hasParsedFile && selectedCount > 0 && title.trim().length > 0;
|
||||
|
||||
useEffect((): () => void => {
|
||||
document.body.style.overflow = 'hidden';
|
||||
return (): void => {
|
||||
document.body.style.overflow = 'auto';
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleFileChange = useCallback(async (e: ChangeEvent<HTMLInputElement>): Promise<void> => {
|
||||
const file: File | undefined = e.target.files?.[0];
|
||||
async function handleFileChange(event: ChangeEvent<HTMLInputElement>): Promise<void> {
|
||||
const file: File | undefined = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!file.name.endsWith('.docx')) {
|
||||
@@ -78,21 +57,21 @@ export default function ImportBookForm({setCloseForm}: { setCloseForm: Dispatch<
|
||||
setChapters([]);
|
||||
|
||||
try {
|
||||
const response: ParsedDocxResponse = await System.authUploadFileToServer<ParsedDocxResponse>(
|
||||
const response: ParsedDocxResponse = await apiUpload<ParsedDocxResponse>(
|
||||
'book/import/parse',
|
||||
file,
|
||||
token,
|
||||
lang,
|
||||
lang
|
||||
);
|
||||
|
||||
setImportId(response.importId);
|
||||
setChapters(
|
||||
response.chapters.map((chapter: { index: number; title: string; wordCount: number }): ImportChapterSelection => ({
|
||||
response.chapters.map((chapter: ParsedChapterPreview): ImportChapterSelection => ({
|
||||
index: chapter.index,
|
||||
title: chapter.title,
|
||||
wordCount: chapter.wordCount,
|
||||
selected: true,
|
||||
})),
|
||||
}))
|
||||
);
|
||||
} catch (parseError: unknown) {
|
||||
if (parseError instanceof Error) {
|
||||
@@ -103,26 +82,26 @@ export default function ImportBookForm({setCloseForm}: { setCloseForm: Dispatch<
|
||||
} finally {
|
||||
setIsParsing(false);
|
||||
}
|
||||
}, [token, lang, errorMessage, t]);
|
||||
}
|
||||
|
||||
const toggleChapter = useCallback((chapterIndex: number): void => {
|
||||
function toggleChapter(chapterIndex: number): void {
|
||||
setChapters((previousChapters: ImportChapterSelection[]): ImportChapterSelection[] =>
|
||||
previousChapters.map((chapter: ImportChapterSelection): ImportChapterSelection =>
|
||||
chapter.index === chapterIndex ? {...chapter, selected: !chapter.selected} : chapter,
|
||||
),
|
||||
chapter.index === chapterIndex ? {...chapter, selected: !chapter.selected} : chapter
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
}
|
||||
|
||||
const toggleAllChapters = useCallback((selectAll: boolean): void => {
|
||||
function toggleAllChapters(selectAll: boolean): void {
|
||||
setChapters((previousChapters: ImportChapterSelection[]): ImportChapterSelection[] =>
|
||||
previousChapters.map((chapter: ImportChapterSelection): ImportChapterSelection => ({
|
||||
...chapter,
|
||||
selected: selectAll,
|
||||
})),
|
||||
}))
|
||||
);
|
||||
}, []);
|
||||
}
|
||||
|
||||
const handleImport = useCallback(async (): Promise<void> => {
|
||||
async function handleImport(): Promise<void> {
|
||||
if (!title.trim()) {
|
||||
errorMessage(t('importBook.error.titleRequired'));
|
||||
return;
|
||||
@@ -142,9 +121,7 @@ export default function ImportBookForm({setCloseForm}: { setCloseForm: Dispatch<
|
||||
.filter((chapter: ImportChapterSelection): boolean => chapter.selected)
|
||||
.map((chapter: ImportChapterSelection): number => chapter.index);
|
||||
|
||||
await System.authPostToServer<{ bookId: string }>(
|
||||
'book/import',
|
||||
{
|
||||
const importBody: ImportConfirmBody = {
|
||||
importId,
|
||||
title: title.trim(),
|
||||
subTitle: subTitle.trim(),
|
||||
@@ -152,16 +129,21 @@ export default function ImportBookForm({setCloseForm}: { setCloseForm: Dispatch<
|
||||
type: selectedBookType,
|
||||
version: parseInt(selectedVersion, 10),
|
||||
selectedChapterIndexes,
|
||||
},
|
||||
};
|
||||
|
||||
const importResponse: { bookId: string } = await apiPost<{ bookId: string }>(
|
||||
'book/import',
|
||||
importBody,
|
||||
token,
|
||||
lang,
|
||||
lang
|
||||
);
|
||||
|
||||
setServerOnlyBooks((prevBooks: SyncedBook[]): SyncedBook[] => [...prevBooks, {
|
||||
id: importId,
|
||||
const newBook: SyncedBook = {
|
||||
id: importResponse.bookId,
|
||||
type: selectedBookType,
|
||||
title: title.trim(),
|
||||
subTitle: subTitle.trim(),
|
||||
subTitle: subTitle.trim() || null,
|
||||
seriesId: null,
|
||||
lastUpdate: new Date().getTime() / 1000,
|
||||
chapters: [],
|
||||
characters: [],
|
||||
@@ -173,11 +155,8 @@ export default function ImportBookForm({setCloseForm}: { setCloseForm: Dispatch<
|
||||
actSummaries: [],
|
||||
guideLine: null,
|
||||
aiGuideLine: null,
|
||||
bookTools: null,
|
||||
seriesId: null,
|
||||
spells: [],
|
||||
spellTags: []
|
||||
}]);
|
||||
};
|
||||
setServerSyncedBooks((prev: SyncedBook[]): SyncedBook[] => [...prev, newBook]);
|
||||
|
||||
successMessage(t('importBook.success'));
|
||||
setCloseForm(false);
|
||||
@@ -190,52 +169,50 @@ export default function ImportBookForm({setCloseForm}: { setCloseForm: Dispatch<
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
}, [title, subTitle, summary, selectedBookType, selectedVersion, importId, chapters, selectedCount, token, lang, errorMessage, successMessage, t, setCloseForm, setServerOnlyBooks]);
|
||||
}
|
||||
|
||||
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={faFileImport} className="w-6 h-6"/>
|
||||
{t("importBook.header.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)}
|
||||
<Modal
|
||||
icon={FileInput}
|
||||
title={t("importBook.header")}
|
||||
onClose={(): void => setCloseForm(false)}
|
||||
size="sm"
|
||||
footer={hasParsedFile ? (
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setCloseForm(false)}>{t("common.cancel")}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleImport}
|
||||
isLoading={isImporting}
|
||||
loadingText={t("importBook.importing")}
|
||||
icon={FileInput}
|
||||
>{t("importBook.submit")}</Button>
|
||||
</>
|
||||
) : undefined}
|
||||
>
|
||||
<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">
|
||||
<label
|
||||
className={`flex items-center justify-center gap-3 py-4 border-2 border-dashed border-primary rounded-xl cursor-pointer
|
||||
bg-secondary/20 hover:bg-secondary/40 transition-all duration-200
|
||||
${isParsing ? 'opacity-70 pointer-events-none' : ''}`}
|
||||
>
|
||||
{isParsing ? (
|
||||
<FontAwesomeIcon icon={faSpinner} className="w-5 h-5 text-primary animate-spin"/>
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faFileWord} className="w-5 h-5 text-primary"/>
|
||||
)}
|
||||
<span className="font-['ADLaM_Display'] text-primary">
|
||||
{isParsing ? t('importBook.parsing') : t('importBook.pickFile')}
|
||||
</span>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={DOCX_ACCEPT}
|
||||
accept={docxAccept}
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
disabled={isParsing}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
variant="dashed"
|
||||
size="lg"
|
||||
icon={isParsing ? undefined : FileText}
|
||||
isLoading={isParsing}
|
||||
loadingText={t('importBook.parsing')}
|
||||
onClick={(): void => fileInputRef.current?.click()}
|
||||
disabled={isParsing}
|
||||
fullWidth
|
||||
>
|
||||
{t('importBook.pickFile')}
|
||||
</Button>
|
||||
|
||||
{hasParsedFile && (
|
||||
<>
|
||||
<InputField icon={faBookOpen} fieldName={t("importBook.fields.type.label")} input={
|
||||
<InputField icon={BookOpen} fieldName={t("importBook.fields.type")} input={
|
||||
<SelectBox
|
||||
onChangeCallBack={(e: ChangeEvent<HTMLSelectElement>): void => setSelectedBookType(e.target.value)}
|
||||
data={bookTypes.map((types: SelectBoxProps): SelectBoxProps => ({
|
||||
@@ -243,34 +220,35 @@ export default function ImportBookForm({setCloseForm}: { setCloseForm: Dispatch<
|
||||
label: t(types.label)
|
||||
}))}
|
||||
defaultValue={selectedBookType}
|
||||
placeholder={t("addNewBookForm.typePlaceholder")}
|
||||
/>
|
||||
}/>
|
||||
|
||||
<InputField icon={faBook} fieldName={t("importBook.fields.title.label")} input={
|
||||
<InputField icon={Pencil} fieldName={t("importBook.fields.title")} input={
|
||||
<TextInput
|
||||
value={title}
|
||||
setValue={(e: ChangeEvent<HTMLInputElement>): void => setTitle(e.target.value)}
|
||||
placeholder={t("importBook.fields.title.placeholder")}
|
||||
placeholder={t("addNewBookForm.bookTitlePlaceholder")}
|
||||
/>
|
||||
}/>
|
||||
|
||||
<InputField icon={faBookmark} fieldName={t("importBook.fields.subTitle.label")} input={
|
||||
<InputField icon={Pencil} fieldName={t("importBook.fields.subTitle")} input={
|
||||
<TextInput
|
||||
value={subTitle}
|
||||
setValue={(e: ChangeEvent<HTMLInputElement>): void => setSubTitle(e.target.value)}
|
||||
placeholder={t("importBook.fields.subTitle.placeholder")}
|
||||
placeholder={t("addNewBookForm.subtitlePlaceholder")}
|
||||
/>
|
||||
}/>
|
||||
|
||||
<InputField icon={faBook} fieldName={t("importBook.fields.summary.label")} input={
|
||||
<TexteAreaInput
|
||||
<InputField icon={Book} fieldName={t("importBook.fields.summary")} input={
|
||||
<TextAreaInput
|
||||
value={summary}
|
||||
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setSummary(e.target.value)}
|
||||
placeholder={t("importBook.fields.summary.placeholder")}
|
||||
placeholder={t("addNewBookForm.summaryPlaceholder")}
|
||||
/>
|
||||
}/>
|
||||
|
||||
<InputField icon={faLayerGroup} fieldName={t("importBook.fields.version.label")} input={
|
||||
<InputField icon={Layers} fieldName={t("importBook.fields.version")} input={
|
||||
<SelectBox
|
||||
onChangeCallBack={(e: ChangeEvent<HTMLSelectElement>): void => setSelectedVersion(e.target.value)}
|
||||
data={chapterVersions.map((version: SelectBoxProps): SelectBoxProps => ({
|
||||
@@ -278,46 +256,49 @@ export default function ImportBookForm({setCloseForm}: { setCloseForm: Dispatch<
|
||||
label: t(version.label)
|
||||
}))}
|
||||
defaultValue={selectedVersion}
|
||||
placeholder={""}
|
||||
/>
|
||||
}/>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="mt-2">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="font-['ADLaM_Display'] text-lg text-text-primary">
|
||||
<h3 className="text-text-primary text-xl font-['ADLaM_Display'] font-medium flex items-center gap-2">
|
||||
<BookOpen className="text-primary w-5 h-5" strokeWidth={1.75}/>
|
||||
{t('importBook.chapters.title')}
|
||||
</h3>
|
||||
<span className="text-sm text-muted">
|
||||
{t('importBook.chaptersDetected', {count: chapters.length})}
|
||||
</span>
|
||||
<Badge variant="muted" size="sm">
|
||||
{t('importBook.chapters.detected', {count: chapters.length})}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={(): void => toggleAllChapters(selectedCount < chapters.length)}
|
||||
className="text-sm text-primary hover:text-primary/80 mb-3 transition-colors"
|
||||
>
|
||||
<div className="mb-3 hover:underline w-fit">
|
||||
<Button variant="ghost" size="sm"
|
||||
onClick={(): void => toggleAllChapters(selectedCount < chapters.length)}>
|
||||
{selectedCount === chapters.length
|
||||
? t('importBook.chapters.deselectAll')
|
||||
: t('importBook.chapters.selectAll')}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{chapters.map((chapter: ImportChapterSelection) => (
|
||||
<button
|
||||
key={chapter.index}
|
||||
onClick={(): void => toggleChapter(chapter.index)}
|
||||
className="flex items-center gap-3 w-full py-2 px-3 rounded-lg hover:bg-secondary/30 transition-colors text-left"
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-secondary transition-all duration-150"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={chapter.selected ? faSquareCheck : faSquare}
|
||||
className={`w-4 h-4 ${chapter.selected ? 'text-primary' : 'text-muted'}`}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm truncate ${chapter.selected ? 'text-text-primary' : 'text-muted'}`}>
|
||||
{chapter.selected
|
||||
? <SquareCheck className="w-4 h-4 text-primary" strokeWidth={1.75}/>
|
||||
: <Square className="w-4 h-4 text-muted" strokeWidth={1.75}/>
|
||||
}
|
||||
<div className="flex-1 text-left">
|
||||
<span
|
||||
className={`text-sm font-medium block truncate ${chapter.selected ? 'text-text-primary' : 'text-muted'}`}>
|
||||
{chapter.title}
|
||||
</p>
|
||||
<p className="text-xs text-muted">
|
||||
{t('importBook.chapters.words', {count: chapter.wordCount})}
|
||||
</p>
|
||||
</span>
|
||||
<span className="text-xs text-muted">
|
||||
{chapter.wordCount.toLocaleString('fr-FR')} {t('importBook.chapters.words')}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
@@ -325,25 +306,6 @@ export default function ImportBookForm({setCloseForm}: { setCloseForm: Dispatch<
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasParsedFile && (
|
||||
<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={handleImport}
|
||||
isLoading={isImporting}
|
||||
text={t("importBook.submit")}
|
||||
loadingText={t("importBook.importing")}
|
||||
icon={faFileImport}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import {faSearch} from "@fortawesome/free-solid-svg-icons";
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {ChangeEvent, Dispatch, SetStateAction} from "react";
|
||||
import {useTranslations} from "next-intl";
|
||||
import TextInput from "@/components/form/TextInput";
|
||||
import {Search} from 'lucide-react';
|
||||
import React, {ChangeEvent, Dispatch, SetStateAction} from "react";
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
|
||||
export default function SearchBook(
|
||||
{
|
||||
@@ -15,20 +13,16 @@ export default function SearchBook(
|
||||
const t = useTranslations();
|
||||
|
||||
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
|
||||
<div
|
||||
className="flex items-center gap-3 w-full max-w-3xl bg-secondary rounded-xl px-4 py-2.5 border border-secondary hover:bg-gray-dark hover:border-gray-dark focus-within:border-primary focus-within:ring-4 focus-within:ring-primary/20 transition-all duration-200">
|
||||
<Search className="text-muted w-4 h-4 flex-shrink-0" strokeWidth={1.75}/>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
setValue={(e: ChangeEvent<HTMLInputElement>) => setSearchQuery(e.target.value)}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void => setSearchQuery(e.target.value)}
|
||||
placeholder={t("searchBook.placeholder")}
|
||||
className="w-full bg-transparent text-text-primary text-sm placeholder:text-muted outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,38 +1,35 @@
|
||||
'use client'
|
||||
import * as tauri from '@/lib/tauri';
|
||||
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 {X} from 'lucide-react';
|
||||
import IconButton from "@/components/ui/IconButton";
|
||||
import React, {ChangeEvent, forwardRef, useContext, useImperativeHandle, useState} from "react";
|
||||
import {apiDelete, apiPost} from "@/lib/api/client";
|
||||
import axios, {AxiosResponse} from "axios";
|
||||
import {AlertContext} from "@/context/AlertContext";
|
||||
import {BookContext} from "@/context/BookContext";
|
||||
import {SessionContext} from "@/context/SessionContext";
|
||||
import {AlertContext, AlertContextProps} from "@/context/AlertContext";
|
||||
import {BookContext, BookContextProps} from "@/context/BookContext";
|
||||
import {SessionContext, SessionContextProps} from "@/context/SessionContext";
|
||||
import TextInput from "@/components/form/TextInput";
|
||||
import TexteAreaInput from "@/components/form/TexteAreaInput";
|
||||
import TextAreaInput from "@/components/form/TextAreaInput";
|
||||
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 {configs, isDesktop} from "@/lib/configs";
|
||||
import * as tauri from '@/lib/tauri';
|
||||
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
import {LangContext, LangContextProps} from "@/context/LangContext";
|
||||
import {BookProps} from "@/lib/models/Book";
|
||||
import OfflineContext, {OfflineContextType} from "@/context/OfflineContext";
|
||||
import {LocalSyncQueueContext, LocalSyncQueueContextProps} from "@/context/SyncQueueContext";
|
||||
import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext";
|
||||
import {SyncedBook} from "@/lib/models/SyncedBook";
|
||||
import {BookProps} from "@/lib/types/book";
|
||||
import {SettingRef} from "@/lib/types/settings";
|
||||
import ImageDropZone from "@/components/form/ImageDropZone";
|
||||
|
||||
function BasicInformationSetting(props: any, ref: any) {
|
||||
function BasicInformationSetting(_props: object, ref: React.ForwardedRef<SettingRef>): React.JSX.Element {
|
||||
const t = useTranslations();
|
||||
const {lang} = useContext<LangContextProps>(LangContext)
|
||||
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
|
||||
const {addToQueue} = useContext<LocalSyncQueueContextProps>(LocalSyncQueueContext);
|
||||
const {localSyncedBooks} = useContext<BooksSyncContextProps>(BooksSyncContext);
|
||||
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext);
|
||||
|
||||
const {session} = useContext(SessionContext);
|
||||
const {book, setBook} = useContext(BookContext);
|
||||
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
|
||||
const {book, setBook}: BookContextProps = useContext<BookContextProps>(BookContext);
|
||||
const userToken: string = session?.accessToken ? session?.accessToken : '';
|
||||
const {errorMessage, successMessage} = useContext(AlertContext);
|
||||
const {errorMessage, successMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
|
||||
const {isCurrentlyOffline}: OfflineContextType = useContext<OfflineContextType>(OfflineContext);
|
||||
const bookId: string = book?.bookId ? book?.bookId.toString() : '';
|
||||
|
||||
const [currentImage, setCurrentImage] = useState<string>(book?.coverImage ?? '');
|
||||
@@ -42,21 +39,14 @@ function BasicInformationSetting(props: any, ref: any) {
|
||||
const [publicationDate, setPublicationDate] = useState<string>(book?.publicationDate ? book?.publicationDate : '');
|
||||
const [wordCount, setWordCount] = useState<number>(book?.desiredWordCount ? book?.desiredWordCount : 0);
|
||||
|
||||
useImperativeHandle(ref, function () {
|
||||
useImperativeHandle(ref, function (): SettingRef {
|
||||
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();
|
||||
async function handleCoverImageChange(file: File): Promise<void> {
|
||||
const formData: FormData = new FormData();
|
||||
formData.append('bookId', bookId);
|
||||
formData.append('picture', file);
|
||||
|
||||
@@ -69,15 +59,15 @@ function BasicInformationSetting(props: any, ref: any) {
|
||||
},
|
||||
params: {
|
||||
lang: lang,
|
||||
plateforme: 'desktop',
|
||||
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();
|
||||
const blob: Blob = new Blob([query.data], {type: contentType});
|
||||
const reader: FileReader = new FileReader();
|
||||
|
||||
reader.onloadend = function (): void {
|
||||
if (typeof reader.result === 'string') {
|
||||
@@ -87,20 +77,17 @@ function BasicInformationSetting(props: any, ref: any) {
|
||||
|
||||
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);
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
} else {
|
||||
throw new Error('An unexpected error occurred');
|
||||
errorMessage(t('basicInformationSetting.error.unknown'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveCurrentImage(): Promise<void> {
|
||||
try {
|
||||
const response: boolean = await System.authDeleteToServer<boolean>(`book/cover/delete`, {
|
||||
const response: boolean = await apiDelete<boolean>(`book/cover/delete`, {
|
||||
bookId: bookId
|
||||
}, userToken, lang);
|
||||
if (!response) {
|
||||
@@ -123,22 +110,24 @@ function BasicInformationSetting(props: any, ref: any) {
|
||||
}
|
||||
try {
|
||||
let response: boolean;
|
||||
const basicInfoData = {
|
||||
if (isDesktop && (isCurrentlyOffline() || book?.localBook)) {
|
||||
response = await tauri.updateBookBasicInfo({
|
||||
bookId: bookId,
|
||||
title: title,
|
||||
subTitle: subTitle,
|
||||
summary: summary,
|
||||
publicationDate: publicationDate,
|
||||
wordCount: wordCount,
|
||||
});
|
||||
} else {
|
||||
response = await apiPost<boolean>('book/basic-information', {
|
||||
title: title,
|
||||
subTitle: subTitle,
|
||||
summary: summary,
|
||||
publicationDate: publicationDate,
|
||||
wordCount: wordCount,
|
||||
bookId: bookId
|
||||
};
|
||||
if (isCurrentlyOffline() || book?.localBook) {
|
||||
response = await tauri.updateBookBasicInfo(basicInfoData);
|
||||
} else {
|
||||
response = await System.authPostToServer<boolean>('book/basic-information', basicInfoData, userToken, lang);
|
||||
|
||||
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) {
|
||||
addToQueue('update_book_basic_info', {data: basicInfoData});
|
||||
}
|
||||
}, userToken, lang);
|
||||
}
|
||||
if (!response) {
|
||||
errorMessage(t('basicInformationSetting.error.update'));
|
||||
@@ -156,7 +145,7 @@ function BasicInformationSetting(props: any, ref: any) {
|
||||
publicationDate: publicationDate,
|
||||
desiredWordCount: wordCount,
|
||||
};
|
||||
setBook!!(updatedBook);
|
||||
setBook(updatedBook);
|
||||
successMessage(t('basicInformationSetting.success.update'));
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
@@ -169,35 +158,30 @@ function BasicInformationSetting(props: any, ref: any) {
|
||||
|
||||
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">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<InputField fieldName={t('basicInformationSetting.fields.title')} input={<TextInput
|
||||
value={title}
|
||||
setValue={(e: ChangeEvent<HTMLInputElement>) => setTitle(e.target.value)}
|
||||
setValue={(e: ChangeEvent<HTMLInputElement>): void => 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)}
|
||||
setValue={(e: ChangeEvent<HTMLInputElement>): void => 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
|
||||
<InputField fieldName={t('basicInformationSetting.fields.summary')} input={<TextAreaInput
|
||||
value={summary}
|
||||
setValue={(e: ChangeEvent<HTMLTextAreaElement>) => setSummary(e.target.value)}
|
||||
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => 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">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<InputField fieldName={t('basicInformationSetting.fields.publicationDate')} input={
|
||||
<DatePicker
|
||||
date={publicationDate}
|
||||
setDate={(e: ChangeEvent<HTMLInputElement>) => setPublicationDate(e.target.value)}
|
||||
setDate={(e: ChangeEvent<HTMLInputElement>): void => setPublicationDate(e.target.value)}
|
||||
/>
|
||||
}/>
|
||||
<InputField fieldName={t('basicInformationSetting.fields.wordCount')} input={
|
||||
@@ -205,46 +189,28 @@ function BasicInformationSetting(props: any, ref: any) {
|
||||
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">
|
||||
<div>
|
||||
{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>
|
||||
className="rounded-lg border border-secondary w-full h-auto"/>
|
||||
<div className="absolute -top-2 -right-2">
|
||||
<IconButton icon={X} variant="danger" size="sm"
|
||||
onClick={handleRemoveCurrentImage}/>
|
||||
</div>
|
||||
</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>
|
||||
<ImageDropZone
|
||||
onFileSelect={handleCoverImageChange}
|
||||
label={t('basicInformationSetting.fields.coverImage')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef(BasicInformationSetting);
|
||||
export default forwardRef<SettingRef, object>(BasicInformationSetting);
|
||||
@@ -2,8 +2,8 @@
|
||||
import {useState} from "react";
|
||||
import BookSettingSidebar from "@/components/book/settings/BookSettingSidebar";
|
||||
import BookSettingOption from "@/components/book/settings/BookSettingOption";
|
||||
import SettingsPanel from "@/components/SettingsPanel";
|
||||
import {useTranslations} from "next-intl";
|
||||
import SettingsPanel from "@/components/ui/SettingsPanel";
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
|
||||
interface BookSettingProps {
|
||||
onClose: () => void;
|
||||
@@ -19,7 +19,7 @@ export default function BookSetting({onClose}: BookSettingProps) {
|
||||
sidebar={<BookSettingSidebar selectedSetting={currentSetting} setSelectedSetting={setCurrentSetting}/>}
|
||||
onClose={onClose}
|
||||
>
|
||||
<BookSettingOption setting={currentSetting}/>
|
||||
<BookSettingOption key={currentSetting} setting={currentSetting}/>
|
||||
</SettingsPanel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
'use client'
|
||||
import React, {lazy, Suspense, useRef} from 'react';
|
||||
import {faPen, faSave} from '@fortawesome/free-solid-svg-icons';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {faSpinner} from '@fortawesome/free-solid-svg-icons';
|
||||
import {useTranslations} from 'next-intl';
|
||||
import PanelHeader from '@/components/PanelHeader';
|
||||
import React, {lazy, Suspense, useContext, useRef, useState} from 'react';
|
||||
import {Save} from 'lucide-react';
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
import SectionHeader from "@/components/ui/SectionHeader";
|
||||
import IconButton from "@/components/ui/IconButton";
|
||||
import PulseLoader from '@/components/ui/PulseLoader';
|
||||
import ToggleSwitch from "@/components/form/ToggleSwitch";
|
||||
import {BookContext, BookContextProps} from "@/context/BookContext";
|
||||
import {SessionContext, SessionContextProps} from "@/context/SessionContext";
|
||||
import {AlertContext, AlertContextProps} from "@/context/AlertContext";
|
||||
import {LangContext, LangContextProps} from "@/context/LangContext";
|
||||
import {apiPatch} from "@/lib/api/client";
|
||||
import {SettingRef} from "@/lib/types/settings";
|
||||
import {BookProps} from "@/lib/types/book";
|
||||
|
||||
// Lazy loaded components - avec ref (anciens)
|
||||
const BasicInformationSetting = lazy(function () {
|
||||
return import('./BasicInformationSetting');
|
||||
});
|
||||
const GuideLineSetting = lazy(function () {
|
||||
return import('./guide-line/GuideLineSetting');
|
||||
return import('./guideline/GuideLineSetting');
|
||||
});
|
||||
const StorySetting = lazy(function () {
|
||||
return import('./story/StorySetting');
|
||||
@@ -19,8 +26,6 @@ const StorySetting = lazy(function () {
|
||||
const QuillSenseSetting = lazy(function () {
|
||||
return import('./quillsense/QuillSenseSetting');
|
||||
});
|
||||
|
||||
// Lazy loaded components - sans ref (nouveaux avec leur propre header)
|
||||
const WorldSettings = lazy(function () {
|
||||
return import('./world/settings/WorldSettings');
|
||||
});
|
||||
@@ -37,28 +42,41 @@ const ExportSetting = lazy(function () {
|
||||
return import('./ExportSetting');
|
||||
});
|
||||
|
||||
function LoadingSpinner(): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<FontAwesomeIcon icon={faSpinner} className="w-8 h-8 text-primary animate-spin"/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BookSettingOptionProps {
|
||||
setting: string;
|
||||
}
|
||||
|
||||
interface SettingRef {
|
||||
handleSave: () => Promise<void>;
|
||||
}
|
||||
|
||||
// Settings qui gèrent leur propre save (pas de bouton save parent)
|
||||
const selfManagedSettings: string[] = ['characters', 'spells', 'world', 'worlds', 'locations', 'export'];
|
||||
|
||||
type ToolName = 'worlds' | 'locations' | 'characters' | 'spells' | 'quillsense';
|
||||
|
||||
const toggleableSettings: Record<string, ToolName> = {
|
||||
'world': 'worlds',
|
||||
'worlds': 'worlds',
|
||||
'locations': 'locations',
|
||||
'characters': 'characters',
|
||||
'spells': 'spells',
|
||||
'quillsense': 'quillsense',
|
||||
};
|
||||
|
||||
function getInitialToolEnabled(setting: string, book: BookProps | null): boolean {
|
||||
const toolName: ToolName | undefined = toggleableSettings[setting];
|
||||
if (!toolName) return false;
|
||||
if (toolName === 'quillsense') return book?.quillsenseEnabled ?? false;
|
||||
return book?.tools?.[toolName] ?? false;
|
||||
}
|
||||
|
||||
export default function BookSettingOption({setting}: BookSettingOptionProps): React.JSX.Element {
|
||||
const t = useTranslations();
|
||||
const settingRef = useRef<SettingRef>(null);
|
||||
const settingRef: React.RefObject<SettingRef | null> = useRef<SettingRef>(null);
|
||||
const {book, setBook}: BookContextProps = useContext<BookContextProps>(BookContext);
|
||||
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
|
||||
const {errorMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
|
||||
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext);
|
||||
const userToken: string = session?.accessToken ?? '';
|
||||
|
||||
const isToggleable: boolean = setting in toggleableSettings;
|
||||
const [toolEnabled, setToolEnabled] = useState<boolean>(getInitialToolEnabled(setting, book));
|
||||
|
||||
const showSaveButton: boolean = !selfManagedSettings.includes(setting);
|
||||
|
||||
@@ -87,46 +105,90 @@ export default function BookSettingOption({setting}: BookSettingOptionProps): Re
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleTool(enabled: boolean): Promise<void> {
|
||||
const toolName: ToolName | undefined = toggleableSettings[setting];
|
||||
if (!toolName) return;
|
||||
try {
|
||||
const result: boolean = await apiPatch<boolean>('book/tool-setting', {
|
||||
bookId: book?.bookId,
|
||||
toolName: toolName,
|
||||
enabled: enabled
|
||||
}, userToken, lang);
|
||||
if (result && setBook && book) {
|
||||
setToolEnabled(enabled);
|
||||
if (toolName === 'quillsense') {
|
||||
setBook({...book, quillsenseEnabled: enabled});
|
||||
} else {
|
||||
setBook({
|
||||
...book,
|
||||
tools: {
|
||||
characters: toolName === 'characters' ? enabled : (book.tools?.characters ?? false),
|
||||
worlds: toolName === 'worlds' ? enabled : (book.tools?.worlds ?? false),
|
||||
locations: toolName === 'locations' ? enabled : (book.tools?.locations ?? false),
|
||||
spells: toolName === 'spells' ? enabled : (book.tools?.spells ?? false),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
} else {
|
||||
errorMessage(t('bookSettingOption.unknownError'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveClick(): Promise<void> {
|
||||
if (settingRef.current?.handleSave) {
|
||||
await settingRef.current.handleSave();
|
||||
}
|
||||
}
|
||||
|
||||
function renderHeaderActions(): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
{isToggleable && (
|
||||
<ToggleSwitch
|
||||
checked={toolEnabled}
|
||||
onChange={handleToggleTool}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
{showSaveButton && (
|
||||
<IconButton icon={Save} variant="primary" onClick={handleSaveClick}/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-6">
|
||||
<div className="sticky top-0 z-10 bg-tertiary pt-6 pb-4">
|
||||
<PanelHeader
|
||||
icon={faPen}
|
||||
badge="BI"
|
||||
<div className="sticky top-0 z-10 bg-darkest-background pt-6 pb-4">
|
||||
<SectionHeader
|
||||
title={renderTitle()}
|
||||
description=""
|
||||
secondActionCallback={showSaveButton ? handleSaveClick : undefined}
|
||||
callBackAction={showSaveButton ? handleSaveClick : undefined}
|
||||
secondActionIcon={showSaveButton ? faSave : undefined}
|
||||
actions={renderHeaderActions()}
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-secondary/10 rounded-xl p-1 mb-6">
|
||||
<Suspense fallback={<LoadingSpinner/>}>
|
||||
<div className="mb-6">
|
||||
<Suspense fallback={<PulseLoader/>}>
|
||||
{setting === 'basic-information' && <BasicInformationSetting ref={settingRef}/>}
|
||||
{setting === 'guide-line' && <GuideLineSetting ref={settingRef}/>}
|
||||
{setting === 'story' && <StorySetting ref={settingRef}/>}
|
||||
{setting === 'quillsense' && <QuillSenseSetting ref={settingRef}/>}
|
||||
{setting === 'quillsense' && <QuillSenseSetting ref={settingRef} toolEnabled={toolEnabled}/>}
|
||||
{(setting === 'world' || setting === 'worlds') && (
|
||||
<WorldSettings entityType="book" showToggle={true}/>
|
||||
<WorldSettings entityType="book" toolEnabled={toolEnabled}/>
|
||||
)}
|
||||
{setting === 'locations' && (
|
||||
<LocationSettings entityType="book" showToggle={true}/>
|
||||
<LocationSettings entityType="book" toolEnabled={toolEnabled}/>
|
||||
)}
|
||||
{setting === 'characters' && (
|
||||
<CharacterSettings entityType="book" showToggle={true}/>
|
||||
<CharacterSettings entityType="book" toolEnabled={toolEnabled}/>
|
||||
)}
|
||||
{setting === 'spells' && (
|
||||
<SpellSettings entityType="book" showToggle={true}/>
|
||||
)}
|
||||
{setting === 'export' && (
|
||||
<ExportSetting/>
|
||||
<SpellSettings entityType="book" toolEnabled={toolEnabled}/>
|
||||
)}
|
||||
{setting === 'export' && <ExportSetting/>}
|
||||
{!['basic-information', 'guide-line', 'story', 'world', 'worlds', 'locations', 'characters', 'spells', 'quillsense', 'export'].includes(setting) && (
|
||||
<div className="text-text-secondary py-4 text-center">
|
||||
{t("bookSettingOption.notAvailable")}
|
||||
|
||||
@@ -1,26 +1,12 @@
|
||||
'use client'
|
||||
// Removed Next.js Link import for Electron
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faBook,
|
||||
faDownload,
|
||||
faGlobe,
|
||||
faHatWizard,
|
||||
faListAlt,
|
||||
faMapMarkedAlt,
|
||||
faPencilAlt,
|
||||
faUser,
|
||||
faWandMagicSparkles
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import {Dispatch, SetStateAction, useContext} from "react";
|
||||
import {IconDefinition} from "@fortawesome/fontawesome-svg-core";
|
||||
import {useTranslations} from "next-intl";
|
||||
import OfflineContext, {OfflineContextType} from "@/context/OfflineContext";
|
||||
import React, {Dispatch, SetStateAction} from "react";
|
||||
import {Book, Download, Globe, List, LucideIcon, Map, Pencil, User, Wand2} from 'lucide-react';
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
|
||||
interface BookSettingOption {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: IconDefinition;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
export default function BookSettingSidebar(
|
||||
@@ -32,92 +18,44 @@ export default function BookSettingSidebar(
|
||||
setSelectedSetting: Dispatch<SetStateAction<string>>
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
|
||||
|
||||
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: 'spells',
|
||||
name: 'bookSetting.spells',
|
||||
icon: faHatWizard
|
||||
},
|
||||
{
|
||||
id: 'quillsense',
|
||||
name: 'bookSetting.quillsense',
|
||||
icon: faWandMagicSparkles
|
||||
},
|
||||
{
|
||||
id: 'export',
|
||||
name: 'bookSetting.export',
|
||||
icon: faDownload
|
||||
},
|
||||
// {
|
||||
// id: 'objects',
|
||||
// name: t('bookSetting.objects'),
|
||||
// icon: faLocationArrow
|
||||
// },
|
||||
// {
|
||||
// id: 'goals',
|
||||
// name: t('bookSetting.goals'),
|
||||
// icon: faCogs
|
||||
// },
|
||||
]
|
||||
|
||||
// Filter out QuillSense when offline (requires server connection)
|
||||
const availableSettings: BookSettingOption[] = isCurrentlyOffline()
|
||||
? settings.filter((s: BookSettingOption) => s.id !== 'quillsense')
|
||||
: settings;
|
||||
{id: 'basic-information', name: 'bookSetting.basicInformation', icon: Pencil},
|
||||
{id: 'guide-line', name: 'bookSetting.guideLine', icon: List},
|
||||
{id: 'story', name: 'bookSetting.story', icon: Book},
|
||||
{id: 'world', name: 'bookSetting.world', icon: Globe},
|
||||
{id: 'locations', name: 'bookSetting.locations', icon: Map},
|
||||
{id: 'characters', name: 'bookSetting.characters', icon: User},
|
||||
{id: 'spells', name: 'bookSetting.spells', icon: Wand2},
|
||||
{id: 'quillsense', name: 'bookSetting.quillsense', icon: Wand2},
|
||||
{id: 'export', name: 'bookSetting.export', icon: Download},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="py-6 px-3">
|
||||
<nav className="space-y-1">
|
||||
{
|
||||
availableSettings.map((setting: BookSettingOption) => (
|
||||
<div className="py-4 px-2">
|
||||
<nav className="space-y-0.5">
|
||||
{settings.map((setting: BookSettingOption) => {
|
||||
const Icon: LucideIcon = setting.icon;
|
||||
const isActive: boolean = selectedSetting === setting.id;
|
||||
return (
|
||||
<button
|
||||
key={setting.id}
|
||||
onClick={(): void => setSelectedSetting(setting.id)}
|
||||
type="button"
|
||||
className={`flex items-center text-base rounded-xl transition-all duration-200 w-full ${
|
||||
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'}`}/>
|
||||
className={`flex items-center w-full text-sm rounded-lg transition-colors duration-150 px-3 py-2 ${
|
||||
isActive
|
||||
? 'bg-secondary text-text-primary font-medium'
|
||||
: 'text-text-secondary hover:bg-secondary/50 hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
<Icon
|
||||
className={`mr-2.5 w-4 h-4 flex-shrink-0 ${isActive ? 'text-primary' : 'text-muted'}`}
|
||||
strokeWidth={1.75}
|
||||
/>
|
||||
{t(setting.name)}
|
||||
</button>
|
||||
))
|
||||
}
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,118 +1,71 @@
|
||||
import {Trash2} from 'lucide-react';
|
||||
import React, {useContext, useState} from "react";
|
||||
import IconButton from "@/components/ui/IconButton";
|
||||
import {apiDelete} from "@/lib/api/client";
|
||||
import {isDesktop} from '@/lib/configs';
|
||||
import * as tauri from '@/lib/tauri';
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {faTrash} from "@fortawesome/free-solid-svg-icons";
|
||||
import {useContext, useState} from "react";
|
||||
import System from "@/lib/models/System";
|
||||
import {SessionContext} from "@/context/SessionContext";
|
||||
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
|
||||
import {SessionContext, SessionContextProps} from "@/context/SessionContext";
|
||||
import {BookContext, BookContextProps} from "@/context/BookContext";
|
||||
import {LangContext, LangContextProps} from "@/context/LangContext";
|
||||
import {AlertContext, AlertContextProps} from "@/context/AlertContext";
|
||||
import AlertBox from "@/components/AlertBox";
|
||||
import OfflineContext, {OfflineContextType} from "@/context/OfflineContext";
|
||||
import AlertBox from "@/components/ui/AlertBox";
|
||||
import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext";
|
||||
import {SyncedBook} from "@/lib/models/SyncedBook";
|
||||
import {useTranslations} from "next-intl";
|
||||
import {SyncedBook} from "@/lib/types/synced-book";
|
||||
|
||||
interface DeleteBookProps {
|
||||
bookId: string;
|
||||
}
|
||||
|
||||
export default function DeleteBook({bookId}: DeleteBookProps) {
|
||||
const {session} = useContext(SessionContext);
|
||||
const {lang} = useContext<LangContextProps>(LangContext)
|
||||
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
|
||||
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
|
||||
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext)
|
||||
const [showConfirmBox, setShowConfirmBox] = useState<boolean>(false);
|
||||
const [deleteLocalToo, setDeleteLocalToo] = useState<boolean>(false);
|
||||
const {errorMessage} = useContext<AlertContextProps>(AlertContext)
|
||||
const {serverOnlyBooks,setServerOnlyBooks,localOnlyBooks,setLocalOnlyBooks,localSyncedBooks,setLocalSyncedBooks,setServerSyncedBooks} = useContext<BooksSyncContextProps>(BooksSyncContext);
|
||||
const t = useTranslations('deleteBook');
|
||||
|
||||
const ifLocalOnlyBook: SyncedBook | undefined = localOnlyBooks.find((book: SyncedBook): boolean => book.id === bookId);
|
||||
const ifSyncedBook: SyncedBook | undefined = localSyncedBooks.find((book: SyncedBook): boolean => book.id === bookId);
|
||||
const {errorMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext)
|
||||
const {setServerSyncedBooks}: BooksSyncContextProps = useContext<BooksSyncContextProps>(BooksSyncContext)
|
||||
const {book}: BookContextProps = useContext<BookContextProps>(BookContext);
|
||||
const {isCurrentlyOffline}: OfflineContextType = useContext<OfflineContextType>(OfflineContext);
|
||||
|
||||
function handleConfirmation(): void {
|
||||
setDeleteLocalToo(false);
|
||||
setShowConfirmBox(true);
|
||||
}
|
||||
|
||||
async function handleDeleteBook(): Promise<void> {
|
||||
try {
|
||||
let response: boolean;
|
||||
const deletedAt: number = System.timeStampInSeconds();
|
||||
const deleteData = { id: bookId, deletedAt };
|
||||
|
||||
if (isCurrentlyOffline() || ifLocalOnlyBook) {
|
||||
response = await tauri.deleteBook(deleteData.id, deleteData.deletedAt);
|
||||
if (isDesktop && (isCurrentlyOffline() || book?.localBook)) {
|
||||
response = await tauri.deleteBook(bookId, Date.now());
|
||||
} else {
|
||||
response = await System.authDeleteToServer<boolean>(
|
||||
`book/delete`,
|
||||
deleteData,
|
||||
session.accessToken,
|
||||
lang
|
||||
);
|
||||
// If synced book and user wants to delete local too
|
||||
if (response && ifSyncedBook && deleteLocalToo) {
|
||||
await tauri.deleteBook(deleteData.id, deleteData.deletedAt);
|
||||
}
|
||||
response = await apiDelete<boolean>('book/delete', {
|
||||
id: bookId,
|
||||
}, session.accessToken, lang);
|
||||
}
|
||||
if (response) {
|
||||
setShowConfirmBox(false);
|
||||
if (ifLocalOnlyBook) {
|
||||
setLocalOnlyBooks(localOnlyBooks.filter((b: SyncedBook): boolean => b.id !== bookId));
|
||||
} else if (ifSyncedBook) {
|
||||
// Remove from synced lists
|
||||
setLocalSyncedBooks(localSyncedBooks.filter((b: SyncedBook): boolean => b.id !== bookId));
|
||||
setServerSyncedBooks((prev: SyncedBook[]): SyncedBook[] => prev.filter((b: SyncedBook): boolean => b.id !== bookId));
|
||||
// If not deleting local, move to localOnlyBooks
|
||||
if (!deleteLocalToo) {
|
||||
setLocalOnlyBooks([...localOnlyBooks, ifSyncedBook]);
|
||||
}
|
||||
} else {
|
||||
setServerOnlyBooks(serverOnlyBooks.filter((b: SyncedBook): boolean => b.id !== bookId));
|
||||
if (!response) {
|
||||
errorMessage("Une erreur est survenue lors de la suppression du livre.");
|
||||
return;
|
||||
}
|
||||
setServerSyncedBooks((prev: SyncedBook[]): SyncedBook[] => prev.filter((book: SyncedBook): boolean => book.id !== bookId))
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message)
|
||||
} else {
|
||||
errorMessage(t('errorUnknown'));
|
||||
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>
|
||||
<IconButton icon={Trash2} variant="ghost" shape="square" onClick={handleConfirmation}/>
|
||||
{
|
||||
showConfirmBox && (
|
||||
<AlertBox title={t('title')}
|
||||
message={t('message')}
|
||||
type={"danger"}
|
||||
onConfirm={handleDeleteBook}
|
||||
onCancel={() => setShowConfirmBox(false)}
|
||||
confirmText={t('confirm')}
|
||||
cancelText={t('cancel')}>
|
||||
{ifSyncedBook && !isCurrentlyOffline() && (
|
||||
<div className="mt-4 p-3 bg-error/10 border border-error/30 rounded-lg">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={deleteLocalToo}
|
||||
onChange={(e) => setDeleteLocalToo(e.target.checked)}
|
||||
className="w-5 h-5 accent-error cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm text-text-primary">
|
||||
{t('deleteLocalToo')}
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-xs text-error mt-2">
|
||||
{t('deleteLocalWarning')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</AlertBox>
|
||||
<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'}/>
|
||||
)
|
||||
}
|
||||
</>
|
||||
|
||||
@@ -1,228 +1,288 @@
|
||||
'use client'
|
||||
import React, {useCallback, useContext, useEffect, useMemo, useState} from 'react';
|
||||
import {Check} from 'lucide-react';
|
||||
import Button from '@/components/ui/Button';
|
||||
import PulseLoader from '@/components/ui/PulseLoader';
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
import {BookContext, BookContextProps} from '@/context/BookContext';
|
||||
import {SessionContext, SessionContextProps} from '@/context/SessionContext';
|
||||
import {AlertContext, AlertContextProps} from '@/context/AlertContext';
|
||||
import {configs, isDesktop} from '@/lib/configs';
|
||||
import {apiGet} from '@/lib/api/client';
|
||||
import * as tauri from '@/lib/tauri';
|
||||
import React, {useCallback, useContext, useEffect, useState} from 'react';
|
||||
import {useTranslations} from 'next-intl';
|
||||
import {BookContext} from '@/context/BookContext';
|
||||
import {AlertContext} from '@/context/AlertContext';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {faDownload, faSpinner} from '@fortawesome/free-solid-svg-icons';
|
||||
import {
|
||||
ChapterExportInfo,
|
||||
ChapterExportSelection,
|
||||
chapterVersions,
|
||||
ExportFormat
|
||||
} from '@/lib/models/Chapter';
|
||||
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
|
||||
import {LangContext, LangContextProps} from '@/context/LangContext';
|
||||
import {ChapterExportInfo, ChapterExportSelection, ExportFormat,} from '@/lib/types/chapter';
|
||||
import {chapterVersions} from '@/lib/constants/chapter';
|
||||
import {SelectBoxProps} from '@/components/form/SelectBox';
|
||||
|
||||
const exportFormats: {value: ExportFormat; label: string}[] = [
|
||||
{value: 'epub', label: 'EPUB'},
|
||||
{value: 'pdf', label: 'PDF'},
|
||||
{value: 'docx', label: 'DOCX'},
|
||||
];
|
||||
const formats: ExportFormat[] = ['epub', 'pdf', 'docx'];
|
||||
|
||||
const formatExtensions: Record<ExportFormat, string> = {
|
||||
epub: '.epub',
|
||||
pdf: '.pdf',
|
||||
docx: '.docx',
|
||||
};
|
||||
|
||||
export default function ExportSetting(): React.JSX.Element {
|
||||
const t = useTranslations();
|
||||
const {book} = useContext(BookContext);
|
||||
const {errorMessage, successMessage} = useContext(AlertContext);
|
||||
const {book}: BookContextProps = useContext<BookContextProps>(BookContext);
|
||||
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
|
||||
const {successMessage, errorMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
|
||||
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext);
|
||||
const {isCurrentlyOffline}: OfflineContextType = useContext<OfflineContextType>(OfflineContext);
|
||||
|
||||
const [format, setFormat] = useState<ExportFormat>('epub');
|
||||
const [chapters, setChapters] = useState<ChapterExportInfo[]>([]);
|
||||
const [selections, setSelections] = useState<ChapterExportSelection[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [selectedFormat, setSelectedFormat] = useState<ExportFormat>('epub');
|
||||
const [chaptersExportInfo, setChaptersExportInfo] = useState<ChapterExportInfo[]>([]);
|
||||
const [chapterSelections, setChapterSelections] = useState<ChapterExportSelection[]>([]);
|
||||
const [isLoadingChapters, setIsLoadingChapters] = useState<boolean>(true);
|
||||
const [isExporting, setIsExporting] = useState<boolean>(false);
|
||||
|
||||
const loadChapters = useCallback(async (): Promise<void> => {
|
||||
if (!book) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const chaptersInfo: ChapterExportInfo[] = await tauri.getBookExportInfo(book.bookId) as ChapterExportInfo[];
|
||||
setChapters(chaptersInfo);
|
||||
const initialSelections: ChapterExportSelection[] = chaptersInfo.map(
|
||||
(ch: ChapterExportInfo): ChapterExportSelection => ({
|
||||
chapterId: ch.chapterId,
|
||||
version: ch.availableVersions[ch.availableVersions.length - 1],
|
||||
selected: true
|
||||
})
|
||||
const selectedCount: number = useMemo(
|
||||
() => chapterSelections.filter((selection: ChapterExportSelection): boolean => selection.selected).length,
|
||||
[chapterSelections],
|
||||
);
|
||||
setSelections(initialSelections);
|
||||
} catch {
|
||||
errorMessage(t('exportOption.error'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
|
||||
const allSelected: boolean = useMemo(
|
||||
() => chapterSelections.length > 0 && chapterSelections.every((selection: ChapterExportSelection): boolean => selection.selected),
|
||||
[chapterSelections],
|
||||
);
|
||||
|
||||
const canExport: boolean = useMemo(
|
||||
() => !isExporting && selectedCount > 0,
|
||||
[isExporting, selectedCount],
|
||||
);
|
||||
|
||||
const fetchChaptersExportInfo = useCallback(async (): Promise<void> => {
|
||||
if (!book?.bookId) {
|
||||
setIsLoadingChapters(false);
|
||||
return;
|
||||
}
|
||||
}, [book]);
|
||||
setIsLoadingChapters(true);
|
||||
try {
|
||||
let data: ChapterExportInfo[];
|
||||
if (isDesktop && (isCurrentlyOffline() || book?.localBook)) {
|
||||
data = await tauri.getBookExportInfo(book.bookId) as ChapterExportInfo[];
|
||||
} else {
|
||||
data = await apiGet<ChapterExportInfo[]>(
|
||||
'book/chapters/export-info', session.accessToken, lang, {id: book.bookId}
|
||||
);
|
||||
}
|
||||
|
||||
setChaptersExportInfo(data);
|
||||
setChapterSelections(
|
||||
data.map((chapter: ChapterExportInfo): ChapterExportSelection => ({
|
||||
chapterId: chapter.chapterId,
|
||||
version: chapter.availableVersions.length > 0
|
||||
? chapter.availableVersions[chapter.availableVersions.length - 1]
|
||||
: 1,
|
||||
selected: true,
|
||||
})),
|
||||
);
|
||||
} catch {
|
||||
errorMessage(t('exportOption.serverError'));
|
||||
} finally {
|
||||
setIsLoadingChapters(false);
|
||||
}
|
||||
}, [book?.bookId, session.accessToken, lang, errorMessage, t]);
|
||||
|
||||
useEffect((): void => {
|
||||
loadChapters();
|
||||
}, [loadChapters]);
|
||||
fetchChaptersExportInfo();
|
||||
}, [fetchChaptersExportInfo]);
|
||||
|
||||
function toggleChapter(chapterId: string): void {
|
||||
setSelections((prev: ChapterExportSelection[]): ChapterExportSelection[] =>
|
||||
prev.map((s: ChapterExportSelection): ChapterExportSelection =>
|
||||
s.chapterId === chapterId ? {...s, selected: !s.selected} : s
|
||||
)
|
||||
function toggleChapterSelection(chapterId: string): void {
|
||||
setChapterSelections((previous: ChapterExportSelection[]): ChapterExportSelection[] =>
|
||||
previous.map((selection: ChapterExportSelection): ChapterExportSelection =>
|
||||
selection.chapterId === chapterId
|
||||
? {...selection, selected: !selection.selected}
|
||||
: selection,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function toggleAll(selectAll: boolean): void {
|
||||
setSelections((prev: ChapterExportSelection[]): ChapterExportSelection[] =>
|
||||
prev.map((s: ChapterExportSelection): ChapterExportSelection => ({...s, selected: selectAll}))
|
||||
function toggleAllChapters(): void {
|
||||
const newSelected: boolean = !allSelected;
|
||||
setChapterSelections((previous: ChapterExportSelection[]): ChapterExportSelection[] =>
|
||||
previous.map((selection: ChapterExportSelection): ChapterExportSelection => ({
|
||||
...selection,
|
||||
selected: newSelected,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function updateVersion(chapterId: string, version: number): void {
|
||||
setSelections((prev: ChapterExportSelection[]): ChapterExportSelection[] =>
|
||||
prev.map((s: ChapterExportSelection): ChapterExportSelection =>
|
||||
s.chapterId === chapterId ? {...s, version} : s
|
||||
)
|
||||
function setChapterVersion(chapterId: string, version: number): void {
|
||||
setChapterSelections((previous: ChapterExportSelection[]): ChapterExportSelection[] =>
|
||||
previous.map((selection: ChapterExportSelection): ChapterExportSelection =>
|
||||
selection.chapterId === chapterId
|
||||
? {...selection, version}
|
||||
: selection,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function getVersionLabel(version: number): string {
|
||||
const found = chapterVersions.find((v) => v.value === String(version));
|
||||
return found ? t(found.label) : `v${version}`;
|
||||
const match: SelectBoxProps | undefined = chapterVersions.find(
|
||||
(chapterVersion: SelectBoxProps): boolean => chapterVersion.value === String(version),
|
||||
);
|
||||
return match ? t(match.label) : `V${version}`;
|
||||
}
|
||||
|
||||
async function handleExport(): Promise<void> {
|
||||
if (!book) return;
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const selectedChapters = selections
|
||||
.filter((s: ChapterExportSelection): boolean => s.selected)
|
||||
.map((s: ChapterExportSelection) => ({chapterId: s.chapterId, version: s.version}));
|
||||
if (!book?.bookId) {
|
||||
errorMessage(t('exportOption.noBookSelected'));
|
||||
return;
|
||||
}
|
||||
if (selectedCount === 0) {
|
||||
errorMessage(t('exportOption.noChaptersSelected'));
|
||||
return;
|
||||
}
|
||||
|
||||
const result: boolean = await tauri.exportBook({
|
||||
setIsExporting(true);
|
||||
|
||||
const selectedChapters: { chapterId: string; version: number }[] = chapterSelections
|
||||
.filter((selection: ChapterExportSelection): boolean => selection.selected)
|
||||
.map((selection: ChapterExportSelection): { chapterId: string; version: number } => ({
|
||||
chapterId: selection.chapterId,
|
||||
version: selection.version,
|
||||
}));
|
||||
|
||||
try {
|
||||
const response: Response = await fetch(`${configs.apiUrl}book/export`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
bookId: book.bookId,
|
||||
format,
|
||||
selections: selectedChapters.length === chapters.length ? null : selectedChapters
|
||||
format: selectedFormat,
|
||||
chapters: selectedChapters,
|
||||
}),
|
||||
});
|
||||
|
||||
if (result) {
|
||||
successMessage(t('exportOption.success'));
|
||||
if (!response.ok) {
|
||||
errorMessage(t('exportOption.downloadError'));
|
||||
return;
|
||||
}
|
||||
|
||||
const blob: Blob = await response.blob();
|
||||
const bookName: string = book.subTitle ? `${book.title} - ${book.subTitle}` : book.title;
|
||||
const fileName: string = `${bookName}${formatExtensions[selectedFormat]}`;
|
||||
|
||||
const virtualUrl: string = window.URL.createObjectURL(blob);
|
||||
const downloadLink: HTMLAnchorElement = document.createElement('a');
|
||||
downloadLink.href = virtualUrl;
|
||||
downloadLink.download = fileName;
|
||||
document.body.appendChild(downloadLink);
|
||||
downloadLink.click();
|
||||
downloadLink.remove();
|
||||
window.URL.revokeObjectURL(virtualUrl);
|
||||
|
||||
successMessage(t('exportOption.downloadSuccess', {format: selectedFormat.toUpperCase()}));
|
||||
} catch {
|
||||
errorMessage(t('exportOption.error'));
|
||||
errorMessage(t('exportOption.unknownError'));
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const allSelected: boolean = selections.every((s: ChapterExportSelection): boolean => s.selected);
|
||||
const hasSelection: boolean = selections.some((s: ChapterExportSelection): boolean => s.selected);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-4">
|
||||
<div className="p-4 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-text-primary font-semibold mb-2">{t('exportOption.format')}</h3>
|
||||
<h3 className="text-sm font-semibold text-text-primary mb-3">
|
||||
{t('exportOption.formatLabel')}
|
||||
</h3>
|
||||
<div className="flex gap-3">
|
||||
{exportFormats.map(({value, label}) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={(): void => setFormat(value)}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-all duration-200 ${
|
||||
format === value
|
||||
? 'bg-primary text-white shadow-md'
|
||||
: 'bg-secondary/30 text-text-secondary hover:bg-secondary/50'
|
||||
}`}
|
||||
{formats.map((format: ExportFormat) => (
|
||||
<Button
|
||||
key={format}
|
||||
variant={selectedFormat === format ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={(): void => setSelectedFormat(format)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{format.toUpperCase()}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-text-primary font-semibold">{t('exportOption.chapters')}</h3>
|
||||
{chapters.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(): void => toggleAll(!allSelected)}
|
||||
className="text-sm text-primary hover:text-primary/80 transition-colors"
|
||||
>
|
||||
<h3 className="text-sm font-semibold text-text-primary mb-3">
|
||||
{t('exportOption.chapters')}
|
||||
</h3>
|
||||
|
||||
{isLoadingChapters ? (
|
||||
<PulseLoader text={t('exportOption.loadingChapters')} size="sm"/>
|
||||
) : chaptersExportInfo.length === 0 ? (
|
||||
<p className="text-text-secondary text-sm text-center py-8">
|
||||
{t('exportOption.noChaptersAvailable')}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-3">
|
||||
<Button variant="ghost" size="sm" onClick={toggleAllChapters}>
|
||||
{allSelected ? t('exportOption.deselectAll') : t('exportOption.selectAll')}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{chaptersExportInfo.map((chapter: ChapterExportInfo, index: number) => {
|
||||
const selection: ChapterExportSelection = chapterSelections[index];
|
||||
return (
|
||||
<div key={chapter.chapterId} className="border-b border-secondary pb-2">
|
||||
<button
|
||||
onClick={(): void => toggleChapterSelection(chapter.chapterId)}
|
||||
className="flex items-center gap-3 w-full py-2 text-left hover:bg-tertiary rounded-lg px-2 transition-all"
|
||||
>
|
||||
<div
|
||||
className={`w-5 h-5 rounded flex items-center justify-center border transition-colors ${
|
||||
selection.selected
|
||||
? 'bg-primary/20 border-primary'
|
||||
: 'border-secondary bg-tertiary'
|
||||
}`}>
|
||||
{selection.selected && (
|
||||
<Check className="w-3 h-3 text-text-primary" strokeWidth={1.75}/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<FontAwesomeIcon icon={faSpinner} className="w-6 h-6 text-primary animate-spin mr-2"/>
|
||||
<span className="text-text-secondary">{t('exportOption.loadingChapters')}</span>
|
||||
</div>
|
||||
) : chapters.length === 0 ? (
|
||||
<p className="text-text-secondary text-center py-8">{t('exportOption.noChapters')}</p>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto pr-1">
|
||||
{chapters.map((chapter: ChapterExportInfo) => {
|
||||
const selection: ChapterExportSelection | undefined = selections.find(
|
||||
(s: ChapterExportSelection): boolean => s.chapterId === chapter.chapterId
|
||||
);
|
||||
if (!selection) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={chapter.chapterId}
|
||||
className={`flex items-center justify-between p-3 rounded-lg transition-all duration-200 ${
|
||||
selection.selected
|
||||
? 'bg-primary/10 border border-primary/30'
|
||||
: 'bg-secondary/20 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<label className="flex items-center gap-3 cursor-pointer flex-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selection.selected}
|
||||
onChange={(): void => toggleChapter(chapter.chapterId)}
|
||||
className="w-4 h-4 rounded accent-primary cursor-pointer"
|
||||
/>
|
||||
<span className={`text-sm ${
|
||||
<span className={`text-sm font-medium truncate ${
|
||||
selection.selected ? 'text-text-primary' : 'text-text-secondary'
|
||||
}`}>
|
||||
{chapter.chapterOrder !== -1 ? `${chapter.chapterOrder}. ` : ''}
|
||||
{chapter.title}
|
||||
</span>
|
||||
</label>
|
||||
</button>
|
||||
|
||||
{chapter.availableVersions.length > 1 && selection.selected && (
|
||||
<select
|
||||
value={selection.version}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>): void =>
|
||||
updateVersion(chapter.chapterId, parseInt(e.target.value, 10))
|
||||
}
|
||||
className="text-xs bg-secondary/50 text-text-primary rounded-md px-2 py-1 border border-secondary/30 focus:outline-none focus:border-primary"
|
||||
{selection.selected && chapter.availableVersions.length > 1 && (
|
||||
<div className="flex flex-wrap gap-2 ml-10 mt-1 mb-1">
|
||||
{chapter.availableVersions.map((version: number) => (
|
||||
<button
|
||||
key={version}
|
||||
onClick={(): void => setChapterVersion(chapter.chapterId, version)}
|
||||
className={`px-3 py-1 rounded-full text-xs font-medium transition-all border ${
|
||||
selection.version === version
|
||||
? 'bg-primary/20 text-primary border-primary/50'
|
||||
: 'bg-tertiary text-text-secondary border-secondary hover:bg-secondary'
|
||||
}`}
|
||||
>
|
||||
{chapter.availableVersions.map((v: number) => (
|
||||
<option key={v} value={v}>
|
||||
{getVersionLabel(v)}
|
||||
</option>
|
||||
{getVersionLabel(version)}
|
||||
</button>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExport}
|
||||
disabled={isExporting || !hasSelection || isLoading}
|
||||
className="w-full flex items-center justify-center gap-2 bg-primary hover:bg-primary/90 disabled:bg-primary/50 text-white font-semibold py-3 px-6 rounded-xl transition-all duration-200 shadow-md hover:shadow-lg disabled:cursor-not-allowed"
|
||||
>
|
||||
{isExporting ? (
|
||||
<>
|
||||
<FontAwesomeIcon icon={faSpinner} className="w-4 h-4 animate-spin"/>
|
||||
{t('exportOption.exporting')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FontAwesomeIcon icon={faDownload} className="w-4 h-4"/>
|
||||
{t('exportOption.export')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<Button variant="primary" size="lg" onClick={handleExport} disabled={!canExport}
|
||||
isLoading={isExporting} loadingText={t('exportOption.exporting')}
|
||||
fullWidth>
|
||||
{t('exportOption.exportButton')} {selectedFormat.toUpperCase()}
|
||||
{selectedCount > 0 ? ` (${selectedCount})` : ''}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
'use client';
|
||||
import React from 'react';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {faArrowLeft, faEdit, faPlus, faSave, faShare, faTrash, faTimes} from '@fortawesome/free-solid-svg-icons';
|
||||
import {IconDefinition} from '@fortawesome/fontawesome-svg-core';
|
||||
import {useTranslations} from 'next-intl';
|
||||
import {ViewMode} from '@/shared/interface';
|
||||
import {ArrowLeft, LucideIcon, Pencil, Plus, Save, Share2, Trash2, X} from 'lucide-react';
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
import {ViewMode} from '@/lib/types/settings';
|
||||
import Button from '@/components/ui/Button';
|
||||
import IconButton from '@/components/ui/IconButton';
|
||||
|
||||
type ActionVariant = 'primary' | 'danger' | 'ghost';
|
||||
|
||||
interface ActionButton {
|
||||
icon: IconDefinition;
|
||||
icon: LucideIcon;
|
||||
onClick: () => void;
|
||||
title?: string;
|
||||
variant?: 'primary' | 'danger' | 'secondary' | 'blue';
|
||||
variant?: ActionVariant;
|
||||
}
|
||||
|
||||
interface SidebarHeaderProps {
|
||||
@@ -55,45 +57,16 @@ export default function ToolDetailHeader({
|
||||
return null;
|
||||
}
|
||||
|
||||
function getVariantClasses(variant: 'primary' | 'danger' | 'secondary' | 'blue'): string {
|
||||
switch (variant) {
|
||||
case 'primary':
|
||||
return 'bg-primary/10 hover:bg-primary/20 border-primary/30';
|
||||
case 'danger':
|
||||
return 'bg-error/10 hover:bg-error/20 border-error/30';
|
||||
case 'blue':
|
||||
return 'bg-blue-500/10 hover:bg-blue-500/20 border-blue-500/30';
|
||||
case 'secondary':
|
||||
default:
|
||||
return 'bg-secondary/50 hover:bg-secondary border-secondary/50 hover:border-secondary';
|
||||
}
|
||||
}
|
||||
|
||||
function getIconColorClass(variant: 'primary' | 'danger' | 'secondary' | 'blue'): string {
|
||||
switch (variant) {
|
||||
case 'primary':
|
||||
return 'text-primary';
|
||||
case 'danger':
|
||||
return 'text-error';
|
||||
case 'blue':
|
||||
return 'text-blue-500';
|
||||
case 'secondary':
|
||||
default:
|
||||
return 'text-text-primary';
|
||||
}
|
||||
}
|
||||
|
||||
function renderActionButton(button: ActionButton, index: number): React.JSX.Element {
|
||||
const variant = button.variant || 'secondary';
|
||||
return (
|
||||
<button
|
||||
<IconButton
|
||||
key={`action-${index}`}
|
||||
icon={button.icon}
|
||||
variant={button.variant || 'ghost'}
|
||||
shape="square"
|
||||
onClick={button.onClick}
|
||||
title={button.title}
|
||||
className={`group flex items-center justify-center w-10 h-10 rounded-lg border hover:shadow-md hover:scale-110 transition-all duration-200 ${getVariantClasses(variant)}`}
|
||||
>
|
||||
<FontAwesomeIcon icon={button.icon} className={`w-4 h-4 transition-transform group-hover:scale-110 ${getIconColorClass(variant)}`}/>
|
||||
</button>
|
||||
tooltip={button.title}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -103,15 +76,15 @@ export default function ToolDetailHeader({
|
||||
if (viewMode === 'detail') {
|
||||
if (showExport && onExport) {
|
||||
buttons.push({
|
||||
icon: faShare,
|
||||
icon: Share2,
|
||||
onClick: onExport,
|
||||
title: t('common.exportToSeries'),
|
||||
variant: 'blue',
|
||||
variant: 'primary',
|
||||
});
|
||||
}
|
||||
if (showDelete && onDelete) {
|
||||
buttons.push({
|
||||
icon: faTrash,
|
||||
icon: Trash2,
|
||||
onClick: onDelete,
|
||||
title: t('common.delete'),
|
||||
variant: 'danger',
|
||||
@@ -119,7 +92,7 @@ export default function ToolDetailHeader({
|
||||
}
|
||||
if (onEdit) {
|
||||
buttons.push({
|
||||
icon: faEdit,
|
||||
icon: Pencil,
|
||||
onClick: onEdit,
|
||||
title: t('common.edit'),
|
||||
variant: 'primary',
|
||||
@@ -128,7 +101,7 @@ export default function ToolDetailHeader({
|
||||
} else if (viewMode === 'edit') {
|
||||
if (onCancel) {
|
||||
buttons.push({
|
||||
icon: faTimes,
|
||||
icon: X,
|
||||
onClick: onCancel,
|
||||
title: t('common.cancel'),
|
||||
variant: 'danger',
|
||||
@@ -136,7 +109,7 @@ export default function ToolDetailHeader({
|
||||
}
|
||||
if (onSave) {
|
||||
buttons.push({
|
||||
icon: isNew ? faPlus : faSave,
|
||||
icon: isNew ? Plus : Save,
|
||||
onClick: onSave,
|
||||
title: isNew ? t('common.create') : t('common.save'),
|
||||
variant: 'primary',
|
||||
@@ -148,14 +121,11 @@ export default function ToolDetailHeader({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-between items-center p-4 border-b border-secondary/50 bg-tertiary/50 backdrop-blur-sm">
|
||||
<button
|
||||
onClick={onBack}
|
||||
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('common.back')}</span>
|
||||
</button>
|
||||
<div
|
||||
className="flex justify-between items-center p-4 border-b border-secondary bg-darkest-background">
|
||||
<Button variant="secondary" size="sm" icon={ArrowLeft} onClick={onBack}>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
|
||||
<span className="text-text-primary font-semibold text-lg">
|
||||
{title || defaultTitle}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import CollapsableArea from "@/components/CollapsableArea";
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {useState} from "react";
|
||||
import {faTrash} from "@fortawesome/free-solid-svg-icons";
|
||||
import Collapse from "@/components/ui/Collapse";
|
||||
import React, {ChangeEvent, useState} from "react";
|
||||
import {LucideIcon, Trash2} from 'lucide-react';
|
||||
import IconButton from "@/components/ui/IconButton";
|
||||
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";
|
||||
import {Attribute, CharacterAttributeSection, CharacterProps} from "@/lib/types/character";
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
|
||||
|
||||
interface CharacterSectionElementProps {
|
||||
title: string;
|
||||
section: keyof CharacterProps;
|
||||
section: CharacterAttributeSection;
|
||||
placeholder: string;
|
||||
icon: IconDefinition;
|
||||
icon: LucideIcon;
|
||||
selectedCharacter: CharacterProps;
|
||||
setSelectedCharacter: (character: CharacterProps) => void;
|
||||
handleAddElement: (section: keyof CharacterProps, element: Attribute) => void;
|
||||
handleAddElement: (section: CharacterAttributeSection, element: Attribute) => void;
|
||||
handleRemoveElement: (
|
||||
section: keyof CharacterProps,
|
||||
section: CharacterAttributeSection,
|
||||
index: number,
|
||||
attrId: string,
|
||||
) => void;
|
||||
@@ -33,28 +33,26 @@ export default function CharacterSectionElement(
|
||||
setSelectedCharacter,
|
||||
handleAddElement,
|
||||
handleRemoveElement,
|
||||
}: CharacterSectionElementProps) {
|
||||
}: CharacterSectionElementProps): React.JSX.Element {
|
||||
const t = useTranslations();
|
||||
const [element, setElement] = useState<string>('');
|
||||
|
||||
function handleAddNewElement() {
|
||||
function handleAddNewElement(): void {
|
||||
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) => (
|
||||
<Collapse variant="card" title={title} icon={icon}>
|
||||
{selectedCharacter[section].map((item: Attribute, index: number): React.JSX.Element => (
|
||||
<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">
|
||||
className="flex items-center gap-2 bg-secondary rounded-xl border-l-4 border-primary transition-colors 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;
|
||||
value={item.name || ''}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void => {
|
||||
const updatedSection: Attribute[] = [...selectedCharacter[section]];
|
||||
updatedSection[index] = {...updatedSection[index], name: e.target.value};
|
||||
setSelectedCharacter({
|
||||
...selectedCharacter,
|
||||
[section]: updatedSection,
|
||||
@@ -62,28 +60,28 @@ export default function CharacterSectionElement(
|
||||
}}
|
||||
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 className="mr-2">
|
||||
<IconButton
|
||||
icon={Trash2}
|
||||
variant="danger"
|
||||
onClick={(): void => handleRemoveElement(section, index, item.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="border-t border-secondary/50 mt-4 pt-4">
|
||||
<div className="border-t border-secondary mt-4 pt-4">
|
||||
<InputField
|
||||
input={
|
||||
<TextInput
|
||||
value={element}
|
||||
setValue={(e) => setElement(e.target.value)}
|
||||
setValue={(e: ChangeEvent<HTMLInputElement>): void => setElement(e.target.value)}
|
||||
placeholder={t("characterSectionElement.newItem", {item: title.toLowerCase()})}
|
||||
/>
|
||||
}
|
||||
addButtonCallBack={async () => handleAddNewElement()}
|
||||
addButtonCallBack={async (): Promise<void> => handleAddNewElement()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsableArea>
|
||||
</Collapse>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +1,15 @@
|
||||
'use client';
|
||||
import React, {useCallback, useContext, useMemo, useState} from 'react';
|
||||
import {useCharacters, UseCharactersConfig} from '@/hooks/settings/useCharacters';
|
||||
import {useTranslations} from 'next-intl';
|
||||
import {CharacterProps} from '@/lib/models/Character';
|
||||
import {SeriesCharacterProps} from '@/lib/models/Series';
|
||||
import {BookContext} from '@/context/BookContext';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {faSpinner, faToggleOn} from '@fortawesome/free-solid-svg-icons';
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
import {CharacterProps} from '@/lib/types/character';
|
||||
import {SeriesCharacterProps} from '@/lib/types/series';
|
||||
import {BookContext, BookContextProps} from '@/context/BookContext';
|
||||
import PulseLoader from '@/components/ui/PulseLoader';
|
||||
|
||||
import ToolDetailHeader from '@/components/book/settings/ToolDetailHeader';
|
||||
import InputField from '@/components/form/InputField';
|
||||
import ToggleSwitch from '@/components/form/ToggleSwitch';
|
||||
import SeriesImportSelector from '@/components/form/SeriesImportSelector';
|
||||
import AlertBox from '@/components/AlertBox';
|
||||
import AlertBox from '@/components/ui/AlertBox';
|
||||
|
||||
import CharacterEditorList from './CharacterEditorList';
|
||||
import CharacterEditorDetail from './CharacterEditorDetail';
|
||||
@@ -24,7 +21,7 @@ import CharacterEditorEdit from './CharacterEditorEdit';
|
||||
*/
|
||||
export default function CharacterEditor(): React.JSX.Element {
|
||||
const t = useTranslations();
|
||||
const {book} = useContext(BookContext);
|
||||
const {book}: BookContextProps = useContext<BookContextProps>(BookContext);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean>(false);
|
||||
|
||||
const config: UseCharactersConfig = useMemo(function (): UseCharactersConfig {
|
||||
@@ -95,11 +92,7 @@ export default function CharacterEditor(): React.JSX.Element {
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<FontAwesomeIcon icon={faSpinner} className="w-6 h-6 text-primary animate-spin"/>
|
||||
</div>
|
||||
);
|
||||
return <PulseLoader size="sm"/>;
|
||||
}
|
||||
|
||||
const isNew: boolean = selectedCharacter?.id === null;
|
||||
@@ -116,7 +109,9 @@ export default function CharacterEditor(): React.JSX.Element {
|
||||
onEdit={enterEditMode}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
onDelete={function (): void { setShowDeleteConfirm(true); }}
|
||||
onDelete={function (): void {
|
||||
setShowDeleteConfirm(true);
|
||||
}}
|
||||
onExport={canExport ? exportToSeries : undefined}
|
||||
showExport={canExport}
|
||||
showDelete={Boolean(selectedCharacter?.id)}
|
||||
@@ -125,22 +120,6 @@ export default function CharacterEditor(): React.JSX.Element {
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{viewMode === 'list' && (
|
||||
<div className="space-y-3 p-2">
|
||||
{/* Toggle tool */}
|
||||
<div className="bg-secondary/20 rounded-lg p-3 border border-secondary/30">
|
||||
<InputField
|
||||
icon={faToggleOn}
|
||||
fieldName={t('characterComponent.enableTool')}
|
||||
input={
|
||||
<ToggleSwitch
|
||||
checked={toolEnabled}
|
||||
onChange={toggleTool}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{toolEnabled && (
|
||||
<>
|
||||
{/* Import from series */}
|
||||
{bookSeriesId && availableSeriesCharacters.length > 0 && (
|
||||
<SeriesImportSelector
|
||||
@@ -161,8 +140,6 @@ export default function CharacterEditor(): React.JSX.Element {
|
||||
onCharacterClick={enterDetailMode}
|
||||
onAddCharacter={addNewCharacter}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -198,7 +175,9 @@ export default function CharacterEditor(): React.JSX.Element {
|
||||
confirmText={t('common.delete')}
|
||||
cancelText={t('common.cancel')}
|
||||
onConfirm={handleDelete}
|
||||
onCancel={function (): void { setShowDeleteConfirm(false); }}
|
||||
onCancel={function (): void {
|
||||
setShowDeleteConfirm(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
'use client';
|
||||
import React, {useContext, useEffect} from 'react';
|
||||
import {
|
||||
Attribute,
|
||||
CharacterAttribute,
|
||||
characterCategories,
|
||||
CharacterProps
|
||||
} from '@/lib/models/Character';
|
||||
import {SeriesCharacterProps} from '@/lib/models/Series';
|
||||
import {useTranslations} from 'next-intl';
|
||||
import {SessionContext} from '@/context/SessionContext';
|
||||
import {AlertContext} from '@/context/AlertContext';
|
||||
import {LangContext} from '@/context/LangContext';
|
||||
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
|
||||
import {BookContext} from '@/context/BookContext';
|
||||
import System from '@/lib/models/System';
|
||||
import * as tauri from '@/lib/tauri';
|
||||
import {Attribute, CharacterAttribute, CharacterProps} from '@/lib/types/character';
|
||||
import {characterCategories} from '@/lib/constants/character';
|
||||
import {SelectBoxProps} from '@/components/form/SelectBox';
|
||||
import {SeriesCharacterProps} from '@/lib/types/series';
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
import DetailField from '@/components/ui/DetailField';
|
||||
import AvatarIcon from '@/components/ui/AvatarIcon';
|
||||
import {SessionContext, SessionContextProps} from '@/context/SessionContext';
|
||||
import {AlertContext, AlertContextProps} from '@/context/AlertContext';
|
||||
import {LangContext, LangContextProps} from '@/context/LangContext';
|
||||
import {apiGet} from '@/lib/api/client';
|
||||
|
||||
type AttributeResponse = { type: string; values: Attribute[] }[];
|
||||
|
||||
@@ -35,11 +31,9 @@ export default function CharacterEditorDetail({
|
||||
onLoadAttributes,
|
||||
}: CharacterEditorDetailProps): React.JSX.Element {
|
||||
const t = useTranslations();
|
||||
const {lang} = useContext(LangContext);
|
||||
const {session} = useContext(SessionContext);
|
||||
const {errorMessage} = useContext(AlertContext);
|
||||
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
|
||||
const {book} = useContext(BookContext);
|
||||
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext);
|
||||
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
|
||||
const {errorMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
|
||||
|
||||
useEffect(function (): void {
|
||||
if (character?.id !== null) {
|
||||
@@ -49,17 +43,12 @@ export default function CharacterEditorDetail({
|
||||
|
||||
async function getAttributes(): Promise<void> {
|
||||
try {
|
||||
let response: AttributeResponse;
|
||||
if (isCurrentlyOffline() || book?.localBook) {
|
||||
response = await tauri.getCharacterAttributes(character?.id!) as AttributeResponse;
|
||||
} else {
|
||||
response = await System.authGetQueryToServer<AttributeResponse>(
|
||||
const response: AttributeResponse = await apiGet<AttributeResponse>(
|
||||
'character/attribute',
|
||||
session.accessToken,
|
||||
lang,
|
||||
{characterId: character?.id}
|
||||
);
|
||||
}
|
||||
if (response && onLoadAttributes) {
|
||||
const attributes: CharacterAttribute = {};
|
||||
response.forEach(function (item: { type: string; values: Attribute[] }): void {
|
||||
@@ -74,34 +63,19 @@ export default function CharacterEditorDetail({
|
||||
}
|
||||
}
|
||||
|
||||
function renderField(label: string, value: string | number | null | undefined): React.JSX.Element | null {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<span className="text-text-secondary text-xs block mb-1">{label}</span>
|
||||
<p className="text-text-primary text-sm">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getCategoryLabel(category: string | null | undefined): string {
|
||||
if (!category) return '';
|
||||
const found = characterCategories.find(function (c): boolean { return c.value === category; });
|
||||
const found: SelectBoxProps | undefined = characterCategories.find(function (c: SelectBoxProps): boolean {
|
||||
return c.value === category;
|
||||
});
|
||||
return found ? t(found.label) : category;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Image du personnage - version compacte */}
|
||||
{character.image && (
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="w-16 h-16 rounded-full border-2 border-primary overflow-hidden">
|
||||
<img
|
||||
src={character.image}
|
||||
alt={character.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<AvatarIcon size="xl" image={character.image} alt={character.name}/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -109,12 +83,13 @@ export default function CharacterEditorDetail({
|
||||
{character.name} {character.lastName}
|
||||
</h3>
|
||||
|
||||
{renderField(t('characterDetail.role'), getCategoryLabel(character.category))}
|
||||
{renderField(t('characterDetail.title'), character.title)}
|
||||
{renderField(t('characterDetail.gender'), character.gender)}
|
||||
{renderField(t('characterDetail.age'), character.age)}
|
||||
{renderField(t('characterDetail.biography'), character.biography)}
|
||||
{renderField(t('characterDetail.roleFull'), character.role)}
|
||||
<DetailField variant="compact" label={t('characterDetail.role')}
|
||||
value={getCategoryLabel(character.category)}/>
|
||||
<DetailField variant="compact" label={t('characterDetail.title')} value={character.title}/>
|
||||
<DetailField variant="compact" label={t('characterDetail.gender')} value={character.gender}/>
|
||||
<DetailField variant="compact" label={t('characterDetail.age')} value={character.age}/>
|
||||
<DetailField variant="compact" label={t('characterDetail.biography')} value={character.biography}/>
|
||||
<DetailField variant="compact" label={t('characterDetail.roleFull')} value={character.role}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
'use client';
|
||||
import React, {useContext, useEffect, useMemo, useState} from 'react';
|
||||
import React, {Dispatch, SetStateAction, useContext, useEffect, useState} from 'react';
|
||||
import {
|
||||
advancedCharacterElements,
|
||||
Attribute,
|
||||
basicCharacterElements,
|
||||
CharacterAttribute,
|
||||
characterCategories,
|
||||
CharacterAttributeSection,
|
||||
CharacterElement,
|
||||
CharacterProps,
|
||||
isCharacterCategory,
|
||||
isCharacterStatus,
|
||||
} from '@/lib/types/character';
|
||||
import {
|
||||
advancedCharacterElements,
|
||||
basicCharacterElements,
|
||||
characterCategories,
|
||||
characterStatus
|
||||
} from '@/lib/models/Character';
|
||||
import {SeriesCharacterProps} from '@/lib/models/Series';
|
||||
} from '@/lib/constants/character';
|
||||
import {SeriesCharacterProps} from '@/lib/types/series';
|
||||
import InputField from '@/components/form/InputField';
|
||||
import TextInput from '@/components/form/TextInput';
|
||||
import TexteAreaInput from '@/components/form/TexteAreaInput';
|
||||
import TextAreaInput from '@/components/form/TextAreaInput';
|
||||
import NumberInput from '@/components/form/NumberInput';
|
||||
import SelectBox from '@/components/form/SelectBox';
|
||||
import CharacterSectionElement from '@/components/book/settings/characters/CharacterSectionElement';
|
||||
import SyncFieldWrapper from '@/components/form/SyncFieldWrapper';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {faSliders} from '@fortawesome/free-solid-svg-icons';
|
||||
import {useTranslations} from 'next-intl';
|
||||
import {SessionContext} from '@/context/SessionContext';
|
||||
import {AlertContext} from '@/context/AlertContext';
|
||||
import {LangContext} from '@/context/LangContext';
|
||||
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
|
||||
import {BookContext} from '@/context/BookContext';
|
||||
import System from '@/lib/models/System';
|
||||
import {Dispatch, SetStateAction} from 'react';
|
||||
import * as tauri from '@/lib/tauri';
|
||||
import {SlidersHorizontal} from 'lucide-react';
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
import {SessionContext, SessionContextProps} from '@/context/SessionContext';
|
||||
import {AlertContext, AlertContextProps} from '@/context/AlertContext';
|
||||
import {LangContext, LangContextProps} from '@/context/LangContext';
|
||||
import {apiGet} from '@/lib/api/client';
|
||||
|
||||
type AttributeResponse = { type: string; values: Attribute[] }[];
|
||||
|
||||
@@ -36,8 +36,8 @@ interface CharacterEditorEditProps {
|
||||
character: CharacterProps;
|
||||
setCharacter: Dispatch<SetStateAction<CharacterProps | null>>;
|
||||
onCharacterChange: (key: keyof CharacterProps, value: string | number | null) => void;
|
||||
onAddAttribute: (section: keyof CharacterProps, attr: Attribute) => Promise<void>;
|
||||
onRemoveAttribute: (section: keyof CharacterProps, idx: number, id: string) => Promise<void>;
|
||||
onAddAttribute: (section: CharacterAttributeSection, attr: Attribute) => Promise<void>;
|
||||
onRemoveAttribute: (section: CharacterAttributeSection, idx: number, id: string) => Promise<void>;
|
||||
seriesCharacter?: SeriesCharacterProps | null;
|
||||
onSyncComplete?: () => void;
|
||||
}
|
||||
@@ -56,26 +56,11 @@ export default function CharacterEditorEdit({
|
||||
onSyncComplete,
|
||||
}: CharacterEditorEditProps): React.JSX.Element {
|
||||
const t = useTranslations();
|
||||
const {lang} = useContext(LangContext);
|
||||
const {session} = useContext(SessionContext);
|
||||
const {errorMessage} = useContext(AlertContext);
|
||||
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
|
||||
const {book} = useContext(BookContext);
|
||||
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext);
|
||||
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
|
||||
const {errorMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
|
||||
const [showAdvanced, setShowAdvanced] = useState<boolean>(false);
|
||||
|
||||
// Traduire les données des SelectBox
|
||||
const translatedCharacterCategories = useMemo(() =>
|
||||
characterCategories.map((item) => ({
|
||||
...item,
|
||||
label: t(item.label)
|
||||
})), [t]);
|
||||
|
||||
const translatedCharacterStatus = useMemo(() =>
|
||||
characterStatus.map((item) => ({
|
||||
...item,
|
||||
label: t(item.label)
|
||||
})), [t]);
|
||||
|
||||
useEffect(function (): void {
|
||||
if (character?.id !== null) {
|
||||
getAttributes().then();
|
||||
@@ -84,17 +69,12 @@ export default function CharacterEditorEdit({
|
||||
|
||||
async function getAttributes(): Promise<void> {
|
||||
try {
|
||||
let response: AttributeResponse;
|
||||
if (isCurrentlyOffline() || book?.localBook) {
|
||||
response = await tauri.getCharacterAttributes(character?.id!) as AttributeResponse;
|
||||
} else {
|
||||
response = await System.authGetQueryToServer<AttributeResponse>(
|
||||
const response: AttributeResponse = await apiGet<AttributeResponse>(
|
||||
'character/attribute',
|
||||
session.accessToken,
|
||||
lang,
|
||||
{characterId: character?.id}
|
||||
);
|
||||
}
|
||||
if (response) {
|
||||
const attributes: CharacterAttribute = {};
|
||||
response.forEach(function (item: { type: string; values: Attribute[] }): void {
|
||||
@@ -135,7 +115,7 @@ export default function CharacterEditorEdit({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Informations de base */}
|
||||
<div className="border-b border-secondary/30 pb-3">
|
||||
<div className="border-b border-secondary pb-3">
|
||||
<h4 className="text-text-primary font-medium text-sm mb-3">{t('characterDetail.basicInfo')}</h4>
|
||||
<div className="space-y-3">
|
||||
<InputField
|
||||
@@ -148,7 +128,9 @@ export default function CharacterEditorEdit({
|
||||
bookElementId={character.id || ''}
|
||||
field="name"
|
||||
elementType="character"
|
||||
onDownload={function (): void { onCharacterChange('name', seriesCharacter?.name || ''); }}
|
||||
onDownload={function (): void {
|
||||
onCharacterChange('name', seriesCharacter?.name || '');
|
||||
}}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TextInput
|
||||
@@ -172,7 +154,9 @@ export default function CharacterEditorEdit({
|
||||
bookElementId={character.id || ''}
|
||||
field="lastName"
|
||||
elementType="character"
|
||||
onDownload={function (): void { onCharacterChange('lastName', seriesCharacter?.lastName || ''); }}
|
||||
onDownload={function (): void {
|
||||
onCharacterChange('lastName', seriesCharacter?.lastName || '');
|
||||
}}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TextInput
|
||||
@@ -192,11 +176,16 @@ export default function CharacterEditorEdit({
|
||||
<SelectBox
|
||||
defaultValue={character.category || 'none'}
|
||||
onChangeCallBack={function (e: React.ChangeEvent<HTMLSelectElement>): void {
|
||||
const selectedCategory: string = e.target.value;
|
||||
setCharacter(function (prev: CharacterProps | null): CharacterProps | null {
|
||||
return prev ? {...prev, category: e.target.value as CharacterProps['category']} : prev;
|
||||
return prev ? {
|
||||
...prev,
|
||||
category: isCharacterCategory(selectedCategory) ? selectedCategory : 'none'
|
||||
} : prev;
|
||||
});
|
||||
}}
|
||||
data={translatedCharacterCategories}
|
||||
data={characterCategories}
|
||||
translate
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -232,7 +221,7 @@ export default function CharacterEditorEdit({
|
||||
</div>
|
||||
|
||||
{/* Histoire */}
|
||||
<div className="border-b border-secondary/30 pb-3">
|
||||
<div className="border-b border-secondary pb-3">
|
||||
<h4 className="text-text-primary font-medium text-sm mb-3">{t('characterDetail.historySection')}</h4>
|
||||
<div className="space-y-3">
|
||||
<InputField
|
||||
@@ -245,10 +234,12 @@ export default function CharacterEditorEdit({
|
||||
bookElementId={character.id || ''}
|
||||
field="biography"
|
||||
elementType="character"
|
||||
onDownload={function (): void { onCharacterChange('biography', seriesCharacter?.biography || ''); }}
|
||||
onDownload={function (): void {
|
||||
onCharacterChange('biography', seriesCharacter?.biography || '');
|
||||
}}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TexteAreaInput
|
||||
<TextAreaInput
|
||||
value={character.biography || ''}
|
||||
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
|
||||
onCharacterChange('biography', e.target.value);
|
||||
@@ -269,10 +260,12 @@ export default function CharacterEditorEdit({
|
||||
bookElementId={character.id || ''}
|
||||
field="role"
|
||||
elementType="character"
|
||||
onDownload={function (): void { onCharacterChange('role', seriesCharacter?.role || ''); }}
|
||||
onDownload={function (): void {
|
||||
onCharacterChange('role', seriesCharacter?.role || '');
|
||||
}}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TexteAreaInput
|
||||
<TextAreaInput
|
||||
value={character.role || ''}
|
||||
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
|
||||
onCharacterChange('role', e.target.value);
|
||||
@@ -303,17 +296,20 @@ export default function CharacterEditorEdit({
|
||||
})}
|
||||
|
||||
{/* Toggle Mode Avancé */}
|
||||
<div className="flex items-center justify-between p-3 bg-secondary/30 rounded-lg border border-secondary/50">
|
||||
<div
|
||||
className="flex items-center justify-between p-3 bg-secondary rounded-lg border border-secondary">
|
||||
<div className="flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faSliders} className="text-primary w-4 h-4"/>
|
||||
<SlidersHorizontal className="text-primary w-4 h-4" strokeWidth={1.75}/>
|
||||
<span className="text-text-primary font-medium text-sm">{t('characterDetail.advancedMode')}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={function (): void { setShowAdvanced(!showAdvanced); }}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm transition-all duration-200 ${
|
||||
onClick={function (): void {
|
||||
setShowAdvanced(!showAdvanced);
|
||||
}}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm transition-colors duration-150 ${
|
||||
showAdvanced
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-secondary/50 text-text-primary hover:bg-secondary'
|
||||
? 'bg-secondary text-primary'
|
||||
: 'bg-secondary text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
{showAdvanced ? t('characterDetail.hideAdvanced') : t('characterDetail.showAdvanced')}
|
||||
@@ -323,7 +319,7 @@ export default function CharacterEditorEdit({
|
||||
{/* Sections avancées */}
|
||||
{showAdvanced && (
|
||||
<>
|
||||
<div className="border-b border-secondary/30 pb-3">
|
||||
<div className="border-b border-secondary pb-3">
|
||||
<h4 className="text-text-primary font-medium text-sm mb-3">{t('characterDetail.identitySection')}</h4>
|
||||
<div className="space-y-3">
|
||||
<InputField
|
||||
@@ -345,23 +341,28 @@ export default function CharacterEditorEdit({
|
||||
<SelectBox
|
||||
defaultValue={character.status || 'alive'}
|
||||
onChangeCallBack={function (e: React.ChangeEvent<HTMLSelectElement>): void {
|
||||
const selectedStatus: string = e.target.value;
|
||||
setCharacter(function (prev: CharacterProps | null): CharacterProps | null {
|
||||
return prev ? {...prev, status: e.target.value as CharacterProps['status']} : prev;
|
||||
return prev ? {
|
||||
...prev,
|
||||
status: isCharacterStatus(selectedStatus) ? selectedStatus : 'alive'
|
||||
} : prev;
|
||||
});
|
||||
}}
|
||||
data={translatedCharacterStatus}
|
||||
data={characterStatus}
|
||||
translate
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-secondary/30 pb-3">
|
||||
<div className="border-b border-secondary pb-3">
|
||||
<h4 className="text-text-primary font-medium text-sm mb-3">{t('characterDetail.authorSection')}</h4>
|
||||
<InputField
|
||||
fieldName={t('characterDetail.notes')}
|
||||
input={
|
||||
<TexteAreaInput
|
||||
<TextAreaInput
|
||||
value={character.notes || ''}
|
||||
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
|
||||
onCharacterChange('notes', e.target.value);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
'use client';
|
||||
import React, {useState} from 'react';
|
||||
import {CharacterProps} from '@/lib/models/Character';
|
||||
import {CharacterProps} from '@/lib/types/character';
|
||||
import InputField from '@/components/form/InputField';
|
||||
import TextInput from '@/components/form/TextInput';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {faChevronRight, faPlus, faUser} from '@fortawesome/free-solid-svg-icons';
|
||||
import {useTranslations} from 'next-intl';
|
||||
import {Plus, User} from 'lucide-react';
|
||||
import EmptyState from '@/components/ui/EmptyState';
|
||||
import EntityListItem from '@/components/ui/EntityListItem';
|
||||
import AvatarIcon from '@/components/ui/AvatarIcon';
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
|
||||
interface CharacterEditorListProps {
|
||||
characters: CharacterProps[];
|
||||
@@ -48,7 +50,7 @@ export default function CharacterEditorList({
|
||||
placeholder={t('characterList.search')}
|
||||
/>
|
||||
}
|
||||
actionIcon={faPlus}
|
||||
actionIcon={Plus}
|
||||
actionLabel={t('characterList.add')}
|
||||
addButtonCallBack={async function (): Promise<void> {
|
||||
onAddCharacter();
|
||||
@@ -58,55 +60,23 @@ export default function CharacterEditorList({
|
||||
|
||||
<div className="px-2 space-y-2">
|
||||
{filteredCharacters.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mb-3">
|
||||
<FontAwesomeIcon icon={faUser} className="text-primary w-8 h-8"/>
|
||||
</div>
|
||||
<h3 className="text-text-primary font-semibold text-base mb-1">
|
||||
{t('characterList.noCharacters')}
|
||||
</h3>
|
||||
<p className="text-muted text-sm max-w-xs">
|
||||
{t('characterList.noCharactersDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<EmptyState icon={User} title={t('characterList.noCharacters')}
|
||||
description={t('characterList.noCharactersDescription')}/>
|
||||
) : (
|
||||
filteredCharacters.map(function (char: CharacterProps): React.JSX.Element {
|
||||
return (
|
||||
<div
|
||||
<EntityListItem
|
||||
key={char.id}
|
||||
onClick={function (): void { onCharacterClick(char); }}
|
||||
className="group flex items-center p-3 bg-secondary/30 rounded-lg border-l-4 border-primary border border-secondary/50 cursor-pointer hover:bg-secondary hover:shadow-md transition-all duration-200 hover:border-primary/50"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full border-2 border-primary overflow-hidden bg-secondary shadow-sm group-hover:scale-110 transition-transform">
|
||||
{char.image ? (
|
||||
<img
|
||||
src={char.image}
|
||||
alt={char.name}
|
||||
className="w-full h-full object-cover"
|
||||
size="sm"
|
||||
onClick={function (): void {
|
||||
onCharacterClick(char);
|
||||
}}
|
||||
avatar={<AvatarIcon size="sm" image={char.image}
|
||||
initial={char.name?.charAt(0)?.toUpperCase() || '?'}
|
||||
alt={char.name}/>}
|
||||
title={char.name || t('characterList.unknown')}
|
||||
subtitle={char.title || char.role || t('characterList.noRole')}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-primary/10 text-primary font-bold text-sm">
|
||||
{char.name?.charAt(0)?.toUpperCase() || '?'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ml-3 flex-1 min-w-0">
|
||||
<div className="text-text-primary font-semibold text-sm group-hover:text-primary transition-colors truncate">
|
||||
{char.name || t('characterList.unknown')}
|
||||
</div>
|
||||
<div className="text-muted text-xs truncate">
|
||||
{char.title || char.role || t('characterList.noRole')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-6 flex justify-center">
|
||||
<FontAwesomeIcon
|
||||
icon={faChevronRight}
|
||||
className="text-muted group-hover:text-primary group-hover:translate-x-1 transition-all w-3 h-3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
'use client';
|
||||
import React, {useCallback, useContext, useMemo, useState} from 'react';
|
||||
import {useCharacters, UseCharactersConfig} from '@/hooks/settings/useCharacters';
|
||||
import {useTranslations} from 'next-intl';
|
||||
import {CharacterProps} from '@/lib/models/Character';
|
||||
import {SeriesCharacterProps} from '@/lib/models/Series';
|
||||
import {BookContext} from '@/context/BookContext';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {faSpinner, faToggleOn} from '@fortawesome/free-solid-svg-icons';
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
import {CharacterProps} from '@/lib/types/character';
|
||||
import {SeriesCharacterProps} from '@/lib/types/series';
|
||||
import {BookContext, BookContextProps} from '@/context/BookContext';
|
||||
import PulseLoader from '@/components/ui/PulseLoader';
|
||||
|
||||
import ToolDetailHeader from '@/components/book/settings/ToolDetailHeader';
|
||||
import InputField from '@/components/form/InputField';
|
||||
import ToggleSwitch from '@/components/form/ToggleSwitch';
|
||||
import SeriesImportSelector from '@/components/form/SeriesImportSelector';
|
||||
import AlertBox from '@/components/AlertBox';
|
||||
import AlertBox from '@/components/ui/AlertBox';
|
||||
|
||||
import CharacterSettingsList from './CharacterSettingsList';
|
||||
import CharacterSettingsDetail from './CharacterSettingsDetail';
|
||||
@@ -21,7 +18,7 @@ import CharacterSettingsEdit from './CharacterSettingsEdit';
|
||||
interface CharacterSettingsProps {
|
||||
entityType?: 'book' | 'series';
|
||||
entityId?: string;
|
||||
showToggle?: boolean;
|
||||
toolEnabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,10 +29,10 @@ interface CharacterSettingsProps {
|
||||
export default function CharacterSettings({
|
||||
entityType = 'book',
|
||||
entityId,
|
||||
showToggle = true,
|
||||
toolEnabled: parentToolEnabled,
|
||||
}: CharacterSettingsProps): React.JSX.Element {
|
||||
const t = useTranslations();
|
||||
const {book} = useContext(BookContext);
|
||||
const {book}: BookContextProps = useContext<BookContextProps>(BookContext);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean>(false);
|
||||
|
||||
const resolvedEntityId: string = entityId || book?.bookId || '';
|
||||
@@ -109,11 +106,7 @@ export default function CharacterSettings({
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<FontAwesomeIcon icon={faSpinner} className="w-8 h-8 text-primary animate-spin"/>
|
||||
</div>
|
||||
);
|
||||
return <PulseLoader/>;
|
||||
}
|
||||
|
||||
const isNew: boolean = selectedCharacter?.id === null;
|
||||
@@ -131,7 +124,9 @@ export default function CharacterSettings({
|
||||
onEdit={enterEditMode}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
onDelete={function (): void { setShowDeleteConfirm(true); }}
|
||||
onDelete={function (): void {
|
||||
setShowDeleteConfirm(true);
|
||||
}}
|
||||
onExport={canExport ? exportToSeries : undefined}
|
||||
showExport={canExport}
|
||||
showDelete={Boolean(selectedCharacter?.id)}
|
||||
@@ -141,27 +136,7 @@ export default function CharacterSettings({
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{viewMode === 'list' && (
|
||||
<div className="space-y-5 p-4">
|
||||
{/* Toggle tool */}
|
||||
{showToggle && !isSeriesMode && (
|
||||
<div className="bg-secondary/20 rounded-xl p-5 shadow-inner border border-secondary/30">
|
||||
<InputField
|
||||
icon={faToggleOn}
|
||||
fieldName={t('characterComponent.enableTool')}
|
||||
input={
|
||||
<ToggleSwitch
|
||||
checked={toolEnabled}
|
||||
onChange={toggleTool}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<p className="text-muted text-sm mt-2">
|
||||
{t('characterComponent.enableToolDescription')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contenu si outil activé */}
|
||||
{(toolEnabled || isSeriesMode) && (
|
||||
{((parentToolEnabled !== undefined ? parentToolEnabled : toolEnabled) || isSeriesMode) && (
|
||||
<>
|
||||
{/* Import from series */}
|
||||
{!isSeriesMode && bookSeriesId && availableSeriesCharacters.length > 0 && (
|
||||
@@ -222,7 +197,9 @@ export default function CharacterSettings({
|
||||
confirmText={t('common.delete')}
|
||||
cancelText={t('common.cancel')}
|
||||
onConfirm={handleDelete}
|
||||
onCancel={function (): void { setShowDeleteConfirm(false); }}
|
||||
onCancel={function (): void {
|
||||
setShowDeleteConfirm(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,45 +1,39 @@
|
||||
'use client';
|
||||
import React, {useContext, useEffect, useState} from 'react';
|
||||
import {Attribute, CharacterAttribute, CharacterElement, CharacterProps} from '@/lib/types/character';
|
||||
import {SelectBoxProps} from '@/components/form/SelectBox';
|
||||
import {
|
||||
advancedCharacterElements,
|
||||
Attribute,
|
||||
basicCharacterElements,
|
||||
CharacterAttribute,
|
||||
characterCategories,
|
||||
CharacterElement,
|
||||
CharacterProps,
|
||||
characterStatus
|
||||
} from '@/lib/models/Character';
|
||||
import {SeriesCharacterProps} from '@/lib/models/Series';
|
||||
import CollapsableArea from '@/components/CollapsableArea';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
} from '@/lib/constants/character';
|
||||
import {SeriesCharacterProps} from '@/lib/types/series';
|
||||
import Badge from '@/components/ui/Badge';
|
||||
import Collapse from '@/components/ui/Collapse';
|
||||
import {
|
||||
faBook,
|
||||
faCommentDots,
|
||||
faGlobe,
|
||||
faSliders,
|
||||
faStickyNote,
|
||||
faUser,
|
||||
faVenusMars,
|
||||
faCakeCandles,
|
||||
faTag,
|
||||
faCrown,
|
||||
faQuoteLeft,
|
||||
faFlag,
|
||||
faHouse,
|
||||
faSkull,
|
||||
faDna,
|
||||
faPalette,
|
||||
faNoteSticky
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import {useTranslations} from 'next-intl';
|
||||
import {SessionContext} from '@/context/SessionContext';
|
||||
import {AlertContext} from '@/context/AlertContext';
|
||||
import {LangContext} from '@/context/LangContext';
|
||||
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
|
||||
import {BookContext} from '@/context/BookContext';
|
||||
import System from '@/lib/models/System';
|
||||
import * as tauri from '@/lib/tauri';
|
||||
Book,
|
||||
Cake,
|
||||
Dna,
|
||||
Flag,
|
||||
Globe,
|
||||
Home,
|
||||
MessageCircle,
|
||||
Palette,
|
||||
Quote,
|
||||
Skull,
|
||||
SlidersHorizontal,
|
||||
StickyNote,
|
||||
Tag,
|
||||
User,
|
||||
Users
|
||||
} from 'lucide-react';
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
import {SessionContext, SessionContextProps} from '@/context/SessionContext';
|
||||
import {AlertContext, AlertContextProps} from '@/context/AlertContext';
|
||||
import {dynamicBg} from '@/lib/utils/dynamicStyles';
|
||||
import {LangContext, LangContextProps} from '@/context/LangContext';
|
||||
import {apiGet} from '@/lib/api/client';
|
||||
|
||||
type AttributeResponse = { type: string; values: Attribute[] }[];
|
||||
|
||||
@@ -55,11 +49,9 @@ export default function CharacterSettingsDetail({
|
||||
onLoadAttributes,
|
||||
}: CharacterSettingsDetailProps): React.JSX.Element {
|
||||
const t = useTranslations();
|
||||
const {lang} = useContext(LangContext);
|
||||
const {session} = useContext(SessionContext);
|
||||
const {errorMessage} = useContext(AlertContext);
|
||||
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
|
||||
const {book} = useContext(BookContext);
|
||||
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext);
|
||||
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
|
||||
const {errorMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
|
||||
const [showAdvanced, setShowAdvanced] = useState<boolean>(false);
|
||||
|
||||
useEffect(function (): void {
|
||||
@@ -70,17 +62,12 @@ export default function CharacterSettingsDetail({
|
||||
|
||||
async function getAttributes(): Promise<void> {
|
||||
try {
|
||||
let response: AttributeResponse;
|
||||
if (isCurrentlyOffline() || book?.localBook) {
|
||||
response = await tauri.getCharacterAttributes(character?.id!) as AttributeResponse;
|
||||
} else {
|
||||
response = await System.authGetQueryToServer<AttributeResponse>(
|
||||
const response: AttributeResponse = await apiGet<AttributeResponse>(
|
||||
'character/attribute',
|
||||
session.accessToken,
|
||||
lang,
|
||||
{characterId: character?.id}
|
||||
);
|
||||
}
|
||||
if (response && onLoadAttributes) {
|
||||
const attributes: CharacterAttribute = {};
|
||||
response.forEach(function (item: { type: string; values: Attribute[] }): void {
|
||||
@@ -96,47 +83,50 @@ export default function CharacterSettingsDetail({
|
||||
}
|
||||
|
||||
function getCategoryLabel(): string {
|
||||
const cat = characterCategories.find(c => c.value === character.category);
|
||||
const cat: SelectBoxProps | undefined = characterCategories.find((c: SelectBoxProps): boolean => c.value === character.category);
|
||||
return cat ? t(cat.label) : character.category || '—';
|
||||
}
|
||||
|
||||
function getStatusLabel(): string {
|
||||
const stat = characterStatus.find(s => s.value === character.status);
|
||||
const stat: SelectBoxProps | undefined = characterStatus.find((s: SelectBoxProps): boolean => s.value === character.status);
|
||||
return stat ? t(stat.label) : character.status || '—';
|
||||
}
|
||||
|
||||
function renderAttributeSection(element: CharacterElement): React.JSX.Element | null {
|
||||
const attributes: Attribute[] = character[element.section] as Attribute[] || [];
|
||||
const attributes: Attribute[] = character[element.section] || [];
|
||||
if (attributes.length === 0) return null;
|
||||
|
||||
return (
|
||||
<CollapsableArea key={element.section} title={element.title} icon={element.icon}>
|
||||
<div className="flex flex-wrap gap-2 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<Collapse variant="card" key={element.section} title={element.title} icon={element.icon}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{attributes.map(function (attr: Attribute, index: number): React.JSX.Element {
|
||||
return (
|
||||
<span key={index} className="px-3 py-1 bg-primary/20 text-primary rounded-full text-sm border border-primary/30">
|
||||
<span key={index}
|
||||
className="px-3 py-1 bg-primary/20 text-primary rounded-full text-sm border border-primary/30">
|
||||
{attr.name}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CollapsableArea>
|
||||
</Collapse>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 px-2 pb-4">
|
||||
{/* Hero Section - Image + Infos principales */}
|
||||
<div className="flex gap-6 p-6 bg-gradient-to-r from-secondary/30 to-transparent rounded-2xl border border-secondary/30">
|
||||
<div
|
||||
className="flex gap-6 p-6 bg-gradient-to-r from-secondary to-transparent rounded-2xl border border-secondary">
|
||||
{/* Image */}
|
||||
<div className="shrink-0">
|
||||
{character.image ? (
|
||||
<div className="w-32 h-32 rounded-2xl border-4 border-primary/50 overflow-hidden shadow-lg">
|
||||
<div className="w-32 h-32 rounded-2xl border-4 border-primary overflow-hidden">
|
||||
<img src={character.image} alt={character.name} className="w-full h-full object-cover"/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-32 h-32 rounded-2xl border-4 border-secondary/50 bg-secondary/30 flex items-center justify-center">
|
||||
<FontAwesomeIcon icon={faUser} className="w-12 h-12 text-text-secondary/50"/>
|
||||
<div
|
||||
className="w-32 h-32 rounded-2xl border-4 border-secondary bg-secondary flex items-center justify-center">
|
||||
<User className="w-12 h-12 text-text-secondary" strokeWidth={1.75}/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -155,65 +145,60 @@ export default function CharacterSettingsDetail({
|
||||
|
||||
{/* Badges */}
|
||||
<div className="flex flex-wrap gap-2 mt-4">
|
||||
<span className="inline-flex items-center gap-2 px-3 py-1 bg-primary/20 text-primary rounded-lg text-sm border border-primary/30">
|
||||
<FontAwesomeIcon icon={faTag} className="w-3 h-3"/>
|
||||
{getCategoryLabel()}
|
||||
</span>
|
||||
<Badge variant="primary" icon={Tag} shape="rounded">{getCategoryLabel()}</Badge>
|
||||
{character.gender && (
|
||||
<span className="inline-flex items-center gap-2 px-3 py-1 bg-secondary/50 text-text-primary rounded-lg text-sm border border-secondary/50">
|
||||
<FontAwesomeIcon icon={faVenusMars} className="w-3 h-3"/>
|
||||
{character.gender}
|
||||
</span>
|
||||
<Badge variant="muted" icon={Users} shape="rounded">{character.gender}</Badge>
|
||||
)}
|
||||
{character.age && (
|
||||
<span className="inline-flex items-center gap-2 px-3 py-1 bg-secondary/50 text-text-primary rounded-lg text-sm border border-secondary/50">
|
||||
<FontAwesomeIcon icon={faCakeCandles} className="w-3 h-3"/>
|
||||
{character.age} {t('characterDetail.yearsOld')}
|
||||
</span>
|
||||
<Badge variant="muted" icon={Cake}
|
||||
shape="rounded">{character.age} {t('characterDetail.yearsOld')}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Histoire & Biographie */}
|
||||
<CollapsableArea title={t('characterDetail.historySection')} icon={faBook}>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<Collapse variant="card" title={t('characterDetail.historySection')} icon={Book}>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="lg:col-span-2">
|
||||
<h4 className="text-text-secondary text-xs uppercase tracking-wide mb-2">{t('characterDetail.biography')}</h4>
|
||||
<p className={`${character.biography ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
|
||||
<p className={`${character.biography ? 'text-text-primary' : 'text-text-dimmed italic'}`}>
|
||||
{character.biography || '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-text-secondary text-xs uppercase tracking-wide mb-2">{t('characterDetail.history')}</h4>
|
||||
<p className={`${character.history ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
|
||||
<p className={`${character.history ? 'text-text-primary' : 'text-text-dimmed italic'}`}>
|
||||
{character.history || '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-text-secondary text-xs uppercase tracking-wide mb-2">{t('characterDetail.roleFull')}</h4>
|
||||
<p className={`${character.role ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
|
||||
<p className={`${character.role ? 'text-text-primary' : 'text-text-dimmed italic'}`}>
|
||||
{character.role || '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsableArea>
|
||||
</Collapse>
|
||||
|
||||
{/* Attributs de base */}
|
||||
{basicCharacterElements.map(renderAttributeSection)}
|
||||
|
||||
{/* Toggle Mode Avancé */}
|
||||
<div className="flex items-center justify-between p-4 bg-secondary/30 rounded-xl border border-secondary/50">
|
||||
<div
|
||||
className="flex items-center justify-between p-4 bg-secondary rounded-xl border border-secondary">
|
||||
<div className="flex items-center gap-3">
|
||||
<FontAwesomeIcon icon={faSliders} className="text-primary w-5 h-5"/>
|
||||
<SlidersHorizontal className="text-primary w-5 h-5" strokeWidth={1.75}/>
|
||||
<span className="text-text-primary font-medium">{t('characterDetail.advancedMode')}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={function (): void { setShowAdvanced(!showAdvanced); }}
|
||||
onClick={function (): void {
|
||||
setShowAdvanced(!showAdvanced);
|
||||
}}
|
||||
className={`px-4 py-2 rounded-lg transition-all duration-200 ${
|
||||
showAdvanced
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-secondary/50 text-text-primary hover:bg-secondary'
|
||||
? 'bg-primary text-text-primary'
|
||||
: 'bg-secondary text-text-primary hover:bg-gray-dark'
|
||||
}`}
|
||||
>
|
||||
{showAdvanced ? t('characterDetail.hideAdvanced') : t('characterDetail.showAdvanced')}
|
||||
@@ -224,97 +209,102 @@ export default function CharacterSettingsDetail({
|
||||
{showAdvanced && (
|
||||
<>
|
||||
{/* Identité étendue */}
|
||||
<CollapsableArea title={t('characterDetail.identitySection')} icon={faGlobe}>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<div className="p-3 bg-dark-background/30 rounded-lg">
|
||||
<Collapse variant="card" title={t('characterDetail.identitySection')} icon={Globe}>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="p-3 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FontAwesomeIcon icon={faDna} className="w-3 h-3 text-primary"/>
|
||||
<span className="text-text-secondary text-xs uppercase">{t('characterDetail.species')}</span>
|
||||
<Dna className="w-3 h-3 text-primary" strokeWidth={1.75}/>
|
||||
<span
|
||||
className="text-text-secondary text-xs uppercase">{t('characterDetail.species')}</span>
|
||||
</div>
|
||||
<p className={character.species ? 'text-text-primary' : 'text-text-secondary/50 italic'}>
|
||||
<p className={character.species ? 'text-text-primary' : 'text-text-dimmed italic'}>
|
||||
{character.species || '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-dark-background/30 rounded-lg">
|
||||
<div className="p-3 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FontAwesomeIcon icon={faFlag} className="w-3 h-3 text-primary"/>
|
||||
<span className="text-text-secondary text-xs uppercase">{t('characterDetail.nationality')}</span>
|
||||
<Flag className="w-3 h-3 text-primary" strokeWidth={1.75}/>
|
||||
<span
|
||||
className="text-text-secondary text-xs uppercase">{t('characterDetail.nationality')}</span>
|
||||
</div>
|
||||
<p className={character.nationality ? 'text-text-primary' : 'text-text-secondary/50 italic'}>
|
||||
<p className={character.nationality ? 'text-text-primary' : 'text-text-dimmed italic'}>
|
||||
{character.nationality || '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-dark-background/30 rounded-lg">
|
||||
<div className="p-3 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FontAwesomeIcon icon={faSkull} className="w-3 h-3 text-primary"/>
|
||||
<span className="text-text-secondary text-xs uppercase">{t('characterDetail.status')}</span>
|
||||
<Skull className="w-3 h-3 text-primary" strokeWidth={1.75}/>
|
||||
<span
|
||||
className="text-text-secondary text-xs uppercase">{t('characterDetail.status')}</span>
|
||||
</div>
|
||||
<p className={character.status ? 'text-text-primary' : 'text-text-secondary/50 italic'}>
|
||||
<p className={character.status ? 'text-text-primary' : 'text-text-dimmed italic'}>
|
||||
{getStatusLabel()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-dark-background/30 rounded-lg">
|
||||
<div className="p-3 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FontAwesomeIcon icon={faHouse} className="w-3 h-3 text-primary"/>
|
||||
<span className="text-text-secondary text-xs uppercase">{t('characterDetail.residence')}</span>
|
||||
<Home className="w-3 h-3 text-primary" strokeWidth={1.75}/>
|
||||
<span
|
||||
className="text-text-secondary text-xs uppercase">{t('characterDetail.residence')}</span>
|
||||
</div>
|
||||
<p className={character.residence ? 'text-text-primary' : 'text-text-secondary/50 italic'}>
|
||||
<p className={character.residence ? 'text-text-primary' : 'text-text-dimmed italic'}>
|
||||
{character.residence || '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsableArea>
|
||||
</Collapse>
|
||||
|
||||
{/* Voix du personnage */}
|
||||
<CollapsableArea title={t('characterDetail.voiceSection')} icon={faCommentDots}>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<div className="p-4 bg-dark-background/30 rounded-lg">
|
||||
<Collapse variant="card" title={t('characterDetail.voiceSection')} icon={MessageCircle}>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="p-4 rounded-lg">
|
||||
<h4 className="text-text-secondary text-xs uppercase tracking-wide mb-2">{t('characterDetail.speechPattern')}</h4>
|
||||
<p className={`${character.speechPattern ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
|
||||
<p className={`${character.speechPattern ? 'text-text-primary' : 'text-text-dimmed italic'}`}>
|
||||
{character.speechPattern || '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 bg-dark-background/30 rounded-lg relative">
|
||||
<FontAwesomeIcon icon={faQuoteLeft} className="absolute top-2 left-2 w-6 h-6 text-primary/20"/>
|
||||
<div className="p-4 rounded-lg relative">
|
||||
<Quote className="absolute top-2 left-2 w-6 h-6 text-primary/20" strokeWidth={1.75}/>
|
||||
<h4 className="text-text-secondary text-xs uppercase tracking-wide mb-2">{t('characterDetail.catchphrase')}</h4>
|
||||
<p className={`italic ${character.catchphrase ? 'text-text-primary' : 'text-text-secondary/50'}`}>
|
||||
<p className={`italic ${character.catchphrase ? 'text-text-primary' : 'text-text-dimmed'}`}>
|
||||
{character.catchphrase ? `« ${character.catchphrase} »` : '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsableArea>
|
||||
</Collapse>
|
||||
|
||||
{/* Notes de l'auteur */}
|
||||
<CollapsableArea title={t('characterDetail.authorSection')} icon={faStickyNote}>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<div className="lg:col-span-2 p-4 bg-dark-background/30 rounded-lg">
|
||||
<Collapse variant="card" title={t('characterDetail.authorSection')} icon={StickyNote}>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<div className="lg:col-span-2 p-4 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FontAwesomeIcon icon={faNoteSticky} className="w-3 h-3 text-primary"/>
|
||||
<span className="text-text-secondary text-xs uppercase">{t('characterDetail.notes')}</span>
|
||||
<StickyNote className="w-3 h-3 text-primary" strokeWidth={1.75}/>
|
||||
<span
|
||||
className="text-text-secondary text-xs uppercase">{t('characterDetail.notes')}</span>
|
||||
</div>
|
||||
<p className={`${character.notes ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
|
||||
<p className={`${character.notes ? 'text-text-primary' : 'text-text-dimmed italic'}`}>
|
||||
{character.notes || '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 bg-dark-background/30 rounded-lg">
|
||||
<div className="p-4 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FontAwesomeIcon icon={faPalette} className="w-3 h-3 text-primary"/>
|
||||
<span className="text-text-secondary text-xs uppercase">{t('characterDetail.colorLabel')}</span>
|
||||
<Palette className="w-3 h-3 text-primary" strokeWidth={1.75}/>
|
||||
<span
|
||||
className="text-text-secondary text-xs uppercase">{t('characterDetail.colorLabel')}</span>
|
||||
</div>
|
||||
{character.color ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-8 h-8 rounded-lg border-2 border-white/20"
|
||||
style={{backgroundColor: character.color}}
|
||||
className={`w-8 h-8 rounded-lg border-2 border-secondary ${character.color ? dynamicBg(character.color) : ''}`}
|
||||
/>
|
||||
<span className="text-text-primary font-mono">{character.color}</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-text-secondary/50 italic">—</p>
|
||||
<p className="text-text-dimmed italic">—</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsableArea>
|
||||
</Collapse>
|
||||
|
||||
{/* Attributs avancés */}
|
||||
{advancedCharacterElements.map(renderAttributeSection)}
|
||||
|
||||
@@ -1,43 +1,35 @@
|
||||
'use client';
|
||||
import React, {useContext, useEffect, useMemo, useState} from 'react';
|
||||
import React, {Dispatch, SetStateAction, useContext, useEffect, useState} from 'react';
|
||||
import {
|
||||
advancedCharacterElements,
|
||||
Attribute,
|
||||
basicCharacterElements,
|
||||
CharacterAttribute,
|
||||
characterCategories,
|
||||
CharacterAttributeSection,
|
||||
CharacterElement,
|
||||
CharacterProps,
|
||||
isCharacterCategory,
|
||||
isCharacterStatus,
|
||||
} from '@/lib/types/character';
|
||||
import {
|
||||
advancedCharacterElements,
|
||||
basicCharacterElements,
|
||||
characterCategories,
|
||||
characterStatus
|
||||
} from '@/lib/models/Character';
|
||||
import {SeriesCharacterProps} from '@/lib/models/Series';
|
||||
import CollapsableArea from '@/components/CollapsableArea';
|
||||
} from '@/lib/constants/character';
|
||||
import {SeriesCharacterProps} from '@/lib/types/series';
|
||||
import Collapse from '@/components/ui/Collapse';
|
||||
import InputField from '@/components/form/InputField';
|
||||
import TextInput from '@/components/form/TextInput';
|
||||
import TexteAreaInput from '@/components/form/TexteAreaInput';
|
||||
import TextAreaInput from '@/components/form/TextAreaInput';
|
||||
import NumberInput from '@/components/form/NumberInput';
|
||||
import SelectBox from '@/components/form/SelectBox';
|
||||
import CharacterSectionElement from '@/components/book/settings/characters/CharacterSectionElement';
|
||||
import SyncFieldWrapper from '@/components/form/SyncFieldWrapper';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faBook,
|
||||
faCommentDots,
|
||||
faGlobe,
|
||||
faScroll,
|
||||
faSliders,
|
||||
faStickyNote,
|
||||
faUser
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import {useTranslations} from 'next-intl';
|
||||
import {SessionContext} from '@/context/SessionContext';
|
||||
import {AlertContext} from '@/context/AlertContext';
|
||||
import {LangContext} from '@/context/LangContext';
|
||||
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
|
||||
import {BookContext} from '@/context/BookContext';
|
||||
import System from '@/lib/models/System';
|
||||
import {Dispatch, SetStateAction} from 'react';
|
||||
import * as tauri from '@/lib/tauri';
|
||||
import {Book, Globe, MessageCircle, ScrollText, SlidersHorizontal, StickyNote, User} from 'lucide-react';
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
import {SessionContext, SessionContextProps} from '@/context/SessionContext';
|
||||
import {AlertContext, AlertContextProps} from '@/context/AlertContext';
|
||||
import {LangContext, LangContextProps} from '@/context/LangContext';
|
||||
import {apiGet} from '@/lib/api/client';
|
||||
|
||||
type AttributeResponse = { type: string; values: Attribute[] }[];
|
||||
|
||||
@@ -45,8 +37,8 @@ interface CharacterSettingsEditProps {
|
||||
character: CharacterProps;
|
||||
setCharacter: Dispatch<SetStateAction<CharacterProps | null>>;
|
||||
onCharacterChange: (key: keyof CharacterProps, value: string | number | null) => void;
|
||||
onAddAttribute: (section: keyof CharacterProps, attr: Attribute) => Promise<void>;
|
||||
onRemoveAttribute: (section: keyof CharacterProps, idx: number, id: string) => Promise<void>;
|
||||
onAddAttribute: (section: CharacterAttributeSection, attr: Attribute) => Promise<void>;
|
||||
onRemoveAttribute: (section: CharacterAttributeSection, idx: number, id: string) => Promise<void>;
|
||||
seriesCharacter?: SeriesCharacterProps | null;
|
||||
onSyncComplete?: () => void;
|
||||
}
|
||||
@@ -66,26 +58,11 @@ export default function CharacterSettingsEdit({
|
||||
onSyncComplete,
|
||||
}: CharacterSettingsEditProps): React.JSX.Element {
|
||||
const t = useTranslations();
|
||||
const {lang} = useContext(LangContext);
|
||||
const {session} = useContext(SessionContext);
|
||||
const {errorMessage} = useContext(AlertContext);
|
||||
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
|
||||
const {book} = useContext(BookContext);
|
||||
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext);
|
||||
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
|
||||
const {errorMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
|
||||
const [showAdvanced, setShowAdvanced] = useState<boolean>(false);
|
||||
|
||||
// Traduire les données des SelectBox
|
||||
const translatedCharacterCategories = useMemo(() =>
|
||||
characterCategories.map((item) => ({
|
||||
...item,
|
||||
label: t(item.label)
|
||||
})), [t]);
|
||||
|
||||
const translatedCharacterStatus = useMemo(() =>
|
||||
characterStatus.map((item) => ({
|
||||
...item,
|
||||
label: t(item.label)
|
||||
})), [t]);
|
||||
|
||||
useEffect(function (): void {
|
||||
if (character?.id !== null) {
|
||||
getAttributes().then();
|
||||
@@ -94,17 +71,12 @@ export default function CharacterSettingsEdit({
|
||||
|
||||
async function getAttributes(): Promise<void> {
|
||||
try {
|
||||
let response: AttributeResponse;
|
||||
if (isCurrentlyOffline() || book?.localBook) {
|
||||
response = await tauri.getCharacterAttributes(character?.id!) as AttributeResponse;
|
||||
} else {
|
||||
response = await System.authGetQueryToServer<AttributeResponse>(
|
||||
const response: AttributeResponse = await apiGet<AttributeResponse>(
|
||||
'character/attribute',
|
||||
session.accessToken,
|
||||
lang,
|
||||
{characterId: character?.id}
|
||||
);
|
||||
}
|
||||
if (response) {
|
||||
const attributes: CharacterAttribute = {};
|
||||
response.forEach(function (item: { type: string; values: Attribute[] }): void {
|
||||
@@ -145,8 +117,7 @@ export default function CharacterSettingsEdit({
|
||||
return (
|
||||
<div className="space-y-4 px-2 pb-4">
|
||||
{/* Informations de base */}
|
||||
<CollapsableArea title={t('characterDetail.basicInfo')} icon={faUser}>
|
||||
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<Collapse variant="card" title={t('characterDetail.basicInfo')} icon={User}>
|
||||
<InputField
|
||||
fieldName={t('characterDetail.name')}
|
||||
input={
|
||||
@@ -157,7 +128,9 @@ export default function CharacterSettingsEdit({
|
||||
bookElementId={character.id || ''}
|
||||
field="name"
|
||||
elementType="character"
|
||||
onDownload={function (): void { onCharacterChange('name', seriesCharacter?.name || ''); }}
|
||||
onDownload={function (): void {
|
||||
onCharacterChange('name', seriesCharacter?.name || '');
|
||||
}}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TextInput
|
||||
@@ -181,7 +154,9 @@ export default function CharacterSettingsEdit({
|
||||
bookElementId={character.id || ''}
|
||||
field="lastName"
|
||||
elementType="character"
|
||||
onDownload={function (): void { onCharacterChange('lastName', seriesCharacter?.lastName || ''); }}
|
||||
onDownload={function (): void {
|
||||
onCharacterChange('lastName', seriesCharacter?.lastName || '');
|
||||
}}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TextInput
|
||||
@@ -205,7 +180,9 @@ export default function CharacterSettingsEdit({
|
||||
bookElementId={character.id || ''}
|
||||
field="nickname"
|
||||
elementType="character"
|
||||
onDownload={function (): void { onCharacterChange('nickname', seriesCharacter?.nickname || ''); }}
|
||||
onDownload={function (): void {
|
||||
onCharacterChange('nickname', seriesCharacter?.nickname || '');
|
||||
}}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TextInput
|
||||
@@ -230,8 +207,12 @@ export default function CharacterSettingsEdit({
|
||||
field="category"
|
||||
elementType="character"
|
||||
onDownload={function (): void {
|
||||
const categoryValue: string = seriesCharacter?.category || 'none';
|
||||
setCharacter(function (prev: CharacterProps | null): CharacterProps | null {
|
||||
return prev ? {...prev, category: (seriesCharacter?.category || 'none') as CharacterProps['category']} : prev;
|
||||
return prev ? {
|
||||
...prev,
|
||||
category: isCharacterCategory(categoryValue) ? categoryValue : 'none'
|
||||
} : prev;
|
||||
});
|
||||
}}
|
||||
onSyncComplete={onSyncComplete}
|
||||
@@ -239,11 +220,16 @@ export default function CharacterSettingsEdit({
|
||||
<SelectBox
|
||||
defaultValue={character.category || 'none'}
|
||||
onChangeCallBack={function (e: React.ChangeEvent<HTMLSelectElement>): void {
|
||||
const selectedCategory: string = e.target.value;
|
||||
setCharacter(function (prev: CharacterProps | null): CharacterProps | null {
|
||||
return prev ? {...prev, category: e.target.value as CharacterProps['category']} : prev;
|
||||
return prev ? {
|
||||
...prev,
|
||||
category: isCharacterCategory(selectedCategory) ? selectedCategory : 'none'
|
||||
} : prev;
|
||||
});
|
||||
}}
|
||||
data={translatedCharacterCategories}
|
||||
data={characterCategories}
|
||||
translate
|
||||
/>
|
||||
</SyncFieldWrapper>
|
||||
}
|
||||
@@ -259,7 +245,9 @@ export default function CharacterSettingsEdit({
|
||||
bookElementId={character.id || ''}
|
||||
field="title"
|
||||
elementType="character"
|
||||
onDownload={function (): void { onCharacterChange('title', seriesCharacter?.title || ''); }}
|
||||
onDownload={function (): void {
|
||||
onCharacterChange('title', seriesCharacter?.title || '');
|
||||
}}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TextInput
|
||||
@@ -283,7 +271,9 @@ export default function CharacterSettingsEdit({
|
||||
bookElementId={character.id || ''}
|
||||
field="gender"
|
||||
elementType="character"
|
||||
onDownload={function (): void { onCharacterChange('gender', seriesCharacter?.gender || ''); }}
|
||||
onDownload={function (): void {
|
||||
onCharacterChange('gender', seriesCharacter?.gender || '');
|
||||
}}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TextInput
|
||||
@@ -326,15 +316,13 @@ export default function CharacterSettingsEdit({
|
||||
</SyncFieldWrapper>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CollapsableArea>
|
||||
</Collapse>
|
||||
|
||||
{/* Histoire */}
|
||||
<CollapsableArea title={t('characterDetail.historySection')} icon={faBook}>
|
||||
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<Collapse variant="card" title={t('characterDetail.historySection')} icon={Book}>
|
||||
<InputField
|
||||
fieldName={t('characterDetail.biography')}
|
||||
icon={faBook}
|
||||
icon={Book}
|
||||
input={
|
||||
<SyncFieldWrapper
|
||||
seriesElementId={character.seriesCharacterId}
|
||||
@@ -343,10 +331,12 @@ export default function CharacterSettingsEdit({
|
||||
bookElementId={character.id || ''}
|
||||
field="biography"
|
||||
elementType="character"
|
||||
onDownload={function (): void { onCharacterChange('biography', seriesCharacter?.biography || ''); }}
|
||||
onDownload={function (): void {
|
||||
onCharacterChange('biography', seriesCharacter?.biography || '');
|
||||
}}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TexteAreaInput
|
||||
<TextAreaInput
|
||||
value={character.biography || ''}
|
||||
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
|
||||
onCharacterChange('biography', e.target.value);
|
||||
@@ -359,7 +349,7 @@ export default function CharacterSettingsEdit({
|
||||
|
||||
<InputField
|
||||
fieldName={t('characterDetail.history')}
|
||||
icon={faScroll}
|
||||
icon={ScrollText}
|
||||
input={
|
||||
<SyncFieldWrapper
|
||||
seriesElementId={character.seriesCharacterId}
|
||||
@@ -368,10 +358,12 @@ export default function CharacterSettingsEdit({
|
||||
bookElementId={character.id || ''}
|
||||
field="history"
|
||||
elementType="character"
|
||||
onDownload={function (): void { onCharacterChange('history', seriesCharacter?.history || ''); }}
|
||||
onDownload={function (): void {
|
||||
onCharacterChange('history', seriesCharacter?.history || '');
|
||||
}}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TexteAreaInput
|
||||
<TextAreaInput
|
||||
value={character.history || ''}
|
||||
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
|
||||
onCharacterChange('history', e.target.value);
|
||||
@@ -384,7 +376,7 @@ export default function CharacterSettingsEdit({
|
||||
|
||||
<InputField
|
||||
fieldName={t('characterDetail.roleFull')}
|
||||
icon={faScroll}
|
||||
icon={ScrollText}
|
||||
input={
|
||||
<SyncFieldWrapper
|
||||
seriesElementId={character.seriesCharacterId}
|
||||
@@ -393,10 +385,12 @@ export default function CharacterSettingsEdit({
|
||||
bookElementId={character.id || ''}
|
||||
field="role"
|
||||
elementType="character"
|
||||
onDownload={function (): void { onCharacterChange('role', seriesCharacter?.role || ''); }}
|
||||
onDownload={function (): void {
|
||||
onCharacterChange('role', seriesCharacter?.role || '');
|
||||
}}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TexteAreaInput
|
||||
<TextAreaInput
|
||||
value={character.role || ''}
|
||||
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
|
||||
onCharacterChange('role', e.target.value);
|
||||
@@ -406,8 +400,7 @@ export default function CharacterSettingsEdit({
|
||||
</SyncFieldWrapper>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CollapsableArea>
|
||||
</Collapse>
|
||||
|
||||
{/* Attributs de base */}
|
||||
{basicCharacterElements.map(function (item: CharacterElement, index: number): React.JSX.Element {
|
||||
@@ -427,17 +420,20 @@ export default function CharacterSettingsEdit({
|
||||
})}
|
||||
|
||||
{/* Toggle Mode Avancé */}
|
||||
<div className="flex items-center justify-between p-4 bg-secondary/30 rounded-xl border border-secondary/50">
|
||||
<div
|
||||
className="flex items-center justify-between p-4 bg-secondary rounded-xl border border-secondary">
|
||||
<div className="flex items-center gap-3">
|
||||
<FontAwesomeIcon icon={faSliders} className="text-primary w-5 h-5"/>
|
||||
<SlidersHorizontal className="text-primary w-5 h-5" strokeWidth={1.75}/>
|
||||
<span className="text-text-primary font-medium">{t('characterDetail.advancedMode')}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={function (): void { setShowAdvanced(!showAdvanced); }}
|
||||
className={`px-4 py-2 rounded-lg transition-all duration-200 ${
|
||||
onClick={function (): void {
|
||||
setShowAdvanced(!showAdvanced);
|
||||
}}
|
||||
className={`px-4 py-2 rounded-lg transition-colors duration-150 ${
|
||||
showAdvanced
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-secondary/50 text-text-primary hover:bg-secondary'
|
||||
? 'bg-secondary text-primary'
|
||||
: 'bg-secondary text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
{showAdvanced ? t('characterDetail.hideAdvanced') : t('characterDetail.showAdvanced')}
|
||||
@@ -448,8 +444,7 @@ export default function CharacterSettingsEdit({
|
||||
{showAdvanced && (
|
||||
<>
|
||||
{/* Identité étendue */}
|
||||
<CollapsableArea title={t('characterDetail.identitySection')} icon={faGlobe}>
|
||||
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<Collapse variant="card" title={t('characterDetail.identitySection')} icon={Globe}>
|
||||
<InputField
|
||||
fieldName={t('characterDetail.species')}
|
||||
input={
|
||||
@@ -460,7 +455,9 @@ export default function CharacterSettingsEdit({
|
||||
bookElementId={character.id || ''}
|
||||
field="species"
|
||||
elementType="character"
|
||||
onDownload={function (): void { onCharacterChange('species', seriesCharacter?.species || ''); }}
|
||||
onDownload={function (): void {
|
||||
onCharacterChange('species', seriesCharacter?.species || '');
|
||||
}}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TextInput
|
||||
@@ -484,7 +481,9 @@ export default function CharacterSettingsEdit({
|
||||
bookElementId={character.id || ''}
|
||||
field="nationality"
|
||||
elementType="character"
|
||||
onDownload={function (): void { onCharacterChange('nationality', seriesCharacter?.nationality || ''); }}
|
||||
onDownload={function (): void {
|
||||
onCharacterChange('nationality', seriesCharacter?.nationality || '');
|
||||
}}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TextInput
|
||||
@@ -509,8 +508,12 @@ export default function CharacterSettingsEdit({
|
||||
field="status"
|
||||
elementType="character"
|
||||
onDownload={function (): void {
|
||||
const statusValue: string = seriesCharacter?.status || 'alive';
|
||||
setCharacter(function (prev: CharacterProps | null): CharacterProps | null {
|
||||
return prev ? {...prev, status: (seriesCharacter?.status || 'alive') as CharacterProps['status']} : prev;
|
||||
return prev ? {
|
||||
...prev,
|
||||
status: isCharacterStatus(statusValue) ? statusValue : 'alive'
|
||||
} : prev;
|
||||
});
|
||||
}}
|
||||
onSyncComplete={onSyncComplete}
|
||||
@@ -518,11 +521,16 @@ export default function CharacterSettingsEdit({
|
||||
<SelectBox
|
||||
defaultValue={character.status || 'alive'}
|
||||
onChangeCallBack={function (e: React.ChangeEvent<HTMLSelectElement>): void {
|
||||
const selectedStatus: string = e.target.value;
|
||||
setCharacter(function (prev: CharacterProps | null): CharacterProps | null {
|
||||
return prev ? {...prev, status: e.target.value as CharacterProps['status']} : prev;
|
||||
return prev ? {
|
||||
...prev,
|
||||
status: isCharacterStatus(selectedStatus) ? selectedStatus : 'alive'
|
||||
} : prev;
|
||||
});
|
||||
}}
|
||||
data={translatedCharacterStatus}
|
||||
data={characterStatus}
|
||||
translate
|
||||
/>
|
||||
</SyncFieldWrapper>
|
||||
}
|
||||
@@ -538,7 +546,9 @@ export default function CharacterSettingsEdit({
|
||||
bookElementId={character.id || ''}
|
||||
field="residence"
|
||||
elementType="character"
|
||||
onDownload={function (): void { onCharacterChange('residence', seriesCharacter?.residence || ''); }}
|
||||
onDownload={function (): void {
|
||||
onCharacterChange('residence', seriesCharacter?.residence || '');
|
||||
}}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TextInput
|
||||
@@ -551,12 +561,10 @@ export default function CharacterSettingsEdit({
|
||||
</SyncFieldWrapper>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CollapsableArea>
|
||||
</Collapse>
|
||||
|
||||
{/* Voix du personnage */}
|
||||
<CollapsableArea title={t('characterDetail.voiceSection')} icon={faCommentDots}>
|
||||
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<Collapse variant="card" title={t('characterDetail.voiceSection')} icon={MessageCircle}>
|
||||
<InputField
|
||||
fieldName={t('characterDetail.speechPattern')}
|
||||
input={
|
||||
@@ -567,10 +575,12 @@ export default function CharacterSettingsEdit({
|
||||
bookElementId={character.id || ''}
|
||||
field="speechPattern"
|
||||
elementType="character"
|
||||
onDownload={function (): void { onCharacterChange('speechPattern', seriesCharacter?.speechPattern || ''); }}
|
||||
onDownload={function (): void {
|
||||
onCharacterChange('speechPattern', seriesCharacter?.speechPattern || '');
|
||||
}}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TexteAreaInput
|
||||
<TextAreaInput
|
||||
value={character.speechPattern || ''}
|
||||
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
|
||||
onCharacterChange('speechPattern', e.target.value);
|
||||
@@ -591,7 +601,9 @@ export default function CharacterSettingsEdit({
|
||||
bookElementId={character.id || ''}
|
||||
field="catchphrase"
|
||||
elementType="character"
|
||||
onDownload={function (): void { onCharacterChange('catchphrase', seriesCharacter?.catchphrase || ''); }}
|
||||
onDownload={function (): void {
|
||||
onCharacterChange('catchphrase', seriesCharacter?.catchphrase || '');
|
||||
}}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TextInput
|
||||
@@ -604,12 +616,10 @@ export default function CharacterSettingsEdit({
|
||||
</SyncFieldWrapper>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CollapsableArea>
|
||||
</Collapse>
|
||||
|
||||
{/* Notes de l'auteur */}
|
||||
<CollapsableArea title={t('characterDetail.authorSection')} icon={faStickyNote}>
|
||||
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<Collapse variant="card" title={t('characterDetail.authorSection')} icon={StickyNote}>
|
||||
<InputField
|
||||
fieldName={t('characterDetail.notes')}
|
||||
input={
|
||||
@@ -620,10 +630,12 @@ export default function CharacterSettingsEdit({
|
||||
bookElementId={character.id || ''}
|
||||
field="notes"
|
||||
elementType="character"
|
||||
onDownload={function (): void { onCharacterChange('notes', seriesCharacter?.notes || ''); }}
|
||||
onDownload={function (): void {
|
||||
onCharacterChange('notes', seriesCharacter?.notes || '');
|
||||
}}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TexteAreaInput
|
||||
<TextAreaInput
|
||||
value={character.notes || ''}
|
||||
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
|
||||
onCharacterChange('notes', e.target.value);
|
||||
@@ -644,7 +656,9 @@ export default function CharacterSettingsEdit({
|
||||
bookElementId={character.id || ''}
|
||||
field="color"
|
||||
elementType="character"
|
||||
onDownload={function (): void { onCharacterChange('color', seriesCharacter?.color || ''); }}
|
||||
onDownload={function (): void {
|
||||
onCharacterChange('color', seriesCharacter?.color || '');
|
||||
}}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TextInput
|
||||
@@ -657,8 +671,7 @@ export default function CharacterSettingsEdit({
|
||||
</SyncFieldWrapper>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CollapsableArea>
|
||||
</Collapse>
|
||||
|
||||
{/* Attributs avancés */}
|
||||
{advancedCharacterElements.map(function (item: CharacterElement, index: number): React.JSX.Element {
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
'use client';
|
||||
import React, {useState} from 'react';
|
||||
import {characterCategories, CharacterProps} from '@/lib/models/Character';
|
||||
import {CharacterProps} from '@/lib/types/character';
|
||||
import {characterCategories} from '@/lib/constants/character';
|
||||
import InputField from '@/components/form/InputField';
|
||||
import TextInput from '@/components/form/TextInput';
|
||||
import CollapsableArea from '@/components/CollapsableArea';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {faChevronRight, faPlus, faUser} from '@fortawesome/free-solid-svg-icons';
|
||||
import {SelectBoxProps} from '@/shared/interface';
|
||||
import {useTranslations} from 'next-intl';
|
||||
import Collapse from '@/components/ui/Collapse';
|
||||
import {Plus, User} from 'lucide-react';
|
||||
import EntityListItem from '@/components/ui/EntityListItem';
|
||||
import AvatarIcon from '@/components/ui/AvatarIcon';
|
||||
import {SelectBoxProps} from '@/components/form/SelectBox';
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
import EmptyState from '@/components/ui/EmptyState';
|
||||
|
||||
interface CharacterSettingsListProps {
|
||||
characters: CharacterProps[];
|
||||
@@ -50,7 +53,7 @@ export default function CharacterSettingsList({
|
||||
placeholder={t('characterList.search')}
|
||||
/>
|
||||
}
|
||||
actionIcon={faPlus}
|
||||
actionIcon={Plus}
|
||||
actionLabel={t('characterList.add')}
|
||||
addButtonCallBack={async function (): Promise<void> {
|
||||
onAddCharacter();
|
||||
@@ -71,45 +74,32 @@ export default function CharacterSettingsList({
|
||||
}
|
||||
|
||||
return (
|
||||
<CollapsableArea
|
||||
<Collapse variant="card"
|
||||
key={category.value}
|
||||
title={t(category.label)}
|
||||
icon={faUser}
|
||||
icon={User}
|
||||
>
|
||||
<div className="space-y-2 p-2">
|
||||
{categoryCharacters.map(function (char: CharacterProps): React.JSX.Element {
|
||||
return (
|
||||
<div
|
||||
<EntityListItem
|
||||
key={char.id}
|
||||
variant="transparent"
|
||||
onClick={function (): void {
|
||||
onCharacterClick(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}
|
||||
avatar={
|
||||
<AvatarIcon
|
||||
size="lg"
|
||||
image={char.image}
|
||||
initial={char.name?.charAt(0)?.toUpperCase() || '?'}
|
||||
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">
|
||||
}
|
||||
title={char.name || t('characterList.unknown')}
|
||||
subtitle={char.lastName || t('characterList.noLastName')}
|
||||
extra={
|
||||
<div className="w-28">
|
||||
<div className="text-primary text-sm font-semibold truncate">
|
||||
{char.title || t('characterList.noTitle')}
|
||||
</div>
|
||||
@@ -117,33 +107,18 @@ export default function CharacterSettingsList({
|
||||
{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>
|
||||
</CollapsableArea>
|
||||
</Collapse>
|
||||
);
|
||||
})}
|
||||
|
||||
{filteredCharacters.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="w-20 h-20 rounded-full bg-primary/10 flex items-center justify-center mb-4">
|
||||
<FontAwesomeIcon icon={faUser} className="text-primary w-10 h-10"/>
|
||||
</div>
|
||||
<h3 className="text-text-primary font-semibold text-lg mb-2">
|
||||
{t('characterList.noCharacters')}
|
||||
</h3>
|
||||
<p className="text-muted text-sm max-w-xs">
|
||||
{t('characterList.noCharactersDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<EmptyState icon={User} title={t('characterList.noCharacters')}
|
||||
description={t('characterList.noCharactersDescription')}/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client'
|
||||
import {useState} from 'react';
|
||||
import {Plus, Save} from 'lucide-react';
|
||||
import Button from '@/components/ui/Button';
|
||||
|
||||
interface TimeGoal {
|
||||
desiredReleaseDate: string;
|
||||
@@ -81,7 +83,7 @@ export default function GoalsPage() {
|
||||
<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"
|
||||
className="input-base"
|
||||
value={selectedGoalIndex}
|
||||
onChange={(e) => setSelectedGoalIndex(parseInt(e.target.value))}
|
||||
>
|
||||
@@ -93,16 +95,12 @@ export default function GoalsPage() {
|
||||
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"
|
||||
className="input-base"
|
||||
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"
|
||||
>
|
||||
<Button variant="primary" icon={Plus} onClick={handleAddGoal}>
|
||||
Add Goal
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -116,15 +114,15 @@ export default function GoalsPage() {
|
||||
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"
|
||||
className="input-base"
|
||||
/>
|
||||
<label className="block text-white mb-2 mt-4" htmlFor="maxReleaseDate">Max Release Date</label>
|
||||
<label className="block text-text-primary 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"
|
||||
className="input-base"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -137,43 +135,40 @@ export default function GoalsPage() {
|
||||
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"
|
||||
className="input-base"
|
||||
/>
|
||||
<label className="block text-white mb-2 mt-4" htmlFor="maxWordsCount">Max Words Count</label>
|
||||
<label className="block text-text-primary 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"
|
||||
className="input-base"
|
||||
/>
|
||||
<label className="block text-white mb-2 mt-4" htmlFor="desiredWordsCountByChapter">Desired Words
|
||||
<label className="block text-text-primary 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"
|
||||
className="input-base"
|
||||
/>
|
||||
<label className="block text-white mb-2 mt-4" htmlFor="desiredChapterCount">Desired Chapter
|
||||
<label className="block text-text-primary 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"
|
||||
className="input-base"
|
||||
/>
|
||||
</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"
|
||||
>
|
||||
<Button variant="primary" icon={Save}>
|
||||
Update
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -1,457 +0,0 @@
|
||||
'use client'
|
||||
import * as tauri from '@/lib/tauri';
|
||||
import {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";
|
||||
import OfflineContext, {OfflineContextType} from "@/context/OfflineContext";
|
||||
import {LocalSyncQueueContext, LocalSyncQueueContextProps} from "@/context/SyncQueueContext";
|
||||
import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext";
|
||||
import {SyncedBook} from "@/lib/models/SyncedBook";
|
||||
|
||||
function GuideLineSetting(props: any, ref: any) {
|
||||
const t = useTranslations();
|
||||
const {lang} = useContext(LangContext);
|
||||
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
|
||||
const {addToQueue} = useContext<LocalSyncQueueContextProps>(LocalSyncQueueContext);
|
||||
const {localSyncedBooks} = useContext<BooksSyncContextProps>(BooksSyncContext);
|
||||
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 {
|
||||
let response: GuideLineAI;
|
||||
if (isCurrentlyOffline() || book?.localBook) {
|
||||
response = await tauri.getAIGuideLine(bookId);
|
||||
} else {
|
||||
response = 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 {
|
||||
let response: GuideLine;
|
||||
if (isCurrentlyOffline() || book?.localBook) {
|
||||
response = await tauri.getGuideLine(bookId);
|
||||
} else {
|
||||
response = 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 {
|
||||
let response: boolean;
|
||||
const guidelineData = {
|
||||
bookId: bookId,
|
||||
tone: tone,
|
||||
atmosphere: atmosphere,
|
||||
writingStyle: writingStyle,
|
||||
themes: themes,
|
||||
symbolism: symbolism,
|
||||
motifs: motifs,
|
||||
narrativeVoice: narrativeVoice,
|
||||
pacing: pacing,
|
||||
intendedAudience: intendedAudience,
|
||||
keyMessages: keyMessages,
|
||||
};
|
||||
if (isCurrentlyOffline() || book?.localBook) {
|
||||
response = await tauri.updateGuideLine(guidelineData);
|
||||
} else {
|
||||
response = await System.authPostToServer<boolean>(
|
||||
'book/guide-line',
|
||||
guidelineData,
|
||||
userToken,
|
||||
lang,
|
||||
);
|
||||
|
||||
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) {
|
||||
addToQueue('update_guideline', {data: guidelineData});
|
||||
}
|
||||
}
|
||||
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 {
|
||||
let response: boolean;
|
||||
const aiGuidelineData = {
|
||||
bookId: bookId,
|
||||
plotSummary: plotSummary,
|
||||
verbTense: verbTense,
|
||||
narrativeType: narrativeType,
|
||||
dialogueType: dialogueType,
|
||||
toneAtmosphere: toneAtmosphere,
|
||||
language: language,
|
||||
themes: themes,
|
||||
};
|
||||
if (isCurrentlyOffline() || book?.localBook) {
|
||||
response = await tauri.updateAIGuideLine(aiGuidelineData);
|
||||
} else {
|
||||
response = await System.authPostToServer<boolean>(
|
||||
'quillsense/book/guide-line',
|
||||
aiGuidelineData,
|
||||
userToken,
|
||||
lang,
|
||||
);
|
||||
|
||||
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) {
|
||||
addToQueue('update_ai_guideline', {data: aiGuidelineData});
|
||||
}
|
||||
}
|
||||
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);
|
||||
@@ -1,27 +1,19 @@
|
||||
'use client'
|
||||
import {faMapMarkerAlt, faPlus, faShare, faToggleOn, faTrash} from '@fortawesome/free-solid-svg-icons';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {MapPin, Plus, Share2, ToggleRight, Trash2} from 'lucide-react';
|
||||
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 {SessionContext, SessionContextProps} from "@/context/SessionContext";
|
||||
import {AlertContext, AlertContextProps} from "@/context/AlertContext";
|
||||
import {BookContext, BookContextProps} from "@/context/BookContext";
|
||||
import {apiDelete, apiGet, apiPatch, apiPost} from '@/lib/api/client';
|
||||
import InputField from "@/components/form/InputField";
|
||||
import TextInput from '@/components/form/TextInput';
|
||||
import TexteAreaInput from "@/components/form/TexteAreaInput";
|
||||
import {useTranslations} from "next-intl";
|
||||
import TextAreaInput from "@/components/form/TextAreaInput";
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
import {LangContext, LangContextProps} from "@/context/LangContext";
|
||||
import OfflineContext, {OfflineContextType} from "@/context/OfflineContext";
|
||||
import {LocalSyncQueueContext, LocalSyncQueueContextProps} from "@/context/SyncQueueContext";
|
||||
import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext";
|
||||
import {SyncedBook} from "@/lib/models/SyncedBook";
|
||||
import {SeriesContext, SeriesContextProps} from "@/context/SeriesContext";
|
||||
import {SeriesSyncContext, SeriesSyncContextProps} from "@/context/SeriesSyncContext";
|
||||
import {SyncedSeries} from "@/lib/models/SyncedSeries";
|
||||
import ToggleSwitch from "@/components/form/ToggleSwitch";
|
||||
import {SeriesLocationElement, SeriesLocationItem, SeriesLocationSubElement} from "@/lib/models/Series";
|
||||
import {SeriesLocationElement, SeriesLocationItem, SeriesLocationSubElement} from "@/lib/types/series";
|
||||
import SeriesImportSelector from "@/components/form/SeriesImportSelector";
|
||||
import * as tauri from '@/lib/tauri';
|
||||
import IconButton from "@/components/ui/IconButton";
|
||||
|
||||
interface SubElement {
|
||||
id: string;
|
||||
@@ -57,15 +49,10 @@ interface LocationComponentProps {
|
||||
export function LocationComponent(props: LocationComponentProps, ref: React.Ref<{ handleSave: () => Promise<void> }>) {
|
||||
const {showToggle = true, entityType = 'book', entityId} = props;
|
||||
const t = useTranslations();
|
||||
const {lang} = useContext<LangContextProps>(LangContext);
|
||||
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
|
||||
const {addToQueue} = useContext<LocalSyncQueueContextProps>(LocalSyncQueueContext);
|
||||
const {localSyncedBooks} = useContext<BooksSyncContextProps>(BooksSyncContext);
|
||||
const {session} = useContext(SessionContext);
|
||||
const {successMessage, errorMessage} = useContext(AlertContext);
|
||||
const {book, setBook} = useContext(BookContext);
|
||||
const {seriesId, localSeries} = useContext<SeriesContextProps>(SeriesContext);
|
||||
const {localSyncedSeries} = useContext<SeriesSyncContextProps>(SeriesSyncContext);
|
||||
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext);
|
||||
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
|
||||
const {successMessage, errorMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
|
||||
const {book, setBook}: BookContextProps = useContext<BookContextProps>(BookContext);
|
||||
|
||||
const currentEntityId: string = entityId || book?.bookId || '';
|
||||
const isSeriesMode: boolean = entityType === 'series';
|
||||
@@ -100,7 +87,7 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref<
|
||||
async function getSeriesLocations(): Promise<void> {
|
||||
if (!bookSeriesId) return;
|
||||
try {
|
||||
const response: SeriesLocationItem[] = await System.authGetQueryToServer<SeriesLocationItem[]>(
|
||||
const response: SeriesLocationItem[] = await apiGet<SeriesLocationItem[]>(
|
||||
'series/location/list',
|
||||
token,
|
||||
lang,
|
||||
@@ -111,7 +98,7 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref<
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
console.error('Error loading series locations:', e.message);
|
||||
errorMessage(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,31 +106,19 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref<
|
||||
async function handleToggleTool(enabled: boolean): Promise<void> {
|
||||
if (isSeriesMode) return;
|
||||
try {
|
||||
let response: boolean;
|
||||
if (isCurrentlyOffline() || book?.localBook) {
|
||||
response = await tauri.updateBookToolSetting(currentEntityId, 'locations', enabled);
|
||||
} else {
|
||||
response = await System.authPatchToServer<boolean>('book/tool-setting', {
|
||||
const response: boolean = await apiPatch<boolean>('book/tool-setting', {
|
||||
bookId: currentEntityId,
|
||||
toolName: 'locations',
|
||||
enabled: enabled
|
||||
}, token, lang);
|
||||
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) {
|
||||
addToQueue('update_book_tool_setting', {data: {
|
||||
bookId: currentEntityId,
|
||||
toolName: 'locations',
|
||||
enabled: enabled
|
||||
}});
|
||||
}
|
||||
}
|
||||
if (response && setBook && book) {
|
||||
setToolEnabled(enabled);
|
||||
setBook({
|
||||
...book, tools: {
|
||||
characters: book.tools?.characters ?? false,
|
||||
worlds: book.tools?.worlds ?? false,
|
||||
spells: book.tools?.spells ?? false,
|
||||
locations: enabled
|
||||
locations: enabled,
|
||||
spells: book.tools?.spells ?? false
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -157,17 +132,12 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref<
|
||||
async function getAllLocations(): Promise<void> {
|
||||
try {
|
||||
if (isSeriesMode) {
|
||||
let response: SeriesLocationItem[];
|
||||
if (isCurrentlyOffline() || localSeries) {
|
||||
response = await tauri.getSeriesLocationList(currentEntityId) as SeriesLocationItem[];
|
||||
} else {
|
||||
response = await System.authGetQueryToServer<SeriesLocationItem[]>(
|
||||
const response: SeriesLocationItem[] = await apiGet<SeriesLocationItem[]>(
|
||||
'series/location/list',
|
||||
token,
|
||||
lang,
|
||||
{seriesid: currentEntityId}
|
||||
);
|
||||
}
|
||||
if (response) {
|
||||
const mappedLocations: LocationProps[] = response.map((loc: SeriesLocationItem): LocationProps => ({
|
||||
id: loc.id,
|
||||
@@ -186,14 +156,12 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref<
|
||||
setSections(mappedLocations);
|
||||
}
|
||||
} else {
|
||||
let response: LocationListResponse;
|
||||
if (isCurrentlyOffline() || book?.localBook) {
|
||||
response = await tauri.getAllLocations(currentEntityId, true) as LocationListResponse;
|
||||
} else {
|
||||
response = await System.authGetQueryToServer<LocationListResponse>(`location/all`, token, lang, {
|
||||
bookid: currentEntityId,
|
||||
});
|
||||
}
|
||||
const response: LocationListResponse = await apiGet<LocationListResponse>(
|
||||
'location/all',
|
||||
token,
|
||||
lang,
|
||||
{bookid: currentEntityId}
|
||||
);
|
||||
if (response) {
|
||||
setSections(response.locations);
|
||||
setToolEnabled(response.enabled);
|
||||
@@ -202,8 +170,8 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref<
|
||||
...book, tools: {
|
||||
characters: book.tools?.characters ?? false,
|
||||
worlds: book.tools?.worlds ?? false,
|
||||
spells: book.tools?.spells ?? false,
|
||||
locations: response.enabled
|
||||
locations: response.enabled,
|
||||
spells: book.tools?.spells ?? false
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -226,47 +194,29 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref<
|
||||
try {
|
||||
let sectionId: string;
|
||||
if (isSeriesMode) {
|
||||
const addData = {
|
||||
sectionId = await apiPost<string>(
|
||||
'series/location/section/add',
|
||||
{
|
||||
seriesId: currentEntityId,
|
||||
name: newSectionName,
|
||||
};
|
||||
if (isCurrentlyOffline() || localSeries) {
|
||||
sectionId = await tauri.addSeriesLocationSection(addData);
|
||||
} else {
|
||||
sectionId = await System.authPostToServer<string>(
|
||||
'series/location/section/add',
|
||||
addData,
|
||||
},
|
||||
token,
|
||||
lang
|
||||
);
|
||||
if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) {
|
||||
addToQueue('add_series_location_section', {data: addData});
|
||||
}
|
||||
}
|
||||
if (!sectionId) {
|
||||
errorMessage(t('locationComponent.errorUnknownAddSection'));
|
||||
return;
|
||||
}
|
||||
} else if (isCurrentlyOffline() || book?.localBook) {
|
||||
sectionId = await tauri.addLocationSection(newSectionName, currentEntityId);
|
||||
} else {
|
||||
sectionId = await System.authPostToServer<string>(`location/section/add`, {
|
||||
sectionId = await apiPost<string>('location/section/add', {
|
||||
bookId: currentEntityId,
|
||||
locationName: newSectionName,
|
||||
}, token, lang);
|
||||
|
||||
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) {
|
||||
addToQueue('add_location_section', {data: {
|
||||
bookId: currentEntityId,
|
||||
sectionId,
|
||||
locationName: newSectionName,
|
||||
}});
|
||||
}
|
||||
}
|
||||
if (!sectionId) {
|
||||
errorMessage(t('locationComponent.errorUnknownAddSection'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
const newLocation: LocationProps = {
|
||||
id: sectionId,
|
||||
name: newSectionName,
|
||||
@@ -291,50 +241,30 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref<
|
||||
try {
|
||||
let elementId: string;
|
||||
if (isSeriesMode) {
|
||||
const addData = {
|
||||
elementId = await apiPost<string>(
|
||||
'series/location/element/add',
|
||||
{
|
||||
locationId: sectionId,
|
||||
name: newElementNames[sectionId],
|
||||
};
|
||||
if (isCurrentlyOffline() || localSeries) {
|
||||
elementId = await tauri.addSeriesLocationElement(addData);
|
||||
} else {
|
||||
elementId = await System.authPostToServer<string>(
|
||||
'series/location/element/add',
|
||||
addData,
|
||||
},
|
||||
token,
|
||||
lang
|
||||
);
|
||||
if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) {
|
||||
addToQueue('add_series_location_element', {data: addData});
|
||||
}
|
||||
}
|
||||
if (!elementId) {
|
||||
errorMessage(t('locationComponent.errorUnknownAddElement'));
|
||||
return;
|
||||
}
|
||||
} else if (isCurrentlyOffline() || book?.localBook) {
|
||||
elementId = await tauri.addLocationElement(sectionId, newElementNames[sectionId]);
|
||||
} else {
|
||||
elementId = await System.authPostToServer<string>(`location/element/add`, {
|
||||
elementId = await apiPost<string>('location/element/add', {
|
||||
bookId: currentEntityId,
|
||||
locationId: sectionId,
|
||||
elementName: newElementNames[sectionId],
|
||||
},
|
||||
token, lang);
|
||||
|
||||
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) {
|
||||
addToQueue('add_location_element', {data: {
|
||||
bookId: currentEntityId,
|
||||
locationId: sectionId,
|
||||
elementId,
|
||||
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,
|
||||
@@ -384,49 +314,30 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref<
|
||||
);
|
||||
try {
|
||||
let subElementId: string;
|
||||
const elementId = sections[sectionIndex].elements[elementIndex].id;
|
||||
if (isSeriesMode) {
|
||||
const addData = {
|
||||
elementId: elementId,
|
||||
name: newSubElementNames[elementIndex],
|
||||
};
|
||||
if (isCurrentlyOffline() || localSeries) {
|
||||
subElementId = await tauri.addSeriesLocationSubElement(addData);
|
||||
} else {
|
||||
subElementId = await System.authPostToServer<string>(
|
||||
subElementId = await apiPost<string>(
|
||||
'series/location/sub-element/add',
|
||||
addData,
|
||||
{
|
||||
elementId: sections[sectionIndex].elements[elementIndex].id,
|
||||
name: newSubElementNames[elementIndex],
|
||||
},
|
||||
token,
|
||||
lang
|
||||
);
|
||||
if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) {
|
||||
addToQueue('add_series_location_sub_element', {data: addData});
|
||||
}
|
||||
}
|
||||
if (!subElementId) {
|
||||
errorMessage(t('locationComponent.errorUnknownAddSubElement'));
|
||||
return;
|
||||
}
|
||||
} else if (isCurrentlyOffline() || book?.localBook) {
|
||||
subElementId = await tauri.addLocationSubElement(elementId, newSubElementNames[elementIndex]);
|
||||
} else {
|
||||
subElementId = await System.authPostToServer<string>(`location/sub-element/add`, {
|
||||
elementId: elementId,
|
||||
subElementId = await apiPost<string>('location/sub-element/add', {
|
||||
elementId: sections[sectionIndex].elements[elementIndex].id,
|
||||
subElementName: newSubElementNames[elementIndex],
|
||||
}, token, lang);
|
||||
|
||||
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) {
|
||||
addToQueue('add_location_sub_element', {data: {
|
||||
elementId: elementId,
|
||||
subElementId,
|
||||
subElementName: newSubElementNames[elementIndex],
|
||||
}});
|
||||
}
|
||||
}
|
||||
if (!subElementId) {
|
||||
errorMessage(t('locationComponent.errorUnknownAddSubElement'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
const updatedSections: LocationProps[] = [...sections];
|
||||
updatedSections[sectionIndex].elements[elementIndex].subElements.push({
|
||||
id: subElementId,
|
||||
@@ -466,39 +377,24 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref<
|
||||
elementIndex: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
let response: boolean;
|
||||
const elementId = sections.find((section: LocationProps): boolean => section.id === sectionId)
|
||||
const elementId: string | undefined = sections.find((section: LocationProps): boolean => section.id === sectionId)
|
||||
?.elements[elementIndex].id;
|
||||
const deletedAt: number = System.timeStampInSeconds();
|
||||
let success: boolean;
|
||||
if (isSeriesMode) {
|
||||
const deleteData = {elementId: elementId, deletedAt};
|
||||
if (isCurrentlyOffline() || localSeries) {
|
||||
response = await tauri.deleteSeriesLocationElement(deleteData.elementId!, deleteData.deletedAt);
|
||||
} else {
|
||||
response = await System.authDeleteToServer<boolean>('series/location/element/delete', deleteData, token, lang);
|
||||
if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) {
|
||||
addToQueue('delete_series_location_element', {data: deleteData});
|
||||
}
|
||||
}
|
||||
} else if (isCurrentlyOffline() || book?.localBook) {
|
||||
response = await tauri.deleteLocationElement(elementId!, currentEntityId, deletedAt);
|
||||
} else {
|
||||
response = await System.authDeleteToServer<boolean>(`location/element/delete`, {
|
||||
elementId: elementId, bookId: currentEntityId, deletedAt,
|
||||
success = await apiDelete<boolean>('series/location/element/delete', {
|
||||
elementId: elementId
|
||||
}, token, lang);
|
||||
} else {
|
||||
success = await apiDelete<boolean>('location/element/delete', {
|
||||
elementId: elementId,
|
||||
}, token, lang);
|
||||
|
||||
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) {
|
||||
addToQueue('delete_location_element', {data: {
|
||||
elementId: elementId, bookId: currentEntityId, deletedAt,
|
||||
}});
|
||||
}
|
||||
}
|
||||
if (!response) {
|
||||
if (!success) {
|
||||
errorMessage(t('locationComponent.errorUnknownDeleteElement'));
|
||||
return;
|
||||
}
|
||||
const updatedSections: LocationProps[] = [...sections];
|
||||
const sectionIndex: number = updatedSections.findIndex((section: LocationProps): boolean => section.id === sectionId,);
|
||||
const sectionIndex: number = updatedSections.findIndex((section: LocationProps): boolean => section.id === sectionId);
|
||||
updatedSections[sectionIndex].elements.splice(elementIndex, 1);
|
||||
setSections(updatedSections);
|
||||
} catch (e: unknown) {
|
||||
@@ -516,39 +412,25 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref<
|
||||
subElementIndex: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
let response: boolean;
|
||||
const subElementId = sections.find((section: LocationProps): boolean => section.id === sectionId)?.elements[elementIndex].subElements[subElementIndex].id;
|
||||
const deletedAt: number = System.timeStampInSeconds();
|
||||
const subElementId: string | undefined = sections.find((section: LocationProps): boolean => section.id === sectionId)
|
||||
?.elements[elementIndex].subElements[subElementIndex].id;
|
||||
let success: boolean;
|
||||
if (isSeriesMode) {
|
||||
const deleteData = {subElementId: subElementId, deletedAt};
|
||||
if (isCurrentlyOffline() || localSeries) {
|
||||
response = await tauri.deleteSeriesLocationSubElement(deleteData.subElementId!, deleteData.deletedAt);
|
||||
} else {
|
||||
response = await System.authDeleteToServer<boolean>('series/location/sub-element/delete', deleteData, token, lang);
|
||||
if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) {
|
||||
addToQueue('delete_series_location_sub_element', {data: deleteData});
|
||||
}
|
||||
}
|
||||
} else if (isCurrentlyOffline() || book?.localBook) {
|
||||
response = await tauri.deleteLocationSubElement(subElementId!, currentEntityId, deletedAt);
|
||||
} else {
|
||||
response = await System.authDeleteToServer<boolean>(`location/sub-element/delete`, {
|
||||
subElementId: subElementId, bookId: currentEntityId, deletedAt,
|
||||
success = await apiDelete<boolean>('series/location/sub-element/delete', {
|
||||
subElementId: subElementId
|
||||
}, token, lang);
|
||||
} else {
|
||||
success = await apiDelete<boolean>('location/sub-element/delete', {
|
||||
subElementId: subElementId,
|
||||
}, token, lang);
|
||||
|
||||
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) {
|
||||
addToQueue('delete_location_sub_element', {data: {
|
||||
subElementId: subElementId, bookId: currentEntityId, deletedAt,
|
||||
}});
|
||||
}
|
||||
}
|
||||
if (!response) {
|
||||
if (!success) {
|
||||
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,);
|
||||
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) {
|
||||
@@ -561,36 +443,21 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref<
|
||||
|
||||
async function handleRemoveSection(sectionId: string): Promise<void> {
|
||||
try {
|
||||
let response: boolean;
|
||||
const deletedAt: number = System.timeStampInSeconds();
|
||||
let success: boolean;
|
||||
if (isSeriesMode) {
|
||||
const deleteData = {locationId: sectionId, deletedAt};
|
||||
if (isCurrentlyOffline() || localSeries) {
|
||||
response = await tauri.deleteSeriesLocation(deleteData.locationId, deleteData.deletedAt);
|
||||
} else {
|
||||
response = await System.authDeleteToServer<boolean>('series/location/delete', deleteData, token, lang);
|
||||
if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) {
|
||||
addToQueue('delete_series_location', {data: deleteData});
|
||||
}
|
||||
}
|
||||
} else if (isCurrentlyOffline() || book?.localBook) {
|
||||
response = await tauri.deleteLocationSection(sectionId, currentEntityId, deletedAt);
|
||||
} else {
|
||||
response = await System.authDeleteToServer<boolean>(`location/delete`, {
|
||||
locationId: sectionId, bookId: currentEntityId, deletedAt,
|
||||
success = await apiDelete<boolean>('series/location/delete', {
|
||||
locationId: sectionId
|
||||
}, token, lang);
|
||||
} else {
|
||||
success = await apiDelete<boolean>('location/delete', {
|
||||
locationId: sectionId,
|
||||
}, token, lang);
|
||||
|
||||
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) {
|
||||
addToQueue('delete_location_section', {data: {
|
||||
locationId: sectionId, bookId: currentEntityId, deletedAt,
|
||||
}});
|
||||
}
|
||||
}
|
||||
if (!response) {
|
||||
if (!success) {
|
||||
errorMessage(t('locationComponent.errorUnknownDeleteSection'));
|
||||
return;
|
||||
}
|
||||
const updatedSections: LocationProps[] = sections.filter((section: LocationProps): boolean => section.id !== sectionId,);
|
||||
const updatedSections: LocationProps[] = sections.filter((section: LocationProps): boolean => section.id !== sectionId);
|
||||
setSections(updatedSections);
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
@@ -603,20 +470,9 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref<
|
||||
|
||||
async function handleSave(): Promise<void> {
|
||||
try {
|
||||
let response: boolean;
|
||||
if (isCurrentlyOffline() || book?.localBook) {
|
||||
response = await tauri.updateLocations(sections) as boolean;
|
||||
} else {
|
||||
response = await System.authPostToServer<boolean>(`location/update`, {
|
||||
const response: boolean = await apiPost<boolean>(`location/update`, {
|
||||
locations: sections,
|
||||
}, token, lang);
|
||||
|
||||
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) {
|
||||
addToQueue('update_locations', {data: {
|
||||
locations: sections,
|
||||
}});
|
||||
}
|
||||
}
|
||||
if (!response) {
|
||||
errorMessage(t('locationComponent.errorUnknownSave'));
|
||||
return;
|
||||
@@ -635,13 +491,13 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref<
|
||||
if (!bookSeriesId) return;
|
||||
|
||||
try {
|
||||
const seriesLocationId: string = await System.authPostToServer<string>('series/location/section/add', {
|
||||
const seriesLocationId: string = await apiPost<string>('series/location/section/add', {
|
||||
seriesId: bookSeriesId,
|
||||
name: section.name,
|
||||
}, token, lang);
|
||||
|
||||
if (seriesLocationId) {
|
||||
const updateResponse: boolean = await System.authPostToServer<boolean>('location/section/update', {
|
||||
const updateResponse: boolean = await apiPost<boolean>('location/section/update', {
|
||||
sectionId: section.id,
|
||||
sectionName: section.name,
|
||||
seriesLocationId: seriesLocationId,
|
||||
@@ -667,7 +523,7 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref<
|
||||
if (!seriesLocation) return;
|
||||
|
||||
try {
|
||||
const sectionId: string = await System.authPostToServer<string>('location/section/add', {
|
||||
const sectionId: string = await apiPost<string>('location/section/add', {
|
||||
bookId: currentEntityId,
|
||||
locationName: seriesLocation.name,
|
||||
seriesLocationId: seriesLocationId,
|
||||
@@ -681,7 +537,7 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref<
|
||||
const importedElements: Element[] = [];
|
||||
|
||||
for (const seriesElement of seriesLocation.elements) {
|
||||
const elementId: string = await System.authPostToServer<string>('location/element/add', {
|
||||
const elementId: string = await apiPost<string>('location/element/add', {
|
||||
bookId: currentEntityId,
|
||||
locationId: sectionId,
|
||||
elementName: seriesElement.name,
|
||||
@@ -692,7 +548,7 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref<
|
||||
const importedSubElements: SubElement[] = [];
|
||||
|
||||
for (const seriesSubElement of seriesElement.subElements) {
|
||||
const subElementId: string = await System.authPostToServer<string>('location/sub-element/add', {
|
||||
const subElementId: string = await apiPost<string>('location/sub-element/add', {
|
||||
elementId: elementId,
|
||||
subElementName: seriesSubElement.name,
|
||||
}, token, lang);
|
||||
@@ -732,9 +588,9 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref<
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{showToggle && !isSeriesMode && (
|
||||
<div className="bg-secondary/20 rounded-xl p-5 shadow-inner border border-secondary/30">
|
||||
<div className="bg-secondary rounded-xl p-4">
|
||||
<InputField
|
||||
icon={faToggleOn}
|
||||
icon={ToggleRight}
|
||||
fieldName={t('locationComponent.enableTool')}
|
||||
input={
|
||||
<ToggleSwitch
|
||||
@@ -755,13 +611,15 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref<
|
||||
<SeriesImportSelector
|
||||
availableItems={seriesLocations
|
||||
.filter((seriesLocation: SeriesLocationItem): boolean => !sections.some((section: LocationProps): boolean => section.seriesLocationId === seriesLocation.id))
|
||||
.map((seriesLocation: SeriesLocationItem) => ({id: seriesLocation.id, name: seriesLocation.name}))}
|
||||
.map((seriesLocation: SeriesLocationItem) => ({
|
||||
id: seriesLocation.id,
|
||||
name: seriesLocation.name
|
||||
}))}
|
||||
onImport={handleImportFromSeries}
|
||||
placeholder={t("seriesImport.selectElement")}
|
||||
label={t("seriesImport.importFromSeries")}
|
||||
/>
|
||||
)}
|
||||
<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={
|
||||
@@ -771,45 +629,37 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref<
|
||||
placeholder={t("locationComponent.newSectionPlaceholder")}
|
||||
/>
|
||||
}
|
||||
actionIcon={faPlus}
|
||||
actionIcon={Plus}
|
||||
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">
|
||||
<div key={section.id} className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-text-primary mb-4 flex items-center">
|
||||
<FontAwesomeIcon icon={faMapMarkerAlt} className="mr-2 w-5 h-5"/>
|
||||
<MapPin className="mr-2 w-5 h-5" strokeWidth={1.75}/>
|
||||
{section.name}
|
||||
<span
|
||||
className="ml-2 text-sm bg-dark-background text-text-secondary py-0.5 px-2 rounded-full">
|
||||
className="ml-2 text-sm bg-secondary text-text-secondary py-0.5 px-2 rounded-full">
|
||||
{section.elements.length || 0}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{!isSeriesMode && bookSeriesId && !section.seriesLocationId && (
|
||||
<button
|
||||
onClick={(): Promise<void> => handleExportToSeries(section)}
|
||||
title={t("locationComponent.exportToSeries")}
|
||||
className="bg-blue-500/90 text-text-primary rounded-full p-1.5 hover:bg-blue-500 transition-colors shadow-md"
|
||||
>
|
||||
<FontAwesomeIcon icon={faShare} className={'w-5 h-5'}/>
|
||||
</button>
|
||||
<IconButton icon={Share2} variant="ghost" size="sm" shape="square"
|
||||
tooltip={t("locationComponent.exportToSeries")}
|
||||
onClick={(): Promise<void> => handleExportToSeries(section)}/>
|
||||
)}
|
||||
<button onClick={(): Promise<void> => handleRemoveSection(section.id)}
|
||||
className="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>
|
||||
<IconButton icon={Trash2} variant="danger" size="sm" shape="square"
|
||||
onClick={(): Promise<void> => handleRemoveSection(section.id)}/>
|
||||
</div>
|
||||
</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">
|
||||
className="bg-secondary rounded-lg p-3 border-l-4 border-primary">
|
||||
<div className="mb-2">
|
||||
<InputField
|
||||
input={
|
||||
@@ -824,13 +674,13 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref<
|
||||
removeButtonCallBack={(): Promise<void> => handleRemoveElement(section.id, elementIndex)}
|
||||
/>
|
||||
</div>
|
||||
<TexteAreaInput
|
||||
<TextAreaInput
|
||||
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">
|
||||
<div className="mt-4 pt-4 border-t border-secondary">
|
||||
{element.subElements.length > 0 && (
|
||||
<h4 className="text-sm italic text-text-secondary mb-3">{t("locationComponent.subElementsHeading")}</h4>
|
||||
)}
|
||||
@@ -852,7 +702,7 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref<
|
||||
removeButtonCallBack={(): Promise<void> => handleRemoveSubElement(section.id, elementIndex, subElementIndex)}
|
||||
/>
|
||||
</div>
|
||||
<TexteAreaInput
|
||||
<TextAreaInput
|
||||
value={subElement.description}
|
||||
setValue={(e) =>
|
||||
handleSubElementChange(section.id, elementIndex, subElementIndex, 'description', e.target.value)
|
||||
@@ -891,7 +741,10 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref<
|
||||
<TextInput
|
||||
value={newElementNames[section.id] || ''}
|
||||
setValue={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
setNewElementNames({...newElementNames, [section.id]: e.target.value})
|
||||
setNewElementNames({
|
||||
...newElementNames,
|
||||
[section.id]: e.target.value
|
||||
})
|
||||
}
|
||||
placeholder={t("locationComponent.newElementPlaceholder")}
|
||||
/>
|
||||
@@ -902,9 +755,8 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref<
|
||||
</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 className="text-center py-8">
|
||||
<p className="text-text-secondary">{t("locationComponent.noSectionAvailable")}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
'use client';
|
||||
import React, {useCallback, useContext, useMemo, useState} from 'react';
|
||||
import {useLocations, UseLocationsConfig, LocationProps, Element, SubElement} from '@/hooks/settings/useLocations';
|
||||
import {useTranslations} from 'next-intl';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {faSpinner, faPlus, faToggleOn} from '@fortawesome/free-solid-svg-icons';
|
||||
import {BookContext} from '@/context/BookContext';
|
||||
import {SeriesLocationItem} from '@/lib/models/Series';
|
||||
import {LocationProps, useLocations, UseLocationsConfig} from '@/hooks/settings/useLocations';
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
import {Plus} from 'lucide-react';
|
||||
import PulseLoader from '@/components/ui/PulseLoader';
|
||||
import {BookContext, BookContextProps} from '@/context/BookContext';
|
||||
import {SeriesLocationItem} from '@/lib/types/series';
|
||||
import ToolDetailHeader from '@/components/book/settings/ToolDetailHeader';
|
||||
import AlertBox from '@/components/AlertBox';
|
||||
import AlertBox from '@/components/ui/AlertBox';
|
||||
import InputField from '@/components/form/InputField';
|
||||
import TextInput from '@/components/form/TextInput';
|
||||
import ToggleSwitch from '@/components/form/ToggleSwitch';
|
||||
import SeriesImportSelector from '@/components/form/SeriesImportSelector';
|
||||
|
||||
import LocationEditorList from './LocationEditorList';
|
||||
@@ -24,7 +23,7 @@ import LocationEditorEdit from './LocationEditorEdit';
|
||||
*/
|
||||
export default function LocationEditor(): React.JSX.Element {
|
||||
const t = useTranslations();
|
||||
const {book} = useContext(BookContext);
|
||||
const {book}: BookContextProps = useContext<BookContextProps>(BookContext);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean>(false);
|
||||
const [showAddForm, setShowAddForm] = useState<boolean>(false);
|
||||
|
||||
@@ -107,11 +106,7 @@ export default function LocationEditor(): React.JSX.Element {
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<FontAwesomeIcon icon={faSpinner} className="w-6 h-6 text-primary animate-spin"/>
|
||||
</div>
|
||||
);
|
||||
return <PulseLoader size="sm"/>;
|
||||
}
|
||||
|
||||
const selectedSection: LocationProps | undefined = sections[selectedSectionIndex];
|
||||
@@ -128,8 +123,12 @@ export default function LocationEditor(): React.JSX.Element {
|
||||
onEdit={enterEditMode}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
onDelete={function (): void { setShowDeleteConfirm(true); }}
|
||||
onExport={canExport ? function (): Promise<void> { return exportToSeries(selectedSection!); } : undefined}
|
||||
onDelete={function (): void {
|
||||
setShowDeleteConfirm(true);
|
||||
}}
|
||||
onExport={canExport ? function (): Promise<void> {
|
||||
return exportToSeries(selectedSection!);
|
||||
} : undefined}
|
||||
showExport={canExport}
|
||||
showDelete={Boolean(selectedSection)}
|
||||
/>
|
||||
@@ -137,22 +136,6 @@ export default function LocationEditor(): React.JSX.Element {
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{viewMode === 'list' && (
|
||||
<div className="space-y-3 p-2">
|
||||
{/* Toggle tool */}
|
||||
<div className="bg-secondary/20 rounded-lg p-3 border border-secondary/30">
|
||||
<InputField
|
||||
icon={faToggleOn}
|
||||
fieldName={t('locationComponent.enableTool')}
|
||||
input={
|
||||
<ToggleSwitch
|
||||
checked={toolEnabled}
|
||||
onChange={toggleTool}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{toolEnabled && (
|
||||
<>
|
||||
{/* Import from series */}
|
||||
{bookSeriesId && availableSeriesLocations.length > 0 && (
|
||||
<SeriesImportSelector
|
||||
@@ -177,7 +160,7 @@ export default function LocationEditor(): React.JSX.Element {
|
||||
placeholder={t('locationComponent.newSectionPlaceholder')}
|
||||
/>
|
||||
}
|
||||
actionIcon={faPlus}
|
||||
actionIcon={Plus}
|
||||
actionLabel={t('locationComponent.addSectionLabel')}
|
||||
addButtonCallBack={async function (): Promise<void> {
|
||||
await addSection();
|
||||
@@ -192,8 +175,6 @@ export default function LocationEditor(): React.JSX.Element {
|
||||
onSectionClick={handleSectionClick}
|
||||
onAddSection={handleAddSection}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -234,7 +215,9 @@ export default function LocationEditor(): React.JSX.Element {
|
||||
confirmText={t('common.delete')}
|
||||
cancelText={t('common.cancel')}
|
||||
onConfirm={handleDelete}
|
||||
onCancel={function (): void { setShowDeleteConfirm(false); }}
|
||||
onCancel={function (): void {
|
||||
setShowDeleteConfirm(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
'use client';
|
||||
import React from 'react';
|
||||
import {LocationProps, Element, SubElement} from '@/hooks/settings/useLocations';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {faLocationDot, faMapPin} from '@fortawesome/free-solid-svg-icons';
|
||||
import {useTranslations} from 'next-intl';
|
||||
import {Element, LocationProps, SubElement} from '@/hooks/settings/useLocations';
|
||||
import {MapPin, Navigation} from 'lucide-react';
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
|
||||
interface LocationEditorDetailProps {
|
||||
section: LocationProps;
|
||||
@@ -29,9 +28,9 @@ export default function LocationEditorDetail({
|
||||
<div className="space-y-4">
|
||||
{section.elements.map(function (element: Element): React.JSX.Element {
|
||||
return (
|
||||
<div key={element.id} className="border-b border-secondary/30 pb-3 last:border-b-0">
|
||||
<div key={element.id} className="border-b border-secondary pb-3 last:border-b-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<FontAwesomeIcon icon={faMapPin} className="text-primary w-3 h-3"/>
|
||||
<MapPin className="text-primary w-3 h-3" strokeWidth={1.75}/>
|
||||
<span className="text-text-primary font-medium text-sm">{element.name}</span>
|
||||
</div>
|
||||
{element.description && (
|
||||
@@ -43,9 +42,11 @@ export default function LocationEditorDetail({
|
||||
{element.subElements.map(function (subElement: SubElement): React.JSX.Element {
|
||||
return (
|
||||
<div key={subElement.id} className="flex items-start gap-2">
|
||||
<FontAwesomeIcon icon={faLocationDot} className="text-muted w-2 h-2 mt-1.5"/>
|
||||
<Navigation className="text-muted w-2 h-2 mt-1.5"
|
||||
strokeWidth={1.75}/>
|
||||
<div>
|
||||
<span className="text-text-primary text-xs">{subElement.name}</span>
|
||||
<span
|
||||
className="text-text-primary text-xs">{subElement.name}</span>
|
||||
{subElement.description && (
|
||||
<p className="text-muted text-xs">{subElement.description}</p>
|
||||
)}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
'use client';
|
||||
import React, {ChangeEvent} from 'react';
|
||||
import {LocationProps, Element, SubElement} from '@/hooks/settings/useLocations';
|
||||
import {Element, LocationProps, SubElement} from '@/hooks/settings/useLocations';
|
||||
import InputField from '@/components/form/InputField';
|
||||
import TextInput from '@/components/form/TextInput';
|
||||
import TexteAreaInput from '@/components/form/TexteAreaInput';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {faMapPin, faPlus} from '@fortawesome/free-solid-svg-icons';
|
||||
import {useTranslations} from 'next-intl';
|
||||
import TextAreaInput from '@/components/form/TextAreaInput';
|
||||
import {MapPin, Plus} from 'lucide-react';
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
|
||||
interface LocationEditorEditProps {
|
||||
section: LocationProps;
|
||||
@@ -49,9 +48,9 @@ export default function LocationEditorEdit({
|
||||
{/* Éléments existants */}
|
||||
{section.elements.map(function (element: Element, elementIndex: number): React.JSX.Element {
|
||||
return (
|
||||
<div key={element.id} className="bg-secondary/20 rounded-lg p-3 border border-secondary/30">
|
||||
<div key={element.id} className="bg-tertiary rounded-lg p-3 border border-secondary">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FontAwesomeIcon icon={faMapPin} className="text-primary w-3 h-3"/>
|
||||
<MapPin className="text-primary w-3 h-3" strokeWidth={1.75}/>
|
||||
<span className="text-text-secondary text-xs">{t('locationComponent.element')}</span>
|
||||
</div>
|
||||
|
||||
@@ -71,7 +70,7 @@ export default function LocationEditorEdit({
|
||||
/>
|
||||
|
||||
<div className="mt-2">
|
||||
<TexteAreaInput
|
||||
<TextAreaInput
|
||||
value={element.description}
|
||||
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
|
||||
onUpdateElement(section.id, elementIndex, 'description', e.target.value);
|
||||
@@ -82,10 +81,10 @@ export default function LocationEditorEdit({
|
||||
|
||||
{/* Sous-éléments */}
|
||||
{element.subElements.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-secondary/30 space-y-2">
|
||||
<div className="mt-3 pt-3 border-t border-secondary space-y-2">
|
||||
{element.subElements.map(function (subElement: SubElement, subElementIndex: number): React.JSX.Element {
|
||||
return (
|
||||
<div key={subElement.id} className="bg-dark-background/50 rounded p-2">
|
||||
<div key={subElement.id} className="bg-secondary rounded p-2">
|
||||
<InputField
|
||||
input={
|
||||
<TextInput
|
||||
@@ -118,7 +117,7 @@ export default function LocationEditorEdit({
|
||||
placeholder={t('locationComponent.newSubElementPlaceholder')}
|
||||
/>
|
||||
}
|
||||
actionIcon={faPlus}
|
||||
actionIcon={Plus}
|
||||
actionLabel={t('locationComponent.addSubElement')}
|
||||
addButtonCallBack={function (): Promise<void> {
|
||||
return onAddSubElement(section.id, elementIndex);
|
||||
@@ -141,7 +140,7 @@ export default function LocationEditorEdit({
|
||||
placeholder={t('locationComponent.newElementPlaceholder')}
|
||||
/>
|
||||
}
|
||||
actionIcon={faPlus}
|
||||
actionIcon={Plus}
|
||||
addButtonCallBack={function (): Promise<void> {
|
||||
return onAddElement(section.id);
|
||||
}}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
'use client';
|
||||
import React, {useState} from 'react';
|
||||
import {LocationProps, Element} from '@/hooks/settings/useLocations';
|
||||
import {Element, LocationProps} from '@/hooks/settings/useLocations';
|
||||
import InputField from '@/components/form/InputField';
|
||||
import TextInput from '@/components/form/TextInput';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {faChevronRight, faMapMarkerAlt, faPlus} from '@fortawesome/free-solid-svg-icons';
|
||||
import {useTranslations} from 'next-intl';
|
||||
import {MapPin, Plus} from 'lucide-react';
|
||||
import EmptyState from '@/components/ui/EmptyState';
|
||||
import EntityListItem from '@/components/ui/EntityListItem';
|
||||
import AvatarIcon from '@/components/ui/AvatarIcon';
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
|
||||
interface LocationEditorListProps {
|
||||
sections: LocationProps[];
|
||||
@@ -55,7 +57,7 @@ export default function LocationEditorList({
|
||||
placeholder={t('locationComponent.search')}
|
||||
/>
|
||||
}
|
||||
actionIcon={faPlus}
|
||||
actionIcon={Plus}
|
||||
actionLabel={t('locationComponent.addSectionLabel')}
|
||||
addButtonCallBack={async function (): Promise<void> {
|
||||
onAddSection();
|
||||
@@ -65,45 +67,21 @@ export default function LocationEditorList({
|
||||
|
||||
<div className="px-2 space-y-2">
|
||||
{filteredSections.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mb-3">
|
||||
<FontAwesomeIcon icon={faMapMarkerAlt} className="text-primary w-8 h-8"/>
|
||||
</div>
|
||||
<h3 className="text-text-primary font-semibold text-base mb-1">
|
||||
{t('locationComponent.noSectionAvailable')}
|
||||
</h3>
|
||||
<p className="text-muted text-sm max-w-xs">
|
||||
{t('locationComponent.noSectionDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<EmptyState icon={MapPin} title={t('locationComponent.noSectionAvailable')}
|
||||
description={t('locationComponent.noSectionDescription')}/>
|
||||
) : (
|
||||
filteredSections.map(function (section: LocationProps, index: number): React.JSX.Element {
|
||||
return (
|
||||
<div
|
||||
<EntityListItem
|
||||
key={section.id}
|
||||
onClick={function (): void { onSectionClick(section, index); }}
|
||||
className="group flex items-center p-3 bg-secondary/30 rounded-lg border-l-4 border-primary border border-secondary/50 cursor-pointer hover:bg-secondary hover:shadow-md transition-all duration-200 hover:border-primary/50"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full border-2 border-primary overflow-hidden bg-secondary shadow-sm group-hover:scale-110 transition-transform flex items-center justify-center">
|
||||
<FontAwesomeIcon icon={faMapMarkerAlt} className="text-primary w-5 h-5"/>
|
||||
</div>
|
||||
|
||||
<div className="ml-3 flex-1 min-w-0">
|
||||
<div className="text-text-primary font-semibold text-sm group-hover:text-primary transition-colors truncate">
|
||||
{section.name}
|
||||
</div>
|
||||
<div className="text-muted text-xs truncate">
|
||||
{t('locationComponent.elementsCount', {count: countTotalElements(section)})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-6 flex justify-center">
|
||||
<FontAwesomeIcon
|
||||
icon={faChevronRight}
|
||||
className="text-muted group-hover:text-primary group-hover:translate-x-1 transition-all w-3 h-3"
|
||||
size="sm"
|
||||
onClick={function (): void {
|
||||
onSectionClick(section, index);
|
||||
}}
|
||||
avatar={<AvatarIcon size="sm" icon={MapPin}/>}
|
||||
title={section.name}
|
||||
subtitle={t('locationComponent.elementsCount', {count: countTotalElements(section)})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
'use client';
|
||||
import React, {useContext, useMemo, useState} from 'react';
|
||||
import {useLocations, UseLocationsConfig, LocationProps, Element, SubElement} from '@/hooks/settings/useLocations';
|
||||
import {useTranslations} from 'next-intl';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {faSpinner, faToggleOn} from '@fortawesome/free-solid-svg-icons';
|
||||
import {BookContext} from '@/context/BookContext';
|
||||
import {SeriesLocationItem} from '@/lib/models/Series';
|
||||
import InputField from '@/components/form/InputField';
|
||||
import ToggleSwitch from '@/components/form/ToggleSwitch';
|
||||
import {LocationProps, useLocations, UseLocationsConfig} from '@/hooks/settings/useLocations';
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
import PulseLoader from '@/components/ui/PulseLoader';
|
||||
import {BookContext, BookContextProps} from '@/context/BookContext';
|
||||
import {SeriesLocationItem} from '@/lib/types/series';
|
||||
import SeriesImportSelector from '@/components/form/SeriesImportSelector';
|
||||
import ToolDetailHeader from '@/components/book/settings/ToolDetailHeader';
|
||||
import AlertBox from '@/components/AlertBox';
|
||||
import AlertBox from '@/components/ui/AlertBox';
|
||||
|
||||
import LocationSettingsList from './LocationSettingsList';
|
||||
import LocationSettingsDetail from './LocationSettingsDetail';
|
||||
@@ -19,7 +16,7 @@ import LocationSettingsEdit from './LocationSettingsEdit';
|
||||
interface LocationSettingsProps {
|
||||
entityType?: 'book' | 'series';
|
||||
entityId?: string;
|
||||
showToggle?: boolean;
|
||||
toolEnabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,10 +27,10 @@ interface LocationSettingsProps {
|
||||
export default function LocationSettings({
|
||||
entityType = 'book',
|
||||
entityId,
|
||||
showToggle = true,
|
||||
toolEnabled: parentToolEnabled,
|
||||
}: LocationSettingsProps): React.JSX.Element {
|
||||
const t = useTranslations();
|
||||
const {book} = useContext(BookContext);
|
||||
const {book}: BookContextProps = useContext<BookContextProps>(BookContext);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean>(false);
|
||||
|
||||
const resolvedEntityId: string = entityId || book?.bookId || '';
|
||||
@@ -103,11 +100,7 @@ export default function LocationSettings({
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<FontAwesomeIcon icon={faSpinner} className="w-8 h-8 text-primary animate-spin"/>
|
||||
</div>
|
||||
);
|
||||
return <PulseLoader/>;
|
||||
}
|
||||
|
||||
const selectedSection: LocationProps | undefined = sections[selectedSectionIndex];
|
||||
@@ -125,8 +118,12 @@ export default function LocationSettings({
|
||||
onEdit={enterEditMode}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
onDelete={function (): void { setShowDeleteConfirm(true); }}
|
||||
onExport={canExport ? function (): Promise<void> { return exportToSeries(selectedSection!); } : undefined}
|
||||
onDelete={function (): void {
|
||||
setShowDeleteConfirm(true);
|
||||
}}
|
||||
onExport={canExport ? function (): Promise<void> {
|
||||
return exportToSeries(selectedSection!);
|
||||
} : undefined}
|
||||
showExport={canExport}
|
||||
showDelete={Boolean(selectedSection)}
|
||||
/>
|
||||
@@ -135,27 +132,7 @@ export default function LocationSettings({
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{viewMode === 'list' && (
|
||||
<div className="space-y-5 p-4">
|
||||
{/* Toggle tool */}
|
||||
{showToggle && !isSeriesMode && (
|
||||
<div className="bg-secondary/20 rounded-xl p-5 shadow-inner border border-secondary/30">
|
||||
<InputField
|
||||
icon={faToggleOn}
|
||||
fieldName={t('locationComponent.enableTool')}
|
||||
input={
|
||||
<ToggleSwitch
|
||||
checked={toolEnabled}
|
||||
onChange={toggleTool}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<p className="text-muted text-sm mt-2">
|
||||
{t('locationComponent.enableToolDescription')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contenu si outil activé */}
|
||||
{(toolEnabled || isSeriesMode) && (
|
||||
{((parentToolEnabled !== undefined ? parentToolEnabled : toolEnabled) || isSeriesMode) && (
|
||||
<>
|
||||
{/* Import from series */}
|
||||
{!isSeriesMode && bookSeriesId && availableSeriesLocations.length > 0 && (
|
||||
@@ -220,7 +197,9 @@ export default function LocationSettings({
|
||||
confirmText={t('common.delete')}
|
||||
cancelText={t('common.cancel')}
|
||||
onConfirm={handleDelete}
|
||||
onCancel={function (): void { setShowDeleteConfirm(false); }}
|
||||
onCancel={function (): void {
|
||||
setShowDeleteConfirm(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
import React from 'react';
|
||||
import {LocationProps, Element, SubElement} from '@/hooks/settings/useLocations';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {faMapMarkerAlt, faMapPin, faLocationDot, faChevronRight} from '@fortawesome/free-solid-svg-icons';
|
||||
import {useTranslations} from 'next-intl';
|
||||
import {Element, LocationProps, SubElement} from '@/hooks/settings/useLocations';
|
||||
import {ChevronRight, MapPin, Navigation} from 'lucide-react';
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
import DetailHeroSection from '@/components/ui/DetailHeroSection';
|
||||
|
||||
interface LocationSettingsDetailProps {
|
||||
section: LocationProps;
|
||||
@@ -17,56 +17,53 @@ export default function LocationSettingsDetail({
|
||||
return (
|
||||
<div className="space-y-6 px-2 pb-4">
|
||||
{/* Hero Section */}
|
||||
<div className="p-6 bg-gradient-to-r from-primary/10 via-secondary/20 to-transparent rounded-2xl border border-secondary/30">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-16 h-16 rounded-xl bg-primary/20 flex items-center justify-center shrink-0">
|
||||
<FontAwesomeIcon icon={faMapMarkerAlt} className="w-8 h-8 text-primary"/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-2xl font-bold text-text-primary">{section.name}</h2>
|
||||
<DetailHeroSection icon={MapPin} title={section.name}>
|
||||
<p className="text-text-secondary mt-2">
|
||||
{t("locationComponent.elementsCount", {count: section.elements.length})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DetailHeroSection>
|
||||
|
||||
{/* Éléments en grille */}
|
||||
{section.elements.length === 0 ? (
|
||||
<div className="text-center py-12 text-text-secondary bg-secondary/10 rounded-xl border border-secondary/20">
|
||||
<FontAwesomeIcon icon={faMapPin} className="w-8 h-8 mb-3 opacity-50"/>
|
||||
<div
|
||||
className="text-center py-12 text-text-secondary">
|
||||
<MapPin className="w-8 h-8 mb-3 opacity-50" strokeWidth={1.75}/>
|
||||
<p>{t("locationComponent.noElementAvailable")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{section.elements.map(function (element: Element): React.JSX.Element {
|
||||
return (
|
||||
<div key={element.id} className="p-5 bg-secondary/20 rounded-xl border border-secondary/30 hover:border-primary/30 transition-colors">
|
||||
<div key={element.id}
|
||||
className="p-5 bg-tertiary rounded-xl">
|
||||
{/* Element header */}
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/20 flex items-center justify-center">
|
||||
<FontAwesomeIcon icon={faMapPin} className="w-5 h-5 text-primary"/>
|
||||
<div
|
||||
className="w-10 h-10 rounded-lg bg-primary/20 flex items-center justify-center">
|
||||
<MapPin className="w-5 h-5 text-primary" strokeWidth={1.75}/>
|
||||
</div>
|
||||
<h3 className="text-text-primary font-semibold text-lg">{element.name}</h3>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className={`mb-4 ${element.description ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
|
||||
<p className={`mb-4 ${element.description ? 'text-text-primary' : 'text-text-dimmed italic'}`}>
|
||||
{element.description || '—'}
|
||||
</p>
|
||||
|
||||
{/* Sub-elements */}
|
||||
{element.subElements.length > 0 && (
|
||||
<div className="pt-4 border-t border-secondary/30">
|
||||
<div className="pt-4 border-t border-secondary">
|
||||
<h4 className="text-text-secondary text-xs uppercase tracking-wide mb-3 flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faLocationDot} className="w-3 h-3"/>
|
||||
<Navigation className="w-3 h-3" strokeWidth={1.75}/>
|
||||
{t("locationComponent.subElementsHeading")} ({element.subElements.length})
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{element.subElements.map(function (subElement: SubElement): React.JSX.Element {
|
||||
return (
|
||||
<div key={subElement.id} className="flex items-start gap-2 p-2 bg-dark-background/30 rounded-lg">
|
||||
<FontAwesomeIcon icon={faChevronRight} className="w-3 h-3 text-primary mt-1 shrink-0"/>
|
||||
<div key={subElement.id}
|
||||
className="flex items-start gap-2 p-2 bg-secondary rounded-lg">
|
||||
<ChevronRight className="w-3 h-3 text-primary mt-1 shrink-0"
|
||||
strokeWidth={1.75}/>
|
||||
<div className="min-w-0">
|
||||
<p className="text-text-primary font-medium">{subElement.name}</p>
|
||||
{subElement.description && (
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
'use client';
|
||||
import React, {ChangeEvent} from 'react';
|
||||
import {LocationProps, Element, SubElement} from '@/hooks/settings/useLocations';
|
||||
import {Element, LocationProps, SubElement} from '@/hooks/settings/useLocations';
|
||||
import InputField from '@/components/form/InputField';
|
||||
import TextInput from '@/components/form/TextInput';
|
||||
import TexteAreaInput from '@/components/form/TexteAreaInput';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {faMapMarkerAlt, faPlus, faTrash} from '@fortawesome/free-solid-svg-icons';
|
||||
import {useTranslations} from 'next-intl';
|
||||
import TextAreaInput from '@/components/form/TextAreaInput';
|
||||
import {MapPin, Plus} from 'lucide-react';
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
import IconContainer from '@/components/ui/IconContainer';
|
||||
|
||||
interface LocationSettingsEditProps {
|
||||
section: LocationProps;
|
||||
@@ -46,9 +46,7 @@ export default function LocationSettingsEdit({
|
||||
<div className="space-y-4 px-2 pb-4">
|
||||
{/* Header de la section */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<FontAwesomeIcon icon={faMapMarkerAlt} className="text-primary w-6 h-6"/>
|
||||
</div>
|
||||
<IconContainer icon={MapPin} size="md" shape="circle"/>
|
||||
<div>
|
||||
<h2 className="text-text-primary font-bold text-xl">{section.name}</h2>
|
||||
</div>
|
||||
@@ -57,7 +55,7 @@ export default function LocationSettingsEdit({
|
||||
{/* Éléments existants */}
|
||||
{section.elements.map(function (element: Element, elementIndex: number): React.JSX.Element {
|
||||
return (
|
||||
<div key={element.id} className="bg-secondary/30 rounded-xl p-4 border border-secondary/50">
|
||||
<div key={element.id} className="space-y-3">
|
||||
<div className="mb-3">
|
||||
<InputField
|
||||
fieldName={t("locationComponent.elementName")}
|
||||
@@ -76,7 +74,7 @@ export default function LocationSettingsEdit({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TexteAreaInput
|
||||
<TextAreaInput
|
||||
value={element.description}
|
||||
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
|
||||
onUpdateElement(section.id, elementIndex, 'description', e.target.value);
|
||||
@@ -85,7 +83,7 @@ export default function LocationSettingsEdit({
|
||||
/>
|
||||
|
||||
{/* Sous-éléments */}
|
||||
<div className="mt-4 pt-4 border-t border-secondary/50">
|
||||
<div className="mt-4 pt-4 border-t border-secondary">
|
||||
{element.subElements.length > 0 && (
|
||||
<h4 className="text-sm italic text-text-secondary mb-3">
|
||||
{t("locationComponent.subElementsHeading")}
|
||||
@@ -94,7 +92,7 @@ export default function LocationSettingsEdit({
|
||||
|
||||
{element.subElements.map(function (subElement: SubElement, subElementIndex: number): React.JSX.Element {
|
||||
return (
|
||||
<div key={subElement.id} className="bg-dark-background rounded-lg p-3 mb-3">
|
||||
<div key={subElement.id} className="mb-3">
|
||||
<div className="mb-2">
|
||||
<InputField
|
||||
input={
|
||||
@@ -111,7 +109,7 @@ export default function LocationSettingsEdit({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<TexteAreaInput
|
||||
<TextAreaInput
|
||||
value={subElement.description}
|
||||
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
|
||||
onUpdateSubElement(section.id, elementIndex, subElementIndex, 'description', e.target.value);
|
||||
@@ -133,7 +131,7 @@ export default function LocationSettingsEdit({
|
||||
placeholder={t("locationComponent.newSubElementPlaceholder")}
|
||||
/>
|
||||
}
|
||||
actionIcon={faPlus}
|
||||
actionIcon={Plus}
|
||||
actionLabel={t("locationComponent.addSubElement")}
|
||||
addButtonCallBack={function (): Promise<void> {
|
||||
return onAddSubElement(section.id, elementIndex);
|
||||
@@ -145,7 +143,7 @@ export default function LocationSettingsEdit({
|
||||
})}
|
||||
|
||||
{/* Ajouter élément */}
|
||||
<div className="bg-secondary/20 rounded-xl p-4 border border-secondary/30">
|
||||
<div>
|
||||
<InputField
|
||||
fieldName={t("locationComponent.addElement")}
|
||||
input={
|
||||
@@ -157,7 +155,7 @@ export default function LocationSettingsEdit({
|
||||
placeholder={t("locationComponent.newElementPlaceholder")}
|
||||
/>
|
||||
}
|
||||
actionIcon={faPlus}
|
||||
actionIcon={Plus}
|
||||
actionLabel={t("locationComponent.addElement")}
|
||||
addButtonCallBack={function (): Promise<void> {
|
||||
return onAddElement(section.id);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
'use client';
|
||||
import React, {ChangeEvent} from 'react';
|
||||
import {LocationProps, Element} from '@/hooks/settings/useLocations';
|
||||
import {Element, LocationProps} from '@/hooks/settings/useLocations';
|
||||
import InputField from '@/components/form/InputField';
|
||||
import TextInput from '@/components/form/TextInput';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {faChevronRight, faMapMarkerAlt, faPlus} from '@fortawesome/free-solid-svg-icons';
|
||||
import {useTranslations} from 'next-intl';
|
||||
import {MapPin, Plus} from 'lucide-react';
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
import EntityListItem from '@/components/ui/EntityListItem';
|
||||
import EmptyState from '@/components/ui/EmptyState';
|
||||
import AvatarIcon from '@/components/ui/AvatarIcon';
|
||||
|
||||
interface LocationSettingsListProps {
|
||||
sections: LocationProps[];
|
||||
@@ -39,7 +41,6 @@ export default function LocationSettingsList({
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-tertiary/90 backdrop-blur-sm rounded-xl shadow-lg p-4 border border-secondary/50">
|
||||
<InputField
|
||||
input={
|
||||
<TextInput
|
||||
@@ -50,53 +51,27 @@ export default function LocationSettingsList({
|
||||
placeholder={t("locationComponent.newSectionPlaceholder")}
|
||||
/>
|
||||
}
|
||||
actionIcon={faPlus}
|
||||
actionIcon={Plus}
|
||||
actionLabel={t("locationComponent.addSectionLabel")}
|
||||
addButtonCallBack={onAddSection}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 px-2">
|
||||
{sections.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="w-20 h-20 rounded-full bg-primary/10 flex items-center justify-center mb-4">
|
||||
<FontAwesomeIcon icon={faMapMarkerAlt} className="text-primary w-10 h-10"/>
|
||||
</div>
|
||||
<h3 className="text-text-primary font-semibold text-lg mb-2">
|
||||
{t("locationComponent.noSectionAvailable")}
|
||||
</h3>
|
||||
<p className="text-muted text-sm max-w-xs">
|
||||
{t("locationComponent.noSectionDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<EmptyState icon={MapPin} title={t("locationComponent.noSectionAvailable")}
|
||||
description={t("locationComponent.noSectionDescription")}/>
|
||||
) : (
|
||||
sections.map(function (section: LocationProps, index: number): React.JSX.Element {
|
||||
return (
|
||||
<div
|
||||
<EntityListItem
|
||||
key={section.id}
|
||||
onClick={function (): void { onSectionClick(index); }}
|
||||
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-12 h-12 rounded-full border-2 border-primary overflow-hidden bg-secondary shadow-md group-hover:scale-110 transition-transform flex items-center justify-center">
|
||||
<FontAwesomeIcon icon={faMapMarkerAlt} className="text-primary w-6 h-6"/>
|
||||
</div>
|
||||
|
||||
<div className="ml-4 flex-1 min-w-0">
|
||||
<div className="text-text-primary font-bold text-base group-hover:text-primary transition-colors">
|
||||
{section.name}
|
||||
</div>
|
||||
<div className="text-text-secondary text-sm mt-0.5">
|
||||
{t("locationComponent.elementsCount", {count: countTotalElements(section)})}
|
||||
</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"
|
||||
onClick={function (): void {
|
||||
onSectionClick(index);
|
||||
}}
|
||||
avatar={<AvatarIcon icon={MapPin}/>}
|
||||
title={section.name}
|
||||
subtitle={t("locationComponent.elementsCount", {count: countTotalElements(section)})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
import {useState} from 'react';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {faArrowLeft} from '@fortawesome/free-solid-svg-icons';
|
||||
import {ArrowLeft, Plus, Trash2} from 'lucide-react';
|
||||
import Button from '@/components/ui/Button';
|
||||
|
||||
interface RelatedItem {
|
||||
name: string;
|
||||
@@ -127,55 +127,53 @@ export default function Items() {
|
||||
<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>
|
||||
<div className="flex justify-between sticky top-0 z-10 bg-darkest-background py-4">
|
||||
<Button variant="secondary" icon={ArrowLeft} onClick={() => setSelectedItem(null)}>
|
||||
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">
|
||||
<Button variant="primary" onClick={handleSaveItem}>
|
||||
Save Item
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="bg-gray-700 rounded-lg p-8 shadow-lg space-y-4">
|
||||
<div className="bg-secondary rounded-lg p-8 shadow-lg space-y-4">
|
||||
<div>
|
||||
<label className="block text-white mb-2" htmlFor="name">Name</label>
|
||||
<label className="block text-text-primary 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"
|
||||
className="input-base"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white mb-2" htmlFor="description">Description</label>
|
||||
<label className="block text-text-primary 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"
|
||||
className="input-base"
|
||||
></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white mb-2" htmlFor="history">History</label>
|
||||
<label className="block text-text-primary 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"
|
||||
className="input-base"
|
||||
></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white mb-2" htmlFor="location">Location</label>
|
||||
<label className="block text-text-primary 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"
|
||||
className="input-base"
|
||||
>
|
||||
<option value="">Select Location</option>
|
||||
<option value="Castle">Castle</option>
|
||||
@@ -183,12 +181,12 @@ export default function Items() {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white mb-2" htmlFor="ownedBy">Owned By</label>
|
||||
<label className="block text-text-primary 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"
|
||||
className="input-base"
|
||||
>
|
||||
<option value="">Select Owner</option>
|
||||
{items.map((item) => (
|
||||
@@ -197,65 +195,64 @@ export default function Items() {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white mb-2" htmlFor="functionality">Functionality</label>
|
||||
<label className="block text-text-primary 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"
|
||||
className="input-base"
|
||||
></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-white mb-2" htmlFor="image">Image URL</label>
|
||||
<label className="block text-text-primary 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"
|
||||
className="input-base"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-700 rounded-lg p-8 shadow-lg space-y-4 mt-4">
|
||||
<div className="bg-secondary 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>
|
||||
<summary className="text-lg text-text-primary cursor-pointer">{relatedItem.name}</summary>
|
||||
<div className="mt-2">
|
||||
<label className="block text-white mb-2"
|
||||
<label className="block text-text-primary 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"
|
||||
className="input-base"
|
||||
></textarea>
|
||||
<label className="block text-white mb-2 mt-4"
|
||||
<label className="block text-text-primary 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"
|
||||
className="input-base"
|
||||
></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"
|
||||
>
|
||||
<div className="mt-2">
|
||||
<Button variant="danger" icon={Trash2}
|
||||
onClick={() => handleRemoveElement('relatedItems', index)}>
|
||||
Remove
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</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"
|
||||
className="input-base"
|
||||
onChange={(e) => setNewRelatedItem({...newRelatedItem, name: e.target.value})}
|
||||
value={newRelatedItem.name}
|
||||
>
|
||||
@@ -265,46 +262,39 @@ export default function Items() {
|
||||
))}
|
||||
</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"
|
||||
className="input-base"
|
||||
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={() => {
|
||||
<Button variant="primary" icon={Plus} 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>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="flex justify-between sticky top-0 z-10 bg-gray-900 py-4">
|
||||
<div className="flex justify-between sticky top-0 z-10 bg-darkest-background 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"
|
||||
className="input-base"
|
||||
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"
|
||||
>
|
||||
<div className="ml-4">
|
||||
<Button variant="primary" icon={Plus} onClick={handleAddItem}>
|
||||
Add New Item
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-4xl font-['ADLaM_Display'] text-text-primary mb-6">Items</h2>
|
||||
|
||||
@@ -1,30 +1,63 @@
|
||||
'use client'
|
||||
import React, {ChangeEvent, forwardRef, useContext, useEffect, useImperativeHandle, useState} from "react";
|
||||
import {BookContext} from "@/context/BookContext";
|
||||
import {SessionContext} from "@/context/SessionContext";
|
||||
import {AlertContext} from "@/context/AlertContext";
|
||||
import {LangContext} from "@/context/LangContext";
|
||||
import System from "@/lib/models/System";
|
||||
import {QuillSenseSettingsProps} from "@/lib/models/QuillSenseSettings";
|
||||
import {useTranslations} from "next-intl";
|
||||
import ToggleSwitch from "@/components/form/ToggleSwitch";
|
||||
import TexteAreaInput from "@/components/form/TexteAreaInput";
|
||||
import {BookContext, BookContextProps} from "@/context/BookContext";
|
||||
import {SessionContext, SessionContextProps} from "@/context/SessionContext";
|
||||
import {AlertContext, AlertContextProps} from "@/context/AlertContext";
|
||||
import {LangContext, LangContextProps} from "@/context/LangContext";
|
||||
import {apiGet, apiPost, apiPut} from "@/lib/api/client";
|
||||
import {QuillSenseSettingsProps} from "@/lib/types/quillsense";
|
||||
import {GuideLineAI} from "@/lib/types/book";
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
import TextAreaInput from "@/components/form/TextAreaInput";
|
||||
import TextInput from "@/components/form/TextInput";
|
||||
import SelectBox from "@/components/form/SelectBox";
|
||||
import InputField from "@/components/form/InputField";
|
||||
import {faMagicWandSparkles, faToggleOn} from "@fortawesome/free-solid-svg-icons";
|
||||
import PulseLoader from '@/components/ui/PulseLoader';
|
||||
import {SettingRef} from "@/lib/types/settings";
|
||||
import {
|
||||
advancedDialogueTypes,
|
||||
advancedNarrativePersons,
|
||||
beginnerDialogueTypes,
|
||||
beginnerNarrativePersons,
|
||||
intermediateDialogueTypes,
|
||||
intermediateNarrativePersons,
|
||||
langues,
|
||||
verbalTime
|
||||
} from "@/lib/constants/story";
|
||||
|
||||
const QuillSenseSetting = forwardRef(function QuillSenseSetting(props, ref) {
|
||||
type QuillSenseTab = 'ghostwriter' | 'guideline';
|
||||
|
||||
interface QuillSenseSettingProps {
|
||||
toolEnabled?: boolean;
|
||||
}
|
||||
|
||||
const QuillSenseSetting = forwardRef<SettingRef, QuillSenseSettingProps>(function QuillSenseSetting({toolEnabled}: QuillSenseSettingProps, ref: React.ForwardedRef<SettingRef>): React.JSX.Element | null {
|
||||
const t = useTranslations();
|
||||
const {book, setBook} = useContext(BookContext);
|
||||
const {session} = useContext(SessionContext);
|
||||
const {errorMessage, successMessage} = useContext(AlertContext);
|
||||
const {lang} = useContext(LangContext);
|
||||
const {book}: BookContextProps = useContext<BookContextProps>(BookContext);
|
||||
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
|
||||
const {errorMessage, successMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
|
||||
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext);
|
||||
const bookId: string = book?.bookId ?? '';
|
||||
const userToken: string = session?.accessToken ?? '';
|
||||
const authorLevel: string = session.user?.writingLevel?.toString() ?? '1';
|
||||
|
||||
const [quillsenseEnabled, setQuillsenseEnabled] = useState<boolean>(true);
|
||||
const [advancedPrompt, setAdvancedPrompt] = useState<string>('');
|
||||
const [activeTab, setActiveTab] = useState<QuillSenseTab>('ghostwriter');
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleSave
|
||||
// GhostWriter state
|
||||
const [advancedPrompt, setAdvancedPrompt] = useState<string>('');
|
||||
|
||||
// Guideline AI state
|
||||
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>('');
|
||||
const [themes, setThemes] = useState<string>('');
|
||||
|
||||
useImperativeHandle(ref, (): SettingRef => ({
|
||||
handleSave: activeTab === 'ghostwriter' ? handleSaveGhostWriter : handleSaveGuideline
|
||||
}));
|
||||
|
||||
useEffect((): void => {
|
||||
@@ -33,96 +66,221 @@ const QuillSenseSetting = forwardRef(function QuillSenseSetting(props, ref) {
|
||||
}
|
||||
}, [book?.bookId]);
|
||||
|
||||
useEffect((): void => {
|
||||
if (activeTab === 'guideline' && !isLoading) {
|
||||
fetchAIGuideline();
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
async function fetchQuillSenseSettings(): Promise<void> {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const settings: QuillSenseSettingsProps = await System.authGetQueryToServer<QuillSenseSettingsProps>(
|
||||
const settings: QuillSenseSettingsProps = await apiGet<QuillSenseSettingsProps>(
|
||||
'book/quillsense-settings',
|
||||
session.accessToken,
|
||||
lang,
|
||||
{bookId: book?.bookId}
|
||||
);
|
||||
setQuillsenseEnabled(settings.quillsenseEnabled);
|
||||
setAdvancedPrompt(settings.advancedPrompt ?? '');
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
} else {
|
||||
errorMessage(t('quillsenseSetting.unknownError'));
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave(): Promise<void> {
|
||||
async function fetchAIGuideline(): Promise<void> {
|
||||
try {
|
||||
const updateResult: boolean = await System.authPutToServer<boolean>(
|
||||
const response: GuideLineAI = await apiGet<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 handleSaveGhostWriter(): Promise<void> {
|
||||
try {
|
||||
const updateResult: boolean = await apiPut<boolean>(
|
||||
'book/quillsense-settings',
|
||||
{
|
||||
bookId: book?.bookId,
|
||||
quillsenseEnabled: quillsenseEnabled,
|
||||
advancedPrompt: advancedPrompt
|
||||
},
|
||||
session.accessToken,
|
||||
lang
|
||||
);
|
||||
if (updateResult) {
|
||||
successMessage(t('quillSenseSetting.successSave'));
|
||||
// Mettre a jour le contexte du livre
|
||||
if (setBook && book) {
|
||||
setBook({...book, quillsenseEnabled: quillsenseEnabled});
|
||||
}
|
||||
successMessage(t('quillsenseSetting.saveSuccess'));
|
||||
} else {
|
||||
errorMessage(t('quillSenseSetting.errorSave'));
|
||||
errorMessage(t('quillsenseSetting.saveError'));
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
} else {
|
||||
errorMessage(t('quillsenseSetting.unknownError'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveGuideline(): Promise<void> {
|
||||
try {
|
||||
const response: boolean = await apiPost<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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
);
|
||||
return <PulseLoader/>;
|
||||
}
|
||||
|
||||
if (!toolEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-4">
|
||||
<div className="bg-secondary/20 rounded-xl p-5 shadow-inner border border-secondary/30">
|
||||
<InputField
|
||||
icon={faToggleOn}
|
||||
fieldName={t('quillSenseSetting.enableLabel')}
|
||||
input={
|
||||
<ToggleSwitch
|
||||
checked={quillsenseEnabled}
|
||||
onChange={(checked: boolean): void => setQuillsenseEnabled(checked)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<p className="text-muted text-sm mt-2">
|
||||
{t('quillSenseSetting.enableDescription')}
|
||||
</p>
|
||||
<div className="space-y-6">
|
||||
<div className="flex gap-4 border-b border-secondary">
|
||||
<button
|
||||
className={`pb-2 text-sm font-medium transition-colors duration-150 ${
|
||||
activeTab === 'ghostwriter'
|
||||
? 'border-b-2 border-primary text-text-primary'
|
||||
: 'text-muted hover:text-text-primary'
|
||||
}`}
|
||||
onClick={(): void => setActiveTab('ghostwriter')}
|
||||
>
|
||||
GhostWriter
|
||||
</button>
|
||||
<button
|
||||
className={`pb-2 text-sm font-medium transition-colors duration-150 ${
|
||||
activeTab === 'guideline'
|
||||
? 'border-b-2 border-primary text-text-primary'
|
||||
: 'text-muted hover:text-text-primary'
|
||||
}`}
|
||||
onClick={(): void => setActiveTab('guideline')}
|
||||
>
|
||||
{t("bookSetting.guideLine")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-secondary/20 rounded-xl p-5 shadow-inner border border-secondary/30">
|
||||
{activeTab === 'ghostwriter' && (
|
||||
<div className="space-y-4">
|
||||
<InputField
|
||||
icon={faMagicWandSparkles}
|
||||
fieldName={t('quillSenseSetting.advancedPromptLabel')}
|
||||
fieldName={t('quillsenseSetting.advancedPrompt')}
|
||||
input={
|
||||
<TexteAreaInput
|
||||
<TextAreaInput
|
||||
value={advancedPrompt}
|
||||
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setAdvancedPrompt(e.target.value)}
|
||||
placeholder={t('quillSenseSetting.advancedPromptPlaceholder')}
|
||||
placeholder={t('quillsenseSetting.advancedPromptPlaceholder')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<p className="text-muted text-sm mt-2">
|
||||
{t('quillSenseSetting.advancedPromptHint')}
|
||||
<p className="text-muted text-xs">
|
||||
{t('quillsenseSetting.advancedPromptDescription')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'guideline' && (
|
||||
<div className="space-y-4">
|
||||
<InputField fieldName={t("guideLineSetting.plotSummary")} input={
|
||||
<TextAreaInput
|
||||
value={plotSummary}
|
||||
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setPlotSummary(e.target.value)}
|
||||
placeholder={t("guideLineSetting.plotSummaryPlaceholder")}
|
||||
/>
|
||||
}/>
|
||||
<InputField fieldName={t("guideLineSetting.toneAtmosphere")} input={
|
||||
<TextInput
|
||||
value={toneAtmosphere}
|
||||
setValue={(e: ChangeEvent<HTMLInputElement>): void => setToneAtmosphere(e.target.value)}
|
||||
placeholder={t("guideLineSetting.toneAtmospherePlaceholder")}
|
||||
/>
|
||||
}/>
|
||||
<InputField fieldName={t("guideLineSetting.themes")} input={
|
||||
<TextInput
|
||||
value={themes}
|
||||
setValue={(e: ChangeEvent<HTMLInputElement>): void => setThemes(e.target.value)}
|
||||
placeholder={t("guideLineSetting.themesPlaceholderQuill")}
|
||||
/>
|
||||
}/>
|
||||
<InputField fieldName={t("guideLineSetting.verbTense")} input={
|
||||
<SelectBox
|
||||
defaultValue={verbTense}
|
||||
onChangeCallBack={(event: ChangeEvent<HTMLSelectElement>): void => setVerbTense(event.target.value)}
|
||||
data={verbalTime}
|
||||
placeholder={t("guideLineSetting.verbTensePlaceholder")}
|
||||
/>
|
||||
}/>
|
||||
<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")}/>
|
||||
}/>
|
||||
<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")}/>
|
||||
}/>
|
||||
<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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
import React from 'react';
|
||||
import {SpellTagProps} from "@/lib/models/Spell";
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {faX} from "@fortawesome/free-solid-svg-icons";
|
||||
import {SpellTagProps} from "@/lib/types/spell";
|
||||
import {X} from 'lucide-react';
|
||||
import {dynamicBg, dynamicText} from "@/lib/utils/dynamicStyles";
|
||||
|
||||
interface SpellTagChipProps {
|
||||
tag: SpellTagProps;
|
||||
@@ -11,6 +11,16 @@ interface SpellTagChipProps {
|
||||
size?: 'sm' | 'md';
|
||||
}
|
||||
|
||||
function getContrastColor(hexColor: string | null): string {
|
||||
if (!hexColor) return 'var(--color-text-primary)';
|
||||
const hex = hexColor.replace('#', '');
|
||||
const r = parseInt(hex.substring(0, 2), 16);
|
||||
const g = parseInt(hex.substring(2, 4), 16);
|
||||
const b = parseInt(hex.substring(4, 6), 16);
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
return luminance > 0.5 ? 'var(--color-darkest-background)' : 'var(--color-text-primary)';
|
||||
}
|
||||
|
||||
export default function SpellTagChip(
|
||||
{
|
||||
tag,
|
||||
@@ -19,15 +29,9 @@ export default function SpellTagChip(
|
||||
size = 'md'
|
||||
}: SpellTagChipProps) {
|
||||
|
||||
function getContrastColor(hexColor: string | null): string {
|
||||
if (!hexColor) return '#FFFFFF';
|
||||
const hex = hexColor.replace('#', '');
|
||||
const r = parseInt(hex.substring(0, 2), 16);
|
||||
const g = parseInt(hex.substring(2, 4), 16);
|
||||
const b = parseInt(hex.substring(4, 6), 16);
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
return luminance > 0.5 ? '#1F2023' : '#FFFFFF';
|
||||
}
|
||||
const chipColor: string = tag.color || 'var(--color-primary)';
|
||||
const bgClass: string = dynamicBg(chipColor);
|
||||
const textClass: string = dynamicText(getContrastColor(tag.color));
|
||||
|
||||
const sizeClasses = size === 'sm'
|
||||
? 'px-2 py-0.5 text-xs'
|
||||
@@ -37,16 +41,13 @@ export default function SpellTagChip(
|
||||
inline-flex items-center gap-1.5 rounded-full font-medium
|
||||
transition-all duration-200
|
||||
${sizeClasses}
|
||||
${onClick ? 'cursor-pointer hover:scale-105 hover:shadow-md' : ''}
|
||||
${onClick ? 'cursor-pointer hover:brightness-110' : ''}
|
||||
${bgClass} ${textClass}
|
||||
`;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={chipClasses}
|
||||
style={{
|
||||
backgroundColor: tag.color || '#3B82F6',
|
||||
color: getContrastColor(tag.color)
|
||||
}}
|
||||
onClick={onClick}
|
||||
>
|
||||
{tag.name}
|
||||
@@ -56,9 +57,9 @@ export default function SpellTagChip(
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
className="ml-0.5 hover:bg-white/20 rounded-full p-0.5 transition-all duration-200 hover:scale-110"
|
||||
className="ml-0.5 hover:bg-text-primary/20 rounded-full p-0.5 transition-colors duration-200"
|
||||
>
|
||||
<FontAwesomeIcon icon={faX} className={size === 'sm' ? 'w-2 h-2' : 'w-2.5 h-2.5'}/>
|
||||
<X className={size === 'sm' ? 'w-2 h-2' : 'w-2.5 h-2.5'} strokeWidth={1.75}/>
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
'use client';
|
||||
import React, {useContext, useState} from 'react';
|
||||
import {defaultTagColors, SpellTagProps} from "@/lib/models/Spell";
|
||||
import {SpellTagProps} from "@/lib/types/spell";
|
||||
import {defaultTagColors} from "@/lib/constants/spell";
|
||||
import SpellTagChip from "@/components/book/settings/spells/SpellTagChip";
|
||||
import InputField from "@/components/form/InputField";
|
||||
import TextInput from "@/components/form/TextInput";
|
||||
import Modal from "@/components/Modal";
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {faArrowLeft, faEdit, faPlus, faTags, faTrash} from "@fortawesome/free-solid-svg-icons";
|
||||
import {useTranslations} from "next-intl";
|
||||
import {AlertContext} from "@/context/AlertContext";
|
||||
import Modal from "@/components/ui/Modal";
|
||||
import {Pencil, Plus, Tag, Trash2} from 'lucide-react';
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
import {AlertContext, AlertContextProps} from "@/context/AlertContext";
|
||||
import IconContainer from '@/components/ui/IconContainer';
|
||||
import Button from '@/components/ui/Button';
|
||||
import IconButton from '@/components/ui/IconButton';
|
||||
import {dynamicBg} from '@/lib/utils/dynamicStyles';
|
||||
|
||||
interface SpellTagManagerProps {
|
||||
tags: SpellTagProps[];
|
||||
onBack: () => void;
|
||||
onCreateTag: (name: string, color: string) => Promise<SpellTagProps | null>;
|
||||
onUpdateTag: (tagId: string, name: string, color: string) => Promise<boolean>;
|
||||
onDeleteTag: (tagId: string) => Promise<boolean>;
|
||||
@@ -21,13 +24,12 @@ interface SpellTagManagerProps {
|
||||
export default function SpellTagManager(
|
||||
{
|
||||
tags,
|
||||
onBack,
|
||||
onCreateTag,
|
||||
onUpdateTag,
|
||||
onDeleteTag,
|
||||
}: SpellTagManagerProps) {
|
||||
const t = useTranslations();
|
||||
const {successMessage} = useContext(AlertContext);
|
||||
const {successMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
|
||||
|
||||
const [newTagName, setNewTagName] = useState<string>('');
|
||||
const [newTagColor, setNewTagColor] = useState<string>(defaultTagColors[0]);
|
||||
@@ -87,25 +89,9 @@ export default function SpellTagManager(
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<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={onBack}
|
||||
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("spellTagManager.back")}</span>
|
||||
</button>
|
||||
<span className="text-text-primary font-semibold text-lg flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faTags} className="text-primary w-5 h-5"/>
|
||||
{t("spellTagManager.title")}
|
||||
</span>
|
||||
<div className="w-24"/>
|
||||
</div>
|
||||
|
||||
<div className="px-4 space-y-4">
|
||||
<div className="bg-secondary/20 rounded-xl p-4 border border-secondary/30">
|
||||
<div className="bg-tertiary rounded-xl p-4">
|
||||
<h3 className="text-text-primary font-semibold mb-3">{t("spellTagManager.addTag")}</h3>
|
||||
<div className="space-y-3">
|
||||
<InputField
|
||||
@@ -126,29 +112,22 @@ export default function SpellTagManager(
|
||||
<button
|
||||
key={color}
|
||||
onClick={() => setNewTagColor(color)}
|
||||
className={`w-10 h-10 rounded-full transition-all duration-200 ${newTagColor === color ? 'ring-2 ring-offset-2 ring-primary scale-110' : 'hover:scale-110'}`}
|
||||
style={{backgroundColor: color}}
|
||||
className={`w-10 h-10 rounded-full transition-all duration-200 ${newTagColor === color ? 'ring-2 ring-offset-2 ring-primary' : 'hover:ring-1 hover:ring-primary/50'} ${dynamicBg(color)}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddTag}
|
||||
disabled={!newTagName.trim()}
|
||||
className="w-full flex items-center justify-center gap-2 py-2.5 bg-primary text-text-primary rounded-xl hover:bg-primary-dark transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} className="w-4 h-4"/>
|
||||
<Button variant="primary" icon={Plus} onClick={handleAddTag} disabled={!newTagName.trim()}
|
||||
fullWidth>
|
||||
{t("spellTagManager.addTag")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto max-h-[calc(100vh-500px)]">
|
||||
{tags.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mb-4">
|
||||
<FontAwesomeIcon icon={faTags} className="text-primary w-8 h-8"/>
|
||||
</div>
|
||||
<IconContainer icon={Tag} size="lg" shape="circle"/>
|
||||
<p className="text-muted text-sm">{t("spellTagManager.noTags")}</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -156,22 +135,14 @@ export default function SpellTagManager(
|
||||
{tags.map((tag: SpellTagProps) => (
|
||||
<div
|
||||
key={tag.id}
|
||||
className="flex items-center justify-between p-3 bg-secondary/30 rounded-xl border border-secondary/50"
|
||||
className="flex items-center justify-between p-3 bg-secondary rounded-xl"
|
||||
>
|
||||
<SpellTagChip tag={tag}/>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleEditClick(tag)}
|
||||
className="p-2 bg-secondary/50 rounded-lg hover:bg-secondary hover:scale-110 transition-all duration-200"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEdit} className="text-primary w-4 h-4"/>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteClick(tag)}
|
||||
className="p-2 bg-error/10 rounded-lg hover:bg-error/20 hover:scale-110 transition-all duration-200"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} className="text-error w-4 h-4"/>
|
||||
</button>
|
||||
<IconButton icon={Pencil} variant="ghost" size="sm"
|
||||
onClick={() => handleEditClick(tag)}/>
|
||||
<IconButton icon={Trash2} variant="danger" size="sm"
|
||||
onClick={() => handleDeleteClick(tag)}/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -183,7 +154,7 @@ export default function SpellTagManager(
|
||||
{showEditModal && editingTag && (
|
||||
<Modal
|
||||
title={t("spellTagManager.editTag")}
|
||||
size="small"
|
||||
size="sm"
|
||||
onClose={() => setShowEditModal(false)}
|
||||
onConfirm={handleUpdateTag}
|
||||
confirmText={t("common.confirm")}
|
||||
@@ -208,8 +179,7 @@ export default function SpellTagManager(
|
||||
<button
|
||||
key={color}
|
||||
onClick={() => setEditTagColor(color)}
|
||||
className={`w-10 h-10 rounded-full transition-all duration-200 ${editTagColor === color ? 'ring-2 ring-offset-2 ring-primary scale-110' : 'hover:scale-110'}`}
|
||||
style={{backgroundColor: color}}
|
||||
className={`w-10 h-10 rounded-full transition-all duration-200 ${editTagColor === color ? 'ring-2 ring-offset-2 ring-primary' : 'hover:ring-1 hover:ring-primary/50'} ${dynamicBg(color)}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -231,7 +201,7 @@ export default function SpellTagManager(
|
||||
{showDeleteConfirm && tagToDelete && (
|
||||
<Modal
|
||||
title={t("spellTagManager.deleteTagTitle")}
|
||||
size="small"
|
||||
size="sm"
|
||||
onClose={() => setShowDeleteConfirm(false)}
|
||||
onConfirm={handleDeleteTag}
|
||||
confirmText={t("spellTagManager.delete")}
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
'use client';
|
||||
import React, {useCallback, useContext, useMemo, useState} from 'react';
|
||||
import {useSpells, UseSpellsConfig} from '@/hooks/settings/useSpells';
|
||||
import {useTranslations} from 'next-intl';
|
||||
import {SpellEditState, SpellListItem} from '@/lib/models/Spell';
|
||||
import {SeriesSpellListItem} from '@/lib/models/Series';
|
||||
import {BookContext} from '@/context/BookContext';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {faSpinner, faToggleOn} from '@fortawesome/free-solid-svg-icons';
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
import {SpellEditState, SpellListItem} from '@/lib/types/spell';
|
||||
import {SeriesSpellListItem} from '@/lib/types/series';
|
||||
import {BookContext, BookContextProps} from '@/context/BookContext';
|
||||
import PulseLoader from '@/components/ui/PulseLoader';
|
||||
|
||||
import ToolDetailHeader from '@/components/book/settings/ToolDetailHeader';
|
||||
import InputField from '@/components/form/InputField';
|
||||
import ToggleSwitch from '@/components/form/ToggleSwitch';
|
||||
import SeriesImportSelector from '@/components/form/SeriesImportSelector';
|
||||
import AlertBox from '@/components/AlertBox';
|
||||
import AlertBox from '@/components/ui/AlertBox';
|
||||
import SpellTagManager from '@/components/book/settings/spells/SpellTagManager';
|
||||
|
||||
import SpellEditorList from './SpellEditorList';
|
||||
@@ -25,7 +22,7 @@ import SpellEditorEdit from './SpellEditorEdit';
|
||||
*/
|
||||
export default function SpellEditor(): React.JSX.Element {
|
||||
const t = useTranslations();
|
||||
const {book} = useContext(BookContext);
|
||||
const {book}: BookContextProps = useContext<BookContextProps>(BookContext);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean>(false);
|
||||
|
||||
const config: UseSpellsConfig = useMemo(function (): UseSpellsConfig {
|
||||
@@ -94,11 +91,7 @@ export default function SpellEditor(): React.JSX.Element {
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<FontAwesomeIcon icon={faSpinner} className="w-6 h-6 text-primary animate-spin"/>
|
||||
</div>
|
||||
);
|
||||
return <PulseLoader size="sm"/>;
|
||||
}
|
||||
|
||||
const isNew: boolean = selectedSpell?.id === null;
|
||||
@@ -107,39 +100,36 @@ export default function SpellEditor(): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<ToolDetailHeader
|
||||
title={selectedSpell?.name || ''}
|
||||
defaultTitle={t('spellDetail.newSpell')}
|
||||
viewMode={viewMode}
|
||||
title={showTagManager ? t('spellTagManager.title') : (selectedSpell?.name || '')}
|
||||
defaultTitle={showTagManager ? t('spellTagManager.title') : t('spellDetail.newSpell')}
|
||||
viewMode={showTagManager ? 'detail' : viewMode}
|
||||
isNew={isNew}
|
||||
onBack={backToList}
|
||||
onEdit={enterEditMode}
|
||||
onBack={showTagManager ? function (): void {
|
||||
setShowTagManager(false);
|
||||
} : backToList}
|
||||
onEdit={showTagManager ? undefined : enterEditMode}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
onDelete={function (): void { setShowDeleteConfirm(true); }}
|
||||
onExport={canExport ? exportToSeries : undefined}
|
||||
showExport={canExport}
|
||||
showDelete={Boolean(selectedSpell?.id)}
|
||||
onDelete={showTagManager ? undefined : function (): void {
|
||||
setShowDeleteConfirm(true);
|
||||
}}
|
||||
onExport={canExport && !showTagManager ? exportToSeries : undefined}
|
||||
showExport={canExport && !showTagManager}
|
||||
showDelete={!showTagManager && Boolean(selectedSpell?.id)}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{viewMode === 'list' && (
|
||||
<div className="space-y-3 p-2">
|
||||
{/* Toggle tool */}
|
||||
<div className="bg-secondary/20 rounded-lg p-3 border border-secondary/30">
|
||||
<InputField
|
||||
icon={faToggleOn}
|
||||
fieldName={t('spellComponent.enableTool')}
|
||||
input={
|
||||
<ToggleSwitch
|
||||
checked={toolEnabled}
|
||||
onChange={toggleTool}
|
||||
{showTagManager && (
|
||||
<SpellTagManager
|
||||
tags={tags}
|
||||
onCreateTag={createTag}
|
||||
onUpdateTag={updateTag}
|
||||
onDeleteTag={deleteTag}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{toolEnabled && (
|
||||
<>
|
||||
{!showTagManager && viewMode === 'list' && (
|
||||
<div className="space-y-3 p-2">
|
||||
{/* Import from series */}
|
||||
{bookSeriesId && availableSeriesSpells.length > 0 && (
|
||||
<SeriesImportSelector
|
||||
@@ -160,14 +150,14 @@ export default function SpellEditor(): React.JSX.Element {
|
||||
tags={tags}
|
||||
onSpellClick={enterDetailMode}
|
||||
onAddSpell={addNewSpell}
|
||||
onManageTags={function (): void { setShowTagManager(true); }}
|
||||
onManageTags={function (): void {
|
||||
setShowTagManager(true);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === 'detail' && selectedSpell && (
|
||||
{!showTagManager && viewMode === 'detail' && selectedSpell && (
|
||||
<div className="p-4">
|
||||
<SpellEditorDetail
|
||||
spell={selectedSpell}
|
||||
@@ -177,7 +167,7 @@ export default function SpellEditor(): React.JSX.Element {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === 'edit' && selectedSpell && (
|
||||
{!showTagManager && viewMode === 'edit' && selectedSpell && (
|
||||
<div className="p-4">
|
||||
<SpellEditorEdit
|
||||
spell={selectedSpell}
|
||||
@@ -199,19 +189,12 @@ export default function SpellEditor(): React.JSX.Element {
|
||||
confirmText={t('common.delete')}
|
||||
cancelText={t('common.cancel')}
|
||||
onConfirm={handleDelete}
|
||||
onCancel={function (): void { setShowDeleteConfirm(false); }}
|
||||
onCancel={function (): void {
|
||||
setShowDeleteConfirm(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showTagManager && (
|
||||
<SpellTagManager
|
||||
tags={tags}
|
||||
onBack={function (): void { setShowTagManager(false); }}
|
||||
onCreateTag={createTag}
|
||||
onUpdateTag={updateTag}
|
||||
onDeleteTag={deleteTag}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
'use client';
|
||||
import React from 'react';
|
||||
import {SpellEditState, spellPowerLevels, SpellTagProps} from '@/lib/models/Spell';
|
||||
import {SeriesSpellDetailResponse} from '@/lib/models/Series';
|
||||
import {SelectBoxProps} from '@/shared/interface';
|
||||
import {SpellEditState, SpellTagProps} from '@/lib/types/spell';
|
||||
import {spellPowerLevels} from '@/lib/constants/spell';
|
||||
import {SeriesSpellDetailResponse} from '@/lib/types/series';
|
||||
import {SelectBoxProps} from '@/components/form/SelectBox';
|
||||
import SpellTagChip from '@/components/book/settings/spells/SpellTagChip';
|
||||
import {useTranslations} from 'next-intl';
|
||||
import DetailField from '@/components/ui/DetailField';
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
|
||||
interface SpellEditorDetailProps {
|
||||
spell: SpellEditState;
|
||||
@@ -40,16 +42,6 @@ export default function SpellEditorDetail({
|
||||
return level ? t(level.label) : spell.powerLevel;
|
||||
}
|
||||
|
||||
function renderField(label: string, value: string | null | undefined): React.JSX.Element | null {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<span className="text-text-secondary text-xs block mb-1">{label}</span>
|
||||
<p className="text-text-primary text-sm whitespace-pre-wrap">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedTags: SpellTagProps[] = getSelectedTags();
|
||||
const powerLevelText: string = getLocalizedPowerLevel();
|
||||
|
||||
@@ -57,12 +49,13 @@ export default function SpellEditorDetail({
|
||||
<div>
|
||||
<h3 className="text-text-primary font-semibold text-base mb-4">{spell.name}</h3>
|
||||
|
||||
{renderField(t('spellDetail.description'), spell.description)}
|
||||
{renderField(t('spellDetail.appearance'), spell.appearance)}
|
||||
{powerLevelText && renderField(t('spellDetail.powerLevel'), powerLevelText)}
|
||||
{renderField(t('spellDetail.components'), spell.components)}
|
||||
{renderField(t('spellDetail.limitations'), spell.limitations)}
|
||||
{renderField(t('spellDetail.notes'), spell.notes)}
|
||||
<DetailField variant="compact" label={t('spellDetail.description')} value={spell.description}/>
|
||||
<DetailField variant="compact" label={t('spellDetail.appearance')} value={spell.appearance}/>
|
||||
{powerLevelText &&
|
||||
<DetailField variant="compact" label={t('spellDetail.powerLevel')} value={powerLevelText}/>}
|
||||
<DetailField variant="compact" label={t('spellDetail.components')} value={spell.components}/>
|
||||
<DetailField variant="compact" label={t('spellDetail.limitations')} value={spell.limitations}/>
|
||||
<DetailField variant="compact" label={t('spellDetail.notes')} value={spell.notes}/>
|
||||
|
||||
{selectedTags.length > 0 && (
|
||||
<div className="mb-3">
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
'use client';
|
||||
import React, {ChangeEvent, useState} from 'react';
|
||||
import {defaultTagColors, SpellEditState, spellPowerLevels, SpellTagProps} from '@/lib/models/Spell';
|
||||
import {SeriesSpellDetailResponse} from '@/lib/models/Series';
|
||||
import {SelectBoxProps} from '@/shared/interface';
|
||||
import {SpellEditState, SpellTagProps} from '@/lib/types/spell';
|
||||
import {defaultTagColors, spellPowerLevels} from '@/lib/constants/spell';
|
||||
import {SeriesSpellDetailResponse} from '@/lib/types/series';
|
||||
import SelectBox, {SelectBoxProps} from '@/components/form/SelectBox';
|
||||
import InputField from '@/components/form/InputField';
|
||||
import TextInput from '@/components/form/TextInput';
|
||||
import TexteAreaInput from '@/components/form/TexteAreaInput';
|
||||
import SelectBox from '@/components/form/SelectBox';
|
||||
import TextAreaInput from '@/components/form/TextAreaInput';
|
||||
import SpellTagChip from '@/components/book/settings/spells/SpellTagChip';
|
||||
import SyncFieldWrapper from '@/components/form/SyncFieldWrapper';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {faPlus} from '@fortawesome/free-solid-svg-icons';
|
||||
import {useTranslations} from 'next-intl';
|
||||
import {Plus} from 'lucide-react';
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
import {dynamicBg, dynamicBgWithOpacity, dynamicBorderWithOpacity, dynamicText} from '@/lib/utils/dynamicStyles';
|
||||
import Button from '@/components/ui/Button';
|
||||
|
||||
interface SpellEditorEditProps {
|
||||
spell: SpellEditState;
|
||||
@@ -99,7 +100,7 @@ export default function SpellEditorEdit({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Informations de base */}
|
||||
<div className="border-b border-secondary/30 pb-3">
|
||||
<div className="border-b border-secondary pb-3">
|
||||
<h4 className="text-text-primary font-medium text-sm mb-3">{t('spellDetail.basicInfo')}</h4>
|
||||
<div className="space-y-3">
|
||||
<InputField
|
||||
@@ -112,7 +113,9 @@ export default function SpellEditorEdit({
|
||||
bookElementId={spell.id || ''}
|
||||
field="name"
|
||||
elementType="spell"
|
||||
onDownload={function (): void { onSpellChange('name', seriesSpell?.name || ''); }}
|
||||
onDownload={function (): void {
|
||||
onSpellChange('name', seriesSpell?.name || '');
|
||||
}}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TextInput
|
||||
@@ -136,10 +139,12 @@ export default function SpellEditorEdit({
|
||||
bookElementId={spell.id || ''}
|
||||
field="description"
|
||||
elementType="spell"
|
||||
onDownload={function (): void { onSpellChange('description', seriesSpell?.description || ''); }}
|
||||
onDownload={function (): void {
|
||||
onSpellChange('description', seriesSpell?.description || '');
|
||||
}}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TexteAreaInput
|
||||
<TextAreaInput
|
||||
value={spell.description}
|
||||
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
|
||||
onSpellChange('description', e.target.value);
|
||||
@@ -160,10 +165,12 @@ export default function SpellEditorEdit({
|
||||
bookElementId={spell.id || ''}
|
||||
field="appearance"
|
||||
elementType="spell"
|
||||
onDownload={function (): void { onSpellChange('appearance', seriesSpell?.appearance || ''); }}
|
||||
onDownload={function (): void {
|
||||
onSpellChange('appearance', seriesSpell?.appearance || '');
|
||||
}}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TexteAreaInput
|
||||
<TextAreaInput
|
||||
value={spell.appearance}
|
||||
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
|
||||
onSpellChange('appearance', e.target.value);
|
||||
@@ -177,7 +184,7 @@ export default function SpellEditorEdit({
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="border-b border-secondary/30 pb-3">
|
||||
<div className="border-b border-secondary pb-3">
|
||||
<h4 className="text-text-primary font-medium text-sm mb-3">{t('spellDetail.tags')}</h4>
|
||||
<div className="space-y-3">
|
||||
{selectedTags.length > 0 && (
|
||||
@@ -187,7 +194,9 @@ export default function SpellEditorEdit({
|
||||
<SpellTagChip
|
||||
key={tag.id}
|
||||
tag={tag}
|
||||
onRemove={function (): void { handleRemoveTag(tag.id); }}
|
||||
onRemove={function (): void {
|
||||
handleRemoveTag(tag.id);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -208,15 +217,12 @@ export default function SpellEditorEdit({
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
onClick={function (): void { handleAddTag(tag.id); }}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs transition-all duration-200 hover:scale-105 border"
|
||||
style={{
|
||||
backgroundColor: `${tag.color || '#3B82F6'}20`,
|
||||
borderColor: `${tag.color || '#3B82F6'}50`,
|
||||
color: tag.color || '#3B82F6'
|
||||
onClick={function (): void {
|
||||
handleAddTag(tag.id);
|
||||
}}
|
||||
className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs transition-colors duration-200 hover:brightness-110 border ${dynamicBgWithOpacity(tag.color || '#51AE84', '20')} ${dynamicBorderWithOpacity(tag.color || '#51AE84', '50')} ${dynamicText(tag.color || 'var(--color-primary)')}`}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} className="w-2.5 h-2.5"/>
|
||||
<Plus className="w-2.5 h-2.5" strokeWidth={1.75}/>
|
||||
{tag.name}
|
||||
</button>
|
||||
);
|
||||
@@ -226,10 +232,12 @@ export default function SpellEditorEdit({
|
||||
|
||||
{showCreateOption && !isCreatingTag && (
|
||||
<button
|
||||
onClick={function (): void { setIsCreatingTag(true); }}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 bg-tertiary hover:bg-secondary/50 transition-colors text-left rounded-lg border border-secondary/50"
|
||||
onClick={function (): void {
|
||||
setIsCreatingTag(true);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 bg-tertiary hover:bg-secondary transition-colors text-left rounded-lg border border-secondary"
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} className="text-primary w-3 h-3"/>
|
||||
<Plus className="text-primary w-3 h-3" strokeWidth={1.75}/>
|
||||
<span className="text-primary font-medium text-sm">
|
||||
{t('spellDetail.createTag', {name: tagSearchQuery})}
|
||||
</span>
|
||||
@@ -237,7 +245,7 @@ export default function SpellEditorEdit({
|
||||
)}
|
||||
|
||||
{isCreatingTag && (
|
||||
<div className="p-3 bg-tertiary rounded-lg border border-secondary/50">
|
||||
<div className="p-3 bg-tertiary rounded-lg border border-secondary">
|
||||
<p className="text-text-secondary text-xs mb-2">
|
||||
{t('spellDetail.createTag', {name: tagSearchQuery})}
|
||||
</p>
|
||||
@@ -246,26 +254,23 @@ export default function SpellEditorEdit({
|
||||
return (
|
||||
<button
|
||||
key={color}
|
||||
onClick={function (): void { setNewTagColor(color); }}
|
||||
className={`w-6 h-6 rounded-full transition-all duration-200 ${newTagColor === color ? 'ring-2 ring-offset-1 ring-primary scale-110' : 'hover:scale-110'}`}
|
||||
style={{backgroundColor: color}}
|
||||
onClick={function (): void {
|
||||
setNewTagColor(color);
|
||||
}}
|
||||
className={`w-6 h-6 rounded-full transition-all duration-200 ${newTagColor === color ? 'ring-2 ring-offset-1 ring-primary' : 'hover:ring-1 hover:ring-primary/50'} ${dynamicBg(color)}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={function (): void { setIsCreatingTag(false); }}
|
||||
className="flex-1 py-1.5 px-2 bg-secondary/50 text-text-primary rounded-lg hover:bg-secondary transition-colors text-sm"
|
||||
>
|
||||
<Button variant="secondary" size="sm" onClick={function (): void {
|
||||
setIsCreatingTag(false);
|
||||
}}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateTag}
|
||||
className="flex-1 py-1.5 px-2 bg-primary text-text-primary rounded-lg hover:bg-primary-dark transition-colors text-sm"
|
||||
>
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" onClick={handleCreateTag}>
|
||||
{t('common.confirm')}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -273,7 +278,7 @@ export default function SpellEditorEdit({
|
||||
</div>
|
||||
|
||||
{/* Niveau de puissance */}
|
||||
<div className="border-b border-secondary/30 pb-3">
|
||||
<div className="border-b border-secondary pb-3">
|
||||
<h4 className="text-text-primary font-medium text-sm mb-3">{t('spellDetail.powerLevel')}</h4>
|
||||
<SyncFieldWrapper
|
||||
seriesElementId={spell.seriesSpellId}
|
||||
@@ -282,7 +287,9 @@ export default function SpellEditorEdit({
|
||||
bookElementId={spell.id || ''}
|
||||
field="powerLevel"
|
||||
elementType="spell"
|
||||
onDownload={function (): void { onSpellChange('powerLevel', seriesSpell?.powerLevel || null); }}
|
||||
onDownload={function (): void {
|
||||
onSpellChange('powerLevel', seriesSpell?.powerLevel || null);
|
||||
}}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<SelectBox
|
||||
@@ -296,7 +303,7 @@ export default function SpellEditorEdit({
|
||||
</div>
|
||||
|
||||
{/* Composants */}
|
||||
<div className="border-b border-secondary/30 pb-3">
|
||||
<div className="border-b border-secondary pb-3">
|
||||
<h4 className="text-text-primary font-medium text-sm mb-3">{t('spellDetail.components')}</h4>
|
||||
<SyncFieldWrapper
|
||||
seriesElementId={spell.seriesSpellId}
|
||||
@@ -305,10 +312,12 @@ export default function SpellEditorEdit({
|
||||
bookElementId={spell.id || ''}
|
||||
field="components"
|
||||
elementType="spell"
|
||||
onDownload={function (): void { onSpellChange('components', seriesSpell?.components || null); }}
|
||||
onDownload={function (): void {
|
||||
onSpellChange('components', seriesSpell?.components || null);
|
||||
}}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TexteAreaInput
|
||||
<TextAreaInput
|
||||
value={spell.components || ''}
|
||||
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
|
||||
onSpellChange('components', e.target.value || null);
|
||||
@@ -319,7 +328,7 @@ export default function SpellEditorEdit({
|
||||
</div>
|
||||
|
||||
{/* Limitations */}
|
||||
<div className="border-b border-secondary/30 pb-3">
|
||||
<div className="border-b border-secondary pb-3">
|
||||
<h4 className="text-text-primary font-medium text-sm mb-3">{t('spellDetail.limitations')}</h4>
|
||||
<SyncFieldWrapper
|
||||
seriesElementId={spell.seriesSpellId}
|
||||
@@ -328,10 +337,12 @@ export default function SpellEditorEdit({
|
||||
bookElementId={spell.id || ''}
|
||||
field="limitations"
|
||||
elementType="spell"
|
||||
onDownload={function (): void { onSpellChange('limitations', seriesSpell?.limitations || null); }}
|
||||
onDownload={function (): void {
|
||||
onSpellChange('limitations', seriesSpell?.limitations || null);
|
||||
}}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TexteAreaInput
|
||||
<TextAreaInput
|
||||
value={spell.limitations || ''}
|
||||
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
|
||||
onSpellChange('limitations', e.target.value || null);
|
||||
@@ -351,10 +362,12 @@ export default function SpellEditorEdit({
|
||||
bookElementId={spell.id || ''}
|
||||
field="notes"
|
||||
elementType="spell"
|
||||
onDownload={function (): void { onSpellChange('notes', seriesSpell?.notes || null); }}
|
||||
onDownload={function (): void {
|
||||
onSpellChange('notes', seriesSpell?.notes || null);
|
||||
}}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TexteAreaInput
|
||||
<TextAreaInput
|
||||
value={spell.notes || ''}
|
||||
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
|
||||
onSpellChange('notes', e.target.value || null);
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
'use client';
|
||||
import React, {useState} from 'react';
|
||||
import {SpellListItem, SpellTagProps} from '@/lib/models/Spell';
|
||||
import {SpellListItem, SpellTagProps} from '@/lib/types/spell';
|
||||
import InputField from '@/components/form/InputField';
|
||||
import TextInput from '@/components/form/TextInput';
|
||||
import SpellTagChip from '@/components/book/settings/spells/SpellTagChip';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {faChevronRight, faHatWizard, faPlus, faTags} from '@fortawesome/free-solid-svg-icons';
|
||||
import {useTranslations} from 'next-intl';
|
||||
import {Plus, Tag, Wand2} from 'lucide-react';
|
||||
import EntityListItem from '@/components/ui/EntityListItem';
|
||||
import AvatarIcon from '@/components/ui/AvatarIcon';
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
import IconContainer from '@/components/ui/IconContainer';
|
||||
import {dynamicBorderLeft} from '@/lib/utils/dynamicStyles';
|
||||
|
||||
interface SpellEditorListProps {
|
||||
spells: SpellListItem[];
|
||||
@@ -56,7 +59,7 @@ export default function SpellEditorList({
|
||||
placeholder={t('spellList.search')}
|
||||
/>
|
||||
}
|
||||
actionIcon={faPlus}
|
||||
actionIcon={Plus}
|
||||
actionLabel={t('spellList.add')}
|
||||
addButtonCallBack={async function (): Promise<void> {
|
||||
onAddSpell();
|
||||
@@ -67,11 +70,13 @@ export default function SpellEditorList({
|
||||
{/* Tag filter + manage button */}
|
||||
<div className="px-2 flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={function (): void { setSelectedTagId(null); }}
|
||||
onClick={function (): void {
|
||||
setSelectedTagId(null);
|
||||
}}
|
||||
className={`px-2 py-1 text-xs rounded-full transition-colors ${
|
||||
selectedTagId === null
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-secondary/50 text-text-secondary hover:bg-secondary'
|
||||
? 'bg-primary text-text-primary'
|
||||
: 'bg-secondary text-text-secondary hover:bg-secondary'
|
||||
}`}
|
||||
>
|
||||
{t('spellList.allTags')}
|
||||
@@ -80,13 +85,14 @@ export default function SpellEditorList({
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
onClick={function (): void { setSelectedTagId(tag.id === selectedTagId ? null : tag.id); }}
|
||||
onClick={function (): void {
|
||||
setSelectedTagId(tag.id === selectedTagId ? null : tag.id);
|
||||
}}
|
||||
className={`px-2 py-1 text-xs rounded-full transition-colors ${
|
||||
selectedTagId === tag.id
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-secondary/50 text-text-secondary hover:bg-secondary'
|
||||
}`}
|
||||
style={selectedTagId === tag.id ? {} : {borderLeft: `3px solid ${tag.color}`}}
|
||||
? 'bg-primary text-text-primary'
|
||||
: 'bg-secondary text-text-secondary hover:bg-secondary'
|
||||
} ${selectedTagId !== tag.id && tag.color ? dynamicBorderLeft(tag.color) : ''}`}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
@@ -94,9 +100,9 @@ export default function SpellEditorList({
|
||||
})}
|
||||
<button
|
||||
onClick={onManageTags}
|
||||
className="px-2 py-1 text-xs rounded-full bg-secondary/30 text-text-secondary hover:bg-secondary transition-colors flex items-center gap-1"
|
||||
className="px-2 py-1 text-xs rounded-full bg-secondary text-text-secondary hover:bg-secondary transition-colors flex items-center gap-1"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTags} className="w-3 h-3"/>
|
||||
<Tag className="w-3 h-3" strokeWidth={1.75}/>
|
||||
{t('spellList.manageTags')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -104,9 +110,7 @@ export default function SpellEditorList({
|
||||
<div className="px-2 space-y-2">
|
||||
{filteredSpells.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mb-3">
|
||||
<FontAwesomeIcon icon={faHatWizard} className="text-primary w-8 h-8"/>
|
||||
</div>
|
||||
<IconContainer icon={Wand2} size="lg" shape="circle"/>
|
||||
<h3 className="text-text-primary font-semibold text-base mb-1">
|
||||
{t('spellList.noSpells')}
|
||||
</h3>
|
||||
@@ -117,43 +121,29 @@ export default function SpellEditorList({
|
||||
) : (
|
||||
filteredSpells.map(function (spell: SpellListItem): React.JSX.Element {
|
||||
return (
|
||||
<div
|
||||
<EntityListItem
|
||||
key={spell.id}
|
||||
onClick={function (): void { onSpellClick(spell); }}
|
||||
className="group flex items-center p-3 bg-secondary/30 rounded-lg border-l-4 border-primary border border-secondary/50 cursor-pointer hover:bg-secondary hover:shadow-md transition-all duration-200 hover:border-primary/50"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full border-2 border-primary overflow-hidden bg-secondary shadow-sm group-hover:scale-110 transition-transform flex items-center justify-center">
|
||||
<FontAwesomeIcon icon={faHatWizard} className="text-primary w-5 h-5"/>
|
||||
</div>
|
||||
|
||||
<div className="ml-3 flex-1 min-w-0">
|
||||
<div className="text-text-primary font-semibold text-sm group-hover:text-primary transition-colors truncate">
|
||||
{spell.name}
|
||||
</div>
|
||||
<div className="text-muted text-xs truncate">
|
||||
{spell.description}
|
||||
</div>
|
||||
{spell.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
size="sm"
|
||||
onClick={function (): void {
|
||||
onSpellClick(spell);
|
||||
}}
|
||||
avatar={<AvatarIcon size="sm" icon={Wand2}/>}
|
||||
title={spell.name}
|
||||
subtitle={spell.description}
|
||||
extra={spell.tags.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{spell.tags.slice(0, 2).map(function (tag: SpellTagProps): React.JSX.Element {
|
||||
return <SpellTagChip key={tag.id} tag={tag} size="sm"/>;
|
||||
})}
|
||||
{spell.tags.length > 2 && (
|
||||
<span className="text-muted text-xs px-1.5 py-0.5 bg-secondary/50 rounded-full">
|
||||
<span
|
||||
className="text-muted text-xs px-1.5 py-0.5 bg-secondary rounded-full">
|
||||
+{spell.tags.length - 2}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-6 flex justify-center">
|
||||
<FontAwesomeIcon
|
||||
icon={faChevronRight}
|
||||
className="text-muted group-hover:text-primary group-hover:translate-x-1 transition-all w-3 h-3"
|
||||
) : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
'use client';
|
||||
import React, {useCallback, useContext, useMemo, useState} from 'react';
|
||||
import {useSpells, UseSpellsConfig} from '@/hooks/settings/useSpells';
|
||||
import {useTranslations} from 'next-intl';
|
||||
import {SpellEditState, SpellListItem, SpellTagProps} from '@/lib/models/Spell';
|
||||
import {SeriesSpellListItem} from '@/lib/models/Series';
|
||||
import {BookContext} from '@/context/BookContext';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {faSpinner, faToggleOn} from '@fortawesome/free-solid-svg-icons';
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
import {SpellEditState, SpellListItem} from '@/lib/types/spell';
|
||||
import {SeriesSpellListItem} from '@/lib/types/series';
|
||||
import {BookContext, BookContextProps} from '@/context/BookContext';
|
||||
import PulseLoader from '@/components/ui/PulseLoader';
|
||||
|
||||
import ToolDetailHeader from '@/components/book/settings/ToolDetailHeader';
|
||||
import InputField from '@/components/form/InputField';
|
||||
import ToggleSwitch from '@/components/form/ToggleSwitch';
|
||||
import SeriesImportSelector from '@/components/form/SeriesImportSelector';
|
||||
import AlertBox from '@/components/AlertBox';
|
||||
import AlertBox from '@/components/ui/AlertBox';
|
||||
import SpellTagManager from '@/components/book/settings/spells/SpellTagManager';
|
||||
|
||||
import SpellSettingsList from './SpellSettingsList';
|
||||
@@ -22,7 +19,7 @@ import SpellSettingsEdit from './SpellSettingsEdit';
|
||||
interface SpellSettingsProps {
|
||||
entityType?: 'book' | 'series';
|
||||
entityId?: string;
|
||||
showToggle?: boolean;
|
||||
toolEnabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,10 +30,10 @@ interface SpellSettingsProps {
|
||||
export default function SpellSettings({
|
||||
entityType = 'book',
|
||||
entityId,
|
||||
showToggle = true,
|
||||
toolEnabled: parentToolEnabled,
|
||||
}: SpellSettingsProps): React.JSX.Element {
|
||||
const t = useTranslations();
|
||||
const {book} = useContext(BookContext);
|
||||
const {book}: BookContextProps = useContext<BookContextProps>(BookContext);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean>(false);
|
||||
|
||||
const resolvedEntityId: string = entityId || book?.bookId || '';
|
||||
@@ -109,11 +106,7 @@ export default function SpellSettings({
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<FontAwesomeIcon icon={faSpinner} className="w-8 h-8 text-primary animate-spin"/>
|
||||
</div>
|
||||
);
|
||||
return <PulseLoader/>;
|
||||
}
|
||||
|
||||
const isNew: boolean = selectedSpell?.id === null;
|
||||
@@ -121,47 +114,40 @@ export default function SpellSettings({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header - uniquement pour detail/edit */}
|
||||
{/* Header */}
|
||||
<ToolDetailHeader
|
||||
title={selectedSpell?.name || ''}
|
||||
defaultTitle={t('spellDetail.newSpell')}
|
||||
viewMode={viewMode}
|
||||
title={showTagManager ? t('spellTagManager.title') : (selectedSpell?.name || '')}
|
||||
defaultTitle={showTagManager ? t('spellTagManager.title') : t('spellDetail.newSpell')}
|
||||
viewMode={showTagManager ? 'detail' : viewMode}
|
||||
isNew={isNew}
|
||||
onBack={backToList}
|
||||
onEdit={enterEditMode}
|
||||
onBack={showTagManager ? function (): void {
|
||||
setShowTagManager(false);
|
||||
} : backToList}
|
||||
onEdit={showTagManager ? undefined : enterEditMode}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
onDelete={function (): void { setShowDeleteConfirm(true); }}
|
||||
onExport={canExport ? exportToSeries : undefined}
|
||||
showExport={canExport}
|
||||
showDelete={Boolean(selectedSpell?.id)}
|
||||
onDelete={showTagManager ? undefined : function (): void {
|
||||
setShowDeleteConfirm(true);
|
||||
}}
|
||||
onExport={canExport && !showTagManager ? exportToSeries : undefined}
|
||||
showExport={canExport && !showTagManager}
|
||||
showDelete={!showTagManager && Boolean(selectedSpell?.id)}
|
||||
/>
|
||||
|
||||
{/* Contenu principal */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{viewMode === 'list' && (
|
||||
<div className="space-y-5 p-4">
|
||||
{/* Toggle tool */}
|
||||
{showToggle && !isSeriesMode && (
|
||||
<div className="bg-secondary/20 rounded-xl p-5 shadow-inner border border-secondary/30">
|
||||
<InputField
|
||||
icon={faToggleOn}
|
||||
fieldName={t('spellComponent.enableTool')}
|
||||
input={
|
||||
<ToggleSwitch
|
||||
checked={toolEnabled}
|
||||
onChange={toggleTool}
|
||||
{showTagManager && (
|
||||
<SpellTagManager
|
||||
tags={tags}
|
||||
onCreateTag={createTag}
|
||||
onUpdateTag={updateTag}
|
||||
onDeleteTag={deleteTag}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<p className="text-muted text-sm mt-2">
|
||||
{t('spellComponent.enableToolDescription')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contenu si outil activé */}
|
||||
{(toolEnabled || isSeriesMode) && (
|
||||
{!showTagManager && viewMode === 'list' && (
|
||||
<div className="space-y-5 p-4">
|
||||
{((parentToolEnabled !== undefined ? parentToolEnabled : toolEnabled) || isSeriesMode) && (
|
||||
<>
|
||||
{/* Import from series */}
|
||||
{!isSeriesMode && bookSeriesId && availableSeriesSpells.length > 0 && (
|
||||
@@ -184,14 +170,16 @@ export default function SpellSettings({
|
||||
tags={tags}
|
||||
onSpellClick={enterDetailMode}
|
||||
onAddSpell={addNewSpell}
|
||||
onManageTags={function (): void { setShowTagManager(true); }}
|
||||
onManageTags={function (): void {
|
||||
setShowTagManager(true);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === 'detail' && selectedSpell && (
|
||||
{!showTagManager && viewMode === 'detail' && selectedSpell && (
|
||||
<div className="p-4">
|
||||
<SpellSettingsDetail
|
||||
spell={selectedSpell}
|
||||
@@ -201,7 +189,7 @@ export default function SpellSettings({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === 'edit' && selectedSpell && (
|
||||
{!showTagManager && viewMode === 'edit' && selectedSpell && (
|
||||
<div className="p-4">
|
||||
<SpellSettingsEdit
|
||||
spell={selectedSpell}
|
||||
@@ -224,20 +212,12 @@ export default function SpellSettings({
|
||||
confirmText={t('common.delete')}
|
||||
cancelText={t('common.cancel')}
|
||||
onConfirm={handleDelete}
|
||||
onCancel={function (): void { setShowDeleteConfirm(false); }}
|
||||
onCancel={function (): void {
|
||||
setShowDeleteConfirm(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tag Manager Modal */}
|
||||
{showTagManager && (
|
||||
<SpellTagManager
|
||||
tags={tags}
|
||||
onBack={function (): void { setShowTagManager(false); }}
|
||||
onCreateTag={createTag}
|
||||
onUpdateTag={updateTag}
|
||||
onDeleteTag={deleteTag}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
'use client';
|
||||
import React from 'react';
|
||||
import {SpellEditState, spellPowerLevels, SpellTagProps} from '@/lib/models/Spell';
|
||||
import {SeriesSpellDetailResponse} from '@/lib/models/Series';
|
||||
import {SelectBoxProps} from '@/shared/interface';
|
||||
import {SpellEditState, SpellTagProps} from '@/lib/types/spell';
|
||||
import {spellPowerLevels} from '@/lib/constants/spell';
|
||||
import {SeriesSpellDetailResponse} from '@/lib/types/series';
|
||||
import {SelectBoxProps} from '@/components/form/SelectBox';
|
||||
import SpellTagChip from '@/components/book/settings/spells/SpellTagChip';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faBolt,
|
||||
faEye,
|
||||
faHatWizard,
|
||||
faPuzzlePiece,
|
||||
faStickyNote,
|
||||
faTags,
|
||||
faTriangleExclamation,
|
||||
faWandMagicSparkles
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import {useTranslations} from 'next-intl';
|
||||
import {AlertTriangle, Eye, Puzzle, StickyNote, Wand2, Zap} from 'lucide-react';
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
import DetailHeroSection from '@/components/ui/DetailHeroSection';
|
||||
import DetailField from '@/components/ui/DetailField';
|
||||
|
||||
interface SpellSettingsDetailProps {
|
||||
spell: SpellEditState;
|
||||
@@ -48,11 +41,16 @@ export default function SpellSettingsDetail({
|
||||
|
||||
function getPowerLevelColor(): string {
|
||||
switch (spell.powerLevel) {
|
||||
case 'weak': return 'bg-green-500/20 text-green-400 border-green-500/30';
|
||||
case 'moderate': return 'bg-blue-500/20 text-blue-400 border-blue-500/30';
|
||||
case 'strong': return 'bg-orange-500/20 text-orange-400 border-orange-500/30';
|
||||
case 'legendary': return 'bg-purple-500/20 text-purple-400 border-purple-500/30';
|
||||
default: return 'bg-secondary/50 text-text-secondary border-secondary/50';
|
||||
case 'weak':
|
||||
return 'bg-accent-green/20 text-accent-green border-accent-green/30';
|
||||
case 'moderate':
|
||||
return 'bg-accent-blue/20 text-accent-blue border-accent-blue/30';
|
||||
case 'strong':
|
||||
return 'bg-accent-orange/20 text-accent-orange border-accent-orange/30';
|
||||
case 'legendary':
|
||||
return 'bg-accent-purple/20 text-accent-purple border-accent-purple/30';
|
||||
default:
|
||||
return 'bg-secondary text-text-secondary border-secondary';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,23 +59,14 @@ export default function SpellSettingsDetail({
|
||||
return (
|
||||
<div className="space-y-6 px-2 pb-4">
|
||||
{/* Hero Section */}
|
||||
<div className="p-6 bg-gradient-to-r from-primary/10 via-secondary/20 to-transparent rounded-2xl border border-secondary/30">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-16 h-16 rounded-xl bg-primary/20 flex items-center justify-center shrink-0">
|
||||
<FontAwesomeIcon icon={faWandMagicSparkles} className="w-8 h-8 text-primary"/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-2xl font-bold text-text-primary">{spell.name || '—'}</h2>
|
||||
|
||||
{/* Power Level Badge */}
|
||||
<DetailHeroSection icon={Wand2} title={spell.name || '—'}>
|
||||
<div className="flex items-center gap-3 mt-3">
|
||||
<span className={`inline-flex items-center gap-2 px-3 py-1 rounded-lg text-sm border ${getPowerLevelColor()}`}>
|
||||
<FontAwesomeIcon icon={faBolt} className="w-3 h-3"/>
|
||||
<span
|
||||
className={`inline-flex items-center gap-2 px-3 py-1 rounded-lg text-sm border ${getPowerLevelColor()}`}>
|
||||
<Zap className="w-3 h-3" strokeWidth={1.75}/>
|
||||
{getLocalizedPowerLevel()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{selectedTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-4">
|
||||
{selectedTags.map(function (tag: SpellTagProps): React.JSX.Element {
|
||||
@@ -85,48 +74,21 @@ export default function SpellSettingsDetail({
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DetailHeroSection>
|
||||
|
||||
{/* Description & Appearance - Side by side */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="p-5 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<FontAwesomeIcon icon={faHatWizard} className="w-4 h-4 text-primary"/>
|
||||
<h3 className="text-text-primary font-semibold">{t('spellDetail.description')}</h3>
|
||||
</div>
|
||||
<p className={`whitespace-pre-wrap ${spell.description ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
|
||||
{spell.description || '—'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-5 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<FontAwesomeIcon icon={faEye} className="w-4 h-4 text-primary"/>
|
||||
<h3 className="text-text-primary font-semibold">{t('spellDetail.appearance')}</h3>
|
||||
</div>
|
||||
<p className={`whitespace-pre-wrap ${spell.appearance ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
|
||||
{spell.appearance || '—'}
|
||||
</p>
|
||||
</div>
|
||||
<DetailField icon={Wand2} label={t('spellDetail.description')} value={spell.description}/>
|
||||
<DetailField icon={Eye} label={t('spellDetail.appearance')} value={spell.appearance}/>
|
||||
</div>
|
||||
|
||||
{/* Components & Limitations - Side by side */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="p-5 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<FontAwesomeIcon icon={faPuzzlePiece} className="w-4 h-4 text-primary"/>
|
||||
<h3 className="text-text-primary font-semibold">{t('spellDetail.components')}</h3>
|
||||
</div>
|
||||
<p className={`whitespace-pre-wrap ${spell.components ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
|
||||
{spell.components || '—'}
|
||||
</p>
|
||||
</div>
|
||||
<DetailField icon={Puzzle} label={t('spellDetail.components')} value={spell.components}/>
|
||||
|
||||
<div className="p-5 bg-error/10 rounded-xl border border-error/30">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<FontAwesomeIcon icon={faTriangleExclamation} className="w-4 h-4 text-error"/>
|
||||
<AlertTriangle className="w-4 h-4 text-error" strokeWidth={1.75}/>
|
||||
<h3 className="text-text-primary font-semibold">{t('spellDetail.limitations')}</h3>
|
||||
</div>
|
||||
<p className={`whitespace-pre-wrap ${spell.limitations ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
|
||||
@@ -136,15 +98,7 @@ export default function SpellSettingsDetail({
|
||||
</div>
|
||||
|
||||
{/* Notes - Full width */}
|
||||
<div className="p-5 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<FontAwesomeIcon icon={faStickyNote} className="w-4 h-4 text-primary"/>
|
||||
<h3 className="text-text-primary font-semibold">{t('spellDetail.notes')}</h3>
|
||||
</div>
|
||||
<p className={`whitespace-pre-wrap ${spell.notes ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
|
||||
{spell.notes || '—'}
|
||||
</p>
|
||||
</div>
|
||||
<DetailField icon={StickyNote} label={t('spellDetail.notes')} value={spell.notes}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,28 +1,19 @@
|
||||
'use client';
|
||||
import React, {useState} from 'react';
|
||||
import {defaultTagColors, SpellEditState, spellPowerLevels, SpellTagProps} from '@/lib/models/Spell';
|
||||
import {SeriesSpellDetailResponse} from '@/lib/models/Series';
|
||||
import {SelectBoxProps} from '@/shared/interface';
|
||||
import CollapsableArea from '@/components/CollapsableArea';
|
||||
import {SpellEditState, SpellTagProps} from '@/lib/types/spell';
|
||||
import {defaultTagColors, spellPowerLevels} from '@/lib/constants/spell';
|
||||
import {SeriesSpellDetailResponse} from '@/lib/types/series';
|
||||
import SelectBox, {SelectBoxProps} from '@/components/form/SelectBox';
|
||||
import Collapse from '@/components/ui/Collapse';
|
||||
import InputField from '@/components/form/InputField';
|
||||
import TextInput from '@/components/form/TextInput';
|
||||
import TexteAreaInput from '@/components/form/TexteAreaInput';
|
||||
import SelectBox from '@/components/form/SelectBox';
|
||||
import TextAreaInput from '@/components/form/TextAreaInput';
|
||||
import SpellTagChip from '@/components/book/settings/spells/SpellTagChip';
|
||||
import SyncFieldWrapper from '@/components/form/SyncFieldWrapper';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faBolt,
|
||||
faBook,
|
||||
faEye,
|
||||
faHatWizard,
|
||||
faPlus,
|
||||
faPuzzlePiece,
|
||||
faStickyNote,
|
||||
faTags,
|
||||
faTriangleExclamation
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import {useTranslations} from 'next-intl';
|
||||
import {AlertTriangle, Book, Eye, Plus, Puzzle, StickyNote, Tag, Wand2, Zap} from 'lucide-react';
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
import {dynamicBg, dynamicBgWithOpacity, dynamicBorderWithOpacity, dynamicText} from '@/lib/utils/dynamicStyles';
|
||||
import Button from '@/components/ui/Button';
|
||||
|
||||
interface SpellSettingsEditProps {
|
||||
spell: SpellEditState;
|
||||
@@ -111,8 +102,7 @@ export default function SpellSettingsEdit({
|
||||
return (
|
||||
<div className="space-y-4 px-2 pb-4">
|
||||
{/* Informations de base */}
|
||||
<CollapsableArea title={t('spellDetail.basicInfo')} icon={faHatWizard}>
|
||||
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<Collapse variant="card" title={t('spellDetail.basicInfo')} icon={Wand2}>
|
||||
<InputField
|
||||
fieldName={t('spellDetail.name')}
|
||||
input={
|
||||
@@ -123,7 +113,9 @@ export default function SpellSettingsEdit({
|
||||
bookElementId={spell.id || ''}
|
||||
field="name"
|
||||
elementType="spell"
|
||||
onDownload={function (): void { onSpellChange('name', seriesSpell?.name || ''); }}
|
||||
onDownload={function (): void {
|
||||
onSpellChange('name', seriesSpell?.name || '');
|
||||
}}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TextInput
|
||||
@@ -139,7 +131,7 @@ export default function SpellSettingsEdit({
|
||||
|
||||
<InputField
|
||||
fieldName={t('spellDetail.description')}
|
||||
icon={faBook}
|
||||
icon={Book}
|
||||
input={
|
||||
<SyncFieldWrapper
|
||||
seriesElementId={spell.seriesSpellId}
|
||||
@@ -148,10 +140,12 @@ export default function SpellSettingsEdit({
|
||||
bookElementId={spell.id || ''}
|
||||
field="description"
|
||||
elementType="spell"
|
||||
onDownload={function (): void { onSpellChange('description', seriesSpell?.description || ''); }}
|
||||
onDownload={function (): void {
|
||||
onSpellChange('description', seriesSpell?.description || '');
|
||||
}}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TexteAreaInput
|
||||
<TextAreaInput
|
||||
value={spell.description}
|
||||
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
|
||||
onSpellChange('description', e.target.value);
|
||||
@@ -164,7 +158,7 @@ export default function SpellSettingsEdit({
|
||||
|
||||
<InputField
|
||||
fieldName={t('spellDetail.appearance')}
|
||||
icon={faEye}
|
||||
icon={Eye}
|
||||
input={
|
||||
<SyncFieldWrapper
|
||||
seriesElementId={spell.seriesSpellId}
|
||||
@@ -173,10 +167,12 @@ export default function SpellSettingsEdit({
|
||||
bookElementId={spell.id || ''}
|
||||
field="appearance"
|
||||
elementType="spell"
|
||||
onDownload={function (): void { onSpellChange('appearance', seriesSpell?.appearance || ''); }}
|
||||
onDownload={function (): void {
|
||||
onSpellChange('appearance', seriesSpell?.appearance || '');
|
||||
}}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TexteAreaInput
|
||||
<TextAreaInput
|
||||
value={spell.appearance}
|
||||
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
|
||||
onSpellChange('appearance', e.target.value);
|
||||
@@ -186,12 +182,10 @@ export default function SpellSettingsEdit({
|
||||
</SyncFieldWrapper>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CollapsableArea>
|
||||
</Collapse>
|
||||
|
||||
{/* Tags */}
|
||||
<CollapsableArea title={t('spellDetail.tags')} icon={faTags}>
|
||||
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<Collapse variant="card" title={t('spellDetail.tags')} icon={Tag}>
|
||||
{selectedTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{selectedTags.map(function (tag: SpellTagProps): React.JSX.Element {
|
||||
@@ -199,7 +193,9 @@ export default function SpellSettingsEdit({
|
||||
<SpellTagChip
|
||||
key={tag.id}
|
||||
tag={tag}
|
||||
onRemove={function (): void { handleRemoveTag(tag.id); }}
|
||||
onRemove={function (): void {
|
||||
handleRemoveTag(tag.id);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -220,15 +216,12 @@ export default function SpellSettingsEdit({
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
onClick={function (): void { handleAddTag(tag.id); }}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm transition-all duration-200 hover:scale-105 hover:shadow-md border"
|
||||
style={{
|
||||
backgroundColor: `${tag.color || '#3B82F6'}20`,
|
||||
borderColor: `${tag.color || '#3B82F6'}50`,
|
||||
color: tag.color || '#3B82F6'
|
||||
onClick={function (): void {
|
||||
handleAddTag(tag.id);
|
||||
}}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm transition-colors duration-200 hover:brightness-110 border ${dynamicBgWithOpacity(tag.color || '#51AE84', '20')} ${dynamicBorderWithOpacity(tag.color || '#51AE84', '50')} ${dynamicText(tag.color || 'var(--color-primary)')}`}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} className="w-3 h-3"/>
|
||||
<Plus className="w-3 h-3" strokeWidth={1.75}/>
|
||||
{tag.name}
|
||||
</button>
|
||||
);
|
||||
@@ -238,10 +231,12 @@ export default function SpellSettingsEdit({
|
||||
|
||||
{showCreateOption && !isCreatingTag && (
|
||||
<button
|
||||
onClick={function (): void { setIsCreatingTag(true); }}
|
||||
className="w-full flex items-center gap-2 px-4 py-2.5 bg-tertiary hover:bg-secondary/50 transition-colors text-left rounded-xl border border-secondary/50"
|
||||
onClick={function (): void {
|
||||
setIsCreatingTag(true);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-4 py-2.5 bg-tertiary hover:bg-secondary transition-colors text-left rounded-xl border border-secondary"
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} className="text-primary w-3 h-3"/>
|
||||
<Plus className="text-primary w-3 h-3" strokeWidth={1.75}/>
|
||||
<span className="text-primary font-medium">
|
||||
{t('spellDetail.createTag', {name: tagSearchQuery})}
|
||||
</span>
|
||||
@@ -249,7 +244,7 @@ export default function SpellSettingsEdit({
|
||||
)}
|
||||
|
||||
{isCreatingTag && (
|
||||
<div className="p-4 bg-tertiary rounded-xl border border-secondary/50">
|
||||
<div className="p-4 bg-tertiary rounded-xl border border-secondary">
|
||||
<p className="text-text-secondary text-sm mb-3">
|
||||
{t('spellDetail.createTag', {name: tagSearchQuery})}
|
||||
</p>
|
||||
@@ -258,35 +253,30 @@ export default function SpellSettingsEdit({
|
||||
return (
|
||||
<button
|
||||
key={color}
|
||||
onClick={function (): void { setNewTagColor(color); }}
|
||||
className={`w-8 h-8 rounded-full transition-all duration-200 ${newTagColor === color ? 'ring-2 ring-offset-2 ring-primary scale-110' : 'hover:scale-110'}`}
|
||||
style={{backgroundColor: color}}
|
||||
onClick={function (): void {
|
||||
setNewTagColor(color);
|
||||
}}
|
||||
className={`w-8 h-8 rounded-full transition-all duration-200 ${newTagColor === color ? 'ring-2 ring-offset-2 ring-primary' : 'hover:ring-1 hover:ring-primary/50'} ${dynamicBg(color)}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={function (): void { setIsCreatingTag(false); }}
|
||||
className="flex-1 py-2 px-3 bg-secondary/50 text-text-primary rounded-lg hover:bg-secondary transition-colors"
|
||||
>
|
||||
<Button variant="secondary" size="sm" onClick={function (): void {
|
||||
setIsCreatingTag(false);
|
||||
}}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateTag}
|
||||
className="flex-1 py-2 px-3 bg-primary text-text-primary rounded-lg hover:bg-primary-dark transition-colors"
|
||||
>
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" onClick={handleCreateTag}>
|
||||
{t('common.confirm')}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsableArea>
|
||||
</Collapse>
|
||||
|
||||
{/* Niveau de puissance */}
|
||||
<CollapsableArea title={t('spellDetail.powerLevel')} icon={faBolt}>
|
||||
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<Collapse variant="card" title={t('spellDetail.powerLevel')} icon={Zap}>
|
||||
<SyncFieldWrapper
|
||||
seriesElementId={spell.seriesSpellId}
|
||||
seriesValue={seriesSpell?.powerLevel || 'none'}
|
||||
@@ -294,7 +284,9 @@ export default function SpellSettingsEdit({
|
||||
bookElementId={spell.id || ''}
|
||||
field="powerLevel"
|
||||
elementType="spell"
|
||||
onDownload={function (): void { onSpellChange('powerLevel', seriesSpell?.powerLevel || null); }}
|
||||
onDownload={function (): void {
|
||||
onSpellChange('powerLevel', seriesSpell?.powerLevel || null);
|
||||
}}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<SelectBox
|
||||
@@ -305,12 +297,10 @@ export default function SpellSettingsEdit({
|
||||
data={getLocalizedPowerLevels()}
|
||||
/>
|
||||
</SyncFieldWrapper>
|
||||
</div>
|
||||
</CollapsableArea>
|
||||
</Collapse>
|
||||
|
||||
{/* Composants */}
|
||||
<CollapsableArea title={t('spellDetail.components')} icon={faPuzzlePiece}>
|
||||
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<Collapse variant="card" title={t('spellDetail.components')} icon={Puzzle}>
|
||||
<SyncFieldWrapper
|
||||
seriesElementId={spell.seriesSpellId}
|
||||
seriesValue={seriesSpell?.components || ''}
|
||||
@@ -318,10 +308,12 @@ export default function SpellSettingsEdit({
|
||||
bookElementId={spell.id || ''}
|
||||
field="components"
|
||||
elementType="spell"
|
||||
onDownload={function (): void { onSpellChange('components', seriesSpell?.components || null); }}
|
||||
onDownload={function (): void {
|
||||
onSpellChange('components', seriesSpell?.components || null);
|
||||
}}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TexteAreaInput
|
||||
<TextAreaInput
|
||||
value={spell.components || ''}
|
||||
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
|
||||
onSpellChange('components', e.target.value || null);
|
||||
@@ -329,12 +321,10 @@ export default function SpellSettingsEdit({
|
||||
placeholder={t('spellDetail.componentsPlaceholder')}
|
||||
/>
|
||||
</SyncFieldWrapper>
|
||||
</div>
|
||||
</CollapsableArea>
|
||||
</Collapse>
|
||||
|
||||
{/* Limitations */}
|
||||
<CollapsableArea title={t('spellDetail.limitations')} icon={faTriangleExclamation}>
|
||||
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<Collapse variant="card" title={t('spellDetail.limitations')} icon={AlertTriangle}>
|
||||
<SyncFieldWrapper
|
||||
seriesElementId={spell.seriesSpellId}
|
||||
seriesValue={seriesSpell?.limitations || ''}
|
||||
@@ -342,10 +332,12 @@ export default function SpellSettingsEdit({
|
||||
bookElementId={spell.id || ''}
|
||||
field="limitations"
|
||||
elementType="spell"
|
||||
onDownload={function (): void { onSpellChange('limitations', seriesSpell?.limitations || null); }}
|
||||
onDownload={function (): void {
|
||||
onSpellChange('limitations', seriesSpell?.limitations || null);
|
||||
}}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TexteAreaInput
|
||||
<TextAreaInput
|
||||
value={spell.limitations || ''}
|
||||
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
|
||||
onSpellChange('limitations', e.target.value || null);
|
||||
@@ -353,12 +345,10 @@ export default function SpellSettingsEdit({
|
||||
placeholder={t('spellDetail.limitationsPlaceholder')}
|
||||
/>
|
||||
</SyncFieldWrapper>
|
||||
</div>
|
||||
</CollapsableArea>
|
||||
</Collapse>
|
||||
|
||||
{/* Notes */}
|
||||
<CollapsableArea title={t('spellDetail.notes')} icon={faStickyNote}>
|
||||
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<Collapse variant="card" title={t('spellDetail.notes')} icon={StickyNote}>
|
||||
<SyncFieldWrapper
|
||||
seriesElementId={spell.seriesSpellId}
|
||||
seriesValue={seriesSpell?.notes || ''}
|
||||
@@ -366,10 +356,12 @@ export default function SpellSettingsEdit({
|
||||
bookElementId={spell.id || ''}
|
||||
field="notes"
|
||||
elementType="spell"
|
||||
onDownload={function (): void { onSpellChange('notes', seriesSpell?.notes || null); }}
|
||||
onDownload={function (): void {
|
||||
onSpellChange('notes', seriesSpell?.notes || null);
|
||||
}}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TexteAreaInput
|
||||
<TextAreaInput
|
||||
value={spell.notes || ''}
|
||||
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
|
||||
onSpellChange('notes', e.target.value || null);
|
||||
@@ -377,8 +369,7 @@ export default function SpellSettingsEdit({
|
||||
placeholder={t('spellDetail.notesPlaceholder')}
|
||||
/>
|
||||
</SyncFieldWrapper>
|
||||
</div>
|
||||
</CollapsableArea>
|
||||
</Collapse>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
'use client';
|
||||
import React, {useState} from 'react';
|
||||
import {SpellListItem, SpellTagProps} from '@/lib/models/Spell';
|
||||
import {SpellListItem, SpellTagProps} from '@/lib/types/spell';
|
||||
import InputField from '@/components/form/InputField';
|
||||
import TextInput from '@/components/form/TextInput';
|
||||
import SpellTagChip from '@/components/book/settings/spells/SpellTagChip';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {faChevronRight, faCog, faHatWizard, faPlus} from '@fortawesome/free-solid-svg-icons';
|
||||
import {useTranslations} from 'next-intl';
|
||||
import {Plus, Settings, Wand2} from 'lucide-react';
|
||||
import EntityListItem from '@/components/ui/EntityListItem';
|
||||
import AvatarIcon from '@/components/ui/AvatarIcon';
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
import EmptyState from '@/components/ui/EmptyState';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Badge from '@/components/ui/Badge';
|
||||
|
||||
interface SpellSettingsListProps {
|
||||
spells: SpellListItem[];
|
||||
@@ -57,7 +61,7 @@ export default function SpellSettingsList({
|
||||
placeholder={t('spellList.search')}
|
||||
/>
|
||||
}
|
||||
actionIcon={faPlus}
|
||||
actionIcon={Plus}
|
||||
actionLabel={t('spellList.add')}
|
||||
addButtonCallBack={async function (): Promise<void> {
|
||||
onAddSpell();
|
||||
@@ -71,7 +75,7 @@ export default function SpellSettingsList({
|
||||
onChange={function (e: React.ChangeEvent<HTMLSelectElement>): void {
|
||||
setFilterTag(e.target.value);
|
||||
}}
|
||||
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"
|
||||
className="input-base cursor-pointer"
|
||||
>
|
||||
<option value="all" className="bg-tertiary text-text-primary">
|
||||
{t('spellList.allTags')}
|
||||
@@ -85,70 +89,43 @@ export default function SpellSettingsList({
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
onClick={onManageTags}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-secondary/50 rounded-xl border border-secondary/50 hover:bg-secondary hover:border-secondary hover:shadow-md hover:scale-105 transition-all duration-200"
|
||||
>
|
||||
<FontAwesomeIcon icon={faCog} className="text-primary w-4 h-4"/>
|
||||
<span className="text-text-primary text-sm font-medium">{t('spellList.manageTags')}</span>
|
||||
</button>
|
||||
<Button variant="secondary" size="sm" icon={Settings} onClick={onManageTags}>
|
||||
{t('spellList.manageTags')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-2">
|
||||
{filteredSpells.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="w-20 h-20 rounded-full bg-primary/10 flex items-center justify-center mb-4">
|
||||
<FontAwesomeIcon icon={faHatWizard} className="text-primary w-10 h-10"/>
|
||||
</div>
|
||||
<h3 className="text-text-primary font-semibold text-lg mb-2">
|
||||
{t('spellList.noSpells')}
|
||||
</h3>
|
||||
<p className="text-muted text-sm max-w-xs">
|
||||
{t('spellList.noSpellsDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<EmptyState icon={Wand2} title={t('spellList.noSpells')}
|
||||
description={t('spellList.noSpellsDescription')}/>
|
||||
) : (
|
||||
<div className="space-y-2 p-2">
|
||||
{filteredSpells.map(function (spell: SpellListItem): React.JSX.Element {
|
||||
return (
|
||||
<div
|
||||
<EntityListItem
|
||||
key={spell.id}
|
||||
onClick={function (): void { onSpellClick(spell); }}
|
||||
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-12 h-12 rounded-full border-2 border-primary overflow-hidden bg-secondary shadow-md group-hover:scale-110 transition-transform flex items-center justify-center">
|
||||
<FontAwesomeIcon icon={faHatWizard} className="text-primary w-6 h-6"/>
|
||||
</div>
|
||||
|
||||
<div className="ml-4 flex-1 min-w-0">
|
||||
<div className="text-text-primary font-bold text-base group-hover:text-primary transition-colors">
|
||||
{spell.name}
|
||||
</div>
|
||||
<div className="text-text-secondary text-sm mt-0.5 truncate">
|
||||
{spell.description}
|
||||
</div>
|
||||
{spell.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
onClick={function (): void {
|
||||
onSpellClick(spell);
|
||||
}}
|
||||
avatar={<AvatarIcon icon={Wand2}/>}
|
||||
title={spell.name}
|
||||
subtitle={spell.description}
|
||||
extra={
|
||||
spell.tags.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{spell.tags.slice(0, 3).map(function (tag: SpellTagProps): React.JSX.Element {
|
||||
return <SpellTagChip key={tag.id} tag={tag} size="sm"/>;
|
||||
})}
|
||||
{spell.tags.length > 3 && (
|
||||
<span className="text-muted text-xs px-2 py-0.5 bg-secondary/50 rounded-full">
|
||||
<Badge variant="muted" size="sm">
|
||||
+{spell.tags.length - 3}
|
||||
</span>
|
||||
</Badge>
|
||||
)}
|
||||
</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"
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -1,30 +1,21 @@
|
||||
import {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 React, {Dispatch, SetStateAction, useContext, useState} from 'react';
|
||||
import {Flag, Flame, LucideIcon, Puzzle, Scale, Trophy} from 'lucide-react';
|
||||
import {Act as ActType, Incident, PlotPoint} from '@/lib/types/book';
|
||||
import {ActChapter, ChapterListProps} from '@/lib/types/chapter';
|
||||
import {apiDelete, apiPost} from '@/lib/api/client';
|
||||
import {isDesktop} from '@/lib/configs';
|
||||
import * as tauri from '@/lib/tauri';
|
||||
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
|
||||
import {BookContext, BookContextProps} from '@/context/BookContext';
|
||||
import {SessionContext, SessionContextProps} from '@/context/SessionContext';
|
||||
import {AlertContext, AlertContextProps} from '@/context/AlertContext';
|
||||
import Collapse from '@/components/ui/Collapse';
|
||||
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 {useTranslations} from '@/lib/i18n';
|
||||
import {LangContext, LangContextProps} from "@/context/LangContext";
|
||||
import OfflineContext, {OfflineContextType} from "@/context/OfflineContext";
|
||||
import {LocalSyncQueueContext, LocalSyncQueueContextProps} from "@/context/SyncQueueContext";
|
||||
import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext";
|
||||
import {SyncedBook} from "@/lib/models/SyncedBook";
|
||||
import * as tauri from '@/lib/tauri';
|
||||
|
||||
interface ActProps {
|
||||
acts: ActType[];
|
||||
@@ -34,13 +25,11 @@ interface ActProps {
|
||||
|
||||
export default function Act({acts, setActs, mainChapters}: ActProps) {
|
||||
const t = useTranslations('actComponent');
|
||||
const {lang} = useContext<LangContextProps>(LangContext);
|
||||
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
|
||||
const {addToQueue} = useContext<LocalSyncQueueContextProps>(LocalSyncQueueContext);
|
||||
const {localSyncedBooks} = useContext<BooksSyncContextProps>(BooksSyncContext);
|
||||
const {book} = useContext(BookContext);
|
||||
const {session} = useContext(SessionContext);
|
||||
const {errorMessage, successMessage} = useContext(AlertContext);
|
||||
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext);
|
||||
const {book}: BookContextProps = useContext<BookContextProps>(BookContext);
|
||||
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
|
||||
const {errorMessage, successMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
|
||||
const {isCurrentlyOffline}: OfflineContextType = useContext<OfflineContextType>(OfflineContext);
|
||||
|
||||
const bookId: string | undefined = book?.bookId;
|
||||
const token: string = session.accessToken;
|
||||
@@ -80,21 +69,13 @@ export default function Act({acts, setActs, mainChapters}: ActProps) {
|
||||
|
||||
try {
|
||||
let incidentId: string;
|
||||
if (isCurrentlyOffline() || book?.localBook) {
|
||||
incidentId = await tauri.addIncident(bookId!, newIncidentTitle);
|
||||
if (isDesktop && (isCurrentlyOffline() || book?.localBook)) {
|
||||
incidentId = await tauri.addIncident(bookId ?? '', newIncidentTitle);
|
||||
} else {
|
||||
incidentId = await System.authPostToServer<string>('book/incident/new', {
|
||||
incidentId = await apiPost<string>('book/incident/new', {
|
||||
bookId,
|
||||
name: newIncidentTitle,
|
||||
}, token, lang);
|
||||
|
||||
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) {
|
||||
addToQueue('add_incident', {data: {
|
||||
bookId,
|
||||
incidentId,
|
||||
name: newIncidentTitle,
|
||||
}});
|
||||
}
|
||||
}
|
||||
if (!incidentId) {
|
||||
errorMessage(t('errorAddIncident'));
|
||||
@@ -130,15 +111,13 @@ export default function Act({acts, setActs, mainChapters}: ActProps) {
|
||||
async function deleteIncident(actId: number, incidentId: string): Promise<void> {
|
||||
try {
|
||||
let response: boolean;
|
||||
const deleteData = { bookId, incidentId, deletedAt: System.timeStampInSeconds() };
|
||||
if (isCurrentlyOffline() || book?.localBook) {
|
||||
response = await tauri.removeIncident(deleteData.bookId!, deleteData.incidentId, deleteData.deletedAt);
|
||||
if (isDesktop && (isCurrentlyOffline() || book?.localBook)) {
|
||||
response = await tauri.removeIncident(bookId ?? '', incidentId, Date.now());
|
||||
} else {
|
||||
response = await System.authDeleteToServer<boolean>('book/incident/remove', deleteData, token, lang);
|
||||
|
||||
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) {
|
||||
addToQueue('remove_incident', {data: deleteData});
|
||||
}
|
||||
response = await apiDelete<boolean>('book/incident/remove', {
|
||||
bookId,
|
||||
incidentId,
|
||||
}, token, lang);
|
||||
}
|
||||
if (!response) {
|
||||
errorMessage(t('errorDeleteIncident'));
|
||||
@@ -169,22 +148,14 @@ export default function Act({acts, setActs, mainChapters}: ActProps) {
|
||||
if (newPlotPointTitle.trim() === '') return;
|
||||
try {
|
||||
let plotId: string;
|
||||
const plotData = {
|
||||
if (isDesktop && (isCurrentlyOffline() || book?.localBook)) {
|
||||
plotId = await tauri.addPlotPoint(bookId ?? '', newPlotPointTitle, selectedIncidentId);
|
||||
} else {
|
||||
plotId = await apiPost<string>('book/plot/new', {
|
||||
bookId,
|
||||
name: newPlotPointTitle,
|
||||
incidentId: selectedIncidentId,
|
||||
};
|
||||
if (isCurrentlyOffline() || book?.localBook) {
|
||||
plotId = await tauri.addPlotPoint(plotData.bookId!, plotData.name, plotData.incidentId);
|
||||
} else {
|
||||
plotId = await System.authPostToServer<string>('book/plot/new', plotData, token, lang);
|
||||
|
||||
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) {
|
||||
addToQueue('add_plot_point', {data: {
|
||||
...plotData,
|
||||
plotId,
|
||||
}});
|
||||
}
|
||||
}, token, lang);
|
||||
}
|
||||
if (!plotId) {
|
||||
errorMessage(t('errorAddPlotPoint'));
|
||||
@@ -221,15 +192,12 @@ export default function Act({acts, setActs, mainChapters}: ActProps) {
|
||||
async function deletePlotPoint(actId: number, plotPointId: string): Promise<void> {
|
||||
try {
|
||||
let response: boolean;
|
||||
const deleteData = { plotId: plotPointId, bookId, deletedAt: System.timeStampInSeconds() };
|
||||
if (isCurrentlyOffline() || book?.localBook) {
|
||||
response = await tauri.removePlotPoint(deleteData.plotId, deleteData.bookId!, deleteData.deletedAt);
|
||||
if (isDesktop && (isCurrentlyOffline() || book?.localBook)) {
|
||||
response = await tauri.removePlotPoint(plotPointId, bookId ?? '', Date.now());
|
||||
} else {
|
||||
response = await System.authDeleteToServer<boolean>('book/plot/remove', deleteData, token, lang);
|
||||
|
||||
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) {
|
||||
addToQueue('remove_plot_point', {data: deleteData});
|
||||
}
|
||||
response = await apiDelete<boolean>('book/plot/remove', {
|
||||
plotId: plotPointId,
|
||||
}, token, lang);
|
||||
}
|
||||
if (!response) {
|
||||
errorMessage(t('errorDeletePlotPoint'));
|
||||
@@ -269,24 +237,22 @@ export default function Act({acts, setActs, mainChapters}: ActProps) {
|
||||
}
|
||||
try {
|
||||
let linkId: string;
|
||||
const linkData = {
|
||||
if (isDesktop && (isCurrentlyOffline() || book?.localBook)) {
|
||||
linkId = await tauri.addChapterInformation({
|
||||
chapterId: chapterId,
|
||||
actId: actId,
|
||||
bookId: bookId ?? '',
|
||||
plotId: destination === 'plotPoint' ? itemId : undefined,
|
||||
incidentId: destination === 'incident' ? itemId : undefined,
|
||||
});
|
||||
} else {
|
||||
linkId = await apiPost<string>('chapter/resume/add', {
|
||||
bookId,
|
||||
chapterId: chapterId,
|
||||
actId: actId,
|
||||
plotId: destination === 'plotPoint' ? itemId : null,
|
||||
incidentId: destination === 'incident' ? itemId : null,
|
||||
};
|
||||
if (isCurrentlyOffline() || book?.localBook) {
|
||||
linkId = await tauri.addChapterInformation(linkData as any);
|
||||
} else {
|
||||
linkId = await System.authPostToServer<string>('chapter/resume/add', linkData, token, lang);
|
||||
|
||||
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) {
|
||||
addToQueue('add_chapter_information', {data: {
|
||||
...linkData,
|
||||
chapterInfoId: linkId,
|
||||
}});
|
||||
}
|
||||
}, token, lang);
|
||||
}
|
||||
if (!linkId) {
|
||||
errorMessage(t('errorLinkChapter'));
|
||||
@@ -363,15 +329,12 @@ export default function Act({acts, setActs, mainChapters}: ActProps) {
|
||||
): Promise<void> {
|
||||
try {
|
||||
let response: boolean;
|
||||
const unlinkData = { chapterInfoId, bookId, deletedAt: System.timeStampInSeconds() };
|
||||
if (isCurrentlyOffline() || book?.localBook) {
|
||||
response = await tauri.removeChapterInformation(unlinkData.chapterInfoId, unlinkData.bookId!, unlinkData.deletedAt);
|
||||
if (isDesktop && (isCurrentlyOffline() || book?.localBook)) {
|
||||
response = await tauri.removeChapterInformation(chapterInfoId, bookId ?? '', Date.now());
|
||||
} else {
|
||||
response = await System.authDeleteToServer<boolean>('chapter/resume/remove', unlinkData, token, lang);
|
||||
|
||||
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) {
|
||||
addToQueue('remove_chapter_information', {data: unlinkData});
|
||||
}
|
||||
response = await apiDelete<boolean>('chapter/resume/remove', {
|
||||
chapterInfoId,
|
||||
}, token, lang);
|
||||
}
|
||||
if (!response) {
|
||||
errorMessage(t('errorUnlinkChapter'));
|
||||
@@ -621,20 +584,20 @@ export default function Act({acts, setActs, mainChapters}: ActProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function renderActIcon(actId: number): IconDefinition {
|
||||
function renderActIcon(actId: number): LucideIcon {
|
||||
switch (actId) {
|
||||
case 1:
|
||||
return faFlag;
|
||||
return Flag;
|
||||
case 2:
|
||||
return faFire;
|
||||
return Flame;
|
||||
case 3:
|
||||
return faPuzzlePiece;
|
||||
return Puzzle;
|
||||
case 4:
|
||||
return faScaleBalanced;
|
||||
return Scale;
|
||||
case 5:
|
||||
return faTrophy;
|
||||
return Trophy;
|
||||
default:
|
||||
return faFlag;
|
||||
return Flag;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -658,7 +621,7 @@ export default function Act({acts, setActs, mainChapters}: ActProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{acts.map((act: ActType) => (
|
||||
<CollapsableArea key={`act-${act.id}`}
|
||||
<Collapse variant="card" key={`act-${act.id}`}
|
||||
title={renderActTitle(act.id)}
|
||||
icon={renderActIcon(act.id)}
|
||||
children={
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import {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";
|
||||
import OfflineContext, {OfflineContextType} from "@/context/OfflineContext";
|
||||
import {LocalSyncQueueContext, LocalSyncQueueContextProps} from "@/context/SyncQueueContext";
|
||||
import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext";
|
||||
import {SyncedBook} from "@/lib/models/SyncedBook";
|
||||
import React, {ChangeEvent, useContext, useState} from 'react';
|
||||
import {AlertTriangle, Plus, Trash2} from 'lucide-react';
|
||||
import IconButton from "@/components/ui/IconButton";
|
||||
import {Issue} from '@/lib/types/book';
|
||||
import {apiDelete, apiPost} from '@/lib/api/client';
|
||||
import {isDesktop} from '@/lib/configs';
|
||||
import * as tauri from '@/lib/tauri';
|
||||
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
|
||||
import {BookContext, BookContextProps} from '@/context/BookContext';
|
||||
import {SessionContext, SessionContextProps} from '@/context/SessionContext';
|
||||
import {AlertContext, AlertContextProps} from '@/context/AlertContext';
|
||||
import Collapse from "@/components/ui/Collapse";
|
||||
import TextInput from "@/components/form/TextInput";
|
||||
import InputField from "@/components/form/InputField";
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
import {LangContext, LangContextProps} from "@/context/LangContext";
|
||||
|
||||
interface IssuesProps {
|
||||
issues: Issue[];
|
||||
@@ -22,13 +22,11 @@ interface IssuesProps {
|
||||
|
||||
export default function Issues({issues, setIssues}: IssuesProps) {
|
||||
const t = useTranslations();
|
||||
const {lang} = useContext<LangContextProps>(LangContext);
|
||||
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
|
||||
const {addToQueue} = useContext<LocalSyncQueueContextProps>(LocalSyncQueueContext);
|
||||
const {localSyncedBooks} = useContext<BooksSyncContextProps>(BooksSyncContext);
|
||||
const {book} = useContext(BookContext);
|
||||
const {session} = useContext(SessionContext);
|
||||
const {errorMessage} = useContext(AlertContext);
|
||||
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext);
|
||||
const {book}: BookContextProps = useContext<BookContextProps>(BookContext);
|
||||
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
|
||||
const {errorMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
|
||||
const {isCurrentlyOffline}: OfflineContextType = useContext<OfflineContextType>(OfflineContext);
|
||||
|
||||
const bookId: string | undefined = book?.bookId;
|
||||
const token: string = session.accessToken;
|
||||
@@ -42,21 +40,13 @@ export default function Issues({issues, setIssues}: IssuesProps) {
|
||||
}
|
||||
try {
|
||||
let issueId: string;
|
||||
if (isCurrentlyOffline() || book?.localBook) {
|
||||
issueId = await tauri.addIssue(bookId!, newIssueName);
|
||||
if (isDesktop && (isCurrentlyOffline() || book?.localBook)) {
|
||||
issueId = await tauri.addIssue(bookId ?? '', newIssueName);
|
||||
} else {
|
||||
issueId = await System.authPostToServer<string>('book/issue/add', {
|
||||
issueId = await apiPost<string>('book/issue/add', {
|
||||
bookId,
|
||||
name: newIssueName,
|
||||
}, token, lang);
|
||||
|
||||
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) {
|
||||
addToQueue('add_issue', {data: {
|
||||
bookId,
|
||||
issueId,
|
||||
name: newIssueName,
|
||||
}});
|
||||
}
|
||||
}
|
||||
if (!issueId) {
|
||||
errorMessage(t("issues.errorAdd"));
|
||||
@@ -81,33 +71,23 @@ export default function Issues({issues, setIssues}: IssuesProps) {
|
||||
async function deleteIssue(issueId: string): Promise<void> {
|
||||
if (issueId === undefined) {
|
||||
errorMessage(t("issues.errorInvalidId"));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
let response: boolean;
|
||||
const deletedAt: number = System.timeStampInSeconds();
|
||||
if (isCurrentlyOffline() || book?.localBook) {
|
||||
response = await tauri.removeIssue(bookId!, issueId, deletedAt);
|
||||
if (isDesktop && (isCurrentlyOffline() || book?.localBook)) {
|
||||
response = await tauri.removeIssue(bookId ?? '', issueId, Date.now());
|
||||
} else {
|
||||
response = await System.authDeleteToServer<boolean>(
|
||||
response = await apiDelete<boolean>(
|
||||
'book/issue/remove',
|
||||
{
|
||||
bookId,
|
||||
issueId,
|
||||
deletedAt,
|
||||
},
|
||||
token,
|
||||
lang
|
||||
);
|
||||
|
||||
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) {
|
||||
addToQueue('remove_issue', {data: {
|
||||
bookId,
|
||||
issueId,
|
||||
deletedAt,
|
||||
}});
|
||||
}
|
||||
}
|
||||
if (response) {
|
||||
const updatedIssues: Issue[] = issues.filter((issue: Issue): boolean => issue.id !== issueId,);
|
||||
@@ -135,51 +115,48 @@ export default function Issues({issues, setIssues}: IssuesProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<CollapsableArea title={t("issues.title")} children={
|
||||
<div className="p-1">
|
||||
<Collapse variant="card" title={t("issues.title")} icon={AlertTriangle}>
|
||||
<div className="space-y-2">
|
||||
{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"
|
||||
issues.map(function (item: Issue): React.JSX.Element {
|
||||
return (
|
||||
<div key={`issue-${item.id}`} className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<TextInput
|
||||
value={item.name}
|
||||
onChange={(e) => updateIssueName(item.id, e.target.value)}
|
||||
setValue={function (e: ChangeEvent<HTMLInputElement>): void {
|
||||
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>
|
||||
<IconButton icon={Trash2} variant="danger" size="sm"
|
||||
onClick={function (): Promise<void> {
|
||||
return deleteIssue(item.id);
|
||||
}}/>
|
||||
</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"
|
||||
<InputField
|
||||
input={
|
||||
<TextInput
|
||||
value={newIssueName}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setNewIssueName(e.target.value)}
|
||||
setValue={function (e: ChangeEvent<HTMLInputElement>): void {
|
||||
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>
|
||||
}
|
||||
actionIcon={Plus}
|
||||
addButtonCallBack={addNewIssue}
|
||||
isAddButtonDisabled={newIssueName.trim() === ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
} icon={faWarning}/>
|
||||
</Collapse>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,22 @@
|
||||
'use client'
|
||||
|
||||
import {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";
|
||||
import OfflineContext, {OfflineContextType} from "@/context/OfflineContext";
|
||||
import {LocalSyncQueueContext, LocalSyncQueueContextProps} from "@/context/SyncQueueContext";
|
||||
import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext";
|
||||
import {SyncedBook} from "@/lib/models/SyncedBook";
|
||||
import React, {ChangeEvent, useContext, useEffect, useState} from 'react';
|
||||
import {ArrowDown, ArrowUp, Bookmark, Trash2} from 'lucide-react';
|
||||
import {ChapterListProps} from '@/lib/types/chapter';
|
||||
import {apiDelete, apiPost} from '@/lib/api/client';
|
||||
import {isDesktop} from '@/lib/configs';
|
||||
import * as tauri from '@/lib/tauri';
|
||||
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
|
||||
import {BookContext, BookContextProps} from '@/context/BookContext';
|
||||
import {SessionContext, SessionContextProps} from '@/context/SessionContext';
|
||||
import {AlertContext, AlertContextProps} from '@/context/AlertContext';
|
||||
import AlertBox from "@/components/ui/AlertBox";
|
||||
import Collapse from "@/components/ui/Collapse";
|
||||
import IconButton from "@/components/ui/IconButton";
|
||||
import TextInput from "@/components/form/TextInput";
|
||||
import OrderInput from "@/components/form/OrderInput";
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
import {LangContext, LangContextProps} from "@/context/LangContext";
|
||||
|
||||
interface MainChapterProps {
|
||||
chapters: ChapterListProps[];
|
||||
@@ -25,13 +25,11 @@ interface MainChapterProps {
|
||||
|
||||
export default function MainChapter({chapters, setChapters}: MainChapterProps) {
|
||||
const t = useTranslations();
|
||||
const {lang} = useContext(LangContext)
|
||||
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
|
||||
const {addToQueue} = useContext<LocalSyncQueueContextProps>(LocalSyncQueueContext);
|
||||
const {localSyncedBooks} = useContext<BooksSyncContextProps>(BooksSyncContext);
|
||||
const {book} = useContext(BookContext);
|
||||
const {session} = useContext(SessionContext);
|
||||
const {errorMessage, successMessage} = useContext(AlertContext);
|
||||
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext)
|
||||
const {book}: BookContextProps = useContext<BookContextProps>(BookContext);
|
||||
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
|
||||
const {errorMessage, successMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
|
||||
const {isCurrentlyOffline}: OfflineContextType = useContext<OfflineContextType>(OfflineContext);
|
||||
|
||||
const bookId: string | undefined = book?.bookId;
|
||||
const token: string = session.accessToken;
|
||||
@@ -87,20 +85,18 @@ export default function MainChapter({chapters, setChapters}: MainChapterProps) {
|
||||
try {
|
||||
setDeleteConfirmMessage(false);
|
||||
let response: boolean;
|
||||
const deletedAt: number = System.timeStampInSeconds();
|
||||
const deleteData = {
|
||||
if (isDesktop && (isCurrentlyOffline() || book?.localBook)) {
|
||||
response = await tauri.removeChapter(chapterIdToRemove, bookId ?? '', Date.now());
|
||||
} else {
|
||||
response = await apiDelete<boolean>(
|
||||
'chapter/remove',
|
||||
{
|
||||
bookId,
|
||||
chapterId: chapterIdToRemove,
|
||||
deletedAt,
|
||||
};
|
||||
if (isCurrentlyOffline() || book?.localBook) {
|
||||
response = await tauri.removeChapter(deleteData.chapterId, deleteData.bookId!, deleteData.deletedAt);
|
||||
} else {
|
||||
response = await System.authDeleteToServer<boolean>('chapter/remove', deleteData, token, lang);
|
||||
|
||||
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) {
|
||||
addToQueue('remove_chapter', {data: deleteData});
|
||||
}
|
||||
},
|
||||
token,
|
||||
lang,
|
||||
);
|
||||
}
|
||||
if (!response) {
|
||||
errorMessage(t("mainChapter.errorDelete"));
|
||||
@@ -123,30 +119,30 @@ export default function MainChapter({chapters, setChapters}: MainChapterProps) {
|
||||
|
||||
try {
|
||||
let responseId: string;
|
||||
const chapterData = {
|
||||
if (isDesktop && (isCurrentlyOffline() || book?.localBook)) {
|
||||
responseId = await tauri.addChapter({
|
||||
bookId: bookId ?? '',
|
||||
title: newChapterTitle,
|
||||
chapterOrder: newChapterOrder ? newChapterOrder : 0,
|
||||
});
|
||||
} else {
|
||||
responseId = await apiPost<string>(
|
||||
'chapter/add',
|
||||
{
|
||||
bookId: bookId,
|
||||
wordsCount: 0,
|
||||
chapterOrder: newChapterOrder ? newChapterOrder : 0,
|
||||
title: newChapterTitle,
|
||||
};
|
||||
if (isCurrentlyOffline() || book?.localBook) {
|
||||
responseId = await tauri.addChapter(chapterData);
|
||||
} else {
|
||||
responseId = await System.authPostToServer<string>('chapter/add', chapterData, token);
|
||||
|
||||
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) {
|
||||
addToQueue('add_chapter', {data: {
|
||||
...chapterData,
|
||||
chapterId: responseId,
|
||||
}});
|
||||
}
|
||||
},
|
||||
token,
|
||||
);
|
||||
}
|
||||
if (!responseId) {
|
||||
errorMessage(t("mainChapter.errorAdd"));
|
||||
return;
|
||||
}
|
||||
const newChapter: ChapterListProps = {
|
||||
chapterId: responseId as string,
|
||||
chapterId: responseId,
|
||||
title: newChapterTitle,
|
||||
chapterOrder: newChapterOrder,
|
||||
summary: '',
|
||||
@@ -165,16 +161,6 @@ export default function MainChapter({chapters, setChapters}: MainChapterProps) {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -196,50 +182,48 @@ export default function MainChapter({chapters, setChapters}: MainChapterProps) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CollapsableArea icon={faBookmark} title={t("mainChapter.chapters")} children={
|
||||
<Collapse variant="card" icon={Bookmark} title={t("mainChapter.chapters")} children={
|
||||
<div className="space-y-4">
|
||||
{visibleChapters.length > 0 ? (
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
{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">
|
||||
<div key={item.chapterId} className="flex items-center gap-2">
|
||||
<span
|
||||
className="bg-primary-dark text-white text-sm w-6 h-6 rounded-full text-center leading-6 mr-2">
|
||||
className="text-muted text-xs w-5 h-5 rounded-md bg-tertiary text-center leading-5 shrink-0">
|
||||
{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"
|
||||
<div className="flex-1">
|
||||
<TextInput
|
||||
value={item.title}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => handleChapterTitleChange(item.chapterId, e.target.value)}
|
||||
setValue={function (e: ChangeEvent<HTMLInputElement>): void {
|
||||
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'
|
||||
}`}
|
||||
</div>
|
||||
<div className="flex shrink-0">
|
||||
<IconButton
|
||||
icon={ArrowUp}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
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"
|
||||
/>
|
||||
<IconButton
|
||||
icon={ArrowDown}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
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"
|
||||
/>
|
||||
<IconButton
|
||||
icon={Trash2}
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={(): void => {
|
||||
setChapterIdToRemove(item.chapterId);
|
||||
setDeleteConfirmMessage(true);
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} size="sm"/>
|
||||
</button>
|
||||
</div>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -250,44 +234,18 @@ export default function MainChapter({chapters, setChapters}: MainChapterProps) {
|
||||
</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"
|
||||
<div className="mt-3">
|
||||
<OrderInput
|
||||
value={newChapterTitle}
|
||||
onChange={e => setNewChapterTitle(e.target.value)}
|
||||
setValue={function (e: ChangeEvent<HTMLInputElement>): void {
|
||||
setNewChapterTitle(e.target.value);
|
||||
}}
|
||||
order={newChapterOrder}
|
||||
setOrder={setNewChapterOrder}
|
||||
placeholder={t("mainChapter.newChapterPlaceholder")}
|
||||
onAdd={addNewChapter}
|
||||
isAddDisabled={newChapterTitle.trim() === ''}
|
||||
/>
|
||||
<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>
|
||||
}/>
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
'use client'
|
||||
|
||||
import {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 React, {createContext, forwardRef, useContext, useEffect, useImperativeHandle, useState} from 'react';
|
||||
import {BookContext, BookContextProps} from '@/context/BookContext';
|
||||
import {SessionContext, SessionContextProps} from '@/context/SessionContext';
|
||||
import {AlertContext, AlertContextProps} from '@/context/AlertContext';
|
||||
import {apiGet, apiPost} from '@/lib/api/client';
|
||||
import {isDesktop} from '@/lib/configs';
|
||||
import * as tauri from '@/lib/tauri';
|
||||
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
|
||||
import {Act as ActType, Incident, Issue, PlotPoint} from '@/lib/types/book';
|
||||
import {ActChapter, ChapterListProps} from '@/lib/types/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 {useTranslations} from '@/lib/i18n';
|
||||
import {LangContext, LangContextProps} from "@/context/LangContext";
|
||||
import OfflineContext, {OfflineContextType} from "@/context/OfflineContext";
|
||||
import {LocalSyncQueueContext, LocalSyncQueueContextProps} from "@/context/SyncQueueContext";
|
||||
import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext";
|
||||
import {SyncedBook} from "@/lib/models/SyncedBook";
|
||||
import * as tauri from '@/lib/tauri';
|
||||
import {SettingRef} from "@/lib/types/settings";
|
||||
|
||||
export const StoryContext = createContext<{
|
||||
acts: ActType[];
|
||||
@@ -43,24 +42,22 @@ interface StoryFetchData {
|
||||
issues: Issue[];
|
||||
}
|
||||
|
||||
export function Story(props: any, ref: any) {
|
||||
export function Story(_props: object, ref: React.ForwardedRef<SettingRef>): React.JSX.Element {
|
||||
const t = useTranslations();
|
||||
const {lang} = useContext<LangContextProps>(LangContext);
|
||||
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
|
||||
const {addToQueue} = useContext<LocalSyncQueueContextProps>(LocalSyncQueueContext);
|
||||
const {localSyncedBooks} = useContext<BooksSyncContextProps>(BooksSyncContext);
|
||||
const {book} = useContext(BookContext);
|
||||
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext);
|
||||
const {book}: BookContextProps = useContext<BookContextProps>(BookContext);
|
||||
const bookId: string = book?.bookId ? book.bookId.toString() : '';
|
||||
const {session} = useContext(SessionContext);
|
||||
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
|
||||
const userToken: string = session.accessToken;
|
||||
const {errorMessage, successMessage} = useContext(AlertContext);
|
||||
const {errorMessage, successMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
|
||||
const {isCurrentlyOffline}: OfflineContextType = useContext<OfflineContextType>(OfflineContext);
|
||||
|
||||
const [acts, setActs] = useState<ActType[]>([]);
|
||||
const [issues, setIssues] = useState<Issue[]>([]);
|
||||
const [mainChapters, setMainChapters] = useState<ChapterListProps[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
|
||||
useImperativeHandle(ref, function () {
|
||||
useImperativeHandle(ref, function (): SettingRef {
|
||||
return {
|
||||
handleSave: handleSave
|
||||
};
|
||||
@@ -77,10 +74,10 @@ export function Story(props: any, ref: any) {
|
||||
async function getStoryData(): Promise<void> {
|
||||
try {
|
||||
let response: StoryFetchData;
|
||||
if (isCurrentlyOffline() || book?.localBook) {
|
||||
if (isDesktop && (isCurrentlyOffline() || book?.localBook)) {
|
||||
response = await tauri.getBookStory(bookId) as StoryFetchData;
|
||||
} else {
|
||||
response = await System.authGetQueryToServer<StoryFetchData>(`book/story`, userToken, lang, {
|
||||
response = await apiGet<StoryFetchData>(`book/story`, userToken, lang, {
|
||||
bookid: bookId,
|
||||
});
|
||||
}
|
||||
@@ -101,22 +98,22 @@ export function Story(props: any, ref: any) {
|
||||
}
|
||||
|
||||
function cleanupDeletedChapters(): void {
|
||||
const existingChapterIds: string[] = mainChapters.map(ch => ch.chapterId);
|
||||
const existingChapterIds: string[] = mainChapters.map((ch: ChapterListProps): string => ch.chapterId);
|
||||
|
||||
const updatedActs = acts.map((act: ActType) => {
|
||||
const updatedActs: ActType[] = acts.map((act: ActType): ActType => {
|
||||
const filteredChapters: ActChapter[] = act.chapters?.filter((chapter: ActChapter): boolean =>
|
||||
existingChapterIds.includes(chapter.chapterId)) || [];
|
||||
const updatedIncidents = act.incidents?.map(incident => {
|
||||
const updatedIncidents: Incident[] = act.incidents?.map((incident: Incident): Incident => {
|
||||
return {
|
||||
...incident,
|
||||
chapters: incident.chapters?.filter(chapter =>
|
||||
chapters: incident.chapters?.filter((chapter: ActChapter): boolean =>
|
||||
existingChapterIds.includes(chapter.chapterId)) || []
|
||||
};
|
||||
}) || [];
|
||||
const updatedPlotPoints = act.plotPoints?.map(plotPoint => {
|
||||
const updatedPlotPoints: PlotPoint[] = act.plotPoints?.map((plotPoint: PlotPoint): PlotPoint => {
|
||||
return {
|
||||
...plotPoint,
|
||||
chapters: plotPoint.chapters?.filter(chapter =>
|
||||
chapters: plotPoint.chapters?.filter((chapter: ActChapter): boolean =>
|
||||
existingChapterIds.includes(chapter.chapterId)) || []
|
||||
};
|
||||
}) || [];
|
||||
@@ -133,20 +130,20 @@ export function Story(props: any, ref: any) {
|
||||
async function handleSave(): Promise<void> {
|
||||
try {
|
||||
let response: boolean;
|
||||
const storyData = {
|
||||
if (isDesktop && (isCurrentlyOffline() || book?.localBook)) {
|
||||
response = await tauri.updateBookStory({
|
||||
bookId,
|
||||
acts,
|
||||
mainChapters,
|
||||
issues,
|
||||
};
|
||||
if (isCurrentlyOffline() || book?.localBook) {
|
||||
response = await tauri.updateBookStory(storyData);
|
||||
});
|
||||
} else {
|
||||
response = await System.authPostToServer<boolean>('book/story', storyData, userToken, lang);
|
||||
|
||||
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) {
|
||||
addToQueue('update_book_story', {data: storyData});
|
||||
}
|
||||
response = await apiPost<boolean>('book/story', {
|
||||
bookId,
|
||||
acts,
|
||||
mainChapters,
|
||||
issues,
|
||||
}, userToken, lang);
|
||||
}
|
||||
if (!response) {
|
||||
errorMessage(t("story.errorSave"))
|
||||
@@ -186,4 +183,4 @@ export function Story(props: any, ref: any) {
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef(Story);
|
||||
export default forwardRef<SettingRef, object>(Story);
|
||||
@@ -1,9 +1,9 @@
|
||||
import {ChangeEvent} from 'react';
|
||||
import {faTrash} from '@fortawesome/free-solid-svg-icons';
|
||||
import {ActChapter} from '@/lib/models/Chapter';
|
||||
import React, {ChangeEvent} from 'react';
|
||||
import {Trash2} from 'lucide-react';
|
||||
import {ActChapter} from '@/lib/types/chapter';
|
||||
import InputField from '@/components/form/InputField';
|
||||
import TexteAreaInput from '@/components/form/TexteAreaInput';
|
||||
import {useTranslations} from 'next-intl';
|
||||
import TextAreaInput from '@/components/form/TextAreaInput';
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
|
||||
interface ActChapterItemProps {
|
||||
chapter: ActChapter;
|
||||
@@ -15,11 +15,10 @@ export default function ActChapterItem({chapter, onUpdateSummary, onUnlink}: Act
|
||||
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">
|
||||
<div className="mb-3">
|
||||
<InputField
|
||||
input={
|
||||
<TexteAreaInput
|
||||
<TextAreaInput
|
||||
value={chapter.summary || ''}
|
||||
setValue={(e: ChangeEvent<HTMLTextAreaElement>) =>
|
||||
onUpdateSummary(chapter.chapterId, e.target.value)
|
||||
@@ -27,7 +26,7 @@ export default function ActChapterItem({chapter, onUpdateSummary, onUnlink}: Act
|
||||
placeholder={t('chapterSummaryPlaceholder')}
|
||||
/>
|
||||
}
|
||||
actionIcon={faTrash}
|
||||
actionIcon={Trash2}
|
||||
fieldName={chapter.title}
|
||||
action={(): Promise<void> => onUnlink(chapter.chapterInfoId, chapter.chapterId)}
|
||||
actionLabel={t('remove')}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import {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 React, {useState} from 'react';
|
||||
import {ActChapter, ChapterListProps} from '@/lib/types/chapter';
|
||||
import SelectBox, {SelectBoxProps} from '@/components/form/SelectBox';
|
||||
import ActChapterItem from './ActChapter';
|
||||
import InputField from '@/components/form/InputField';
|
||||
import SelectBox from '@/components/form/SelectBox';
|
||||
import {useTranslations} from 'next-intl';
|
||||
import Collapse from '@/components/ui/Collapse';
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
|
||||
interface ActChaptersSectionProps {
|
||||
actId: number;
|
||||
@@ -42,44 +40,38 @@ export default function ActChaptersSection({
|
||||
}
|
||||
|
||||
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">
|
||||
<Collapse title={t('chapters')} defaultOpen={isExpanded}>
|
||||
<div className="space-y-2">
|
||||
{chapters && chapters.length > 0 ? (
|
||||
chapters.map((chapter: ActChapter) => (
|
||||
chapters.map(function (chapter: ActChapter): React.JSX.Element {
|
||||
return (
|
||||
<ActChapterItem
|
||||
key={`chapter-${chapter.chapterInfoId}`}
|
||||
chapter={chapter}
|
||||
onUpdateSummary={(chapterId, summary) =>
|
||||
onUpdateChapterSummary(chapterId, summary)
|
||||
}
|
||||
onUnlink={(chapterInfoId, chapterId) =>
|
||||
onUnlinkChapter(chapterInfoId, chapterId)
|
||||
}
|
||||
onUpdateSummary={function (chapterId: string, summary: string): void {
|
||||
onUpdateChapterSummary(chapterId, summary);
|
||||
}}
|
||||
onUnlink={function (chapterInfoId: string, chapterId: string): Promise<void> {
|
||||
return onUnlinkChapter(chapterInfoId, chapterId);
|
||||
}}
|
||||
/>
|
||||
))
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className="text-text-secondary text-center text-sm p-2">
|
||||
{t('noLinkedChapter')}
|
||||
</p>
|
||||
)}
|
||||
<InputField
|
||||
addButtonCallBack={(): Promise<void> => onLinkChapter(actId, selectedChapterId)}
|
||||
addButtonCallBack={function (): Promise<void> {
|
||||
return onLinkChapter(actId, selectedChapterId);
|
||||
}}
|
||||
input={
|
||||
<SelectBox
|
||||
defaultValue={null}
|
||||
onChangeCallBack={(e) => setSelectedChapterId(e.target.value)}
|
||||
onChangeCallBack={function (e: React.ChangeEvent<HTMLSelectElement>): void {
|
||||
setSelectedChapterId(e.target.value);
|
||||
}}
|
||||
data={mainChaptersData()}
|
||||
placeholder={t('selectChapterPlaceholder')}
|
||||
/>
|
||||
@@ -87,7 +79,6 @@ export default function ActChaptersSection({
|
||||
isAddButtonDisabled={!selectedChapterId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Collapse>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {ChangeEvent} from 'react';
|
||||
import {faTrash} from '@fortawesome/free-solid-svg-icons';
|
||||
import React, {ChangeEvent} from 'react';
|
||||
import {Trash2} from 'lucide-react';
|
||||
import InputField from '@/components/form/InputField';
|
||||
import TexteAreaInput from '@/components/form/TexteAreaInput';
|
||||
import {useTranslations} from 'next-intl';
|
||||
import TextAreaInput from '@/components/form/TextAreaInput';
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
|
||||
interface ActDescriptionProps {
|
||||
actId: number;
|
||||
@@ -44,7 +44,7 @@ export default function ActDescription({actId, summary, onUpdateSummary}: ActDes
|
||||
<InputField
|
||||
fieldName={getActSummaryTitle(actId)}
|
||||
input={
|
||||
<TexteAreaInput
|
||||
<TextAreaInput
|
||||
value={summary || ''}
|
||||
setValue={(e: ChangeEvent<HTMLTextAreaElement>) =>
|
||||
onUpdateSummary(actId, e.target.value)
|
||||
@@ -52,7 +52,7 @@ export default function ActDescription({actId, summary, onUpdateSummary}: ActDes
|
||||
placeholder={getActSummaryPlaceholder(actId)}
|
||||
/>
|
||||
}
|
||||
actionIcon={faTrash}
|
||||
actionIcon={Trash2}
|
||||
actionLabel={t('delete')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import {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 React, {useState} from 'react';
|
||||
import {ChevronDown, ChevronUp, Plus, Trash2} from 'lucide-react';
|
||||
import {Incident} from '@/lib/types/book';
|
||||
import {ActChapter, ChapterListProps} from '@/lib/types/chapter';
|
||||
import ActChapterItem from './ActChapter';
|
||||
import {useTranslations} from 'next-intl';
|
||||
import IconButton from '@/components/ui/IconButton';
|
||||
import Collapse from '@/components/ui/Collapse';
|
||||
import SelectBox, {SelectBoxProps} from '@/components/form/SelectBox';
|
||||
import InputField from '@/components/form/InputField';
|
||||
import TextInput from '@/components/form/TextInput';
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
|
||||
interface ActIncidentsProps {
|
||||
incidents: Incident[];
|
||||
@@ -48,129 +52,120 @@ export default function ActIncidents({
|
||||
}));
|
||||
}
|
||||
|
||||
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>
|
||||
function mainChaptersData(): SelectBoxProps[] {
|
||||
return mainChapters.map(function (chapter: ChapterListProps): SelectBoxProps {
|
||||
return {
|
||||
value: chapter.chapterId,
|
||||
label: `${chapter.chapterOrder}. ${chapter.title}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
{isExpanded && (
|
||||
<div className="p-2">
|
||||
return (
|
||||
<Collapse title={t('incidentsTitle')} defaultOpen={isExpanded}>
|
||||
<div className="space-y-3">
|
||||
{incidents && incidents.length > 0 ? (
|
||||
<>
|
||||
{incidents.map((item: Incident) => {
|
||||
const itemKey = `incident_${item.incidentId}`;
|
||||
incidents.map(function (item: Incident): React.JSX.Element {
|
||||
const itemKey: string = `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"
|
||||
>
|
||||
<div key={`incident-${item.incidentId}`}
|
||||
className="bg-secondary rounded-xl overflow-hidden">
|
||||
<button
|
||||
className="flex justify-between items-center w-full p-2 text-left"
|
||||
onClick={(): void => toggleItem(itemKey)}
|
||||
className="flex justify-between items-center w-full p-3 text-left"
|
||||
onClick={function (): 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) => {
|
||||
<div className="flex items-center gap-1">
|
||||
{isItemExpanded
|
||||
? <ChevronUp className="text-primary w-3.5 h-3.5" strokeWidth={1.75}/>
|
||||
: <ChevronDown className="text-primary w-3.5 h-3.5" strokeWidth={1.75}/>
|
||||
}
|
||||
<div onClick={function (e: React.MouseEvent): void {
|
||||
e.stopPropagation();
|
||||
}}>
|
||||
<IconButton
|
||||
icon={Trash2}
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={async function (): Promise<void> {
|
||||
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>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isItemExpanded && (
|
||||
<div className="p-3 bg-secondary/20">
|
||||
<div className="p-3">
|
||||
{item.chapters && item.chapters.length > 0 ? (
|
||||
<>
|
||||
{item.chapters.map((chapter: ActChapter) => (
|
||||
item.chapters.map(function (chapter: ActChapter): React.JSX.Element {
|
||||
return (
|
||||
<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)
|
||||
}
|
||||
onUpdateSummary={function (chapterId: string, summary: string): void {
|
||||
onUpdateChapterSummary(chapterId, summary, item.incidentId);
|
||||
}}
|
||||
onUnlink={function (chapterInfoId: string, chapterId: string): Promise<void> {
|
||||
return 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)
|
||||
<InputField
|
||||
input={
|
||||
<SelectBox
|
||||
defaultValue={null}
|
||||
onChangeCallBack={function (e: React.ChangeEvent<HTMLSelectElement>): void {
|
||||
setSelectedChapterId(e.target.value);
|
||||
}}
|
||||
data={mainChaptersData()}
|
||||
placeholder={t('selectChapterPlaceholder')}
|
||||
/>
|
||||
}
|
||||
disabled={selectedChapterId.length === 0}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} className="w-3.5 h-3.5"/>
|
||||
</button>
|
||||
</div>
|
||||
addButtonCallBack={function (): Promise<void> {
|
||||
return onLinkChapter(actId, selectedChapterId, item.incidentId);
|
||||
}}
|
||||
isAddButtonDisabled={selectedChapterId.length === 0}
|
||||
/>
|
||||
</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"
|
||||
<InputField
|
||||
input={
|
||||
<TextInput
|
||||
value={newIncidentTitle}
|
||||
onChange={(e) => setNewIncidentTitle(e.target.value)}
|
||||
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
|
||||
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>
|
||||
)}
|
||||
}
|
||||
actionIcon={Plus}
|
||||
addButtonCallBack={function (): Promise<void> {
|
||||
return onAddIncident(actId);
|
||||
}}
|
||||
isAddButtonDisabled={newIncidentTitle.trim() === ''}
|
||||
/>
|
||||
</div>
|
||||
</Collapse>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import {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 React, {useState} from 'react';
|
||||
import {ChevronDown, ChevronUp, Plus, Trash2} from 'lucide-react';
|
||||
import {Incident, PlotPoint} from '@/lib/types/book';
|
||||
import {ActChapter, ChapterListProps} from '@/lib/types/chapter';
|
||||
import SelectBox, {SelectBoxProps} from '@/components/form/SelectBox';
|
||||
import ActChapterItem from './ActChapter';
|
||||
import InputField from '@/components/form/InputField';
|
||||
import SelectBox from '@/components/form/SelectBox';
|
||||
import {useTranslations} from 'next-intl';
|
||||
import TextInput from '@/components/form/TextInput';
|
||||
import IconButton from '@/components/ui/IconButton';
|
||||
import Collapse from '@/components/ui/Collapse';
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
|
||||
interface ActPlotPointsProps {
|
||||
plotPoints: PlotPoint[];
|
||||
@@ -65,36 +66,26 @@ export default function ActPlotPoints({
|
||||
}
|
||||
|
||||
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">
|
||||
<Collapse title={t('plotPointsTitle')} defaultOpen={isExpanded}>
|
||||
<div className="space-y-3">
|
||||
{plotPoints && plotPoints.length > 0 ? (
|
||||
plotPoints.map((item: PlotPoint) => {
|
||||
const itemKey = `plotpoint_${item.plotPointId}`;
|
||||
plotPoints.map(function (item: PlotPoint): React.JSX.Element {
|
||||
const itemKey: string = `plotpoint_${item.plotPointId}`;
|
||||
const isItemExpanded: boolean = expandedItems[itemKey];
|
||||
const linkedIncident: Incident | undefined = incidents.find(
|
||||
(inc: Incident): boolean => inc.incidentId === item.linkedIncidentId
|
||||
function (inc: Incident): boolean {
|
||||
return 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"
|
||||
>
|
||||
<div key={`plot-point-${item.plotPointId}`}
|
||||
className="bg-secondary rounded-xl overflow-hidden">
|
||||
<button
|
||||
className="flex justify-between items-center w-full p-2 text-left"
|
||||
onClick={(): void => toggleItem(itemKey)}
|
||||
className="flex justify-between items-center w-full p-3 text-left"
|
||||
onClick={function (): void {
|
||||
toggleItem(itemKey);
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<p className="font-bold text-text-primary">{item.title}</p>
|
||||
@@ -104,64 +95,70 @@ export default function ActPlotPoints({
|
||||
</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> => {
|
||||
<div className="flex items-center gap-1">
|
||||
{isItemExpanded
|
||||
? <ChevronUp className="text-primary w-3.5 h-3.5" strokeWidth={1.75}/>
|
||||
: <ChevronDown className="text-primary w-3.5 h-3.5" strokeWidth={1.75}/>
|
||||
}
|
||||
<div onClick={function (e: React.MouseEvent): void {
|
||||
e.stopPropagation();
|
||||
}}>
|
||||
<IconButton
|
||||
icon={Trash2}
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={async function (): Promise<void> {
|
||||
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>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isItemExpanded && (
|
||||
<div className="p-3 bg-secondary/20">
|
||||
<div className="p-3">
|
||||
{item.chapters && item.chapters.length > 0 ? (
|
||||
item.chapters.map((chapter: ActChapter) => (
|
||||
item.chapters.map(function (chapter: ActChapter): React.JSX.Element {
|
||||
return (
|
||||
<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)
|
||||
}
|
||||
onUpdateSummary={function (chapterId: string, summary: string): void {
|
||||
onUpdateChapterSummary(chapterId, summary, item.plotPointId);
|
||||
}}
|
||||
onUnlink={function (chapterInfoId: string, chapterId: string): Promise<void> {
|
||||
return 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>
|
||||
<InputField
|
||||
input={
|
||||
<SelectBox
|
||||
onChangeCallBack={function (e: React.ChangeEvent<HTMLSelectElement>): void {
|
||||
setSelectedChapterId(e.target.value);
|
||||
}}
|
||||
data={mainChapters.map(function (chapter: ChapterListProps): SelectBoxProps {
|
||||
return {
|
||||
label: `${chapter.chapterOrder}. ${chapter.title}`,
|
||||
value: chapter.chapterId,
|
||||
};
|
||||
})}
|
||||
defaultValue={selectedChapterId}
|
||||
placeholder={t('selectChapterPlaceholder')}
|
||||
/>
|
||||
}
|
||||
addButtonCallBack={function (): Promise<void> {
|
||||
return onLinkChapter(actId, selectedChapterId, item.plotPointId);
|
||||
}}
|
||||
isAddButtonDisabled={!selectedChapterId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -173,30 +170,32 @@ export default function ActPlotPoints({
|
||||
</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"
|
||||
<div className="space-y-2">
|
||||
<TextInput
|
||||
value={newPlotPointTitle}
|
||||
onChange={(e) => setNewPlotPointTitle(e.target.value)}
|
||||
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
|
||||
setNewPlotPointTitle(e.target.value);
|
||||
}}
|
||||
placeholder={t('newPlotPointPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<InputField
|
||||
input={
|
||||
<SelectBox
|
||||
defaultValue={``}
|
||||
onChangeCallBack={(e) => setSelectedIncidentId(e.target.value)}
|
||||
defaultValue=""
|
||||
onChangeCallBack={function (e: React.ChangeEvent<HTMLSelectElement>): void {
|
||||
setSelectedIncidentId(e.target.value);
|
||||
}}
|
||||
data={getIncidentData()}
|
||||
/>
|
||||
}
|
||||
addButtonCallBack={(): Promise<void> => onAddPlotPoint(actId)}
|
||||
actionIcon={Plus}
|
||||
addButtonCallBack={function (): Promise<void> {
|
||||
return onAddPlotPoint(actId);
|
||||
}}
|
||||
isAddButtonDisabled={newPlotPointTitle.trim() === ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Collapse>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,33 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import {ChangeEvent, useContext, useState} from "react";
|
||||
import {WorldContext} from "@/context/WorldContext";
|
||||
import React, {ChangeEvent, useContext, useState} from "react";
|
||||
import {WorldContext, WorldContextProps} 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";
|
||||
import OfflineContext, {OfflineContextType} from "@/context/OfflineContext";
|
||||
import {BookContext} from "@/context/BookContext";
|
||||
import {LocalSyncQueueContext, LocalSyncQueueContextProps} from "@/context/SyncQueueContext";
|
||||
import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext";
|
||||
import {SyncedBook} from "@/lib/models/SyncedBook";
|
||||
import {SeriesContext, SeriesContextProps} from "@/context/SeriesContext";
|
||||
import {SeriesSyncContext, SeriesSyncContextProps} from "@/context/SeriesSyncContext";
|
||||
import {SyncedSeries} from "@/lib/models/SyncedSeries";
|
||||
import TextAreaInput from "@/components/form/TextAreaInput";
|
||||
import {WorldElement, WorldElementSection, WorldProps} from "@/lib/types/world";
|
||||
import {AlertContext, AlertContextProps} from "@/context/AlertContext";
|
||||
import {SessionContext, SessionContextProps} from "@/context/SessionContext";
|
||||
import {apiDelete, apiPost} from "@/lib/api/client";
|
||||
import {isDesktop} from '@/lib/configs';
|
||||
import * as tauri from '@/lib/tauri';
|
||||
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
|
||||
import {BookContext, BookContextProps} from '@/context/BookContext';
|
||||
import InputField from "@/components/form/InputField";
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
import {LangContext, LangContextProps} from "@/context/LangContext";
|
||||
|
||||
interface WorldElementInputProps {
|
||||
sectionLabel: string;
|
||||
sectionType: string;
|
||||
sectionType: WorldElementSection;
|
||||
}
|
||||
|
||||
function getElementTypeNumber(sectionType: string): number {
|
||||
const typeMap: { [key: string]: number } = {
|
||||
function getElementTypeNumber(sectionType: WorldElementSection): number {
|
||||
const typeMap: Record<WorldElementSection, number> = {
|
||||
'laws': 0,
|
||||
'biomes': 1,
|
||||
'issues': 2,
|
||||
@@ -44,71 +39,53 @@ function getElementTypeNumber(sectionType: string): number {
|
||||
return typeMap[sectionType] ?? 0;
|
||||
}
|
||||
|
||||
export default function WorldElementComponent({sectionLabel, sectionType}: WorldElementInputProps) {
|
||||
export default function WorldElementComponent({sectionLabel, sectionType}: WorldElementInputProps): React.JSX.Element {
|
||||
const t = useTranslations();
|
||||
const {lang} = useContext<LangContextProps>(LangContext);
|
||||
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
|
||||
const {addToQueue} = useContext<LocalSyncQueueContextProps>(LocalSyncQueueContext);
|
||||
const {localSyncedBooks} = useContext<BooksSyncContextProps>(BooksSyncContext);
|
||||
const {book} = useContext(BookContext);
|
||||
const {worlds, setWorlds, selectedWorldIndex, isSeriesMode} = useContext(WorldContext);
|
||||
const {errorMessage} = useContext(AlertContext);
|
||||
const {session} = useContext(SessionContext);
|
||||
const {seriesId, localSeries} = useContext<SeriesContextProps>(SeriesContext);
|
||||
const {localSyncedSeries} = useContext<SeriesSyncContextProps>(SeriesSyncContext);
|
||||
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext);
|
||||
const {
|
||||
worlds,
|
||||
setWorlds,
|
||||
selectedWorldIndex,
|
||||
isSeriesMode
|
||||
}: WorldContextProps = useContext<WorldContextProps>(WorldContext);
|
||||
const {errorMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
|
||||
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
|
||||
const {book}: BookContextProps = useContext<BookContextProps>(BookContext);
|
||||
const {isCurrentlyOffline}: OfflineContextType = useContext<OfflineContextType>(OfflineContext);
|
||||
|
||||
const [newElementName, setNewElementName] = useState<string>('');
|
||||
|
||||
async function handleRemoveElement(
|
||||
section: keyof WorldProps,
|
||||
section: WorldElementSection,
|
||||
index: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const elements: WorldElement[] = worlds[selectedWorldIndex][section];
|
||||
let response: boolean;
|
||||
const elementId = (worlds[selectedWorldIndex][section] as WorldElement[])[index].id;
|
||||
const deletedAt: number = System.timeStampInSeconds();
|
||||
if (isSeriesMode) {
|
||||
const deleteData = {elementId, deletedAt};
|
||||
if (isCurrentlyOffline() || localSeries) {
|
||||
response = await tauri.deleteSeriesWorldElement(elementId, deletedAt);
|
||||
if (!isSeriesMode && isDesktop && (isCurrentlyOffline() || book?.localBook)) {
|
||||
response = await tauri.removeWorldElement(elements[index].id, book?.bookId ?? '', Date.now());
|
||||
} else {
|
||||
response = await System.authDeleteToServer<boolean>('series/world/element/delete', deleteData, session.accessToken, lang);
|
||||
if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) {
|
||||
addToQueue('delete_series_world_element', {data: deleteData});
|
||||
}
|
||||
}
|
||||
} else if (isCurrentlyOffline() || book?.localBook) {
|
||||
response = await tauri.removeWorldElement(elementId, book?.bookId || '', deletedAt);
|
||||
} else {
|
||||
response = await System.authDeleteToServer<boolean>('book/world/element/delete', {
|
||||
elementId, bookId: book?.bookId, deletedAt,
|
||||
const endpoint: string = isSeriesMode ? 'series/world/element/delete' : 'book/world/element/delete';
|
||||
response = await apiDelete<boolean>(endpoint, {
|
||||
elementId: elements[index].id,
|
||||
}, session.accessToken, lang);
|
||||
|
||||
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === book?.bookId)) {
|
||||
addToQueue('remove_world_element', {data: {
|
||||
elementId: elementId, bookId: book?.bookId, deletedAt,
|
||||
}});
|
||||
}
|
||||
}
|
||||
if (!response) {
|
||||
errorMessage(t("worldSetting.unknownError"))
|
||||
}
|
||||
const updatedWorlds: WorldProps[] = [...worlds];
|
||||
(updatedWorlds[selectedWorldIndex][section] as WorldElement[]).splice(
|
||||
index,
|
||||
1,
|
||||
);
|
||||
updatedWorlds[selectedWorldIndex][section].splice(index, 1);
|
||||
setWorlds(updatedWorlds);
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.toString());
|
||||
errorMessage(e.message);
|
||||
} else {
|
||||
errorMessage(t("worldElementComponent.errorUnknown"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddElement(section: keyof WorldProps): Promise<void> {
|
||||
async function handleAddElement(section: WorldElementSection): Promise<void> {
|
||||
if (newElementName.trim() === '') {
|
||||
errorMessage(t("worldElementComponent.emptyField", {section: sectionLabel}));
|
||||
return;
|
||||
@@ -116,52 +93,39 @@ export default function WorldElementComponent({sectionLabel, sectionType}: World
|
||||
try {
|
||||
let elementId: string;
|
||||
if (isSeriesMode) {
|
||||
const addData = {
|
||||
worldId: worlds[selectedWorldIndex].id,
|
||||
elementType: getElementTypeNumber(section as string),
|
||||
name: newElementName,
|
||||
};
|
||||
if (isCurrentlyOffline() || localSeries) {
|
||||
elementId = await tauri.addSeriesWorldElement(addData);
|
||||
} else {
|
||||
elementId = await System.authPostToServer<string>(
|
||||
elementId = await apiPost<string>(
|
||||
'series/world/element/add',
|
||||
addData,
|
||||
{
|
||||
worldId: worlds[selectedWorldIndex].id,
|
||||
elementType: getElementTypeNumber(section),
|
||||
name: newElementName,
|
||||
},
|
||||
session.accessToken,
|
||||
lang
|
||||
);
|
||||
if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) {
|
||||
addToQueue('add_series_world_element', {data: addData});
|
||||
}
|
||||
}
|
||||
if (!elementId) {
|
||||
errorMessage(t("worldSetting.unknownError"))
|
||||
return;
|
||||
}
|
||||
} else if (isCurrentlyOffline() || book?.localBook) {
|
||||
elementId = await tauri.addWorldElement(worlds[selectedWorldIndex].id, newElementName, section as string);
|
||||
} else if (isDesktop && (isCurrentlyOffline() || book?.localBook)) {
|
||||
elementId = await tauri.addWorldElement(worlds[selectedWorldIndex].id, newElementName, section);
|
||||
if (!elementId) {
|
||||
errorMessage(t("worldSetting.unknownError"))
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
elementId = await System.authPostToServer('book/world/element/add', {
|
||||
elementId = await apiPost<string>('book/world/element/add', {
|
||||
elementType: section,
|
||||
worldId: worlds[selectedWorldIndex].id,
|
||||
elementName: newElementName,
|
||||
}, session.accessToken, lang);
|
||||
|
||||
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === book?.bookId)) {
|
||||
addToQueue('add_world_element', {data: {
|
||||
elementType: section,
|
||||
worldId: worlds[selectedWorldIndex].id,
|
||||
elementId,
|
||||
elementName: newElementName,
|
||||
}});
|
||||
}
|
||||
}
|
||||
if (!elementId) {
|
||||
errorMessage(t("worldSetting.unknownError"))
|
||||
return;
|
||||
}
|
||||
}
|
||||
const updatedWorlds: WorldProps[] = [...worlds];
|
||||
(updatedWorlds[selectedWorldIndex][section] as WorldElement[]).push({
|
||||
updatedWorlds[selectedWorldIndex][section].push({
|
||||
id: elementId,
|
||||
name: newElementName,
|
||||
description: '',
|
||||
@@ -178,48 +142,44 @@ export default function WorldElementComponent({sectionLabel, sectionType}: World
|
||||
}
|
||||
|
||||
function handleElementChange(
|
||||
section: keyof WorldProps,
|
||||
section: WorldElementSection,
|
||||
index: number,
|
||||
field: keyof WorldElement,
|
||||
value: string,
|
||||
): void {
|
||||
const updatedWorlds: WorldProps[] = [...worlds];
|
||||
const sectionElements = updatedWorlds[selectedWorldIndex][
|
||||
section
|
||||
] as WorldElement[];
|
||||
const sectionElements: WorldElement[] = updatedWorlds[selectedWorldIndex][section];
|
||||
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) => (
|
||||
{worlds[selectedWorldIndex][sectionType].map(
|
||||
(element: WorldElement, index: number): React.JSX.Element => (
|
||||
<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">
|
||||
className="bg-secondary rounded-xl p-4 border-l-4 border-primary transition-colors 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)}
|
||||
setValue={(e: ChangeEvent<HTMLInputElement>): void => handleElementChange(sectionType, index, 'name', e.target.value)}
|
||||
placeholder={t("worldElementComponent.namePlaceholder", {section: sectionLabel.toLowerCase()})}
|
||||
/>}
|
||||
removeButtonCallBack={(): Promise<void> => handleRemoveElement(sectionType as keyof WorldProps, index)}/>
|
||||
removeButtonCallBack={(): Promise<void> => handleRemoveElement(sectionType, index)}/>
|
||||
</div>
|
||||
<TexteAreaInput
|
||||
<TextAreaInput
|
||||
value={element.description}
|
||||
setValue={(e) => handleElementChange(sectionType as keyof WorldProps, index, 'description', e.target.value)}
|
||||
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => handleElementChange(sectionType, 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)}/>
|
||||
/>} addButtonCallBack={(): Promise<void> => handleAddElement(sectionType)}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +1,29 @@
|
||||
'use client'
|
||||
import React, {ChangeEvent, forwardRef, useContext, useEffect, useImperativeHandle, useState} from 'react';
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {faPlus, faShare, faToggleOn, IconDefinition} from "@fortawesome/free-solid-svg-icons";
|
||||
import {LucideIcon, Plus, Share2, ToggleRight} from 'lucide-react';
|
||||
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, WorldListResponse, WorldProps} from "@/lib/models/World";
|
||||
import {SessionContext} from "@/context/SessionContext";
|
||||
import {BookContext, BookContextProps} from "@/context/BookContext";
|
||||
import {AlertContext, AlertContextProps} from "@/context/AlertContext";
|
||||
import SelectBox, {SelectBoxProps} from "@/components/form/SelectBox";
|
||||
import {apiGet, apiPatch, apiPost} from "@/lib/api/client";
|
||||
import {isDesktop} from '@/lib/configs';
|
||||
import * as tauri from '@/lib/tauri';
|
||||
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
|
||||
import {ElementSection, WorldListResponse, WorldProps, WorldTextField} from "@/lib/types/world";
|
||||
import {SettingRef} from "@/lib/types/settings";
|
||||
import {elementSections} from "@/lib/constants/world";
|
||||
import {SessionContext, SessionContextProps} from "@/context/SessionContext";
|
||||
import InputField from "@/components/form/InputField";
|
||||
import TextInput from '@/components/form/TextInput';
|
||||
import TexteAreaInput from "@/components/form/TexteAreaInput";
|
||||
import TextAreaInput from "@/components/form/TextAreaInput";
|
||||
import WorldElementComponent from './WorldElement';
|
||||
import SelectBox from "@/components/form/SelectBox";
|
||||
import {useTranslations} from "next-intl";
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
import {LangContext, LangContextProps} from "@/context/LangContext";
|
||||
import OfflineContext, {OfflineContextType} from "@/context/OfflineContext";
|
||||
import {LocalSyncQueueContext, LocalSyncQueueContextProps} from "@/context/SyncQueueContext";
|
||||
import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext";
|
||||
import {SyncedBook} from "@/lib/models/SyncedBook";
|
||||
import ToggleSwitch from "@/components/form/ToggleSwitch";
|
||||
import {SeriesWorldProps, SeriesWorldListItem} from "@/lib/models/Series";
|
||||
import {SeriesWorldListItem, SeriesWorldProps} from "@/lib/types/series";
|
||||
import SeriesImportSelector from "@/components/form/SeriesImportSelector";
|
||||
import SyncFieldWrapper from "@/components/form/SyncFieldWrapper";
|
||||
import * as tauri from '@/lib/tauri';
|
||||
|
||||
export interface ElementSection {
|
||||
title: string;
|
||||
section: keyof WorldProps;
|
||||
icon: IconDefinition;
|
||||
}
|
||||
import Button from "@/components/ui/Button";
|
||||
|
||||
interface WorldSettingProps {
|
||||
showToggle?: boolean;
|
||||
@@ -38,16 +31,14 @@ interface WorldSettingProps {
|
||||
entityId?: string;
|
||||
}
|
||||
|
||||
export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSave: () => Promise<void> }>) {
|
||||
export function WorldSetting(props: WorldSettingProps, ref: React.ForwardedRef<SettingRef>): React.JSX.Element {
|
||||
const {showToggle = true, entityType = 'book', entityId} = props;
|
||||
const t = useTranslations();
|
||||
const {lang} = useContext<LangContextProps>(LangContext);
|
||||
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
|
||||
const {addToQueue} = useContext<LocalSyncQueueContextProps>(LocalSyncQueueContext);
|
||||
const {localSyncedBooks} = useContext<BooksSyncContextProps>(BooksSyncContext);
|
||||
const {errorMessage, successMessage} = useContext(AlertContext);
|
||||
const {session} = useContext(SessionContext);
|
||||
const {book, setBook} = useContext(BookContext);
|
||||
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext);
|
||||
const {errorMessage, successMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
|
||||
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
|
||||
const {book, setBook}: BookContextProps = useContext<BookContextProps>(BookContext);
|
||||
const {isCurrentlyOffline}: OfflineContextType = useContext<OfflineContextType>(OfflineContext);
|
||||
|
||||
const currentEntityId: string = entityId || book?.bookId || '';
|
||||
const isSeriesMode: boolean = entityType === 'series';
|
||||
@@ -61,11 +52,9 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa
|
||||
const [toolEnabled, setToolEnabled] = useState<boolean>(isSeriesMode ? true : (book?.tools?.worlds ?? false));
|
||||
const bookSeriesId: string | null = book?.seriesId || null;
|
||||
|
||||
useImperativeHandle(ref, function () {
|
||||
return {
|
||||
useImperativeHandle(ref, (): SettingRef => ({
|
||||
handleSave: handleUpdateWorld,
|
||||
};
|
||||
});
|
||||
}));
|
||||
|
||||
useEffect((): void => {
|
||||
if (currentEntityId) {
|
||||
@@ -74,15 +63,15 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa
|
||||
}, [currentEntityId]);
|
||||
|
||||
useEffect((): void => {
|
||||
if (bookSeriesId && !isSeriesMode && !isCurrentlyOffline()) {
|
||||
if (bookSeriesId && !isSeriesMode) {
|
||||
getSeriesWorlds().then();
|
||||
}
|
||||
}, [bookSeriesId]);
|
||||
|
||||
async function getSeriesWorlds(): Promise<void> {
|
||||
if (!bookSeriesId || isCurrentlyOffline()) return;
|
||||
if (!bookSeriesId) return;
|
||||
try {
|
||||
const response: SeriesWorldProps[] = await System.authGetQueryToServer<SeriesWorldProps[]>('series/world/list', session.accessToken, lang, {
|
||||
const response: SeriesWorldProps[] = await apiGet<SeriesWorldProps[]>('series/world/list', session.accessToken, lang, {
|
||||
seriesid: bookSeriesId
|
||||
});
|
||||
if (response) {
|
||||
@@ -90,7 +79,7 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
console.error('Error loading series worlds:', e.message);
|
||||
errorMessage(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,21 +88,14 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa
|
||||
if (isSeriesMode) return;
|
||||
try {
|
||||
let response: boolean;
|
||||
if (isCurrentlyOffline() || book?.localBook) {
|
||||
if (isDesktop && (isCurrentlyOffline() || book?.localBook)) {
|
||||
response = await tauri.updateBookToolSetting(currentEntityId, 'worlds', enabled);
|
||||
} else {
|
||||
response = await System.authPatchToServer<boolean>('book/tool-setting', {
|
||||
response = await apiPatch<boolean>('book/tool-setting', {
|
||||
bookId: currentEntityId,
|
||||
toolName: 'worlds',
|
||||
enabled: enabled
|
||||
}, session.accessToken, lang);
|
||||
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) {
|
||||
addToQueue('update_book_tool_setting', {data: {
|
||||
bookId: currentEntityId,
|
||||
toolName: 'worlds',
|
||||
enabled: enabled
|
||||
}});
|
||||
}
|
||||
}
|
||||
if (response && setBook && book) {
|
||||
setToolEnabled(enabled);
|
||||
@@ -136,8 +118,7 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa
|
||||
async function getWorlds(): Promise<void> {
|
||||
try {
|
||||
if (isSeriesMode) {
|
||||
// Series mode: server only (series are not local)
|
||||
const response: SeriesWorldProps[] = await System.authGetQueryToServer<SeriesWorldProps[]>('series/world/list', session.accessToken, lang, {
|
||||
const response: SeriesWorldProps[] = await apiGet<SeriesWorldProps[]>('series/world/list', session.accessToken, lang, {
|
||||
seriesid: currentEntityId
|
||||
});
|
||||
if (response) {
|
||||
@@ -169,16 +150,31 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa
|
||||
}));
|
||||
setWorldsSelector(formattedWorlds);
|
||||
}
|
||||
} else {
|
||||
// Book mode: dual offline/online logic
|
||||
let response: WorldListResponse;
|
||||
if (isCurrentlyOffline() || book?.localBook) {
|
||||
response = await tauri.getWorlds(currentEntityId, true);
|
||||
} else {
|
||||
response = await System.authGetQueryToServer<WorldListResponse>('book/worlds', session.accessToken, lang, {
|
||||
bookid: currentEntityId,
|
||||
} else if (isDesktop && (isCurrentlyOffline() || book?.localBook)) {
|
||||
const response: WorldListResponse = await tauri.getWorlds(currentEntityId, toolEnabled) as WorldListResponse;
|
||||
if (response) {
|
||||
setWorlds(response.worlds);
|
||||
setToolEnabled(response.enabled);
|
||||
if (setBook && book) {
|
||||
setBook({
|
||||
...book, tools: {
|
||||
characters: book.tools?.characters ?? false,
|
||||
worlds: response.enabled,
|
||||
locations: book.tools?.locations ?? false,
|
||||
spells: book.tools?.spells ?? false
|
||||
}
|
||||
});
|
||||
}
|
||||
const formattedWorlds: SelectBoxProps[] = response.worlds.map(
|
||||
(world: WorldProps): SelectBoxProps => ({
|
||||
label: world.name,
|
||||
value: world.id.toString(),
|
||||
}),
|
||||
);
|
||||
setWorldsSelector(formattedWorlds);
|
||||
}
|
||||
} else {
|
||||
const response: WorldListResponse = await apiGet<WorldListResponse>('book/worlds', session.accessToken, lang, {bookid: currentEntityId});
|
||||
if (response) {
|
||||
setWorlds(response.worlds);
|
||||
setToolEnabled(response.enabled);
|
||||
@@ -218,8 +214,7 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa
|
||||
try {
|
||||
let newWorldId: string;
|
||||
if (isSeriesMode) {
|
||||
// Series mode: server only
|
||||
newWorldId = await System.authPostToServer<string>(
|
||||
newWorldId = await apiPost<string>(
|
||||
'series/world/add',
|
||||
{
|
||||
seriesId: currentEntityId,
|
||||
@@ -232,30 +227,22 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa
|
||||
errorMessage(t("worldSetting.addWorldError"));
|
||||
return;
|
||||
}
|
||||
} else if (isCurrentlyOffline() || book?.localBook) {
|
||||
// Book mode: offline/local
|
||||
} else if (isDesktop && (isCurrentlyOffline() || book?.localBook)) {
|
||||
newWorldId = await tauri.addWorld(currentEntityId, newWorldName);
|
||||
if (!newWorldId) {
|
||||
errorMessage(t("worldSetting.addWorldError"));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Book mode: online
|
||||
newWorldId = await System.authPostToServer<string>('book/world/add', {
|
||||
const worldId: string = await apiPost<string>('book/world/add', {
|
||||
worldName: newWorldName,
|
||||
bookId: currentEntityId,
|
||||
}, session.accessToken, lang);
|
||||
if (!newWorldId) {
|
||||
if (!worldId) {
|
||||
errorMessage(t("worldSetting.addWorldError"));
|
||||
return;
|
||||
}
|
||||
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) {
|
||||
addToQueue('add_world', {data: {
|
||||
worldName: newWorldName,
|
||||
worldId: newWorldId,
|
||||
bookId: currentEntityId,
|
||||
}});
|
||||
}
|
||||
newWorldId = worldId;
|
||||
}
|
||||
const newWorld: WorldProps = {
|
||||
id: newWorldId,
|
||||
@@ -298,11 +285,8 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa
|
||||
if (worlds.length === 0) return;
|
||||
try {
|
||||
const currentWorld: WorldProps = worlds[selectedWorldIndex];
|
||||
let response: boolean;
|
||||
|
||||
if (isSeriesMode) {
|
||||
// Series mode: server only
|
||||
response = await System.authPatchToServer<boolean>('series/world/update', {
|
||||
const response: boolean = await apiPatch<boolean>('series/world/update', {
|
||||
worldId: currentWorld.id,
|
||||
name: currentWorld.name,
|
||||
history: currentWorld.history,
|
||||
@@ -311,27 +295,26 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa
|
||||
religion: currentWorld.religion,
|
||||
languages: currentWorld.languages,
|
||||
}, session.accessToken, lang);
|
||||
} else if (isCurrentlyOffline() || book?.localBook) {
|
||||
// Book mode: offline/local
|
||||
response = await tauri.updateWorld(currentWorld);
|
||||
} else {
|
||||
// Book mode: online
|
||||
response = await System.authPatchToServer<boolean>('book/world/update', {
|
||||
world: currentWorld,
|
||||
bookId: currentEntityId,
|
||||
}, session.accessToken, lang);
|
||||
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) {
|
||||
addToQueue('update_world', {data: {
|
||||
world: currentWorld,
|
||||
bookId: currentEntityId,
|
||||
}});
|
||||
}
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
errorMessage(t("worldSetting.updateWorldError"));
|
||||
return;
|
||||
}
|
||||
} else if (isDesktop && (isCurrentlyOffline() || book?.localBook)) {
|
||||
const response: boolean = await tauri.updateWorld(currentWorld);
|
||||
if (!response) {
|
||||
errorMessage(t("worldSetting.updateWorldError"));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const response: boolean = await apiPatch<boolean>('book/world/update', {
|
||||
world: currentWorld,
|
||||
bookId: currentEntityId,
|
||||
}, session.accessToken, lang);
|
||||
if (!response) {
|
||||
errorMessage(t("worldSetting.updateWorldError"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
successMessage(t("worldSetting.updateWorldSuccess"));
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
@@ -349,19 +332,18 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa
|
||||
}
|
||||
}
|
||||
|
||||
function handleInputChange(value: string, field: keyof WorldProps) {
|
||||
const updatedWorlds = [...worlds] as WorldProps[];
|
||||
(updatedWorlds[selectedWorldIndex][field] as string) = value;
|
||||
function handleInputChange(value: string, field: WorldTextField): void {
|
||||
const updatedWorlds: WorldProps[] = [...worlds];
|
||||
updatedWorlds[selectedWorldIndex][field] = value;
|
||||
setWorlds(updatedWorlds);
|
||||
}
|
||||
|
||||
async function handleExportToSeries(): Promise<void> {
|
||||
if (isCurrentlyOffline()) return;
|
||||
const selectedWorld: WorldProps | undefined = worlds[selectedWorldIndex];
|
||||
if (!selectedWorld || !bookSeriesId) return;
|
||||
|
||||
try {
|
||||
const seriesWorldId: string = await System.authPostToServer<string>('series/world/add', {
|
||||
const seriesWorldId: string = await apiPost<string>('series/world/add', {
|
||||
seriesId: bookSeriesId,
|
||||
world: {
|
||||
name: selectedWorld.name,
|
||||
@@ -374,7 +356,7 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa
|
||||
}, session.accessToken, lang);
|
||||
|
||||
if (seriesWorldId) {
|
||||
const updateResponse: boolean = await System.authPostToServer<boolean>('book/world/update', {
|
||||
const updateResponse: boolean = await apiPost<boolean>('book/world/update', {
|
||||
world: {
|
||||
...selectedWorld,
|
||||
seriesWorldId: seriesWorldId
|
||||
@@ -397,12 +379,11 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa
|
||||
}
|
||||
|
||||
async function handleImportFromSeries(seriesWorldId: string): Promise<void> {
|
||||
if (isCurrentlyOffline()) return;
|
||||
const seriesWorld: SeriesWorldListItem | undefined = seriesWorlds.find((w) => w.id === seriesWorldId);
|
||||
const seriesWorld: SeriesWorldListItem | undefined = seriesWorlds.find((w: SeriesWorldListItem): boolean => w.id === seriesWorldId);
|
||||
if (!seriesWorld) return;
|
||||
|
||||
try {
|
||||
const worldId: string = await System.authPostToServer<string>('book/world/add', {
|
||||
const worldId: string = await apiPost<string>('book/world/add', {
|
||||
worldName: seriesWorld.name,
|
||||
bookId: currentEntityId,
|
||||
seriesWorldId: seriesWorldId,
|
||||
@@ -413,15 +394,6 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa
|
||||
return;
|
||||
}
|
||||
|
||||
// Sync to local if book is synced
|
||||
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) {
|
||||
addToQueue('add_world', {data: {
|
||||
worldName: seriesWorld.name,
|
||||
worldId: worldId,
|
||||
bookId: currentEntityId,
|
||||
}});
|
||||
}
|
||||
|
||||
const newWorld: WorldProps = {
|
||||
id: worldId,
|
||||
name: seriesWorld.name,
|
||||
@@ -465,9 +437,9 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{showToggle && !isSeriesMode && (
|
||||
<div className="bg-secondary/20 rounded-xl p-5 shadow-inner border border-secondary/30">
|
||||
<div className="bg-secondary rounded-xl p-4">
|
||||
<InputField
|
||||
icon={faToggleOn}
|
||||
icon={ToggleRight}
|
||||
fieldName={t('worldSetting.enableTool')}
|
||||
input={
|
||||
<ToggleSwitch
|
||||
@@ -483,25 +455,26 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa
|
||||
)}
|
||||
{(toolEnabled || isSeriesMode) && (
|
||||
<>
|
||||
{!isSeriesMode && bookSeriesId && !isCurrentlyOffline() &&
|
||||
{!isSeriesMode && bookSeriesId &&
|
||||
seriesWorlds.filter((seriesWorld: SeriesWorldProps): boolean => !worlds.some((world: WorldProps): boolean => world.seriesWorldId === seriesWorld.id)).length > 0 && (
|
||||
<SeriesImportSelector
|
||||
availableItems={seriesWorlds
|
||||
.filter((seriesWorld: SeriesWorldProps): boolean => !worlds.some((world: WorldProps): boolean => world.seriesWorldId === seriesWorld.id))
|
||||
.map((seriesWorld: SeriesWorldProps) => ({id: seriesWorld.id, name: seriesWorld.name}))}
|
||||
.map((seriesWorld: SeriesWorldProps): { id: string; name: string } => ({
|
||||
id: seriesWorld.id,
|
||||
name: seriesWorld.name
|
||||
}))}
|
||||
onImport={handleImportFromSeries}
|
||||
placeholder={t("seriesImport.selectElement")}
|
||||
label={t("seriesImport.importFromSeries")}
|
||||
/>
|
||||
)}
|
||||
<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) => handleWorldSelect(e.target.value)}
|
||||
onChangeCallBack={(e: ChangeEvent<HTMLSelectElement>): void => handleWorldSelect(e.target.value)}
|
||||
data={worldsSelector.length > 0 ? worldsSelector : [{
|
||||
label: t("worldSetting.noWorldAvailable"),
|
||||
value: '0'
|
||||
@@ -510,9 +483,9 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa
|
||||
placeholder={t("worldSetting.selectWorldPlaceholder")}
|
||||
/>
|
||||
}
|
||||
actionIcon={faPlus}
|
||||
actionIcon={Plus}
|
||||
actionLabel={t("worldSetting.addWorldLabel")}
|
||||
action={async () => setShowAddNewWorld(!showAddNewWorld)}
|
||||
action={async (): Promise<void> => setShowAddNewWorld(!showAddNewWorld)}
|
||||
/>
|
||||
|
||||
{showAddNewWorld && (
|
||||
@@ -520,31 +493,25 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa
|
||||
input={
|
||||
<TextInput
|
||||
value={newWorldName}
|
||||
setValue={(e: ChangeEvent<HTMLInputElement>) => setNewWorldName(e.target.value)}
|
||||
setValue={(e: ChangeEvent<HTMLInputElement>): void => setNewWorldName(e.target.value)}
|
||||
placeholder={t("worldSetting.newWorldPlaceholder")}
|
||||
/>
|
||||
}
|
||||
actionIcon={faPlus}
|
||||
actionIcon={Plus}
|
||||
actionLabel={t("worldSetting.createWorldLabel")}
|
||||
addButtonCallBack={handleAddNewWorld}
|
||||
/>
|
||||
)}
|
||||
{!isSeriesMode && bookSeriesId && !isCurrentlyOffline() && worlds[selectedWorldIndex] && !worlds[selectedWorldIndex].seriesWorldId && (
|
||||
<button
|
||||
onClick={handleExportToSeries}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-500/90 hover:bg-blue-500 rounded-xl border border-blue-600 shadow-md hover:shadow-lg transition-all duration-200"
|
||||
>
|
||||
<FontAwesomeIcon icon={faShare} className="w-4 h-4 text-text-primary"/>
|
||||
<span className="text-text-primary font-medium">{t("worldSetting.exportToSeries")}</span>
|
||||
</button>
|
||||
{!isSeriesMode && bookSeriesId && worlds[selectedWorldIndex] && !worlds[selectedWorldIndex].seriesWorldId && (
|
||||
<Button variant="secondary" size="sm" icon={Share2} onClick={handleExportToSeries}>
|
||||
{t("worldSetting.exportToSeries")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{worlds.length > 0 && worlds[selectedWorldIndex] ? (
|
||||
<WorldContext.Provider value={{worlds, setWorlds, selectedWorldIndex, isSeriesMode}}>
|
||||
<div
|
||||
className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-lg">
|
||||
<div className="space-y-6">
|
||||
<div className="mb-4">
|
||||
<InputField
|
||||
fieldName={t("worldSetting.worldName")}
|
||||
@@ -556,8 +523,8 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa
|
||||
bookElementId={worlds[selectedWorldIndex].id}
|
||||
field="name"
|
||||
elementType="world"
|
||||
onDownload={() => {
|
||||
const seriesWorld = getSeriesWorldForCurrentWorld();
|
||||
onDownload={(): void => {
|
||||
const seriesWorld: SeriesWorldProps | null = getSeriesWorldForCurrentWorld();
|
||||
if (seriesWorld) {
|
||||
const updatedWorlds: WorldProps[] = [...worlds];
|
||||
updatedWorlds[selectedWorldIndex].name = seriesWorld.name;
|
||||
@@ -568,7 +535,7 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa
|
||||
>
|
||||
<TextInput
|
||||
value={worlds[selectedWorldIndex].name}
|
||||
setValue={(e: ChangeEvent<HTMLInputElement>) => {
|
||||
setValue={(e: ChangeEvent<HTMLInputElement>): void => {
|
||||
const updatedWorlds: WorldProps[] = [...worlds];
|
||||
updatedWorlds[selectedWorldIndex].name = e.target.value
|
||||
setWorlds(updatedWorlds);
|
||||
@@ -589,24 +556,21 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa
|
||||
bookElementId={worlds[selectedWorldIndex].id}
|
||||
field="history"
|
||||
elementType="world"
|
||||
onDownload={() => {
|
||||
const seriesWorld = getSeriesWorldForCurrentWorld();
|
||||
onDownload={(): void => {
|
||||
const seriesWorld: SeriesWorldProps | null = getSeriesWorldForCurrentWorld();
|
||||
if (seriesWorld) handleInputChange(seriesWorld.history || '', 'history');
|
||||
}}
|
||||
onSyncComplete={getSeriesWorlds}
|
||||
>
|
||||
<TexteAreaInput
|
||||
<TextAreaInput
|
||||
value={worlds[selectedWorldIndex].history || ''}
|
||||
setValue={(e) => handleInputChange(e.target.value, 'history')}
|
||||
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => handleInputChange(e.target.value, 'history')}
|
||||
placeholder={t("worldSetting.worldHistoryPlaceholder")}
|
||||
/>
|
||||
</SyncFieldWrapper>
|
||||
}
|
||||
/>
|
||||
</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")}
|
||||
@@ -618,15 +582,15 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa
|
||||
bookElementId={worlds[selectedWorldIndex].id}
|
||||
field="politics"
|
||||
elementType="world"
|
||||
onDownload={() => {
|
||||
const seriesWorld = getSeriesWorldForCurrentWorld();
|
||||
onDownload={(): void => {
|
||||
const seriesWorld: SeriesWorldProps | null = getSeriesWorldForCurrentWorld();
|
||||
if (seriesWorld) handleInputChange(seriesWorld.politics || '', 'politics');
|
||||
}}
|
||||
onSyncComplete={getSeriesWorlds}
|
||||
>
|
||||
<TexteAreaInput
|
||||
<TextAreaInput
|
||||
value={worlds[selectedWorldIndex].politics || ''}
|
||||
setValue={(e) => handleInputChange(e.target.value, 'politics')}
|
||||
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => handleInputChange(e.target.value, 'politics')}
|
||||
placeholder={t("worldSetting.politicsPlaceholder")}
|
||||
/>
|
||||
</SyncFieldWrapper>
|
||||
@@ -642,25 +606,22 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa
|
||||
bookElementId={worlds[selectedWorldIndex].id}
|
||||
field="economy"
|
||||
elementType="world"
|
||||
onDownload={() => {
|
||||
const seriesWorld = getSeriesWorldForCurrentWorld();
|
||||
onDownload={(): void => {
|
||||
const seriesWorld: SeriesWorldProps | null = getSeriesWorldForCurrentWorld();
|
||||
if (seriesWorld) handleInputChange(seriesWorld.economy || '', 'economy');
|
||||
}}
|
||||
onSyncComplete={getSeriesWorlds}
|
||||
>
|
||||
<TexteAreaInput
|
||||
<TextAreaInput
|
||||
value={worlds[selectedWorldIndex].economy || ''}
|
||||
setValue={(e) => handleInputChange(e.target.value, 'economy')}
|
||||
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => handleInputChange(e.target.value, 'economy')}
|
||||
placeholder={t("worldSetting.economyPlaceholder")}
|
||||
/>
|
||||
</SyncFieldWrapper>
|
||||
}
|
||||
/>
|
||||
</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")}
|
||||
@@ -672,15 +633,15 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa
|
||||
bookElementId={worlds[selectedWorldIndex].id}
|
||||
field="religion"
|
||||
elementType="world"
|
||||
onDownload={() => {
|
||||
const seriesWorld = getSeriesWorldForCurrentWorld();
|
||||
onDownload={(): void => {
|
||||
const seriesWorld: SeriesWorldProps | null = getSeriesWorldForCurrentWorld();
|
||||
if (seriesWorld) handleInputChange(seriesWorld.religion || '', 'religion');
|
||||
}}
|
||||
onSyncComplete={getSeriesWorlds}
|
||||
>
|
||||
<TexteAreaInput
|
||||
<TextAreaInput
|
||||
value={worlds[selectedWorldIndex].religion || ''}
|
||||
setValue={(e) => handleInputChange(e.target.value, 'religion')}
|
||||
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => handleInputChange(e.target.value, 'religion')}
|
||||
placeholder={t("worldSetting.religionPlaceholder")}
|
||||
/>
|
||||
</SyncFieldWrapper>
|
||||
@@ -696,31 +657,31 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa
|
||||
bookElementId={worlds[selectedWorldIndex].id}
|
||||
field="languages"
|
||||
elementType="world"
|
||||
onDownload={() => {
|
||||
const seriesWorld = getSeriesWorldForCurrentWorld();
|
||||
onDownload={(): void => {
|
||||
const seriesWorld: SeriesWorldProps | null = getSeriesWorldForCurrentWorld();
|
||||
if (seriesWorld) handleInputChange(seriesWorld.languages || '', 'languages');
|
||||
}}
|
||||
onSyncComplete={getSeriesWorlds}
|
||||
>
|
||||
<TexteAreaInput
|
||||
<TextAreaInput
|
||||
value={worlds[selectedWorldIndex].languages || ''}
|
||||
setValue={(e) => handleInputChange(e.target.value, 'languages')}
|
||||
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => handleInputChange(e.target.value, 'languages')}
|
||||
placeholder={t("worldSetting.languagesPlaceholder")}
|
||||
/>
|
||||
</SyncFieldWrapper>
|
||||
}
|
||||
/>
|
||||
</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">
|
||||
{elementSections.map((section: ElementSection, index: number): React.JSX.Element => {
|
||||
const SectionIcon: LucideIcon = section.icon;
|
||||
return (
|
||||
<div key={index}>
|
||||
<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"/>
|
||||
<SectionIcon className="mr-2 w-5 h-5" strokeWidth={1.75}/>
|
||||
{section.title}
|
||||
<span
|
||||
className="ml-2 text-sm bg-dark-background text-text-secondary py-0.5 px-2 rounded-full">
|
||||
className="ml-2 text-sm bg-secondary text-text-secondary py-0.5 px-2 rounded-full">
|
||||
{worlds[selectedWorldIndex][section.section]?.length || 0}
|
||||
</span>
|
||||
</h3>
|
||||
@@ -729,12 +690,13 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa
|
||||
sectionType={section.section}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</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 className="text-center py-8">
|
||||
<p className="text-text-secondary">{t("worldSetting.noWorldAvailable")}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -743,4 +705,4 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef(WorldSetting);
|
||||
export default forwardRef<SettingRef, WorldSettingProps>(WorldSetting);
|
||||
@@ -1,17 +1,16 @@
|
||||
'use client';
|
||||
import React, {useCallback, useContext, useMemo, useState} from 'react';
|
||||
import {useWorlds, UseWorldsConfig} from '@/hooks/settings/useWorlds';
|
||||
import {useTranslations} from 'next-intl';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {faPlus, faSpinner, faToggleOn} from '@fortawesome/free-solid-svg-icons';
|
||||
import {BookContext} from '@/context/BookContext';
|
||||
import {WorldProps} from '@/lib/models/World';
|
||||
import {SeriesWorldProps} from '@/lib/models/Series';
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
import {Plus} from 'lucide-react';
|
||||
import PulseLoader from '@/components/ui/PulseLoader';
|
||||
import {BookContext, BookContextProps} from '@/context/BookContext';
|
||||
import {WorldProps, WorldTextField} from '@/lib/types/world';
|
||||
import {SeriesWorldProps} from '@/lib/types/series';
|
||||
import ToolDetailHeader from '@/components/book/settings/ToolDetailHeader';
|
||||
import AlertBox from '@/components/AlertBox';
|
||||
import AlertBox from '@/components/ui/AlertBox';
|
||||
import InputField from '@/components/form/InputField';
|
||||
import TextInput from '@/components/form/TextInput';
|
||||
import ToggleSwitch from '@/components/form/ToggleSwitch';
|
||||
import SeriesImportSelector from '@/components/form/SeriesImportSelector';
|
||||
|
||||
import WorldEditorList from './WorldEditorList';
|
||||
@@ -25,7 +24,7 @@ import WorldEditorEdit from './WorldEditorEdit';
|
||||
*/
|
||||
export default function WorldEditor(): React.JSX.Element {
|
||||
const t = useTranslations();
|
||||
const {book} = useContext(BookContext);
|
||||
const {book}: BookContextProps = useContext<BookContextProps>(BookContext);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean>(false);
|
||||
const [showAddForm, setShowAddForm] = useState<boolean>(false);
|
||||
|
||||
@@ -69,7 +68,7 @@ export default function WorldEditor(): React.JSX.Element {
|
||||
});
|
||||
}, [seriesWorlds, worlds]);
|
||||
|
||||
const handleWorldFieldChange = useCallback(function (field: keyof WorldProps, value: string): void {
|
||||
const handleWorldFieldChange = useCallback(function (field: WorldTextField, value: string): void {
|
||||
updateWorldField(field, value);
|
||||
}, [updateWorldField]);
|
||||
|
||||
@@ -97,11 +96,7 @@ export default function WorldEditor(): React.JSX.Element {
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<FontAwesomeIcon icon={faSpinner} className="w-6 h-6 text-primary animate-spin"/>
|
||||
</div>
|
||||
);
|
||||
return <PulseLoader size="sm"/>;
|
||||
}
|
||||
|
||||
const selectedWorld: WorldProps | undefined = worlds[selectedWorldIndex];
|
||||
@@ -126,22 +121,6 @@ export default function WorldEditor(): React.JSX.Element {
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{viewMode === 'list' && (
|
||||
<div className="space-y-3 p-2">
|
||||
{/* Toggle tool */}
|
||||
<div className="bg-secondary/20 rounded-lg p-3 border border-secondary/30">
|
||||
<InputField
|
||||
icon={faToggleOn}
|
||||
fieldName={t('worldSetting.enableTool')}
|
||||
input={
|
||||
<ToggleSwitch
|
||||
checked={toolEnabled}
|
||||
onChange={toggleTool}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{toolEnabled && (
|
||||
<>
|
||||
{/* Import from series */}
|
||||
{bookSeriesId && availableSeriesWorlds.length > 0 && (
|
||||
<SeriesImportSelector
|
||||
@@ -166,7 +145,7 @@ export default function WorldEditor(): React.JSX.Element {
|
||||
placeholder={t('worldSetting.newWorldPlaceholder')}
|
||||
/>
|
||||
}
|
||||
actionIcon={faPlus}
|
||||
actionIcon={Plus}
|
||||
actionLabel={t('worldSetting.createWorldLabel')}
|
||||
addButtonCallBack={async function (): Promise<void> {
|
||||
await addNewWorld();
|
||||
@@ -181,8 +160,6 @@ export default function WorldEditor(): React.JSX.Element {
|
||||
onWorldClick={handleWorldClick}
|
||||
onAddWorld={handleAddWorld}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -217,8 +194,12 @@ export default function WorldEditor(): React.JSX.Element {
|
||||
type="danger"
|
||||
confirmText={t('common.delete')}
|
||||
cancelText={t('common.cancel')}
|
||||
onConfirm={async function (): Promise<void> { setShowDeleteConfirm(false); }}
|
||||
onCancel={function (): void { setShowDeleteConfirm(false); }}
|
||||
onConfirm={async function (): Promise<void> {
|
||||
setShowDeleteConfirm(false);
|
||||
}}
|
||||
onCancel={function (): void {
|
||||
setShowDeleteConfirm(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
'use client';
|
||||
import React from 'react';
|
||||
import {WorldProps, elementSections, ElementSection, WorldElement} from '@/lib/models/World';
|
||||
import {SeriesWorldProps} from '@/lib/models/Series';
|
||||
import {useTranslations} from 'next-intl';
|
||||
import {ElementSection, WorldElement, WorldProps} from '@/lib/types/world';
|
||||
import {elementSections} from '@/lib/constants/world';
|
||||
import {SeriesWorldProps} from '@/lib/types/series';
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
import DetailField from '@/components/ui/DetailField';
|
||||
|
||||
interface WorldEditorDetailProps {
|
||||
world: WorldProps;
|
||||
@@ -19,27 +21,17 @@ export default function WorldEditorDetail({
|
||||
}: WorldEditorDetailProps): React.JSX.Element {
|
||||
const t = useTranslations();
|
||||
|
||||
function renderField(label: string, value: string | null | undefined): React.JSX.Element | null {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<span className="text-text-secondary text-xs block mb-1">{label}</span>
|
||||
<p className="text-text-primary text-sm whitespace-pre-wrap">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderElementSection(section: ElementSection): React.JSX.Element | null {
|
||||
const elements: WorldElement[] = world[section.section] as WorldElement[];
|
||||
const elements: WorldElement[] = world[section.section];
|
||||
if (!elements || elements.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={section.section} className="border-b border-secondary/30 pb-3">
|
||||
<div key={section.section} className="border-b border-secondary pb-3">
|
||||
<h4 className="text-text-primary font-medium text-sm mb-2">{section.title}</h4>
|
||||
<div className="space-y-2">
|
||||
{elements.map(function (element: WorldElement): React.JSX.Element {
|
||||
return (
|
||||
<div key={element.id} className="bg-secondary/20 rounded-lg p-2">
|
||||
<div key={element.id} className="bg-tertiary rounded-lg p-2">
|
||||
<p className="text-text-primary font-medium text-sm">{element.name}</p>
|
||||
{element.description && (
|
||||
<p className="text-text-secondary text-xs mt-1">{element.description}</p>
|
||||
@@ -55,26 +47,26 @@ export default function WorldEditorDetail({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Informations de base */}
|
||||
<div className="border-b border-secondary/30 pb-3">
|
||||
<div className="border-b border-secondary pb-3">
|
||||
<h3 className="text-text-primary font-semibold text-base mb-3">{world.name}</h3>
|
||||
{renderField(t('worldSetting.worldHistory'), world.history)}
|
||||
<DetailField variant="compact" label={t('worldSetting.worldHistory')} value={world.history}/>
|
||||
</div>
|
||||
|
||||
{/* Politique et économie */}
|
||||
{(world.politics || world.economy) && (
|
||||
<div className="border-b border-secondary/30 pb-3">
|
||||
<div className="border-b border-secondary pb-3">
|
||||
<h4 className="text-text-primary font-medium text-sm mb-2">{t('worldSetting.politicsEconomy')}</h4>
|
||||
{renderField(t('worldSetting.politics'), world.politics)}
|
||||
{renderField(t('worldSetting.economy'), world.economy)}
|
||||
<DetailField variant="compact" label={t('worldSetting.politics')} value={world.politics}/>
|
||||
<DetailField variant="compact" label={t('worldSetting.economy')} value={world.economy}/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Religion et langues */}
|
||||
{(world.religion || world.languages) && (
|
||||
<div className="border-b border-secondary/30 pb-3">
|
||||
<div className="border-b border-secondary pb-3">
|
||||
<h4 className="text-text-primary font-medium text-sm mb-2">{t('worldSetting.cultureLanguages')}</h4>
|
||||
{renderField(t('worldSetting.religion'), world.religion)}
|
||||
{renderField(t('worldSetting.languages'), world.languages)}
|
||||
<DetailField variant="compact" label={t('worldSetting.religion')} value={world.religion}/>
|
||||
<DetailField variant="compact" label={t('worldSetting.languages')} value={world.languages}/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
'use client';
|
||||
import React, {ChangeEvent, Dispatch, SetStateAction} from 'react';
|
||||
import {WorldProps, elementSections, ElementSection} from '@/lib/models/World';
|
||||
import {SeriesWorldProps} from '@/lib/models/Series';
|
||||
import {ElementSection, WorldProps, WorldTextField} from '@/lib/types/world';
|
||||
import {elementSections} from '@/lib/constants/world';
|
||||
import {SeriesWorldProps} from '@/lib/types/series';
|
||||
import {WorldContext} from '@/context/WorldContext';
|
||||
import InputField from '@/components/form/InputField';
|
||||
import TextInput from '@/components/form/TextInput';
|
||||
import TexteAreaInput from '@/components/form/TexteAreaInput';
|
||||
import TextAreaInput from '@/components/form/TextAreaInput';
|
||||
import SyncFieldWrapper from '@/components/form/SyncFieldWrapper';
|
||||
import WorldElementComponent from '@/components/book/settings/world/WorldElement';
|
||||
import {useTranslations} from 'next-intl';
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
|
||||
interface WorldEditorEditProps {
|
||||
world: WorldProps;
|
||||
worlds: WorldProps[];
|
||||
selectedWorldIndex: number;
|
||||
setWorlds: Dispatch<SetStateAction<WorldProps[]>>;
|
||||
onWorldFieldChange: (field: keyof WorldProps, value: string) => void;
|
||||
onWorldFieldChange: (field: WorldTextField, value: string) => void;
|
||||
seriesWorld?: SeriesWorldProps | null;
|
||||
onSyncComplete?: () => void;
|
||||
}
|
||||
@@ -40,7 +41,7 @@ export default function WorldEditorEdit({
|
||||
<WorldContext.Provider value={{worlds, setWorlds, selectedWorldIndex, isSeriesMode: false}}>
|
||||
<div className="space-y-4">
|
||||
{/* Informations de base */}
|
||||
<div className="border-b border-secondary/30 pb-3">
|
||||
<div className="border-b border-secondary pb-3">
|
||||
<h4 className="text-text-primary font-medium text-sm mb-3">{t('worldSetting.basicInfo')}</h4>
|
||||
<div className="space-y-3">
|
||||
<InputField
|
||||
@@ -90,7 +91,7 @@ export default function WorldEditorEdit({
|
||||
}}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TexteAreaInput
|
||||
<TextAreaInput
|
||||
value={world.history || ''}
|
||||
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
|
||||
onWorldFieldChange('history', e.target.value);
|
||||
@@ -104,7 +105,7 @@ export default function WorldEditorEdit({
|
||||
</div>
|
||||
|
||||
{/* Politique et économie */}
|
||||
<div className="border-b border-secondary/30 pb-3">
|
||||
<div className="border-b border-secondary pb-3">
|
||||
<h4 className="text-text-primary font-medium text-sm mb-3">{t('worldSetting.politicsEconomy')}</h4>
|
||||
<div className="space-y-3">
|
||||
<InputField
|
||||
@@ -122,7 +123,7 @@ export default function WorldEditorEdit({
|
||||
}}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TexteAreaInput
|
||||
<TextAreaInput
|
||||
value={world.politics || ''}
|
||||
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
|
||||
onWorldFieldChange('politics', e.target.value);
|
||||
@@ -148,7 +149,7 @@ export default function WorldEditorEdit({
|
||||
}}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TexteAreaInput
|
||||
<TextAreaInput
|
||||
value={world.economy || ''}
|
||||
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
|
||||
onWorldFieldChange('economy', e.target.value);
|
||||
@@ -162,7 +163,7 @@ export default function WorldEditorEdit({
|
||||
</div>
|
||||
|
||||
{/* Religion et langues */}
|
||||
<div className="border-b border-secondary/30 pb-3">
|
||||
<div className="border-b border-secondary pb-3">
|
||||
<h4 className="text-text-primary font-medium text-sm mb-3">{t('worldSetting.cultureLanguages')}</h4>
|
||||
<div className="space-y-3">
|
||||
<InputField
|
||||
@@ -180,7 +181,7 @@ export default function WorldEditorEdit({
|
||||
}}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TexteAreaInput
|
||||
<TextAreaInput
|
||||
value={world.religion || ''}
|
||||
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
|
||||
onWorldFieldChange('religion', e.target.value);
|
||||
@@ -206,7 +207,7 @@ export default function WorldEditorEdit({
|
||||
}}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TexteAreaInput
|
||||
<TextAreaInput
|
||||
value={world.languages || ''}
|
||||
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
|
||||
onWorldFieldChange('languages', e.target.value);
|
||||
@@ -222,7 +223,7 @@ export default function WorldEditorEdit({
|
||||
{/* Sections d'éléments */}
|
||||
{elementSections.map(function (section: ElementSection): React.JSX.Element {
|
||||
return (
|
||||
<div key={section.section} className="border-b border-secondary/30 pb-3 last:border-b-0">
|
||||
<div key={section.section} className="border-b border-secondary pb-3 last:border-b-0">
|
||||
<h4 className="text-text-primary font-medium text-sm mb-3">{section.title}</h4>
|
||||
<WorldElementComponent
|
||||
sectionLabel={section.title}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
'use client';
|
||||
import React, {useState} from 'react';
|
||||
import {WorldProps} from '@/lib/models/World';
|
||||
import {WorldProps} from '@/lib/types/world';
|
||||
import InputField from '@/components/form/InputField';
|
||||
import TextInput from '@/components/form/TextInput';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {faChevronRight, faGlobe, faPlus} from '@fortawesome/free-solid-svg-icons';
|
||||
import {useTranslations} from 'next-intl';
|
||||
import {Globe, Plus} from 'lucide-react';
|
||||
import EmptyState from '@/components/ui/EmptyState';
|
||||
import EntityListItem from '@/components/ui/EntityListItem';
|
||||
import AvatarIcon from '@/components/ui/AvatarIcon';
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
|
||||
interface WorldEditorListProps {
|
||||
worlds: WorldProps[];
|
||||
@@ -47,7 +49,7 @@ export default function WorldEditorList({
|
||||
placeholder={t('worldSetting.search')}
|
||||
/>
|
||||
}
|
||||
actionIcon={faPlus}
|
||||
actionIcon={Plus}
|
||||
actionLabel={t('worldSetting.addWorldLabel')}
|
||||
addButtonCallBack={async function (): Promise<void> {
|
||||
onAddWorld();
|
||||
@@ -57,47 +59,21 @@ export default function WorldEditorList({
|
||||
|
||||
<div className="px-2 space-y-2">
|
||||
{filteredWorlds.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mb-3">
|
||||
<FontAwesomeIcon icon={faGlobe} className="text-primary w-8 h-8"/>
|
||||
</div>
|
||||
<h3 className="text-text-primary font-semibold text-base mb-1">
|
||||
{t('worldSetting.noWorldAvailable')}
|
||||
</h3>
|
||||
<p className="text-muted text-sm max-w-xs">
|
||||
{t('worldSetting.noWorldDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<EmptyState icon={Globe} title={t('worldSetting.noWorldAvailable')}
|
||||
description={t('worldSetting.noWorldDescription')}/>
|
||||
) : (
|
||||
filteredWorlds.map(function (world: WorldProps): React.JSX.Element {
|
||||
return (
|
||||
<div
|
||||
<EntityListItem
|
||||
key={world.id}
|
||||
onClick={function (): void { onWorldClick(world); }}
|
||||
className="group flex items-center p-3 bg-secondary/30 rounded-lg border-l-4 border-primary border border-secondary/50 cursor-pointer hover:bg-secondary hover:shadow-md transition-all duration-200 hover:border-primary/50"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full border-2 border-primary overflow-hidden bg-secondary shadow-sm group-hover:scale-110 transition-transform flex items-center justify-center">
|
||||
<FontAwesomeIcon icon={faGlobe} className="text-primary w-5 h-5"/>
|
||||
</div>
|
||||
|
||||
<div className="ml-3 flex-1 min-w-0">
|
||||
<div className="text-text-primary font-semibold text-sm group-hover:text-primary transition-colors truncate">
|
||||
{world.name}
|
||||
</div>
|
||||
{world.history && (
|
||||
<div className="text-muted text-xs truncate">
|
||||
{world.history.substring(0, 50)}{world.history.length > 50 ? '...' : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-6 flex justify-center">
|
||||
<FontAwesomeIcon
|
||||
icon={faChevronRight}
|
||||
className="text-muted group-hover:text-primary group-hover:translate-x-1 transition-all w-3 h-3"
|
||||
size="sm"
|
||||
onClick={function (): void {
|
||||
onWorldClick(world);
|
||||
}}
|
||||
avatar={<AvatarIcon size="sm" icon={Globe}/>}
|
||||
title={world.name}
|
||||
subtitle={world.history ? world.history.substring(0, 50) + (world.history.length > 50 ? '...' : '') : null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
'use client';
|
||||
import React, {useCallback, useContext, useMemo, useState} from 'react';
|
||||
import {useWorlds, UseWorldsConfig} from '@/hooks/settings/useWorlds';
|
||||
import {useTranslations} from 'next-intl';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {faSpinner, faToggleOn} from '@fortawesome/free-solid-svg-icons';
|
||||
import {BookContext} from '@/context/BookContext';
|
||||
import {WorldProps} from '@/lib/models/World';
|
||||
import {SeriesWorldProps} from '@/lib/models/Series';
|
||||
import InputField from '@/components/form/InputField';
|
||||
import ToggleSwitch from '@/components/form/ToggleSwitch';
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
import PulseLoader from '@/components/ui/PulseLoader';
|
||||
import {BookContext, BookContextProps} from '@/context/BookContext';
|
||||
import {WorldProps, WorldTextField} from '@/lib/types/world';
|
||||
import {SeriesWorldProps} from '@/lib/types/series';
|
||||
import SeriesImportSelector from '@/components/form/SeriesImportSelector';
|
||||
import ToolDetailHeader from '@/components/book/settings/ToolDetailHeader';
|
||||
import AlertBox from '@/components/AlertBox';
|
||||
|
||||
import WorldSettingsList from './WorldSettingsList';
|
||||
import WorldSettingsDetail from './WorldSettingsDetail';
|
||||
@@ -20,7 +16,7 @@ import WorldSettingsEdit from './WorldSettingsEdit';
|
||||
interface WorldSettingsProps {
|
||||
entityType?: 'book' | 'series';
|
||||
entityId?: string;
|
||||
showToggle?: boolean;
|
||||
toolEnabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,10 +27,10 @@ interface WorldSettingsProps {
|
||||
export default function WorldSettings({
|
||||
entityType = 'book',
|
||||
entityId,
|
||||
showToggle = true,
|
||||
toolEnabled: parentToolEnabled,
|
||||
}: WorldSettingsProps): React.JSX.Element {
|
||||
const t = useTranslations();
|
||||
const {book} = useContext(BookContext);
|
||||
const {book}: BookContextProps = useContext<BookContextProps>(BookContext);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean>(false);
|
||||
|
||||
const resolvedEntityId: string = entityId || book?.bookId || '';
|
||||
@@ -80,7 +76,7 @@ export default function WorldSettings({
|
||||
});
|
||||
}, [seriesWorlds, worlds]);
|
||||
|
||||
const handleWorldFieldChange = useCallback(function (field: keyof WorldProps, value: string): void {
|
||||
const handleWorldFieldChange = useCallback(function (field: WorldTextField, value: string): void {
|
||||
updateWorldField(field, value);
|
||||
}, [updateWorldField]);
|
||||
|
||||
@@ -93,11 +89,7 @@ export default function WorldSettings({
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<FontAwesomeIcon icon={faSpinner} className="w-8 h-8 text-primary animate-spin"/>
|
||||
</div>
|
||||
);
|
||||
return <PulseLoader/>;
|
||||
}
|
||||
|
||||
const selectedWorld: WorldProps | undefined = worlds[selectedWorldIndex];
|
||||
@@ -124,27 +116,7 @@ export default function WorldSettings({
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{viewMode === 'list' && (
|
||||
<div className="space-y-5 p-4">
|
||||
{/* Toggle tool */}
|
||||
{showToggle && !isSeriesMode && (
|
||||
<div className="bg-secondary/20 rounded-xl p-5 shadow-inner border border-secondary/30">
|
||||
<InputField
|
||||
icon={faToggleOn}
|
||||
fieldName={t('worldSetting.enableTool')}
|
||||
input={
|
||||
<ToggleSwitch
|
||||
checked={toolEnabled}
|
||||
onChange={toggleTool}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<p className="text-muted text-sm mt-2">
|
||||
{t('worldSetting.enableToolDescription')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contenu si outil activé */}
|
||||
{(toolEnabled || isSeriesMode) && (
|
||||
{((parentToolEnabled !== undefined ? parentToolEnabled : toolEnabled) || isSeriesMode) && (
|
||||
<>
|
||||
{/* Import from series */}
|
||||
{!isSeriesMode && bookSeriesId && availableSeriesWorlds.length > 0 && (
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
'use client';
|
||||
import React from 'react';
|
||||
import {WorldProps, elementSections, ElementSection, WorldElement} from '@/lib/models/World';
|
||||
import {SeriesWorldProps} from '@/lib/models/Series';
|
||||
import CollapsableArea from '@/components/CollapsableArea';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faGlobe,
|
||||
faLandmark,
|
||||
faBook,
|
||||
faCoins,
|
||||
faChurch,
|
||||
faLanguage,
|
||||
faScroll
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import {useTranslations} from 'next-intl';
|
||||
import {ElementSection, WorldElement, WorldProps} from '@/lib/types/world';
|
||||
import {elementSections} from '@/lib/constants/world';
|
||||
import {SeriesWorldProps} from '@/lib/types/series';
|
||||
import Collapse from '@/components/ui/Collapse';
|
||||
import {Church, Coins, Globe, Landmark, Languages, ScrollText} from 'lucide-react';
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
import DetailHeroSection from '@/components/ui/DetailHeroSection';
|
||||
import DetailField from '@/components/ui/DetailField';
|
||||
|
||||
interface WorldSettingsDetailProps {
|
||||
world: WorldProps;
|
||||
@@ -27,15 +21,16 @@ export default function WorldSettingsDetail({
|
||||
const t = useTranslations();
|
||||
|
||||
function renderElementSection(section: ElementSection): React.JSX.Element | null {
|
||||
const elements: WorldElement[] = world[section.section] as WorldElement[];
|
||||
const elements: WorldElement[] = world[section.section];
|
||||
if (!elements || elements.length === 0) return null;
|
||||
|
||||
return (
|
||||
<CollapsableArea key={section.section} title={section.title} icon={section.icon}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<Collapse variant="card" key={section.section} title={section.title} icon={section.icon}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{elements.map(function (element: WorldElement): React.JSX.Element {
|
||||
return (
|
||||
<div key={element.id} className="p-4 bg-dark-background/30 rounded-lg border border-secondary/20 hover:border-primary/30 transition-colors">
|
||||
<div key={element.id}
|
||||
className="p-4 rounded-lg">
|
||||
<h4 className="text-text-primary font-semibold">{element.name}</h4>
|
||||
{element.description && (
|
||||
<p className="text-text-secondary text-sm mt-2 line-clamp-3">{element.description}</p>
|
||||
@@ -44,79 +39,28 @@ export default function WorldSettingsDetail({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CollapsableArea>
|
||||
</Collapse>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 px-2 pb-4">
|
||||
{/* Hero Section */}
|
||||
<div className="p-6 bg-gradient-to-r from-primary/10 via-secondary/20 to-transparent rounded-2xl border border-secondary/30">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-16 h-16 rounded-xl bg-primary/20 flex items-center justify-center shrink-0">
|
||||
<FontAwesomeIcon icon={faGlobe} className="w-8 h-8 text-primary"/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-2xl font-bold text-text-primary">{world.name || '—'}</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DetailHeroSection icon={Globe} title={world.name || '—'}/>
|
||||
|
||||
{/* Histoire du monde - Full width */}
|
||||
<div className="p-5 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<FontAwesomeIcon icon={faScroll} className="w-4 h-4 text-primary"/>
|
||||
<h3 className="text-text-primary font-semibold">{t('worldSetting.worldHistory')}</h3>
|
||||
</div>
|
||||
<p className={`whitespace-pre-wrap ${world.history ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
|
||||
{world.history || '—'}
|
||||
</p>
|
||||
</div>
|
||||
<DetailField icon={ScrollText} label={t('worldSetting.worldHistory')} value={world.history}/>
|
||||
|
||||
{/* Politique & Économie - Side by side */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="p-5 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<FontAwesomeIcon icon={faLandmark} className="w-4 h-4 text-primary"/>
|
||||
<h3 className="text-text-primary font-semibold">{t('worldSetting.politics')}</h3>
|
||||
</div>
|
||||
<p className={`whitespace-pre-wrap ${world.politics ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
|
||||
{world.politics || '—'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-5 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<FontAwesomeIcon icon={faCoins} className="w-4 h-4 text-primary"/>
|
||||
<h3 className="text-text-primary font-semibold">{t('worldSetting.economy')}</h3>
|
||||
</div>
|
||||
<p className={`whitespace-pre-wrap ${world.economy ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
|
||||
{world.economy || '—'}
|
||||
</p>
|
||||
</div>
|
||||
<DetailField icon={Landmark} label={t('worldSetting.politics')} value={world.politics}/>
|
||||
<DetailField icon={Coins} label={t('worldSetting.economy')} value={world.economy}/>
|
||||
</div>
|
||||
|
||||
{/* Religion & Langues - Side by side */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="p-5 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<FontAwesomeIcon icon={faChurch} className="w-4 h-4 text-primary"/>
|
||||
<h3 className="text-text-primary font-semibold">{t('worldSetting.religion')}</h3>
|
||||
</div>
|
||||
<p className={`whitespace-pre-wrap ${world.religion ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
|
||||
{world.religion || '—'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-5 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<FontAwesomeIcon icon={faLanguage} className="w-4 h-4 text-primary"/>
|
||||
<h3 className="text-text-primary font-semibold">{t('worldSetting.languages')}</h3>
|
||||
</div>
|
||||
<p className={`whitespace-pre-wrap ${world.languages ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
|
||||
{world.languages || '—'}
|
||||
</p>
|
||||
</div>
|
||||
<DetailField icon={Church} label={t('worldSetting.religion')} value={world.religion}/>
|
||||
<DetailField icon={Languages} label={t('worldSetting.languages')} value={world.languages}/>
|
||||
</div>
|
||||
|
||||
{/* Sections d'éléments - Grille de cards */}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
'use client';
|
||||
import React, {ChangeEvent, Dispatch, SetStateAction} from 'react';
|
||||
import {WorldProps, elementSections, ElementSection} from '@/lib/models/World';
|
||||
import {SeriesWorldProps} from '@/lib/models/Series';
|
||||
import {ElementSection, WorldProps, WorldTextField} from '@/lib/types/world';
|
||||
import {elementSections} from '@/lib/constants/world';
|
||||
import {SeriesWorldProps} from '@/lib/types/series';
|
||||
import {WorldContext} from '@/context/WorldContext';
|
||||
import CollapsableArea from '@/components/CollapsableArea';
|
||||
import Collapse from '@/components/ui/Collapse';
|
||||
import InputField from '@/components/form/InputField';
|
||||
import TextInput from '@/components/form/TextInput';
|
||||
import TexteAreaInput from '@/components/form/TexteAreaInput';
|
||||
import TextAreaInput from '@/components/form/TextAreaInput';
|
||||
import SyncFieldWrapper from '@/components/form/SyncFieldWrapper';
|
||||
import WorldElementComponent from '@/components/book/settings/world/WorldElement';
|
||||
import {faBook, faGlobe, faLandmark} from '@fortawesome/free-solid-svg-icons';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {useTranslations} from 'next-intl';
|
||||
import {Book, Globe, Landmark} from 'lucide-react';
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
|
||||
interface WorldSettingsEditProps {
|
||||
world: WorldProps;
|
||||
worlds: WorldProps[];
|
||||
selectedWorldIndex: number;
|
||||
setWorlds: Dispatch<SetStateAction<WorldProps[]>>;
|
||||
onWorldFieldChange: (field: keyof WorldProps, value: string) => void;
|
||||
onWorldFieldChange: (field: WorldTextField, value: string) => void;
|
||||
seriesWorld?: SeriesWorldProps | null;
|
||||
isSeriesMode: boolean;
|
||||
onSyncComplete?: () => void;
|
||||
@@ -45,8 +45,7 @@ export default function WorldSettingsEdit({
|
||||
<WorldContext.Provider value={{worlds, setWorlds, selectedWorldIndex, isSeriesMode}}>
|
||||
<div className="space-y-4 px-2 pb-4">
|
||||
{/* Informations de base */}
|
||||
<CollapsableArea title={t('worldSetting.basicInfo')} icon={faGlobe}>
|
||||
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<Collapse variant="card" title={t('worldSetting.basicInfo')} icon={Globe}>
|
||||
<InputField
|
||||
fieldName={t("worldSetting.worldName")}
|
||||
input={
|
||||
@@ -94,7 +93,7 @@ export default function WorldSettingsEdit({
|
||||
}}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TexteAreaInput
|
||||
<TextAreaInput
|
||||
value={world.history || ''}
|
||||
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
|
||||
onWorldFieldChange('history', e.target.value);
|
||||
@@ -104,12 +103,10 @@ export default function WorldSettingsEdit({
|
||||
</SyncFieldWrapper>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CollapsableArea>
|
||||
</Collapse>
|
||||
|
||||
{/* Politique et économie */}
|
||||
<CollapsableArea title={t('worldSetting.politicsEconomy')} icon={faLandmark}>
|
||||
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<Collapse variant="card" title={t('worldSetting.politicsEconomy')} icon={Landmark}>
|
||||
<InputField
|
||||
fieldName={t("worldSetting.politics")}
|
||||
input={
|
||||
@@ -125,7 +122,7 @@ export default function WorldSettingsEdit({
|
||||
}}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TexteAreaInput
|
||||
<TextAreaInput
|
||||
value={world.politics || ''}
|
||||
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
|
||||
onWorldFieldChange('politics', e.target.value);
|
||||
@@ -151,7 +148,7 @@ export default function WorldSettingsEdit({
|
||||
}}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TexteAreaInput
|
||||
<TextAreaInput
|
||||
value={world.economy || ''}
|
||||
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
|
||||
onWorldFieldChange('economy', e.target.value);
|
||||
@@ -161,12 +158,10 @@ export default function WorldSettingsEdit({
|
||||
</SyncFieldWrapper>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CollapsableArea>
|
||||
</Collapse>
|
||||
|
||||
{/* Religion et langues */}
|
||||
<CollapsableArea title={t('worldSetting.cultureLanguages')} icon={faBook}>
|
||||
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<Collapse variant="card" title={t('worldSetting.cultureLanguages')} icon={Book}>
|
||||
<InputField
|
||||
fieldName={t("worldSetting.religion")}
|
||||
input={
|
||||
@@ -182,7 +177,7 @@ export default function WorldSettingsEdit({
|
||||
}}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TexteAreaInput
|
||||
<TextAreaInput
|
||||
value={world.religion || ''}
|
||||
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
|
||||
onWorldFieldChange('religion', e.target.value);
|
||||
@@ -208,7 +203,7 @@ export default function WorldSettingsEdit({
|
||||
}}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TexteAreaInput
|
||||
<TextAreaInput
|
||||
value={world.languages || ''}
|
||||
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
|
||||
onWorldFieldChange('languages', e.target.value);
|
||||
@@ -218,20 +213,17 @@ export default function WorldSettingsEdit({
|
||||
</SyncFieldWrapper>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CollapsableArea>
|
||||
</Collapse>
|
||||
|
||||
{/* Sections d'éléments */}
|
||||
{elementSections.map(function (section: ElementSection): React.JSX.Element {
|
||||
return (
|
||||
<CollapsableArea key={section.section} title={section.title} icon={section.icon}>
|
||||
<div className="p-4 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<Collapse variant="card" key={section.section} title={section.title} icon={section.icon}>
|
||||
<WorldElementComponent
|
||||
sectionLabel={section.title}
|
||||
sectionType={section.section}
|
||||
/>
|
||||
</div>
|
||||
</CollapsableArea>
|
||||
</Collapse>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
'use client';
|
||||
import React, {ChangeEvent, useState} from 'react';
|
||||
import {WorldProps} from '@/lib/models/World';
|
||||
import {WorldProps} from '@/lib/types/world';
|
||||
import InputField from '@/components/form/InputField';
|
||||
import TextInput from '@/components/form/TextInput';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {faChevronRight, faGlobe, faPlus} from '@fortawesome/free-solid-svg-icons';
|
||||
import {useTranslations} from 'next-intl';
|
||||
import {Globe, Plus} from 'lucide-react';
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
import EntityListItem from '@/components/ui/EntityListItem';
|
||||
import EmptyState from '@/components/ui/EmptyState';
|
||||
import AvatarIcon from '@/components/ui/AvatarIcon';
|
||||
|
||||
interface WorldSettingsListProps {
|
||||
worlds: WorldProps[];
|
||||
@@ -42,7 +44,6 @@ export default function WorldSettingsList({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Recherche et ajout */}
|
||||
<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">
|
||||
<InputField
|
||||
input={
|
||||
@@ -67,57 +68,29 @@ export default function WorldSettingsList({
|
||||
placeholder={t('worldSetting.newWorldPlaceholder')}
|
||||
/>
|
||||
}
|
||||
actionIcon={faPlus}
|
||||
actionIcon={Plus}
|
||||
actionLabel={t('worldSetting.createWorldLabel')}
|
||||
addButtonCallBack={onAddWorld}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Liste des mondes cliquables */}
|
||||
<div className="space-y-2 px-2">
|
||||
{filteredWorlds.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="w-20 h-20 rounded-full bg-primary/10 flex items-center justify-center mb-4">
|
||||
<FontAwesomeIcon icon={faGlobe} className="text-primary w-10 h-10"/>
|
||||
</div>
|
||||
<h3 className="text-text-primary font-semibold text-lg mb-2">
|
||||
{t('worldSetting.noWorldAvailable')}
|
||||
</h3>
|
||||
<p className="text-muted text-sm max-w-xs">
|
||||
{t('worldSetting.noWorldDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<EmptyState icon={Globe} title={t('worldSetting.noWorldAvailable')}
|
||||
description={t('worldSetting.noWorldDescription')}/>
|
||||
) : (
|
||||
filteredWorlds.map(function (world: WorldProps): React.JSX.Element {
|
||||
return (
|
||||
<div
|
||||
<EntityListItem
|
||||
key={world.id}
|
||||
onClick={function (): void { onWorldClick(world.id); }}
|
||||
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-12 h-12 rounded-full border-2 border-primary overflow-hidden bg-secondary shadow-md group-hover:scale-110 transition-transform flex items-center justify-center">
|
||||
<FontAwesomeIcon icon={faGlobe} className="text-primary w-6 h-6"/>
|
||||
</div>
|
||||
|
||||
<div className="ml-4 flex-1 min-w-0">
|
||||
<div className="text-text-primary font-bold text-base group-hover:text-primary transition-colors">
|
||||
{world.name}
|
||||
</div>
|
||||
{world.history && (
|
||||
<div className="text-text-secondary text-sm mt-0.5 truncate">
|
||||
{world.history.substring(0, 60)}{world.history.length > 60 ? '...' : ''}
|
||||
</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"
|
||||
onClick={function (): void {
|
||||
onWorldClick(world.id);
|
||||
}}
|
||||
avatar={<AvatarIcon icon={Globe}/>}
|
||||
title={world.name}
|
||||
subtitle={world.history ? world.history.substring(0, 60) + (world.history.length > 60 ? '...' : '') : null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
@@ -1,40 +1,33 @@
|
||||
import {ChangeEvent, useContext, useEffect, useState} from "react";
|
||||
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 {apiGet} from "@/lib/api/client";
|
||||
import {configs, isDesktop} from '@/lib/configs';
|
||||
import * as tauri from '@/lib/tauri';
|
||||
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
|
||||
import {textContentToHtml} from "@/lib/utils/html";
|
||||
import {ChapterContext, ChapterContextProps} from "@/context/ChapterContext";
|
||||
import {BookContext, BookContextProps} from "@/context/BookContext";
|
||||
import {SelectBoxProps} from "@/components/form/SelectBox";
|
||||
import {AlertContext, AlertContextProps} from "@/context/AlertContext";
|
||||
import {SessionContext, SessionContextProps} from "@/context/SessionContext";
|
||||
import {Boxes, Feather, Globe, MapPin, Palette, User, Wand2} from "lucide-react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import QSTextGeneratedPreview from "@/components/ui/QSTextGeneratedPreview";
|
||||
import {EditorContext, EditorContextProps} from "@/context/EditorContext";
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
import {getSubLevel, isOpenAIEnabled} from "@/lib/utils/quillsense";
|
||||
import TextInput from "@/components/form/TextInput";
|
||||
import InputField from "@/components/form/InputField";
|
||||
import TexteAreaInput from "@/components/form/TexteAreaInput";
|
||||
import TextAreaInput from "@/components/form/TextAreaInput";
|
||||
import SuggestFieldInput from "@/components/form/SuggestFieldInput";
|
||||
import Collapse from "@/components/Collapse";
|
||||
import Collapse from "@/components/ui/Collapse";
|
||||
import {LangContext, LangContextProps} from "@/context/LangContext";
|
||||
import {BookTags} from "@/lib/models/Book";
|
||||
import {BookTags} from "@/lib/types/book";
|
||||
import {AIUsageContext, AIUsageContextProps} from "@/context/AIUsageContext";
|
||||
import {configs} from "@/lib/configs";
|
||||
import OfflineContext, {OfflineContextType} from "@/context/OfflineContext";
|
||||
import AdvancedGenerationOptions from "@/components/form/AdvancedGenerationOptions";
|
||||
import * as tauri from '@/lib/tauri';
|
||||
|
||||
interface CompanionContent {
|
||||
version: number;
|
||||
@@ -44,9 +37,8 @@ interface CompanionContent {
|
||||
|
||||
export default function DraftCompanion() {
|
||||
const t = useTranslations();
|
||||
const {setTotalPrice, setTotalCredits} = useContext<AIUsageContextProps>(AIUsageContext)
|
||||
const {lang} = useContext<LangContextProps>(LangContext)
|
||||
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
|
||||
const {setTotalPrice, setTotalCredits}: AIUsageContextProps = useContext<AIUsageContextProps>(AIUsageContext)
|
||||
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext)
|
||||
|
||||
const mainEditor: Editor | null = useEditor({
|
||||
extensions: [
|
||||
@@ -60,11 +52,12 @@ export default function DraftCompanion() {
|
||||
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 {editor}: EditorContextProps = useContext<EditorContextProps>(EditorContext);
|
||||
const {chapter}: ChapterContextProps = useContext<ChapterContextProps>(ChapterContext);
|
||||
const {book}: BookContextProps = useContext<BookContextProps>(BookContext);
|
||||
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
|
||||
const {errorMessage, infoMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
|
||||
const {isCurrentlyOffline}: OfflineContextType = useContext<OfflineContextType>(OfflineContext);
|
||||
|
||||
const [draftVersion, setDraftVersion] = useState<number>(0);
|
||||
const [draftWordCount, setDraftWordCount] = useState<number>(0);
|
||||
@@ -100,9 +93,9 @@ export default function DraftCompanion() {
|
||||
const [useExplicit, setUseExplicit] = useState<boolean>(false);
|
||||
const [useSmart, setUseSmart] = useState<boolean>(false);
|
||||
|
||||
const isGPTEnabled: boolean = QuillSense.isOpenAIEnabled(session);
|
||||
const isSubTierTree: boolean = QuillSense.getSubLevel(session) === 3;
|
||||
const hasAccess: boolean = (isGPTEnabled || isSubTierTree) && !isCurrentlyOffline() && !book?.localBook;
|
||||
const isGPTEnabled: boolean = isOpenAIEnabled(session);
|
||||
const isSubTierTree: boolean = getSubLevel(session) === 3;
|
||||
const hasAccess: boolean = isGPTEnabled || isSubTierTree;
|
||||
|
||||
useEffect((): void => {
|
||||
getDraftContent().then();
|
||||
@@ -113,14 +106,11 @@ export default function DraftCompanion() {
|
||||
|
||||
async function getDraftContent(): Promise<void> {
|
||||
try {
|
||||
let response: CompanionContent | null;
|
||||
if (isCurrentlyOffline() || book?.localBook) {
|
||||
response = await tauri.getCompanionContent(
|
||||
chapter?.chapterId ?? '',
|
||||
chapter?.chapterContent.version ?? 0,
|
||||
) as CompanionContent | null;
|
||||
let response: CompanionContent;
|
||||
if (isDesktop && (isCurrentlyOffline() || book?.localBook)) {
|
||||
response = await tauri.getCompanionContent(chapter?.chapterId ?? '', chapter?.chapterContent.version ?? 0) as CompanionContent;
|
||||
} else {
|
||||
response = await System.authGetQueryToServer<CompanionContent>(`chapter/content/companion`, session.accessToken, lang, {
|
||||
response = await apiGet<CompanionContent>(`chapter/content/companion`, session.accessToken, lang, {
|
||||
bookid: book?.bookId,
|
||||
chapterid: chapter?.chapterId,
|
||||
version: chapter?.chapterContent.version,
|
||||
@@ -160,11 +150,11 @@ export default function DraftCompanion() {
|
||||
|
||||
async function fetchTags(): Promise<void> {
|
||||
try {
|
||||
let responseTags: BookTags | null;
|
||||
if (isCurrentlyOffline() || book?.localBook) {
|
||||
responseTags = await tauri.getBookTags(book?.bookId ?? '') as BookTags | null;
|
||||
let responseTags: BookTags;
|
||||
if (isDesktop && (isCurrentlyOffline() || book?.localBook)) {
|
||||
responseTags = await tauri.getBookTags(book?.bookId ?? '') as BookTags;
|
||||
} else {
|
||||
responseTags = await System.authGetQueryToServer<BookTags>(`book/tags`, session.accessToken, lang, {
|
||||
responseTags = await apiGet<BookTags>(`book/tags`, session.accessToken, lang, {
|
||||
bookId: book?.bookId
|
||||
});
|
||||
}
|
||||
@@ -258,11 +248,12 @@ export default function DraftCompanion() {
|
||||
useYourKey?: boolean;
|
||||
} = JSON.parse(dataStr);
|
||||
|
||||
// Si c'est un chunk de contenu
|
||||
if ('content' in data && data.content) {
|
||||
accumulatedText += data.content;
|
||||
setRefinedText(accumulatedText);
|
||||
}
|
||||
|
||||
// Si c'est le message final avec les totaux
|
||||
else if ('useYourKey' in data && 'totalPrice' in data) {
|
||||
if (data.useYourKey) {
|
||||
setTotalPrice((prev: number): number => prev + data.totalPrice!);
|
||||
@@ -271,7 +262,7 @@ export default function DraftCompanion() {
|
||||
}
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
console.error('Error parsing SSE data:', e);
|
||||
errorMessage(t("draftCompanion.sseParsingError"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -301,7 +292,7 @@ export default function DraftCompanion() {
|
||||
if (editor.getText().length > 0) {
|
||||
editor.commands.insertContent('\n\n');
|
||||
}
|
||||
editor.commands.insertContent(System.textContentToHtml(refinedText));
|
||||
editor.commands.insertContent(textContentToHtml(refinedText));
|
||||
setShowRefinedText(false);
|
||||
}
|
||||
}
|
||||
@@ -430,23 +421,21 @@ export default function DraftCompanion() {
|
||||
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">
|
||||
className="flex items-center justify-between p-4 flex-shrink-0">
|
||||
<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"
|
||||
>
|
||||
<Button variant="secondary" onClick={(): void => setShowEnhancer(false)}>
|
||||
Retour
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4 custom-scrollbar">
|
||||
<Collapse
|
||||
variant="card"
|
||||
title="Style d'écriture"
|
||||
content={
|
||||
<div className="space-y-4">
|
||||
<InputField
|
||||
icon={faPalette}
|
||||
icon={Palette}
|
||||
fieldName={t("ghostWriter.toneAtmosphere")}
|
||||
input={
|
||||
<TextInput
|
||||
@@ -461,11 +450,12 @@ export default function DraftCompanion() {
|
||||
/>
|
||||
|
||||
<Collapse
|
||||
variant="card"
|
||||
title="Tags contextuels"
|
||||
content={
|
||||
<div className="space-y-4">
|
||||
<SuggestFieldInput inputFieldName={`Personnages`}
|
||||
inputFieldIcon={faUser}
|
||||
inputFieldIcon={User}
|
||||
searchTags={searchCharacters}
|
||||
tagued={taguedCharacters}
|
||||
handleTagSearch={(e) => handleCharacterSearch(e.target.value)}
|
||||
@@ -478,7 +468,7 @@ export default function DraftCompanion() {
|
||||
/>
|
||||
|
||||
<SuggestFieldInput inputFieldName={`Lieux`}
|
||||
inputFieldIcon={faMapPin}
|
||||
inputFieldIcon={MapPin}
|
||||
searchTags={searchLocations}
|
||||
tagued={taguedLocations}
|
||||
handleTagSearch={(e) => handleLocationSearch(e.target.value)}
|
||||
@@ -491,7 +481,7 @@ export default function DraftCompanion() {
|
||||
/>
|
||||
|
||||
<SuggestFieldInput inputFieldName={`Objets`}
|
||||
inputFieldIcon={faCubes}
|
||||
inputFieldIcon={Boxes}
|
||||
searchTags={searchObjects}
|
||||
tagued={taguedObjects}
|
||||
handleTagSearch={(e) => handleObjectSearch(e.target.value)}
|
||||
@@ -504,7 +494,7 @@ export default function DraftCompanion() {
|
||||
/>
|
||||
|
||||
<SuggestFieldInput inputFieldName={`Éléments mondiaux`}
|
||||
inputFieldIcon={faGlobe}
|
||||
inputFieldIcon={Globe}
|
||||
searchTags={searchWorldElements}
|
||||
tagued={taguedWorldElements}
|
||||
handleTagSearch={(e) => handleWorldElementSearch(e.target.value)}
|
||||
@@ -519,12 +509,12 @@ export default function DraftCompanion() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="bg-secondary/20 rounded-xl p-5 shadow-inner border border-secondary/30">
|
||||
<div className="bg-tertiary rounded-xl p-4">
|
||||
<InputField
|
||||
icon={faMagicWandSparkles}
|
||||
icon={Wand2}
|
||||
fieldName="Spécifications"
|
||||
input={
|
||||
<TexteAreaInput
|
||||
<TextAreaInput
|
||||
value={specifications}
|
||||
setValue={(e: ChangeEvent<HTMLTextAreaElement>) => setSpecifications(e.target.value)}
|
||||
placeholder="Spécifications particulières pour l'amélioration..."
|
||||
@@ -543,15 +533,15 @@ export default function DraftCompanion() {
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="p-5 border-t border-secondary/50 bg-secondary/30 backdrop-blur-sm shrink-0 shadow-inner">
|
||||
className="p-5 shrink-0">
|
||||
<div className="flex justify-center">
|
||||
<SubmitButtonWLoading
|
||||
callBackAction={handleQuillSenseRefined}
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleQuillSenseRefined}
|
||||
isLoading={isRefining}
|
||||
text={t("draftCompanion.refine")}
|
||||
loadingText={t("draftCompanion.refining")}
|
||||
icon={faMagicWandSparkles}
|
||||
/>
|
||||
icon={Wand2}
|
||||
>{t("draftCompanion.refine")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
{(showRefinedText || isRefining) && (
|
||||
@@ -571,21 +561,21 @@ export default function DraftCompanion() {
|
||||
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">
|
||||
className="flex items-center justify-between p-4 flex-shrink-0 font-['ADLaM_Display']">
|
||||
<div className="mr-4 text-muted">
|
||||
<span>{t("draftCompanion.words")}: </span>
|
||||
<span className="text-text-primary">{draftWordCount}</span>
|
||||
</div>
|
||||
{
|
||||
hasAccess && book?.quillsenseEnabled !== false && chapter?.chapterContent.version === 3 && (
|
||||
book?.quillsenseEnabled !== false && hasAccess && chapter?.chapterContent.version === 3 && (
|
||||
<div className="flex gap-2">
|
||||
<SubmitButtonWLoading
|
||||
callBackAction={(): void => setShowEnhancer(true)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={(): void => setShowEnhancer(true)}
|
||||
isLoading={isRefining}
|
||||
text={t("draftCompanion.refine")}
|
||||
loadingText={t("draftCompanion.refining")}
|
||||
icon={faFeather}
|
||||
/>
|
||||
icon={Feather}
|
||||
>{t("draftCompanion.refine")}</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user