diff --git a/app/globals.css b/app/globals.css index 8b09ce4..84ff6ef 100644 --- a/app/globals.css +++ b/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; +} diff --git a/app/layout.tsx b/app/layout.tsx index f524906..159a6bf 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,21 +1,30 @@ +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( { children, }: Readonly<{ children: ReactNode; -}>) { + }>) { return ( - - ERitors Scribe - - - - {children} + {children} ); diff --git a/app/login/LoginWrapper.tsx b/app/login/LoginWrapper.tsx index abb7dc6..c00236f 100644 --- a/app/login/LoginWrapper.tsx +++ b/app/login/LoginWrapper.tsx @@ -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({ 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,36 +36,36 @@ export default function LoginWrapper({children}: { children: React.ReactNode }) }, [session]); async function checkAuthentification(): Promise { - 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 ( - - - {children} -
- { - successMessage && { - setSuccessMessage('') - }}/> - } - { - errorMessage && { - setErrorMessage('') - }}/> - } -
-
-
+ + {children} +
+ { + successMessage && { + setSuccessMessage('') + }}/> + } + { + errorMessage && { + setErrorMessage('') + }}/> + } +
+
) -} \ No newline at end of file +} diff --git a/app/login/login/LoginForm.tsx b/app/login/login/LoginForm.tsx index 6fdaad1..233e07e 100755 --- a/app/login/login/LoginForm.tsx +++ b/app/login/login/LoginForm.tsx @@ -1,117 +1,106 @@ -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 {AlertContext} from "@/context/AlertContext"; -import {useTranslations} from "next-intl"; -import {LangContext, LangContextProps} from "@/context/LangContext"; -import * as tauri from '@/lib/tauri'; - -export default function LoginForm() { - const {errorMessage} = useContext(AlertContext); - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [isLoading, setIsLoading] = useState(false); - const t = useTranslations(); - const {lang} = useContext(LangContext) - - async function handleSubmit(e: React.FormEvent): Promise { - e.preventDefault(); - setIsLoading(true); - errorMessage(''); - - if (email.length === 0) { - errorMessage(t('loginForm.error.emailRequired')); - return; - } - if (password.length === 0) { - errorMessage(t('loginForm.error.passwordRequired')); - return; - } - if (email.length < 3) { - errorMessage(t('loginForm.error.emailLength')); - return; - } - - if (System.verifyInput(email) || System.verifyInput(password)) { - errorMessage(t('loginForm.error.emailInvalidChars')); - return; - } - try { - const response: string = await System.postToServer('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 ( -
-
- -
- - setEmail(e.target.value)} - required - /> -
-
- -
-
- - - {t('loginForm.fields.password.forgot')} - -
-
- - setPassword(e.target.value)} - required - /> -
-
- - -
- ) -} +import {useContext, useState} from "react"; +import {Mail, Lock} from "lucide-react"; +import {AlertContext} from "@/context/AlertContext"; +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); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const t = useTranslations(); + const {lang} = useContext(LangContext) + + async function handleSubmit(e: React.FormEvent): Promise { + 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('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 ( +
+ setEmail(e.target.value)} + placeholder={t('loginForm.fields.email.placeholder')} + size="lg" + /> + }/> + +
+
+

+ + {t('loginForm.fields.password.label')} +

+ + {t('loginForm.fields.password.forgot')} + +
+ setPassword(e.target.value)} + required + /> +
+ + + + ) +} diff --git a/app/login/login/SocialForm.tsx b/app/login/login/SocialForm.tsx index 1e78be5..331cfbb 100755 --- a/app/login/login/SocialForm.tsx +++ b/app/login/login/SocialForm.tsx @@ -1,124 +1,140 @@ -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 {LangContext, LangContextProps} from "@/context/LangContext"; -import * as tauri from '@/lib/tauri'; - -export default function SocialForm() { - const {errorMessage} = useContext(AlertContext); - const t = useTranslations(); - const {lang} = useContext(LangContext) - - useEffect((): void => { - const params = new URLSearchParams(window.location.search); - const provider: string | null = params.get('provider'); - if (!provider) return; - - const code: string | null = params.get('code'); - if (!code) return; - - if (provider === 'google') { - handleGoogleLogin(code).then(); - return; - } - if (provider === 'facebook') { - const state: string | null = params.get('state'); - if (!state) return; - handleFacebookLogin(code, state).then(); - return; - } - if (provider === 'apple') { - const state: string | null = params.get('state'); - if (!state) return; - handleAppleLogin(code, state).then(); - return; - } - }, []); - - async function handleLoginSuccess(token: string): Promise { - await tauri.setToken(token); - await tauri.loginSuccess(); - } - - async function handleFacebookLogin(code: string, state: string): Promise { - if (code && state) { - const response: string = await System.postToServer(`auth/facebook`, { - code: code, - state: state, - }, lang); - if (!response) { - errorMessage(t('socialForm.error.connection')); - return; - } - await handleLoginSuccess(response); - } - } - - async function handleGoogleLogin(code: string): Promise { - if (code) { - const response: string = await System.postToServer(`auth/google`, { - code: code, - }, lang); - if (!response) { - errorMessage(t('socialForm.error.connection')); - return; - } - await handleLoginSuccess(response); - } - } - - async function handleAppleLogin(code: string, state: string): Promise { - if (code && state) { - const response: string = await System.postToServer(`auth/apple`, { - code: code, - state: state, - }, lang); - if (!response) { - errorMessage(t('socialForm.error.connection')); - return; - } - await handleLoginSuccess(response); - } - } - - async function handleOAuthClick(provider: 'google' | 'facebook' | 'apple'): Promise { - try { - const oauthUrl = `${configs.baseUrl}auth/${provider}/desktop`; - await tauri.openExternal(oauthUrl); - } catch { - errorMessage(t('socialForm.error.connection')); - } - } - - return ( -
- - - - - -
- ) -} +import React, {useContext, useEffect} from "react"; +import {AlertContext} from "@/context/AlertContext"; +import {configs} from "@/lib/configs"; +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 = () => ( + + + +); + +const GoogleIcon = () => ( + + + +); + +const AppleIcon = () => ( + + + +); + +export default function SocialForm() { + const {errorMessage} = useContext(AlertContext); + const t = useTranslations(); + const {lang} = useContext(LangContext) + + useEffect((): void => { + const params = new URLSearchParams(window.location.search); + const provider: string | null = params.get('provider'); + if (!provider) return; + + const code: string | null = params.get('code'); + if (!code) return; + + if (provider === 'google') { + handleGoogleLogin(code).then(); + return; + } + if (provider === 'facebook') { + const state: string | null = params.get('state'); + if (!state) return; + handleFacebookLogin(code, state).then(); + return; + } + if (provider === 'apple') { + const state: string | null = params.get('state'); + if (!state) return; + handleAppleLogin(code, state).then(); + return; + } + }, []); + + async function handleLoginSuccess(token: string): Promise { + await tauri.setToken(token); + await tauri.loginSuccess(); + } + + async function handleFacebookLogin(code: string, state: string): Promise { + if (code && state) { + const response: string = await apiPostPublic(`auth/facebook`, { + code: code, + state: state, + }, lang); + if (!response) { + errorMessage(t('socialForm.error.connection')); + return; + } + await handleLoginSuccess(response); + } + } + + async function handleGoogleLogin(code: string): Promise { + if (code) { + const response: string = await apiPostPublic(`auth/google`, { + code: code, + }, lang); + if (!response) { + errorMessage(t('socialForm.error.connection')); + return; + } + await handleLoginSuccess(response); + } + } + + async function handleAppleLogin(code: string, state: string): Promise { + if (code && state) { + const response: string = await apiPostPublic(`auth/apple`, { + code: code, + state: state, + }, lang); + if (!response) { + errorMessage(t('socialForm.error.connection')); + return; + } + await handleLoginSuccess(response); + } + } + + async function handleOAuthClick(provider: 'google' | 'facebook' | 'apple'): Promise { + try { + const oauthUrl = `${configs.baseUrl}auth/${provider}/desktop`; + await tauri.openExternal(oauthUrl); + } catch { + errorMessage(t('socialForm.error.connection')); + } + } + + return ( +
+ + + + + +
+ ) +} diff --git a/app/login/login/page.tsx b/app/login/login/page.tsx index f12c149..2b49041 100755 --- a/app/login/login/page.tsx +++ b/app/login/login/page.tsx @@ -1,172 +1,214 @@ -'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 SocialForm from "@/app/login/login/SocialForm"; -import {useTranslations} from "next-intl"; -import {LangContext} from "@/context/LangContext"; -import System from "@/lib/models/System"; -import * as tauri from '@/lib/tauri'; - -export default function LoginPage() { - const t = useTranslations(); - const {lang, setLang} = useContext(LangContext); - 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) { - setShowOfflineWarning(true); - } - } catch (error) { - console.error('Error checking first connection:', error); - } - } - - checkFirstConnectionAndNetwork(); - - const handleOnline = () => { - setIsOnline(true); - setShowOfflineWarning(false); - }; - const handleOffline = async () => { - setIsOnline(false); - try { - const token = await tauri.getToken(); - if (!token) { - setShowOfflineWarning(true); - } - } catch { - setShowOfflineWarning(true); - } - }; - - window.addEventListener('online', handleOnline); - window.addEventListener('offline', handleOffline); - - return () => { - window.removeEventListener('online', handleOnline); - window.removeEventListener('offline', handleOffline); - }; - }, []); - - return ( -
-
-
-
- ERitors -
-
- - {/* Offline warning notification */} - {showOfflineWarning && ( -
-
-
-
- -
-
-
-
-

- - {t('loginPage.offlineWarning.title')} -

-

- {t('loginPage.offlineWarning.message')} -

-
-
-
- )} - -
- -
-
-
- -
-
-

{t('loginPage.title')}

-

{t('loginPage.welcome')}

-
- -
-
-
-
-
- {t('loginPage.orSocial')} -
-
-
- - - - -
-
-
-
- {isDev && ( - resetDone - ? Reset OK - : - )} -
- ); -} \ No newline at end of file +'use client' +import {useContext, useEffect, useState} from 'react'; +import {Mail, Lock, Wifi, CloudUpload, Globe} from "lucide-react"; +import SocialForm from "@/app/login/login/SocialForm"; +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 {errorMessage} = useContext(AlertContext); + const {lang, setLang} = useContext(LangContext); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [showOfflineWarning, setShowOfflineWarning] = useState(false); + const [resetDone, setResetDone] = useState(false); + + const isDev = process.env.NODE_ENV === 'development'; + + useEffect(() => { + async function checkFirstConnectionAndNetwork() { + try { + const token = await tauri.getToken(); + const online = navigator.onLine; + if (!token && !online) { + setShowOfflineWarning(true); + } + } catch (error) { + console.error('Error checking first connection:', error); + } + } + + checkFirstConnectionAndNetwork(); + + const handleOnline = () => setShowOfflineWarning(false); + const handleOffline = async () => { + try { + const token = await tauri.getToken(); + if (!token) setShowOfflineWarning(true); + } catch { + setShowOfflineWarning(true); + } + }; + + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + }; + }, []); + + async function handleSubmit(e: React.FormEvent): Promise { + 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('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 ( +
+
+
+ ERitors +
+ + {showOfflineWarning && ( +
+
+ +
+

+ + {t('loginPage.offlineWarning.title')} +

+

+ {t('loginPage.offlineWarning.message')} +

+
+
+
+ )} + +
+

{t('loginPage.title')}

+

{t('loginPage.welcome')}

+
+ +
+
+
+ { + setLang(value as 'fr' | 'en'); + setCookie('lang', value, 365); + }} + icon={Globe} + size="sm" + /> +
+ setEmail(e.target.value)} + placeholder={t('loginForm.fields.email.placeholder')} size="lg"/> + }/> +
+
+

+ + {t('loginForm.fields.password.label')} +

+ + {t('loginForm.fields.password.forgot')} + +
+ setPassword(e.target.value)} + required + /> +
+
+ +
+ +
+
+ +
+
+
+
+
+ {t('loginPage.orSocial')} +
+
+ +
+ + + + +
+
+ + {isDev && ( + resetDone + ? Reset OK + : + )} +
+ ); +} diff --git a/app/login/offline/page.tsx b/app/login/offline/page.tsx index 9edba72..024a47a 100644 --- a/app/login/offline/page.tsx +++ b/app/login/offline/page.tsx @@ -1,79 +1,67 @@ -'use client'; - -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 * as tauri from '@/lib/tauri'; - -export default function OfflineLoginPage() { - const t = useTranslations(); - - async function handlePinSuccess(userId: string): Promise { - try { - const encryptionKey = await tauri.getUserEncryptionKey(userId); - if (encryptionKey) { - await tauri.dbInitialize(userId, encryptionKey); - await tauri.loginSuccess(); - } - } catch (error) { - console.error('[OfflineLogin] Error initializing database:', error); - } - } - - function handleBackToOnline(): void { - tauri.logout(); - } - - useEffect((): void => { - async function checkOfflineCapability() { - const offlineStatus = await tauri.offlineModeGet(); - if (!offlineStatus.hasPin) { - window.location.href = '/login/login'; - } - } - - checkOfflineCapability().then(); - }, []); - - return ( -
-
- {/* Offline indicator */} -
-
- - {t('offline.mode.title')} -
-
- - {/* Logo */} -
- ERitors -
- - {/* PIN Verify Component */} - - - {/* Back to online link */} -
- -
-
-
- ); -} \ No newline at end of file +'use client'; + +import {useEffect} from 'react'; +import OfflinePinVerify from '@/components/offline/OfflinePinVerify'; +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() { + const t = useTranslations(); + + async function handlePinSuccess(userId: string): Promise { + try { + const encryptionKey = await tauri.getUserEncryptionKey(userId); + if (encryptionKey) { + await tauri.dbInitialize(userId, encryptionKey); + await tauri.loginSuccess(); + } + } catch (error) { + console.error('[OfflineLogin] Error initializing database:', error); + } + } + + function handleBackToOnline(): void { + tauri.logout(); + } + + useEffect((): void => { + async function checkOfflineCapability() { + const offlineStatus = await tauri.offlineModeGet(); + if (!offlineStatus.hasPin) { + window.location.href = '/login/login'; + } + } + + checkOfflineCapability().then(); + }, []); + + return ( +
+
+
+ ERitors +
+ +
+
+ + {t('offline.mode.title')} +
+
+ +
+ +
+ + +
+
+ ); +} diff --git a/app/login/register/StepOne.tsx b/app/login/register/StepOne.tsx index 9ed0dbc..837d5e3 100755 --- a/app/login/register/StepOne.tsx +++ b/app/login/register/StepOne.tsx @@ -1,205 +1,147 @@ -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 {LangContext, LangContextProps} from "@/context/LangContext"; - -export default function StepOne( - { - username, - email, - setUsername, - setEmail, - handleNextStep - }: { - username: string; - email: string; - setUsername: Dispatch>, - setEmail: Dispatch>, - handleNextStep: Function; - }) { - const {errorMessage, successMessage} = useContext(AlertContext); - const [firstName, setFirstName] = useState(''); - const [lastName, setLastName] = useState(''); - const [password, setPassword] = useState(''); - const [repeatPassword, setRepeatPassword] = useState(''); - const [userId, setUserId] = useState('') - const t = useTranslations(); - const {lang} = useContext(LangContext) - - async function handleStep(): Promise { - - if (!firstName || !lastName || !username || !password || !repeatPassword || !email) { - errorMessage(t('registerStepOne.error.requiredFields')); - return; - } - if (firstName.length < 2 || firstName.length > 50) { - errorMessage(t('registerStepOne.error.firstNameLength')); - return; - } - if (lastName.length < 2 || lastName.length > 50) { - errorMessage(t('registerStepOne.error.lastNameLength')); - return; - } - if (username.length < 3 || username.length > 50) { - 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)) { - errorMessage(t('registerStepOne.error.invalidInput')); - return; - } - - if (password != repeatPassword) { - errorMessage(t('registerStepOne.error.passwordMismatch')); - return; - } - try { - const response: string = await System.postToServer(`register/pre`, { - firstName, - lastName, - username, - email, - password, - retypePass: repeatPassword - }, lang); - if (!response) { - errorMessage(t('registerStepOne.error.preRegister')); - return; - } - setUserId(response); - successMessage(t('registerStepOne.success.preRegister')); - handleNextStep(); - } catch (e: unknown) { - if (e instanceof Error) { - errorMessage(e.message); - } else { - errorMessage(t('registerStepOne.error.unknown')); - } - } - } - - return ( -
-
- -
- - setFirstName(e.target.value)} - required - /> -
-
-
- -
- - setLastName(e.target.value)} - required - /> -
-
-
- -
- - setUsername(e.target.value)} - required - /> -
-

{t('registerStepOne.fields.username.note')}

-
-
- -
- - setEmail(e.target.value)} - required - /> -
-
- -
- -
- - setPassword(e.target.value)} - required - /> -
-
-
- -
- - setRepeatPassword(e.target.value)} - required - /> -
-
- -
- ) -} +import React, {Dispatch, SetStateAction, useContext, useState} from "react"; +import {AlertContext} from "@/context/AlertContext"; +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( + { + username, + email, + setUsername, + setEmail, + handleNextStep + }: { + username: string; + email: string; + setUsername: Dispatch>, + setEmail: Dispatch>, + handleNextStep: Function; + }) { + const {errorMessage, successMessage} = useContext(AlertContext); + const [firstName, setFirstName] = useState(''); + const [lastName, setLastName] = useState(''); + const [password, setPassword] = useState(''); + const [repeatPassword, setRepeatPassword] = useState(''); + const [userId, setUserId] = useState('') + const t = useTranslations(); + const {lang} = useContext(LangContext) + + async function handleStep(): Promise { + + if (!firstName || !lastName || !username || !password || !repeatPassword || !email) { + errorMessage(t('registerStepOne.error.requiredFields')); + return; + } + if (firstName.length < 2 || firstName.length > 50) { + errorMessage(t('registerStepOne.error.firstNameLength')); + return; + } + if (lastName.length < 2 || lastName.length > 50) { + errorMessage(t('registerStepOne.error.lastNameLength')); + return; + } + if (username.length < 3 || username.length > 50) { + errorMessage(t('registerStepOne.error.usernameLength')); + return; + } + if (verifyInput(firstName) || verifyInput(lastName) || 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(`register/pre`, { + firstName, + lastName, + username, + email, + password, + retypePass: repeatPassword + }, lang); + if (!response) { + errorMessage(t('registerStepOne.error.preRegister')); + return; + } + setUserId(response); + successMessage(t('registerStepOne.success.preRegister')); + handleNextStep(); + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t('registerStepOne.error.unknown')); + } + } + } + + return ( +
+ setFirstName(e.target.value)} + placeholder={t('registerStepOne.fields.firstName.placeholder')}/> + }/> + + setLastName(e.target.value)} + placeholder={t('registerStepOne.fields.lastName.placeholder')}/> + }/> + +
+ setUsername(e.target.value)} + placeholder={t('registerStepOne.fields.username.placeholder')}/> + }/> +

{t('registerStepOne.fields.username.note')}

+
+ + setEmail(e.target.value)} + placeholder={t('registerStepOne.fields.email.placeholder')}/> + }/> + +
+

+ + {t('registerStepOne.fields.password.label')} +

+ setPassword(e.target.value)} + required + /> +
+ +
+

+ + {t('registerStepOne.fields.repeatPassword.label')} +

+ setRepeatPassword(e.target.value)} + required + /> +
+ + + + ) +} diff --git a/app/login/register/StepTree.tsx b/app/login/register/StepTree.tsx index 2b52512..8252b8f 100755 --- a/app/login/register/StepTree.tsx +++ b/app/login/register/StepTree.tsx @@ -1,114 +1,91 @@ -import React, {useContext, useState} from "react"; -import Link from "next/link"; -import System from "@/lib/models/System"; -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 {LangContext, LangContextProps} from "@/context/LangContext"; - -export default function StepTree( - { - email, - prevStep - }: { - email: string; - prevStep: Function; - }) { - - const {errorMessage, successMessage} = useContext(AlertContext); - - const [isConfirmed, setIsConfirmed] = useState(false); - const [verifyCode, setVerifyCode] = useState(''); - const t = useTranslations(); - const {lang} = useContext(LangContext) - - async function handleVerifyCode(): Promise { - if (verifyCode === '') { - errorMessage(t('registerStepTwo.error.codeIncorrect')); - return; - } - try { - const response: boolean = await System.postToServer('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 ( - !isConfirmed ? ( -
-
-

{t('registerStepTwo.instructions.sent')}

-

{t('registerStepTwo.instructions.checkInbox')}

-
- -
- -
- - setVerifyCode(e.target.value)} - required - /> -
-
- -
- - - -
-
- ) : ( -
-
-

{t('registerStepTwo.confirmed')}

-
-
- - - -
-
- ) - ) -} +import React, {useContext, useState} from "react"; +import {Link} from "@/lib/navigation"; +import {AlertContext} from "@/context/AlertContext"; +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( + { + email, + prevStep + }: { + email: string; + prevStep: Function; + }) { + + const {errorMessage, successMessage} = useContext(AlertContext); + + const [isConfirmed, setIsConfirmed] = useState(false); + const [verifyCode, setVerifyCode] = useState(''); + const t = useTranslations(); + const {lang} = useContext(LangContext) + + async function handleVerifyCode(): Promise { + if (verifyCode === '') { + errorMessage(t('registerStepTwo.error.codeIncorrect')); + return; + } + try { + const response: boolean = await apiPostPublic('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 ( + !isConfirmed ? ( +
+
+

{t('registerStepTwo.instructions.sent')}

+

{t('registerStepTwo.instructions.checkInbox')}

+
+ + setVerifyCode(e.target.value)} + placeholder={t('registerStepTwo.fields.code.placeholder')}/> + }/> + +
+ + + +
+
+ ) : ( +
+
+

{t('registerStepTwo.confirmed')}

+
+
+ + + +
+
+ ) + ) +} diff --git a/app/login/register/page.tsx b/app/login/register/page.tsx index 5a6d9dd..3143328 100755 --- a/app/login/register/page.tsx +++ b/app/login/register/page.tsx @@ -1,95 +1,202 @@ -'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'; - -export default function Register() { - const t = useTranslations(); - const [username, setUsername] = useState(''); - const [email, setEmail] = useState(''); - const [step, setStep] = useState(1); - - function handleNextStep(): void { - setStep(step + 1); - } - - function handlePrevStep(): void { - setStep(step - 1); - } - - return ( -
-
-
-
- ERitors -
-
- -
-
-
-
- -
-
-

{t('registerPage.title')}

-

{t('registerPage.subtitle')}

-
- -
-
-
= 1 ? 'bg-gradient-to-r from-primary to-info' : 'bg-secondary'}`}>
-
-
= 2 ? 'bg-gradient-to-r from-primary to-info' : 'bg-secondary'}`}>
-
-
- {t('registerPage.progress.infos')} - {t('registerPage.progress.verif')} -
-
- { - step === 1 && - } - { - step === 1 && ( - <> - - - - {t('registerPage.backToLogin')} - - - ) - } - { - step === 2 && ( - - ) - } -
-
-
-
- ); -} \ No newline at end of file +'use client' +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(LangContext); + + const [step, setStep] = useState(1); + const [username, setUsername] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [repeatPassword, setRepeatPassword] = useState(''); + const [verifyCode, setVerifyCode] = useState(''); + const [isConfirmed, setIsConfirmed] = useState(false); + + async function handleStepOne(): Promise { + 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('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')); + } + } + } + + async function handleVerifyCode(): Promise { + if (verifyCode === '') { + errorMessage(t('registerStepTwo.error.codeIncorrect')); + return; + } + try { + const response: boolean = await apiPostPublic('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 ( +
+
+
+ ERitors +
+ +
+

{t('registerPage.title')}

+

{t('registerPage.subtitle')}

+
+ + {step === 1 && !isConfirmed && ( + <> +
+
+
= 1 ? 'bg-primary' : 'bg-secondary'}`}>
+
+
= 2 ? 'bg-primary' : 'bg-secondary'}`}>
+
+
+
+ setUsername(e.target.value)} + placeholder={t('registerStepOne.fields.username.placeholder')}/> + }/> +

{t('registerStepOne.fields.username.note')}

+
+ setEmail(e.target.value)} + placeholder={t('registerStepOne.fields.email.placeholder')}/> + }/> +
+

+ + {t('registerStepOne.fields.password.label')} +

+ setPassword(e.target.value)} required/> +
+
+

+ + {t('registerStepOne.fields.repeatPassword.label')} +

+ setRepeatPassword(e.target.value)} required/> +
+
+
+ +
+ + + + +
+ + )} + + {step === 2 && !isConfirmed && ( + <> +
+
+
+
+
+
+
+
+

{t('registerStepTwo.instructions.sent')}

+

{t('registerStepTwo.instructions.checkInbox')}

+
+ setVerifyCode(e.target.value)} + placeholder={t('registerStepTwo.fields.code.placeholder')}/> + }/> +
+
+ +
+ + +
+ + )} + + {isConfirmed && ( + <> +
+
+

{t('registerStepTwo.confirmed')}

+
+
+ + + + + + )} +
+
+ ); +} diff --git a/app/login/reset-password/page.tsx b/app/login/reset-password/page.tsx index 33ba747..e45a642 100755 --- a/app/login/reset-password/page.tsx +++ b/app/login/reset-password/page.tsx @@ -1,277 +1,190 @@ -'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 {QueryDataResponse} from "@/shared/interface"; -import {AlertContext} from "@/context/AlertContext"; -import {useTranslations} from "next-intl"; -import {LangContext, LangContextProps} from "@/context/LangContext"; - -export default function ForgetPasswordPage() { - const [step, setStep] = useState(1); - const [email, setEmail] = useState(''); - const [verificationCode, setVerificationCode] = useState(''); - const [isConfirmed, setIsConfirmed] = useState(false); - const [newPassword, setNewPassword] = useState(''); - const {errorMessage, successMessage} = useContext(AlertContext); - const t = useTranslations(); - const {lang} = useContext(LangContext) - - function handleNextStep(): void { - setStep(step + 1); - } - - function handlePrevStep(): void { - setStep(step - 1); - } - - async function handleConfirm() { - try { - const response: QueryDataResponse = await System.postToServer>('user/verify-code', { - verifyCode: verificationCode, - email, - }, lang); - if (response.valid) { - successMessage(response.message ?? ''); - setIsConfirmed(true); - } else { - errorMessage(response.message ?? ''); - } - } catch (e: unknown) { - if (e instanceof Error) { - errorMessage(t('resetPassword.error.codeServer')); - } else { - errorMessage(t('resetPassword.error.codeUnknown')); - } - } - } - - async function handleEmailCheck(): Promise { - 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 = await System.postToServer>('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 { - try { - const response: QueryDataResponse = await System.postToServer('password/reset', { - email: email, - newPassword: newPassword, - code: verificationCode - }, lang); - if (response.valid) { - successMessage(response.message ?? ''); - handleNextStep(); - } else { - errorMessage(response.message ?? ''); - } - } catch (e: unknown) { - if (e instanceof Error) { - errorMessage(t('resetPassword.error.passwordServer')); - } else { - errorMessage(t('resetPassword.error.passwordUnknown')); - } - } - } - - return ( -
-
-
-
- ERitors -
-
-
-
-
-
- -
-
-

{t('resetPassword.title')}

-

{t('resetPassword.subtitle')}

-
- -
-
-
= 1 ? 'bg-gradient-to-r from-primary to-info' : 'bg-secondary'}`}>
-
-
= 2 ? 'bg-gradient-to-r from-primary to-info' : 'bg-secondary'}`}>
-
-
= 3 ? 'bg-gradient-to-r from-primary to-info' : 'bg-secondary'}`}>
-
-
- {t('resetPassword.progress.email')} - {t('resetPassword.progress.verification')} - {t('resetPassword.progress.final')} -
-
- - {step === 1 && ( -
-
- -
- - setEmail(e.target.value)} - required - /> -
-
- -
- - - - - {t('resetPassword.backToLogin')} - -
-
- )} - - {step === 2 && ( -
-
- -
- - setVerificationCode(e.target.value)} - disabled={isConfirmed} - required - /> -
-
- - {isConfirmed && ( -
- -
- - setNewPassword(e.target.value)} - required - /> -
-
- )} - -
- - - -
-
- )} - - {step === 3 && ( -
-
-

- {t('resetPassword.success')} -

-
- -
- )} -
-
-
-
- ) -} +'use client' +import {useContext, useState} from "react"; +import {Mail, KeyRound, Lock, ArrowLeft} from 'lucide-react'; +import {QueryDataResponse} from "@/shared/interface"; +import {AlertContext} from "@/context/AlertContext"; +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); + const [email, setEmail] = useState(''); + const [verificationCode, setVerificationCode] = useState(''); + const [isConfirmed, setIsConfirmed] = useState(false); + const [newPassword, setNewPassword] = useState(''); + const {errorMessage, successMessage} = useContext(AlertContext); + const t = useTranslations(); + const {lang} = useContext(LangContext) + + async function handleEmailCheck(): Promise { + if (!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 = await apiPostPublic>('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 { + try { + const response: QueryDataResponse = await apiPostPublic>('user/verify-code', { + verifyCode: verificationCode, email, + }, lang); + if (response.valid) { + successMessage(response.message ?? ''); + setIsConfirmed(true); + } else { + errorMessage(response.message ?? ''); + } + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(t('resetPassword.error.codeServer')); + } else { + errorMessage(t('resetPassword.error.codeUnknown')); + } + } + } + + async function handleNewPassword(): Promise { + try { + const response: QueryDataResponse = await apiPostPublic>('password/reset', { + email, newPassword, code: verificationCode + }, lang); + if (response.valid) { + successMessage(response.message ?? ''); + setStep(3); + } else { + errorMessage(response.message ?? ''); + } + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(t('resetPassword.error.passwordServer')); + } else { + errorMessage(t('resetPassword.error.passwordUnknown')); + } + } + } + + return ( +
+
+
+ ERitors +
+ +
+

{t('resetPassword.title')}

+

{t('resetPassword.subtitle')}

+
+ + {step < 3 && ( + <> +
+
+
= 1 ? 'bg-primary' : 'bg-secondary'}`}>
+
+
= 2 ? 'bg-primary' : 'bg-secondary'}`}>
+
+
= 3 ? 'bg-primary' : 'bg-secondary'}`}>
+
+
+ {step === 1 && ( + setEmail(e.target.value)} + placeholder={t('resetPassword.fields.email.placeholder')}/> + }/> + )} + {step === 2 && ( + <> + setVerificationCode(e.target.value)} + placeholder={t('resetPassword.fields.code.placeholder')} + disabled={isConfirmed}/> + }/> + {isConfirmed && ( +
+

+ + {t('resetPassword.fields.newPassword.label')} +

+ setNewPassword(e.target.value)} required/> +
+ )} + + )} +
+
+ +
+ {step === 1 && ( + <> + + + + + + )} + {step === 2 && ( + <> + + + + )} +
+ + )} + + {step === 3 && ( + <> +
+
+

{t('resetPassword.success')}

+
+
+ + + + + + )} +
+
+ ) +} diff --git a/app/page.tsx b/app/page.tsx index c2aef6d..5741499 100644 --- a/app/page.tsx +++ b/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(false); - const hasRefreshedRef = useRef(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[] = []; - - 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({user: null, accessToken: '', isConnected: false}); - const [currentChapter, setCurrentChapter] = useState(undefined); - const [currentBook, setCurrentBook] = useState(null); - - const [serverSyncedBooks, setServerSyncedBooks] = useState([]); - const [localSyncedBooks, setLocalSyncedBooks] = useState([]); - const [bookSyncDiffsFromServer, setBookSyncDiffsFromServer] = useState([]); - const [bookSyncDiffsToServer, setBookSyncDiffsToServer] = useState([]); - const [serverOnlyBooks, setServerOnlyBooks] = useState([]); - const [localOnlyBooks, setLocalOnlyBooks] = useState([]); - - const [serverSyncedSeries, setServerSyncedSeries] = useState([]); - const [localSyncedSeries, setLocalSyncedSeries] = useState([]); - const [seriesSyncDiffsFromServer, setSeriesSyncDiffsFromServer] = useState([]); - const [seriesSyncDiffsToServer, setSeriesSyncDiffsToServer] = useState([]); - const [serverOnlySeries, setServerOnlySeries] = useState([]); - const [localOnlySeries, setLocalOnlySeries] = useState([]); - - const [currentCredits, setCurrentCredits] = useState(160); - const [amountSpent, setAmountSpent] = useState(session.user?.aiUsage || 0); - - const [isLoading, setIsLoading] = useState(true); - - const [isTermsAccepted, setIsTermsAccepted] = useState(false); - const [homeStepsGuide, setHomeStepsGuide] = useState(false); - const [showPinSetup, setShowPinSetup] = useState(false); - const [showPinVerify, setShowPinVerify] = useState(false); - - const [localSyncQueue, setLocalSyncQueue] = useState([]); - const [isQueueProcessing, setIsQueueProcessing] = useState(false); - - - function addToLocalSyncQueue(channel: string, data: Record): 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 { - 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: ( -
-

{t("homePage.guide.step0.description1")}

-
-

{t("homePage.guide.step0.description2")}

-
- ), - }, - { - id: 1, position: 'right', - targetSelector: `[data-guide="left-panel-container"]`, - title: t("homePage.guide.step1.title"), - content: ( -
-

- - : - - {t("homePage.guide.step1.addBook")} -

-
-

: {t("homePage.guide.step1.generateStory")} -

-
- ), - }, - { - id: 2, - title: t("homePage.guide.step2.title"), position: 'bottom', - targetSelector: `[data-guide="search-bar"]`, - content: ( -
-

{t("homePage.guide.step2.description")}

-
- ), - }, - { - id: 3, - title: t("homePage.guide.step3.title"), - targetSelector: `[data-guide="user-dropdown"]`, - position: 'auto', - content: ( -
-

{t("homePage.guide.step3.description")}

-
- ), - }, - { - id: 4, - title: t("homePage.guide.step4.title"), - content: ( -
-

{t("homePage.guide.step4.description1")}

-
-

{t("homePage.guide.step4.description2")}

-
- ), - }, - ]; - - useEffect((): void => { - checkAuthentification().then(); - - 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 { - 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('books/synced', { lastOnlineTimestamp, tombstones: localTombstones }, session.accessToken, locale); - serverBooksResponse = serverResponse.books; - await tauri.applyBookTombstones(serverResponse.tombstones); - } else { - const serverResponse: SyncedBooksResponse = await System.authPostToServer('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 { - 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('series/synced', { lastOnlineTimestamp, tombstones: localTombstones }, session.accessToken, locale); - serverSeriesResponse = serverResponse.series; - await tauri.applySeriesTombstones(serverResponse.tombstones); - } else { - const serverResponse: SyncedSeriesResponse = await System.authPostToServer('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 { - 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 { - try { - if (!isCurrentlyOffline()) { - const response: boolean = await System.authPostToServer('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 { - 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('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 { - try { - const response: boolean = await System.authPostToServer(`user/terms/accept`, { - version: '2025-07-1' - }, session.accessToken, locale); - if (response) { - setIsTermsAccepted(true); - setHomeStepsGuide(true); - const newSession: SessionProps = { - ...session, - user: { - ...session?.user as UserProps, - termsAccepted: true - } - } - setSession(newSession); - } else { - errorMessage(t("homePage.errors.termsAcceptError")); - } - } catch (e: unknown) { - if (e instanceof Error) { - errorMessage(e.message); - } else { - errorMessage(t("homePage.errors.termsAcceptError")); - } - } - } - - async function getLastChapter(): Promise { - if (session?.accessToken) { - try { - 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(`chapter/last-chapter`, session.accessToken, locale, {bookid: currentBook?.bookId}); - } - } - if (response) { - setCurrentChapter(response) - } else { - setCurrentChapter(undefined); - } - } catch (e: unknown) { - if (e instanceof Error) { - errorMessage(e.message); - } else { - errorMessage(t("homePage.errors.lastChapterError")); - } - } - } - } - - if (isLoading) { - return ( -
-
-
- ERitors Logo -
-
-
-
-
-
-

- {t("homePage.loading")} -

-
-
- ) - } - - return ( - - - - - - - - -
- - - -
- - - -
- -
-
- { - homeStepsGuide && !isCurrentlyOffline() && - setHomeStepsGuide(false)}/> - } - { - !isTermsAccepted && !isCurrentlyOffline() && - } - { - showPinSetup && ( - setShowPinSetup(false)} - onSuccess={():void => { - setShowPinSetup(false); - }} - /> - ) - } - { - showPinVerify && ( - {}} - /> - ) - } -
-
-
-
-
-
-
- ); -} - -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 ( - - - - - - - - - - ); +export default function HomePage() { + return ; } diff --git a/components/AlertBox.tsx b/components/AlertBox.tsx deleted file mode 100644 index dae6978..0000000 --- a/components/AlertBox.tsx +++ /dev/null @@ -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; - 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 = ( -
-
-
-
-
- -
-

{title}

-
-
- -
-

{message}

- {children} -
- - -
-
-
-
- ); - - if (!mounted) return null; - - return createPortal(alertContent, document.body); -} diff --git a/components/AlertStack.tsx b/components/AlertStack.tsx deleted file mode 100644 index 3958ebc..0000000 --- a/components/AlertStack.tsx +++ /dev/null @@ -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 = ( -
- {alerts.map((alert, index) => ( -
- onClose(alert.id)} - /> -
- ))} - -
- ); - - return createPortal(alertContent, document.body); -} diff --git a/components/CollapsableArea.tsx b/components/CollapsableArea.tsx deleted file mode 100644 index ed79ab5..0000000 --- a/components/CollapsableArea.tsx +++ /dev/null @@ -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 ( -
- - - {isExpanded && ( -
- {children} -
- )} -
- ); -} diff --git a/components/CollapsableButton.tsx b/components/CollapsableButton.tsx deleted file mode 100644 index 9954a6a..0000000 --- a/components/CollapsableButton.tsx +++ /dev/null @@ -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 ( - - ) -} diff --git a/components/Collapse.tsx b/components/Collapse.tsx deleted file mode 100644 index 08d8534..0000000 --- a/components/Collapse.tsx +++ /dev/null @@ -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(false); - - function toggleCollapse(): void { - setIsOpen(!isOpen); - } - - return ( -
- - {isOpen && ( -
-
{content}
-
- )} -
- ); -} \ No newline at end of file diff --git a/components/CreditMeters.tsx b/components/CreditMeters.tsx deleted file mode 100644 index da83f0b..0000000 --- a/components/CreditMeters.tsx +++ /dev/null @@ -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(AIUsageContext) - - if (isCredit) { - return ( -
- - - {Math.round(totalCredits)} crédits - -
- ); - } - - return ( -
- - - {totalPrice ? totalPrice.toFixed(2) : '0.00'} - -
- ); -} \ No newline at end of file diff --git a/components/GuideTour.tsx b/components/GuideTour.tsx index 314a078..f5a7328 100644 --- a/components/GuideTour.tsx +++ b/components/GuideTour.tsx @@ -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(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(step.targetSelector); if (!element) { return { left: '50%', @@ -88,12 +106,12 @@ function getPopoverPosition(step: GuideStep): React.CSSProperties { transform: 'translate(-50%, -50%)' }; } - + const rect: DOMRect = element.getBoundingClientRect(); const {left, top, width, height} = rect; - const popoverWidth = 420; - const popoverHeight = 300; - const margin = 20; + const 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(0); const [isVisible, setIsVisible] = useState(false); const [rendered, setRendered] = useState(false); + const overlayRef: React.RefObject = useRef(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(null); + const timeoutRef: React.RefObject = useRef(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(step.targetSelector); if (element) { element.scrollIntoView({behavior: 'smooth', block: 'center'}); } @@ -224,42 +243,46 @@ export default function GuideTour({stepId, steps, onClose, onComplete}: GuideTou }, 50); }, 600); }, 200); - }; + } useEffect((): () => void => { showStep(0); - + return (): void => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } }; }, []); + + 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]); - const handleNext: () => void = (): void => { + 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 ( -
+ return createPortal( +
{rendered && ( @@ -273,7 +296,8 @@ export default function GuideTour({stepId, steps, onClose, onComplete}: GuideTou onClose={onClose} /> )} -
+
, + document.body ); } @@ -309,18 +333,25 @@ function GuidePopup( onNext: () => void; onClose: () => void; }): JSX.Element { - const positionStyle = useMemo(() => { - return getPopoverPosition(step); + const popupRef: React.RefObject = useRef(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 ( + + return (
-
+

@@ -331,24 +362,18 @@ function GuidePopup( Étape {currentStep + 1} sur {totalSteps}
- {Array.from({length: totalSteps}).map((_, index) => ( + {Array.from({length: totalSteps}).map((_: unknown, index: number) => (
))}

- +
@@ -356,26 +381,18 @@ function GuidePopup( {step.content}
-
+
{currentStep > 0 ? ( - + ) : (
)} - +
diff --git a/components/ListItem.tsx b/components/ListItem.tsx deleted file mode 100644 index cd718d5..0000000 --- a/components/ListItem.tsx +++ /dev/null @@ -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(false); - const [editMode, setEditMode] = useState(false); - - const [newName, setNewName] = useState(''); - const [newChapterOrder, setNewChapterOrder] = useState(numericalIdentifier ?? 0); - - function handleEdit(itemName: string): void { - setNewName(itemName) - setEditMode(true) - } - - function handleSave(): void { - if (!handleUpdate) return; - handleUpdate(id as string, newName, newChapterOrder) - setEditMode(false); - } - - - function moveItem(direction: "up" | "down"): void { - switch (direction) { - case "up": - if (newChapterOrder > 0) { - setNewChapterOrder(newChapterOrder - 1) - } - break; - case "down": - if (newChapterOrder < 100) { - setNewChapterOrder(newChapterOrder + 1) - } - break; - default: - break; - } - } - - return ( -
  • setItemHover(true)} onMouseLeave={(): void => setItemHover(false)} - className={`group relative flex items-center p-3 rounded-xl transition-colors duration-200 border-l-4 ${ - selectedId === id - ? 'bg-secondary border-primary' - : 'bg-secondary/50 hover:bg-secondary border-transparent' - }`}> - { - (numericalIdentifier != null && newChapterOrder >= 0) && ( - - {newChapterOrder >= 0 ? newChapterOrder : numericalIdentifier}. - - ) - } - { - icon && ( -
    - -
    - ) - } -
    - { - editMode ? ( -
    -
    - ): void => setNewName(e.target.value)} - placeholder="" - /> -
    - - -
    - ) : ( - {text} - ) - } - { - !editMode && isEditable && ( -
    - - -
    - ) - } - { - editMode && isEditable && ( -
    - - -
    - ) - } -
    -
  • - ) -} diff --git a/components/Modal.tsx b/components/Modal.tsx deleted file mode 100644 index 65c08d9..0000000 --- a/components/Modal.tsx +++ /dev/null @@ -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 = ( -
    -
    -
    -

    {title}

    - -
    -
    - {children} -
    - { - enableFooter && ( -
    - - -
    - ) - } -
    -
    - ); - - if (!mounted) return null; - - return createPortal(modalContent, document.body); -} diff --git a/components/NoPicture.tsx b/components/NoPicture.tsx deleted file mode 100644 index 05fc915..0000000 --- a/components/NoPicture.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import {useContext} from "react"; -import {SessionContext} from "@/context/SessionContext"; - -export default function NoPicture() { - const {session} = useContext(SessionContext); - return ( -
    - {session.user?.name && session.user.name.charAt(0).toUpperCase()} - {session.user?.lastName && session.user.lastName.charAt(0).toUpperCase()} -
    - ) -} diff --git a/components/PanelHeader.tsx b/components/PanelHeader.tsx deleted file mode 100644 index fc8728c..0000000 --- a/components/PanelHeader.tsx +++ /dev/null @@ -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; - secondActionIcon?: IconDefinition; - secondActionCallback?: () => Promise; - actionIcon?: IconDefinition; - actionText?: string; -} - -export default function PanelHeader( - { - title, - badge, - description, - icon, - callBackAction, - secondActionCallback, - secondActionIcon = faSave, - actionIcon = faX, - actionText - }: PanelHeaderProps) { - return ( -
    -
    -
    -

    - { - icon && ( -
    - -
    - ) - } - {title} - { - badge && - {badge} - } -

    - {description &&

    {description}

    } -
    -
    - { - actionText && ( - - ) - } - { - secondActionCallback && ( - - ) - } - { - callBackAction && actionIcon && !actionText && ( - - ) - } -
    -
    -
    - ); -} diff --git a/components/QSTextGeneratedPreview.tsx b/components/QSTextGeneratedPreview.tsx deleted file mode 100644 index 3b45a85..0000000 --- a/components/QSTextGeneratedPreview.tsx +++ /dev/null @@ -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 = ( -
    -
    -
    -
    -
    -

    {t("qsTextPreview.title")}

    -
    - -
    - {isGenerating && onStop ? ( - - ) : ( - - )} - -
    -
    -
    -
    - {isGenerating && !hasRealContent ? ( -
    -
    - - - - - - - -
    -
    - - - - - - -
    -
    - - - - - - - -
    -
    - - - - - -
    -
    - ) : ( -
    -
    - {filteredValue} -
    -
    - )} -
    -
    -
    - -
    -
    -
    - ); - - return createPortal(modalContent, document.body); -} diff --git a/components/ScribeControllerBar.tsx b/components/ScribeControllerBar.tsx deleted file mode 100644 index 84c7d2c..0000000 --- a/components/ScribeControllerBar.tsx +++ /dev/null @@ -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(LangContext); - const {isCurrentlyOffline} = useContext(OfflineContext) - const {serverSyncedBooks, serverOnlyBooks, localOnlyBooks} = useContext(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(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(`chapter/whole`, session.accessToken, lang, { - bookid: book?.bookId, - id: chapter?.chapterId, - version: version, - }); - } - if (!response) { - errorMessage(t("controllerBar.chapterNotFound")); - return; - } - setChapter(response); - } catch (e: unknown) { - if (e instanceof Error) { - errorMessage(e.message); - } else { - errorMessage(t("controllerBar.unknownChapterError")); - } - } - } - - async function getBook(bookId: string): Promise { - try { - const response: BookProps = await System.authGetQueryToServer(`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 ( -
    -
    -
    - {book && ( - - )} - { - book && ( - - ) - } -
    -
    - getBook(e.target.value)} - data={Book.booksToSelectBox([...serverOnlyBooks, ...localOnlyBooks])} defaultValue={book?.bookId} - placeholder={t("controllerBar.selectBook")}/> -
    - {chapter && ( -
    - 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()}/> -
    - )} -
    -
    - { - hasAccess && book?.quillsenseEnabled !== false && - - } -
    -
    - -
    - - -
    - -
    - {showSettingPanel && setShowSettingPanel(false)}/>} -
    - ) -} \ No newline at end of file diff --git a/components/ScribeFooterBar.tsx b/components/ScribeFooterBar.tsx deleted file mode 100644 index 5ccf2d7..0000000 --- a/components/ScribeFooterBar.tsx +++ /dev/null @@ -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(OfflineContext) - const {localOnlyBooks,serverSyncedBooks} = useContext(BooksSyncContext); - - const [wordsCount, setWordsCount] = useState(0); - - useEffect((): void => { - getWordCount(); - }, [editor?.state.doc.textContent]); - - function getWordCount(): void { - if (editor) { - try { - const content: string = editor?.state.doc.textContent; - const texteNormalise: string = content - .replace(/'/g, ' ') - .replace(/-/g, ' ') - .replace(/\s+/g, ' ') - .trim(); - const mots: string[] = texteNormalise.split(' '); - const wordCount: number = mots.filter( - (mot: string): boolean => mot.length > 0, - ).length; - setWordsCount(wordCount); - } catch (e: unknown) { - if (e instanceof Error) { - errorMessage(t('errors.wordCountError') + ` (${e.message})`); - } else { - errorMessage(t('errors.wordCountError')); - } - } - } - } - - return ( -
    -
    - - {chapter && ( - - - {chapter.chapterOrder < 0 ? t('scribeFooterBar.sheet') : `${chapter.chapterOrder}.`} - - - )} - - { - chapter?.title || book?.title || ( - <> - {t('scribeFooterBar.madeWith')} - - - )} - - -
    - { - chapter || book ? ( -
    -
    - - {t('scribeFooterBar.words')}: - {wordsCount} -
    -
    - - {Math.ceil(wordsCount / 300)} -
    -
    - ) : ( -
    - { - !offlineMode.isOffline &&
    - - {serverSyncedBooks.length} -
    - } - {(localOnlyBooks.length > 0 || offlineMode.isOffline) && ( -
    - - {localOnlyBooks.length} -
    - )} -
    - ) - } -
    - ) -} \ No newline at end of file diff --git a/components/ScribeTopBar.tsx b/components/ScribeTopBar.tsx deleted file mode 100644 index e6e8b83..0000000 --- a/components/ScribeTopBar.tsx +++ /dev/null @@ -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 ( -
    -
    -
    - {t("scribeTopBar.logoAlt")} -
    - {t("scribeTopBar.scribe")} -
    - {book.book && ( -
    -
    -
    -

    - {book.book.title} -

    - {book.book.subTitle && ( -

    - {book.book.subTitle} -

    - )} -
    -
    -
    - )} -
    - -
    -
    - ) -} \ No newline at end of file diff --git a/components/SettingsPanel.tsx b/components/SettingsPanel.tsx deleted file mode 100644 index 4dcc8d2..0000000 --- a/components/SettingsPanel.tsx +++ /dev/null @@ -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(false); - - useEffect((): void => { - setMounted(true); - }, []); - - if (!mounted) return null; - - return createPortal( -
    -
    - -
    -

    {title}

    - -
    - -
    -
    - {sidebar} -
    -
    - {children} -
    -
    - -
    -
    , - document.body - ); -} diff --git a/components/ShortStoryGenerator.tsx b/components/ShortStoryGenerator.tsx index 5f75e70..00f1e4a 100644 --- a/components/ShortStoryGenerator.tsx +++ b/components/ShortStoryGenerator.tsx @@ -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(LangContext) + const {session}: SessionContextProps = useContext(SessionContext); + const {errorMessage, infoMessage}: AlertContextProps = useContext(AlertContext); + const {lang}: LangContextProps = useContext(LangContext) const t = useTranslations(); - const {setTotalPrice, setTotalCredits} = useContext(AIUsageContext) + const {setTotalPrice, setTotalCredits}: AIUsageContextProps = useContext(AIUsageContext) const [tone, setTone] = useState(''); const [atmosphere, setAtmosphere] = useState(''); const [verbTense, setVerbTense] = useState('0'); const [person, setPerson] = useState('0'); const [characters, setCharacters] = useState(''); - const [language, setLanguage] = useState( - session.user?.writingLang.toString() ?? '0', - ); + const [language, setLanguage] = useState(session.user?.writingLang?.toString() ?? '0'); const [dialogueType, setDialogueType] = useState('0'); const [wordsCount, setWordsCount] = useState(500) const [directives, setDirectives] = useState(''); - const [authorLevel, setAuthorLevel] = useState( - session.user?.writingLevel.toString() ?? '0', - ); + const [authorLevel, setAuthorLevel] = useState(session.user?.writingLevel?.toString() ?? '0'); const [presetType, setPresetType] = useState('0'); const [activeTab, setActiveTab] = useState(1); const [progress, setProgress] = useState(25); - const modalRef: RefObject = useRef(null); const [isGenerating, setIsGenerating] = useState(false); + const progressRef = useRef(null); const [generatedText, setGeneratedText] = useState(''); const [generatedStoryTitle, setGeneratedStoryTitle] = useState(''); @@ -98,10 +104,16 @@ export default function ShortStoryGenerator({onClose}: ShortStoryGeneratorProps) const [useExplicit, setUseExplicit] = useState(false); const [useSmart, setUseSmart] = useState(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: [ StarterKit, @@ -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]); @@ -151,7 +163,7 @@ export default function ShortStoryGenerator({onClose}: ShortStoryGeneratorProps) infoMessage(t("shortStoryGenerator.result.abortSuccess")); } } - + async function handleGeneration(): Promise { setIsGenerating(true); setGeneratedText(''); @@ -219,12 +231,13 @@ export default function ShortStoryGenerator({onClose}: ShortStoryGeneratorProps) totalPrice?: number; totalCost?: number; } = JSON.parse(line.slice(6)); - + if (data.content && data.content !== 'starting') { accumulatedText += data.content; 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) { @@ -242,7 +255,7 @@ export default function ShortStoryGenerator({onClose}: ShortStoryGeneratorProps) break; } } - + setIsGenerating(false); setHasGenerated(true); setAbortController(null); @@ -284,10 +297,10 @@ export default function ShortStoryGenerator({onClose}: ShortStoryGeneratorProps) } async function handleSave(): Promise { - let content: string = ''; - if (editor) content = editor?.state?.doc.toJSON(); + let content: Record | string = ''; + if (editor) content = editor.state.doc.toJSON(); try { - const bookId: string = await System.authPostToServer( + const bookId: string = await apiPost( `quillsense/generate/add`, { title: generatedStoryTitle, @@ -324,106 +337,102 @@ export default function ShortStoryGenerator({onClose}: ShortStoryGeneratorProps) if (!hasAccess) { return (
    + className="fixed inset-0 flex items-center justify-center p-4 bg-darkest-background/60 z-50 backdrop-blur-md animate-fadeIn">
    + className="relative bg-tertiary text-text-primary rounded-xl overflow-hidden w-full max-w-md p-6">

    - + {t("shortStoryGenerator.accessDenied.title")}

    {t("shortStoryGenerator.accessDenied.message")}

    - +
    ); } - + return ( -
    -
    +
    +
    -
    +

    - + {t("shortStoryGenerator.title")}

    - +
    -
    -
    -
    +
    +
    +
    +
    +
    -
    - -
    - {[ - {id: 1, label: t("shortStoryGenerator.tabs.basics"), icon: faBookOpen}, - {id: 2, label: t("shortStoryGenerator.tabs.structure"), icon: faUserEdit}, - {id: 3, label: t("shortStoryGenerator.tabs.atmosphere"), icon: faCloudSun}, - ...(hasGenerated || isGenerating ? [{ - id: 4, - label: t("shortStoryGenerator.tabs.result"), - icon: faFileLines - }] : []) - ].map(tab => ( - - ))} -
    - -
    + +
    + {([ + {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: FileText + }] : []) + ] satisfies TabItem[]).map((tab: TabItem): React.JSX.Element => { + const TabIcon: LucideIcon = tab.icon; + return ( + + ); + })} +
    + +
    {activeTab === 1 && ( -
    +
    setAuthorLevel(e.target.value)} + onChangeCallBack={(e: ChangeEvent): void => setAuthorLevel(e.target.value)} data={writingLevel} defaultValue={authorLevel} /> } /> setPresetType(e.target.value)} + onChangeCallBack={(e: ChangeEvent): void => setPresetType(e.target.value)} data={ authorLevel === '1' ? beginnerPredefinedType @@ -436,18 +445,18 @@ export default function ShortStoryGenerator({onClose}: ShortStoryGeneratorProps) } /> setLanguage(e.target.value)} + onChangeCallBack={(e: ChangeEvent): void => setLanguage(e.target.value)} data={langues} defaultValue={language} /> } />
    )} - + {activeTab === 2 && ( -
    +
    setVerbTense(e.target.value)} + onChangeCallBack={(e: ChangeEvent): void => setVerbTense(e.target.value)} data={verbalTime} defaultValue={verbTense} /> } /> setPerson(e.target.value)} + onChangeCallBack={(e: ChangeEvent): void => setPerson(e.target.value)} data={ authorLevel === '1' ? beginnerNarrativePersons @@ -493,13 +502,13 @@ export default function ShortStoryGenerator({onClose}: ShortStoryGeneratorProps) } />
    - + setDialogueType(e.target.value)} + onChangeCallBack={(e: ChangeEvent): void => setDialogueType(e.target.value)} data={ authorLevel === '1' ? beginnerDialogueTypes @@ -511,12 +520,12 @@ export default function ShortStoryGenerator({onClose}: ShortStoryGeneratorProps) /> } /> - + ) => setDirectives(e.target.value)} placeholder={t("shortStoryGenerator.placeholders.directives")} @@ -525,39 +534,35 @@ export default function ShortStoryGenerator({onClose}: ShortStoryGeneratorProps) />
    )} - + {activeTab === 3 && ( -
    -
    - ) => setTone(e.target.value)} - placeholder={t("shortStoryGenerator.placeholders.tone")} - /> - } - /> -
    - -
    - ) => setAtmosphere(e.target.value)} - placeholder={t("shortStoryGenerator.placeholders.atmosphere")} - /> - } - /> -
    +
    + ) => setTone(e.target.value)} + placeholder={t("shortStoryGenerator.placeholders.tone")} + /> + } + /> ) => setAtmosphere(e.target.value)} + placeholder={t("shortStoryGenerator.placeholders.atmosphere")} + /> + } + /> + +
    )} - + {activeTab === 4 && ( -
    +

    {generatedStoryTitle || t("shortStoryGenerator.result.title")}

    - +
    {isGenerating ? ( - + ) : generatedText && ( <> - - + + )}
    - + {isGenerating && !generatedText ? ( -
    - -

    {t("shortStoryGenerator.result.generating")}

    -
    + ) : (
    + className="rounded-lg p-6 overflow-auto max-h-96 fade-in-text">
    )} - + {generatedText && (
    - + {totalWordsCount} {t("shortStoryGenerator.result.words")}
    )}
    )} +
    -
    - - + +
    - - + + {activeTab < 3 ? ( - + + ) : activeTab === 3 && ( - + )}
    diff --git a/components/StaticAlert.tsx b/components/StaticAlert.tsx deleted file mode 100644 index 0e91b92..0000000 --- a/components/StaticAlert.tsx +++ /dev/null @@ -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 ( -
    -
    -
    -
    - -
    -
    -
    - {typeof message === 'string' ? message : String(message ?? 'Une erreur est survenue')} -
    -
    - -
    -
    -
    -
    - -
    - ); -} diff --git a/components/SyncBook.tsx b/components/SyncBook.tsx index 9fdcc29..b75f798 100644 --- a/components/SyncBook.tsx +++ b/components/SyncBook.tsx @@ -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; @@ -27,7 +27,7 @@ export default function SyncBook({bookId, status}: SyncBookProps) { async function upload(): Promise { if (isOffline) return; setIsLoading(true); - const success:boolean = await hookUpload(bookId); + const success: boolean = await hookUpload(bookId); if (success) setCurrentStatus('synced'); setIsLoading(false); } @@ -59,9 +59,7 @@ export default function SyncBook({bookId, status}: SyncBookProps) { if (isLoading) { return (
    - - - +
    ); } @@ -69,57 +67,48 @@ export default function SyncBook({bookId, status}: SyncBookProps) { return (
    {currentStatus === 'synced' && ( - - - + )} - + {currentStatus === 'local-only' && ( - + tooltip={t("bookCard.localOnly")} + /> )} {currentStatus === 'server-only' && ( - + tooltip={t("bookCard.serverOnly")} + /> )} {currentStatus === 'to-sync-from-server' && ( - + tooltip={t("bookCard.toSyncFromServer")} + /> )} {currentStatus === 'to-sync-to-server' && ( - + tooltip={t("bookCard.toSyncToServer")} + /> )}
    ); diff --git a/components/SyncSeries.tsx b/components/SyncSeries.tsx index 114121b..89ce2f2 100644 --- a/components/SyncSeries.tsx +++ b/components/SyncSeries.tsx @@ -1,131 +1,115 @@ -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faCloud, faCloudArrowDown, faCloudArrowUp, faSpinner } from "@fortawesome/free-solid-svg-icons"; -import { useTranslations } from "next-intl"; -import { useState, useContext, useEffect } from "react"; -import OfflineContext, { OfflineContextType } from "@/context/OfflineContext"; -import { SeriesSyncType } from "@/context/SeriesSyncContext"; -import useSyncSeries from "@/hooks/useSyncSeries"; - -interface SyncSeriesProps { - seriesId: string; - status: SeriesSyncType; -} - -export default function SyncSeries({ seriesId, status }: SyncSeriesProps) { - const t = useTranslations(); - const { isCurrentlyOffline } = useContext(OfflineContext); - const [isLoading, setIsLoading] = useState(false); - const [currentStatus, setCurrentStatus] = useState(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 { - event.stopPropagation(); - if (isOffline) return; - setIsLoading(true); - const success: boolean = await hookUpload(seriesId); - if (success) setCurrentStatus('synced'); - setIsLoading(false); - } - - async function download(event: React.MouseEvent): Promise { - event.stopPropagation(); - if (isOffline) return; - setIsLoading(true); - const success = await hookDownload(seriesId); - if (success) setCurrentStatus('synced'); - setIsLoading(false); - } - - async function syncFromServer(event: React.MouseEvent): Promise { - event.stopPropagation(); - if (isOffline) return; - setIsLoading(true); - const success = await hookSyncFromServer(seriesId); - if (success) setCurrentStatus('synced'); - setIsLoading(false); - } - - async function syncToServer(event: React.MouseEvent): Promise { - event.stopPropagation(); - if (isOffline || isLoading) return; - setIsLoading(true); - const success = await hookSyncToServer(seriesId); - if (success) setCurrentStatus('synced'); - setIsLoading(false); - } - - if (isLoading) { - return ( -
    - - - -
    - ); - } - - return ( -
    - {currentStatus === 'synced' && ( - - - - )} - - {currentStatus === 'local-only' && ( - - )} - {currentStatus === 'server-only' && ( - - )} - {currentStatus === 'to-sync-from-server' && ( - - )} - {currentStatus === 'to-sync-to-server' && ( - - )} -
    - ); -} +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; + status: SeriesSyncType; +} + +export default function SyncSeries({seriesId, status}: SyncSeriesProps) { + const t = useTranslations(); + const {isCurrentlyOffline} = useContext(OfflineContext); + const [isLoading, setIsLoading] = useState(false); + const [currentStatus, setCurrentStatus] = useState(status); + const {upload: hookUpload, download: hookDownload, syncFromServer: hookSyncFromServer, syncToServer: hookSyncToServer} = useSyncSeries(); + + useEffect(() => { + setCurrentStatus(status); + }, [status]); + + const isOffline: boolean = isCurrentlyOffline(); + + async function upload(): Promise { + if (isOffline) return; + setIsLoading(true); + const success: boolean = await hookUpload(seriesId); + if (success) setCurrentStatus('synced'); + setIsLoading(false); + } + + async function download(): Promise { + if (isOffline) return; + setIsLoading(true); + const success = await hookDownload(seriesId); + if (success) setCurrentStatus('synced'); + setIsLoading(false); + } + + async function syncFromServer(): Promise { + if (isOffline) return; + setIsLoading(true); + const success = await hookSyncFromServer(seriesId); + if (success) setCurrentStatus('synced'); + setIsLoading(false); + } + + async function syncToServer(): Promise { + if (isOffline || isLoading) return; + setIsLoading(true); + const success = await hookSyncToServer(seriesId); + if (success) setCurrentStatus('synced'); + setIsLoading(false); + } + + if (isLoading) { + return ( +
    e.stopPropagation()}> + +
    + ); + } + + return ( +
    e.stopPropagation()}> + {currentStatus === 'synced' && ( + + )} + + {currentStatus === 'local-only' && ( + + )} + {currentStatus === 'server-only' && ( + + )} + {currentStatus === 'to-sync-from-server' && ( + + )} + {currentStatus === 'to-sync-to-server' && ( + + )} +
    + ); +} diff --git a/components/TermsOfUse.tsx b/components/TermsOfUse.tsx index 948ca61..01a8725 100644 --- a/components/TermsOfUse.tsx +++ b/components/TermsOfUse.tsx @@ -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 (
    + className="fixed inset-0 z-50 bg-darkest-background/90 backdrop-blur-sm flex items-center justify-center p-6 font-['Lora']">
    -
    + className="bg-tertiary border border-primary/40 rounded-2xl max-w-2xl w-full max-h-[90vh] overflow-hidden"> +
    - +

    Termes d'utilisation

    @@ -52,7 +55,7 @@ export default function TermsOfUse({onAccept}: TermsOfUseProps) {
    -
    +

    Documentation complète

    @@ -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" > Consulter les termes complets - +
    - +

    @@ -90,28 +93,23 @@ export default function TermsOfUse({onAccept}: TermsOfUseProps) {

    -
    +
    - + Décision requise pour continuer
    - - Refuser et quitter - - +
    diff --git a/components/TwoFactorSetup.tsx b/components/TwoFactorSetup.tsx index 7412656..9ec2a9b 100644 --- a/components/TwoFactorSetup.tsx +++ b/components/TwoFactorSetup.tsx @@ -1,196 +1,198 @@ -'use client'; - -import {ChangeEvent, Dispatch, SetStateAction, useContext, useState} from 'react'; -import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; -import {faApple, faGooglePlay} from '@fortawesome/free-brands-svg-icons'; -import {faCheck, faKey, faMobileAlt, faQrcode} from '@fortawesome/free-solid-svg-icons'; -import System from "@/lib/models/System"; -import {AlertContext, AlertContextProps} from "@/context/AlertContext"; -import {FormResponse} from "@/shared/interface"; -import {SessionContext} from "@/context/SessionContext"; -import TextInput from "@/components/form/TextInput"; - -export default function TwoFactorSetup({setShowSetup}: { setShowSetup: Dispatch> }) { - const {session} = useContext(SessionContext); - const alert: AlertContextProps = useContext(AlertContext); - - const [step, setStep] = useState(1); - const [token, setToken] = useState('') - const [qrCode, setQrCode] = useState(null); - const [loadingQRCode, setLoadingQRCode] = useState(false); - - async function getQRCode() { - try { - const response: { qrCode: string } = await System.authPostToServer('twofactor/setup', { - email: session?.user?.email, - }, session?.accessToken ?? ''); - setQrCode(response.qrCode); - } catch (e: any) { - alert.errorMessage(e.message); - console.error(e); - } - } - - async function handleNextStep() { - if (step === 3) { - await validateToken(); - } else if (step === 1) { - if (qrCode === null) { - getQRCode(); - } - setStep((prev: number) => Math.min(prev + 1, 3)); - } else { - setStep((prev: number) => Math.min(prev + 1, 3)); - } - } - - async function validateToken() { - try { - const response: FormResponse = await System.authPostToServer('twofactor/activate', { - email: session?.user?.email, token: token - }, session?.accessToken ?? ''); - if (response.valid) { - alert.successMessage(response.message ?? ''); - setShowSetup(false); - } - } catch (e: any) { - alert.errorMessage(e.message); - console.error(e); - } - } - - function handlePrevStep() { - setStep((prev) => Math.max(prev - 1, 1)); - } - - function getProgressClass(currentStep: number) { - return `flex-grow h-2.5 rounded-full transition-all duration-300 ${ - step >= currentStep ? 'bg-primary shadow-sm' : 'bg-secondary/50' - }`; - } - - return ( -
    -

    - Setup Two-Factor Authentication -

    - - {/* Step Indicator */} -
    -
    -
    -
    -
    -
    -
    -
    -
    - - {/* Step Content */} -
    - {step === 1 && ( -
    -

    - Follow these steps to enable two-factor authentication for your account: -

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

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

    -
    -
    - {loadingQRCode ? ( -
    Loading QR Code...
    - ) : qrCode ? ( - QR Code - ) : ( -
    Failed to load QR Code.
    - )} -
    -
    -

    - Having trouble? Make sure your app supports QR code scanning. -

    -
    - )} - {step === 3 && ( -
    -

    - Enter the 6-digit code generated by your authentication app to verify the setup. -

    -
    - ) => setToken(e.target.value)} - placeholder="Enter 6-digit code" - /> - -
    -
    - )} -
    - - {/* Navigation Buttons */} -
    - - -
    -
    - ); -} +'use client'; + +import {ChangeEvent, Dispatch, SetStateAction, useContext, useState} from 'react'; +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 = () => ( + + + +); + +const GooglePlayIcon = () => ( + + + +); + +export default function TwoFactorSetup({setShowSetup}: { setShowSetup: Dispatch> }) { + const {session} = useContext(SessionContext); + const alert: AlertContextProps = useContext(AlertContext); + + const [step, setStep] = useState(1); + const [token, setToken] = useState('') + const [qrCode, setQrCode] = useState(null); + const [loadingQRCode, setLoadingQRCode] = useState(false); + + async function getQRCode() { + try { + const response: { qrCode: string } = await apiPost('twofactor/setup', { + email: session?.user?.email, + }, session?.accessToken ?? ''); + setQrCode(response.qrCode); + } catch (e: unknown) { + if (e instanceof Error) alert.errorMessage(e.message); + } + } + + async function handleNextStep() { + if (step === 3) { + await validateToken(); + } else if (step === 1) { + if (qrCode === null) { + getQRCode(); + } + setStep((prev: number) => Math.min(prev + 1, 3)); + } else { + setStep((prev: number) => Math.min(prev + 1, 3)); + } + } + + async function validateToken() { + try { + const response: FormResponse = await apiPost('twofactor/activate', { + email: session?.user?.email, token: token + }, session?.accessToken ?? ''); + if (response.valid) { + alert.successMessage(response.message ?? ''); + setShowSetup(false); + } + } catch (e: unknown) { + if (e instanceof Error) alert.errorMessage(e.message); + } + } + + function handlePrevStep() { + setStep((prev) => Math.max(prev - 1, 1)); + } + + function getProgressClass(currentStep: number) { + return `flex-grow h-2.5 rounded-full transition-all duration-300 ${ + step >= currentStep ? 'bg-primary shadow-sm' : 'bg-secondary/50' + }`; + } + + return ( +
    +

    + Setup Two-Factor Authentication +

    + +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    + {step === 1 && ( +
    +

    + Follow these steps to enable two-factor authentication for your account: +

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

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

    +
    +
    + {loadingQRCode ? ( +
    Loading QR Code...
    + ) : qrCode ? ( + QR Code + ) : ( +
    Failed to load QR Code.
    + )} +
    +
    +

    + Having trouble? Make sure your app supports QR code scanning. +

    +
    + )} + {step === 3 && ( +
    +

    + Enter the 6-digit code generated by your authentication app to verify the setup. +

    + ) => setToken(e.target.value)} + placeholder="Enter 6-digit code" + /> + } + /> +
    + )} +
    + +
    + + +
    +
    + ); +} diff --git a/components/UserMenu.tsx b/components/UserMenu.tsx deleted file mode 100644 index 8e39af4..0000000 --- a/components/UserMenu.tsx +++ /dev/null @@ -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 = useRef(null); - - const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false); - - function handleProfileClick(): void { - setIsProfileMenuOpen(!isProfileMenuOpen); - } - - useEffect((): () => void => { - function handleClickOutside(event: MouseEvent): void { - if (profileMenuRef.current && !profileMenuRef.current.contains(event.target as Node)) { - setIsProfileMenuOpen(false); - } - } - - if (isProfileMenuOpen) { - document.addEventListener("mousedown", handleClickOutside); - } - - return (): void => { - document.removeEventListener("mousedown", handleClickOutside); - }; - }, [isProfileMenuOpen]); - - async function handleLogout(): Promise { - System.removeCookie("token"); - await tauri.removeToken(); - tauri.logout(); - } - - return ( -
    - - {isProfileMenuOpen && ( -
    -
    -

    {session.user?.username}

    -

    {session.user?.email}

    -
    - - {t('userMenu.settings')} - - - {t('userMenu.logout')} - -
    - )} -
    - ) -} diff --git a/components/book/AddNewBookForm.tsx b/components/book/AddNewBookForm.tsx index 53190d0..3680292 100644 --- a/components/book/AddNewBookForm.tsx +++ b/components/book/AddNewBookForm.tsx @@ -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> }) { const t = useTranslations(); - const {lang} = useContext(LangContext); - const {session} = useContext(SessionContext); - const {errorMessage} = useContext(AlertContext); - const {setServerOnlyBooks, setLocalOnlyBooks} = useContext(BooksSyncContext) - const {isCurrentlyOffline} = useContext(OfflineContext); - const modalRef: RefObject = useRef(null); - + const {lang}: LangContextProps = useContext(LangContext); + const {session, setSession}: SessionContextProps = useContext(SessionContext); + const {errorMessage}: AlertContextProps = useContext(AlertContext); + const {setServerSyncedBooks}: BooksSyncContextProps = useContext(BooksSyncContext) + const {isCurrentlyOffline}: OfflineContextType = useContext(OfflineContext); const [title, setTitle] = useState(''); const [subtitle, setSubtitle] = useState(''); const [summary, setSummary] = useState(''); @@ -66,27 +54,27 @@ export default function AddNewBookForm({setCloseForm}: { setCloseForm: Dispatch< content: (
    -
    +

    {t("addNewBookForm.bookTypeHint.nouvelle.title")}

    {t("addNewBookForm.bookTypeHint.nouvelle.range")}

    {t("addNewBookForm.bookTypeHint.nouvelle.description")}

    -
    +

    {t("addNewBookForm.bookTypeHint.novelette.title")}

    {t("addNewBookForm.bookTypeHint.novelette.range")}

    {t("addNewBookForm.bookTypeHint.novelette.description")}

    -
    +

    {t("addNewBookForm.bookTypeHint.novella.title")}

    {t("addNewBookForm.bookTypeHint.novella.range")}

    {t("addNewBookForm.bookTypeHint.novella.description")}

    -
    +

    {t("addNewBookForm.bookTypeHint.chapbook.title")}

    {t("addNewBookForm.bookTypeHint.chapbook.range")}

    {t("addNewBookForm.bookTypeHint.chapbook.description")}

    -
    +

    {t("addNewBookForm.bookTypeHint.roman.title")}

    {t("addNewBookForm.bookTypeHint.roman.range")}

    {t("addNewBookForm.bookTypeHint.roman.description")}

    @@ -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 { if (!title) { errorMessage(t('addNewBookForm.error.titleMissing')); @@ -128,85 +109,60 @@ export default function AddNewBookForm({setCloseForm}: { setCloseForm: Dispatch< } setIsAddingBook(true); try { - const bookData = { - title, - subTitle: subtitle, - type: selectedBookType, - summary, - serie: 0, - publicationDate, - desiredWordCount: wordCount - }; - let bookId: string; - if (isCurrentlyOffline()) { - bookId = await tauri.createBook(bookData); + if (isDesktop && isCurrentlyOffline()) { + bookId = await tauri.createBook({ + title: title, + subTitle: subtitle, + type: selectedBookType, + summary: summary, + desiredReleaseDate: publicationDate, + desiredWordCount: wordCount, + }); } else { - bookId = await System.authPostToServer('book/add', bookData, token, lang); + bookId = await apiPost('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,96 +203,83 @@ export default function AddNewBookForm({setCloseForm}: { setCloseForm: Dispatch< } return ( -
    -
    -
    -

    - - {t("addNewBookForm.title")} -

    - -
    + <> + setCloseForm(false)} + size="sm" + footer={ + <> + + + + } + > + ): void => setSelectedBookType(e.target.value)} + data={bookTypes.map((types: SelectBoxProps): SelectBoxProps => { + return { + value: types.value, + label: t(types.label) + } + })} defaultValue={selectedBookType} + placeholder={t("addNewBookForm.typePlaceholder")}/> + } action={async (): Promise => setBookTypeHint(true)} actionIcon={Info}/> + ): void => setTitle(e.target.value)} + placeholder={t("addNewBookForm.bookTitlePlaceholder")}/> + }/> + { + selectedBookType !== 'lyric' && ( + ): void => setSubtitle(e.target.value)} + placeholder={t("addNewBookForm.subtitlePlaceholder")}/> + }/> + ) + } -
    -
    - ): void => setSelectedBookType(e.target.value)} - data={bookTypes.map((types: SelectBoxProps): SelectBoxProps => { - return { - value: types.value, - label: t(types.label) - } - })} defaultValue={selectedBookType} - placeholder={t("addNewBookForm.typePlaceholder")}/> - } action={async (): Promise => setBookTypeHint(true)} actionIcon={faInfo}/> - ): void => setTitle(e.target.value)} - placeholder={t("addNewBookForm.bookTitlePlaceholder")}/> - }/> - { - selectedBookType !== 'lyric' && ( - ): void => setSubtitle(e.target.value)} - placeholder={t("addNewBookForm.subtitlePlaceholder")}/> - }/> - ) - } - - ): void => setPublicationDate(e.target.value)}/> - }/> - - { - selectedBookType !== 'lyric' && ( - <> - 0 ? maxWordsCountHint().max.toLocaleString('fr-FR') : '∞'} ${t("addNewBookForm.words")}`} - input={ - - }/> - - ): void => setPublicationDate(e.target.value)}/> + }/> + + { + selectedBookType !== 'lyric' && ( + <> + 0 ? maxWordsCountHint().max.toLocaleString('fr-FR') : '∞'} ${t("addNewBookForm.words")}`} input={ - ): void => setSummary(e.target.value)} - placeholder={t("addNewBookForm.summaryPlaceholder")} - /> - } + + }/> + + ): void => setSummary(e.target.value)} + placeholder={t("addNewBookForm.summaryPlaceholder")} /> - - ) - } -
    -
    - -
    -
    -
    - setCloseForm(false)}/> - -
    -
    -
    + } + /> + + ) + } + {bookTypeHint && setBookTypeHint(false)} onComplete={async (): Promise => setBookTypeHint(false)}/>} -
    + ); } \ No newline at end of file diff --git a/components/book/BookCard.tsx b/components/book/BookCard.tsx index 416062b..dbe2d19 100644 --- a/components/book/BookCard.tsx +++ b/components/book/BookCard.tsx @@ -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; - index: number; - syncStatus: SyncType; -} - -export default function BookCard({book, onClickCallback, index, syncStatus}: BookCardProps) { +export default function BookCard( + { + book, + onClickCallback, + index + }: { + book: BookProps, + onClickCallback: Function; + index: number; + }) { const t = useTranslations(); + return ( -
    -
    - +
    +
    -
    - -
    -
    - -
    - {book.subTitle ? ( - <> -
    -

    - {book.subTitle} -

    -
    - - ) : null} -
    -
    -
    - -
    - -
    + 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'})} + > +
    -
    + ) -} \ No newline at end of file +} diff --git a/components/book/BookCardSkeleton.tsx b/components/book/BookCardSkeleton.tsx index 07d3c6a..dd16a79 100644 --- a/components/book/BookCardSkeleton.tsx +++ b/components/book/BookCardSkeleton.tsx @@ -1,26 +1,25 @@ -export default function BookCardSkeleton() { - return ( -
    -
    -
    -
    -
    - -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    - ); -} +export default function BookCardSkeleton() { + return ( +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + ); +} diff --git a/components/book/BookList.tsx b/components/book/BookList.tsx index 8d0ff0e..3818eba 100644 --- a/components/book/BookList.tsx +++ b/components/book/BookList.tsx @@ -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(SessionContext); const accessToken: string = session?.accessToken || ''; - const {errorMessage} = useContext(AlertContext); - const {setBook} = useContext(BookContext); + const {errorMessage}: AlertContextProps = useContext(AlertContext); + const router = useRouter(); const t = useTranslations(); - const {lang} = useContext(LangContext); - const {isCurrentlyOffline, offlineMode} = useContext(OfflineContext); - const { - booksToSyncFromServer, - booksToSyncToServer, - serverOnlyBooks, - localOnlyBooks, - serverSyncedBooks - } = useContext(BooksSyncContext); - const { - seriesToSyncFromServer, - seriesToSyncToServer, - serverOnlySeries, - localOnlySeries - } = useContext(SeriesSyncContext); + const {lang}: LangContextProps = useContext(LangContext) + const {serverSyncedBooks}: BooksSyncContextProps = useContext(BooksSyncContext) + const {isCurrentlyOffline}: OfflineContextType = useContext(OfflineContext); const [searchQuery, setSearchQuery] = useState(''); const [groupedItems, setGroupedItems] = useState>({}); const [isLoadingBooks, setIsLoadingBooks] = useState(true); const [showSeriesSettingId, setShowSeriesSettingId] = useState(null); - const [isLocalSeries, setIsLocalSeries] = useState(false); const carouselRefs = useRef>({}); const [bookGuide, setBookGuide] = useState(false); @@ -92,240 +78,165 @@ export default function BookList() { content: (

    - + {t("bookList.guideStep2ContentGear")}

    - + {t("bookList.guideStep2ContentDownload")}

    - + {t("bookList.guideStep2ContentTrash")}

    ), }, - ]; + ] 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 { try { - if (!isCurrentlyOffline()) { - const response: boolean = await System.authPostToServer( - 'logs/tour', - {plateforme: 'desktop', tour: 'new-first-book'}, - session.accessToken, lang - ); - if (response) { - setSession(User.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')); + const response: boolean = await apiPost( + 'logs/tour', + {plateforme: 'web', tour: 'new-first-book'}, + session.accessToken, lang + ); + if (response) { + setSession(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")); } } } - + async function loadBooksAndSeries(): Promise { 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('books', accessToken, lang), - offlineMode.isDatabaseInitialized - ? tauri.getBooks() - : Promise.resolve([]), - System.authGetQueryToServer('series/list', accessToken, lang), - offlineMode.isDatabaseInitialized - ? tauri.getSeriesList() as Promise - : 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 + [booksResponse, seriesResponse] = await Promise.all([ + apiGet('books', accessToken, lang), + apiGet('series/list', accessToken, lang) ]); - booksResponse = localBooks.map(b => ({...b, itIsLocal: true})); - seriesResponse = localSeries; } - - // ═══════════════════════════════════════════════════════════════ - // PARTIE 2 : CRÉATION DU MAPPING BOOK → SERIES - // ═══════════════════════════════════════════════════════════════ - - const bookToSeriesMap: Map = new Map(); - seriesResponse.forEach((series: SeriesListItemProps): void => { - series.bookIds.forEach((bookId: string): void => { - bookToSeriesMap.set(bookId, series); + + if (booksResponse) { + const seriesList: SeriesListItemProps[] = seriesResponse || []; + + // Créer un mapping bookId -> seriesInfo + const bookToSeriesMap: Map = new Map(); + 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 : ''; - return { - bookId: book.bookId, - type: Book.getBookTypeLabel(book.type), - title: book.title, - subTitle: book.subTitle, - summary: book.summary, - serie: book.serie, - publicationDate: book.publicationDate, - desiredWordCount: book.desiredWordCount, - totalWordCount: 0, - coverImage: imageDataUrl, - itIsLocal: book.itIsLocal - }; - }); - - // ═══════════════════════════════════════════════════════════════ - // PARTIE 4 : GROUPEMENT PAR CATÉGORIE AVEC SÉRIES - // ═══════════════════════════════════════════════════════════════ - - const itemsByCategory: Record = {}; - const processedSeriesIds: Set = new Set(); - - transformedBooks.forEach((book): void => { - const categoryLabel: string = t(book.type); - if (!itemsByCategory[categoryLabel]) { - itemsByCategory[categoryLabel] = []; - } - - const seriesInfo = bookToSeriesMap.get(book.bookId); - - if (seriesInfo && !processedSeriesIds.has(seriesInfo.id)) { - // 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 - const seriesBooks: BookProps[] = transformedBooks.filter( - b => seriesInfo.bookIds.includes(b.bookId) - ); - - const seriesCard: SeriesCardProps = { - id: seriesInfo.id, - name: seriesInfo.name, - coverImage: seriesInfo.coverImage, - books: seriesBooks + + // 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: getBookTypeLabel(book.type), + title: book.title, + subTitle: book.subTitle, + summary: book.summary, + serie: book.serie, + publicationDate: book.publicationDate, + desiredWordCount: book.desiredWordCount, + totalWordCount: 0, + coverImage: imageDataUrl, }; - - itemsByCategory[categoryLabel].push({ - type: 'series', - series: seriesCard - }); - } else if (!seriesInfo) { - // Livre individuel (pas dans une série) - itemsByCategory[categoryLabel].push({ - type: 'book', - book: book - }); - } - }); - - // Ajouter les séries vides (orphelines) - seriesResponse.forEach((series): void => { - if (series.bookIds.length === 0) { - const emptySeriesCategory = t('bookList.emptySeries'); - if (!itemsByCategory[emptySeriesCategory]) { - itemsByCategory[emptySeriesCategory] = []; + }); + + // Grouper par catégorie avec séries + const itemsByCategory: Record = {}; + const processedSeriesIds: Set = new Set(); + + transformedBooks.forEach((book: BookProps): void => { + const categoryLabel: string = t(book.type); + if (!itemsByCategory[categoryLabel]) { + itemsByCategory[categoryLabel] = []; } - itemsByCategory[emptySeriesCategory].push({ - type: 'series', - series: { + + const seriesInfo: SeriesListItemProps | undefined = bookToSeriesMap.get(book.bookId); + + if (seriesInfo && !processedSeriesIds.has(seriesInfo.id)) { + // 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 dans cette catégorie + const seriesBooks: BookProps[] = transformedBooks.filter( + (bookItem: BookProps): boolean => seriesInfo.bookIds.includes(bookItem.bookId) + ); + + const seriesCard: SeriesCardProps = { + id: seriesInfo.id, + name: seriesInfo.name, + coverImage: seriesInfo.coverImage, + books: seriesBooks + }; + + itemsByCategory[categoryLabel].push({ + type: 'series', + series: seriesCard + }); + } else if (!seriesInfo) { + // Livre individuel (pas dans une série) + itemsByCategory[categoryLabel].push({ + type: 'book', + 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 sans livres (orphelines) + seriesList.forEach((series: SeriesListItemProps): void => { + if (series.bookIds.length === 0) { + const emptySeriesCategory: string = t('bookList.emptySeries'); + if (!itemsByCategory[emptySeriesCategory]) { + itemsByCategory[emptySeriesCategory] = []; + } + + const emptySeriesCard: SeriesCardProps = { id: series.id, name: series.name, coverImage: series.coverImage, books: [] - } - }); - } - }); - - setGroupedItems(itemsByCategory); - + }; + + itemsByCategory[emptySeriesCategory].push({ + type: 'series', + series: emptySeriesCard + }); + } + }); + + setGroupedItems(itemsByCategory); + } } catch (error: unknown) { if (error instanceof Error) { errorMessage(error.message); @@ -336,114 +247,58 @@ export default function BookList() { setIsLoadingBooks(false); } } - + function getFilteredGroupedItems(): Record { if (!searchQuery) { return groupedItems; } - + const filtered: Record = {}; - - 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; } return false; }); - + if (filteredItems.length > 0) { filtered[category] = filteredItems; } }); - + return filtered; } - + 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'; + + function handleSeriesSettingsClick(seriesId: string): void { + setShowSeriesSettingId(seriesId); } - - async function handleBookClick(bookId: string): Promise { - 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( - '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 scrollCarousel(category: string, direction: 'left' | 'right'): void { const container: HTMLDivElement | null = carouselRefs.current[category]; if (!container) return; @@ -454,25 +309,18 @@ export default function BookList() { behavior: 'smooth' }); } - - 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 = getFilteredGroupedItems(); return (
    - {session?.user && ( -
    - -
    - )} + className="flex flex-col h-full overflow-hidden w-full text-text-primary font-['Lora']">
    + {session?.user && ( +
    + +
    + )} { isLoadingBooks ? ( <> @@ -483,10 +331,10 @@ export default function BookList() {
    -
    -
    +
    +
    - +
    {Array.from({length: 6}).map((_, id: number) => (
    {t("bookList.library")}

    {t("bookList.booksAreMirrors")}

    - - {Object.entries(filteredItems).map(([category, items], index) => ( + + {Object.entries(filteredItems).map(([category, items]: [string, CategoryItem[]], index: number) => (
    {category} - + {getTotalItemsCount(items)} {t("bookList.works")} - +
    - -
    - - + +
    { carouselRefs.current[category] = el; }} - className="flex items-start w-full overflow-hidden px-4 gap-2 scroll-smooth" + className="absolute left-3 top-1/2 -translate-y-1/2 z-10 opacity-0 group-hover/carousel:opacity-100"> + +
    + +
    { + 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 (
    + 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]'}`}> @@ -553,21 +404,22 @@ export default function BookList() { series={item.series} onBookClick={handleBookClick} onSettingsClick={handleSeriesSettingsClick} - getSyncStatus={detectBookSyncStatus} - seriesSyncStatus={detectSeriesSyncStatus(item.series.id)} /> ); } return null; })}
    - - + +
    + +
    ))} @@ -575,9 +427,11 @@ export default function BookList() { ) : (
    -
    - +
    +
    + +

    {t("bookList.welcomeWritingWorkshop")}

    @@ -591,16 +445,8 @@ export default function BookList() { bookGuide && setBookGuide(false)}/> } - {showSeriesSettingId && ( - { - setShowSeriesSettingId(null); - setIsLocalSeries(false); - }} - /> - )} + {showSeriesSettingId && + setShowSeriesSettingId(null)}/>}

    ); } diff --git a/components/book/ImportBookForm.tsx b/components/book/ImportBookForm.tsx index 95a78b7..23701a9 100644 --- a/components/book/ImportBookForm.tsx +++ b/components/book/ImportBookForm.tsx @@ -1,48 +1,34 @@ '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> }) { const t = useTranslations(); - const {lang} = useContext(LangContext); - const {session} = useContext(SessionContext); - const {errorMessage, successMessage} = useContext(AlertContext); - const {setServerOnlyBooks} = useContext(BooksSyncContext); - const modalRef: RefObject = useRef(null); - - const token: string = session?.accessToken ?? ''; - + const {lang}: LangContextProps = useContext(LangContext); + const {session}: SessionContextProps = useContext(SessionContext); + const {errorMessage, successMessage}: AlertContextProps = useContext(AlertContext); + const {setServerSyncedBooks}: BooksSyncContextProps = useContext(BooksSyncContext); + const fileInputRef: React.RefObject = useRef(null); + const [isParsing, setIsParsing] = useState(false); const [isImporting, setIsImporting] = useState(false); const [importId, setImportId] = useState(''); @@ -52,47 +38,40 @@ export default function ImportBookForm({setCloseForm}: { setCloseForm: Dispatch< const [summary, setSummary] = useState(''); const [selectedBookType, setSelectedBookType] = useState('short'); const [selectedVersion, setSelectedVersion] = useState('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): Promise => { - const file: File | undefined = e.target.files?.[0]; + + async function handleFileChange(event: ChangeEvent): Promise { + const file: File | undefined = event.target.files?.[0]; if (!file) return; - + if (!file.name.endsWith('.docx')) { errorMessage(t('importBook.error.invalidFormat')); return; } - + setIsParsing(true); setImportId(''); setChapters([]); - + try { - const response: ParsedDocxResponse = await System.authUploadFileToServer( + const response: ParsedDocxResponse = await apiUpload( '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 => { + } + + async function handleImport(): Promise { if (!title.trim()) { errorMessage(t('importBook.error.titleRequired')); return; @@ -135,33 +114,36 @@ export default function ImportBookForm({setCloseForm}: { setCloseForm: Dispatch< errorMessage(t('importBook.error.noChaptersSelected')); return; } - + setIsImporting(true); try { const selectedChapterIndexes: number[] = chapters .filter((chapter: ImportChapterSelection): boolean => chapter.selected) .map((chapter: ImportChapterSelection): number => chapter.index); - - await System.authPostToServer<{ bookId: string }>( - 'book/import', - { - importId, - title: title.trim(), - subTitle: subTitle.trim(), - summary: summary.trim(), - type: selectedBookType, - version: parseInt(selectedVersion, 10), - selectedChapterIndexes, - }, - token, - lang, - ); - - setServerOnlyBooks((prevBooks: SyncedBook[]): SyncedBook[] => [...prevBooks, { - id: importId, - type: selectedBookType, + + const importBody: ImportConfirmBody = { + importId, title: title.trim(), subTitle: subTitle.trim(), + summary: summary.trim(), + type: selectedBookType, + version: parseInt(selectedVersion, 10), + selectedChapterIndexes, + }; + + const importResponse: { bookId: string } = await apiPost<{ bookId: string }>( + 'book/import', + importBody, + token, + lang + ); + + const newBook: SyncedBook = { + id: importResponse.bookId, + type: selectedBookType, + title: title.trim(), + subTitle: subTitle.trim() || null, + seriesId: null, lastUpdate: new Date().getTime() / 1000, chapters: [], characters: [], @@ -173,12 +155,9 @@ 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); } catch (importError: unknown) { @@ -190,160 +169,143 @@ 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 ( -
    -
    -
    -

    - - {t("importBook.header.title")} -

    - -
    - -
    -
    - - - {hasParsedFile && ( - <> - ): void => setSelectedBookType(e.target.value)} - data={bookTypes.map((types: SelectBoxProps): SelectBoxProps => ({ - value: types.value, - label: t(types.label) - }))} - defaultValue={selectedBookType} - /> - }/> - - ): void => setTitle(e.target.value)} - placeholder={t("importBook.fields.title.placeholder")} - /> - }/> - - ): void => setSubTitle(e.target.value)} - placeholder={t("importBook.fields.subTitle.placeholder")} - /> - }/> - - ): void => setSummary(e.target.value)} - placeholder={t("importBook.fields.summary.placeholder")} - /> - }/> - - ): void => setSelectedVersion(e.target.value)} - data={chapterVersions.map((version: SelectBoxProps): SelectBoxProps => ({ - value: version.value, - label: t(version.label) - }))} - defaultValue={selectedVersion} - /> - }/> - -
    -
    -

    - {t('importBook.chapters.title')} -

    - - {t('importBook.chaptersDetected', {count: chapters.length})} + setCloseForm(false)} + size="sm" + footer={hasParsedFile ? ( + <> + + + + ) : undefined} + > + + + + {hasParsedFile && ( + <> + ): void => setSelectedBookType(e.target.value)} + data={bookTypes.map((types: SelectBoxProps): SelectBoxProps => ({ + value: types.value, + label: t(types.label) + }))} + defaultValue={selectedBookType} + placeholder={t("addNewBookForm.typePlaceholder")} + /> + }/> + + ): void => setTitle(e.target.value)} + placeholder={t("addNewBookForm.bookTitlePlaceholder")} + /> + }/> + + ): void => setSubTitle(e.target.value)} + placeholder={t("addNewBookForm.subtitlePlaceholder")} + /> + }/> + + ): void => setSummary(e.target.value)} + placeholder={t("addNewBookForm.summaryPlaceholder")} + /> + }/> + + ): void => setSelectedVersion(e.target.value)} + data={chapterVersions.map((version: SelectBoxProps): SelectBoxProps => ({ + value: version.value, + label: t(version.label) + }))} + defaultValue={selectedVersion} + placeholder={""} + /> + }/> + +
    +
    +

    + + {t('importBook.chapters.title')} +

    + + {t('importBook.chapters.detected', {count: chapters.length})} + +
    + +
    + +
    + +
    + {chapters.map((chapter: ImportChapterSelection) => ( + - -
    - {chapters.map((chapter: ImportChapterSelection) => ( - - ))} -
    -
    - - )} -
    -
    - - {hasParsedFile && ( -
    -
    -
    - setCloseForm(false)}/> - + + ))}
    - )} -
    -
    + + )} + ); } diff --git a/components/book/SearchBook.tsx b/components/book/SearchBook.tsx index babe067..dbc1516 100644 --- a/components/book/SearchBook.tsx +++ b/components/book/SearchBook.tsx @@ -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 ( -
    -
    -
    - -
    - ) => setSearchQuery(e.target.value)} - placeholder={t("searchBook.placeholder")} - /> -
    -
    -
    +
    + + ): void => setSearchQuery(e.target.value)} + placeholder={t("searchBook.placeholder")} + className="w-full bg-transparent text-text-primary text-sm placeholder:text-muted outline-none" + />
    ) } diff --git a/components/book/settings/BasicInformationSetting.tsx b/components/book/settings/BasicInformationSetting.tsx index dc3e504..034dd5e 100644 --- a/components/book/settings/BasicInformationSetting.tsx +++ b/components/book/settings/BasicInformationSetting.tsx @@ -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): React.JSX.Element { const t = useTranslations(); - const {lang} = useContext(LangContext) - const {isCurrentlyOffline} = useContext(OfflineContext); - const {addToQueue} = useContext(LocalSyncQueueContext); - const {localSyncedBooks} = useContext(BooksSyncContext); - - const {session} = useContext(SessionContext); - const {book, setBook} = useContext(BookContext); + const {lang}: LangContextProps = useContext(LangContext); + + const {session}: SessionContextProps = useContext(SessionContext); + const {book, setBook}: BookContextProps = useContext(BookContext); const userToken: string = session?.accessToken ? session?.accessToken : ''; - const {errorMessage, successMessage} = useContext(AlertContext); + const {errorMessage, successMessage}: AlertContextProps = useContext(AlertContext); + const {isCurrentlyOffline}: OfflineContextType = useContext(OfflineContext); const bookId: string = book?.bookId ? book?.bookId.toString() : ''; const [currentImage, setCurrentImage] = useState(book?.coverImage ?? ''); @@ -42,21 +39,14 @@ function BasicInformationSetting(props: any, ref: any) { const [publicationDate, setPublicationDate] = useState(book?.publicationDate ? book?.publicationDate : ''); const [wordCount, setWordCount] = useState(book?.desiredWordCount ? book?.desiredWordCount : 0); - useImperativeHandle(ref, function () { + useImperativeHandle(ref, function (): SettingRef { return { handleSave: handleSave }; }); - async function handleCoverImageChange(e: ChangeEvent): Promise { - const file: File | undefined = e.target.files?.[0]; - - if (!file) { - errorMessage(t('basicInformationSetting.error.noFileSelected')); - return; - } - - const formData = new FormData(); + async function handleCoverImageChange(file: File): Promise { + 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 { try { - const response: boolean = await System.authDeleteToServer(`book/cover/delete`, { + const response: boolean = await apiDelete(`book/cover/delete`, { bookId: bookId }, userToken, lang); if (!response) { @@ -115,7 +102,7 @@ function BasicInformationSetting(props: any, ref: any) { } } } - + async function handleSave(): Promise { if (!title) { errorMessage(t('basicInformationSetting.error.titleRequired')); @@ -123,22 +110,24 @@ function BasicInformationSetting(props: any, ref: any) { } try { let response: boolean; - const basicInfoData = { - title: title, - subTitle: subTitle, - summary: summary, - publicationDate: publicationDate, - wordCount: wordCount, - bookId: bookId - }; - if (isCurrentlyOffline() || book?.localBook) { - response = await tauri.updateBookBasicInfo(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 System.authPostToServer('book/basic-information', basicInfoData, userToken, lang); - - if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) { - addToQueue('update_book_basic_info', {data: basicInfoData}); - } + response = await apiPost('book/basic-information', { + title: title, + subTitle: subTitle, + summary: summary, + publicationDate: publicationDate, + wordCount: wordCount, + bookId: bookId + }, 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) { @@ -166,85 +155,62 @@ function BasicInformationSetting(props: any, ref: any) { } } } - + return (
    -
    -
    - ) => setTitle(e.target.value)} - placeholder={t('basicInformationSetting.fields.titlePlaceholder')} - />}/> - ) => setSubTitle(e.target.value)} - placeholder={t('basicInformationSetting.fields.subtitlePlaceholder')} - />}/> -
    -
    - -
    - ) => setSummary(e.target.value)} - placeholder={t('basicInformationSetting.fields.summaryPlaceholder')} +
    + ): void => setTitle(e.target.value)} + placeholder={t('basicInformationSetting.fields.titlePlaceholder')} + />}/> + ): void => setSubTitle(e.target.value)} + placeholder={t('basicInformationSetting.fields.subtitlePlaceholder')} />}/>
    -
    -
    - ) => setPublicationDate(e.target.value)} - /> - }/> - - }/> -
    + ): void => setSummary(e.target.value)} + placeholder={t('basicInformationSetting.fields.summaryPlaceholder')} + />}/> + +
    + ): void => setPublicationDate(e.target.value)} + /> + }/> + + }/>
    -
    +
    {currentImage ? (
    {t('basicInformationSetting.fields.coverImageAlt')} - -
    -
    - ) : ( -
    -
    -
    - { - }} input={}/> + className="rounded-lg border border-secondary w-full h-auto"/> +
    +
    + ) : ( + )}
    ); } -export default forwardRef(BasicInformationSetting); \ No newline at end of file +export default forwardRef(BasicInformationSetting); \ No newline at end of file diff --git a/components/book/settings/BookSetting.tsx b/components/book/settings/BookSetting.tsx index e8e3104..89303b2 100644 --- a/components/book/settings/BookSetting.tsx +++ b/components/book/settings/BookSetting.tsx @@ -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={} onClose={onClose} > - + ); } diff --git a/components/book/settings/BookSettingOption.tsx b/components/book/settings/BookSettingOption.tsx index cda7011..fb8810f 100644 --- a/components/book/settings/BookSettingOption.tsx +++ b/components/book/settings/BookSettingOption.tsx @@ -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 ( -
    - -
    - ); -} - interface BookSettingOptionProps { setting: string; } -interface SettingRef { - handleSave: () => Promise; -} - -// 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 = { + '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(null); + const settingRef: React.RefObject = useRef(null); + const {book, setBook}: BookContextProps = useContext(BookContext); + const {session}: SessionContextProps = useContext(SessionContext); + const {errorMessage}: AlertContextProps = useContext(AlertContext); + const {lang}: LangContextProps = useContext(LangContext); + const userToken: string = session?.accessToken ?? ''; + + const isToggleable: boolean = setting in toggleableSettings; + const [toolEnabled, setToolEnabled] = useState(getInitialToolEnabled(setting, book)); const showSaveButton: boolean = !selfManagedSettings.includes(setting); @@ -86,47 +104,91 @@ export default function BookSettingOption({setting}: BookSettingOptionProps): Re return ""; } } + + async function handleToggleTool(enabled: boolean): Promise { + const toolName: ToolName | undefined = toggleableSettings[setting]; + if (!toolName) return; + try { + const result: boolean = await apiPatch('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 { if (settingRef.current?.handleSave) { await settingRef.current.handleSave(); } } + + function renderHeaderActions(): React.JSX.Element { + return ( +
    + {isToggleable && ( + + )} + {showSaveButton && ( + + )} +
    + ); + } return (
    -
    - +
    -
    - }> +
    + }> {setting === 'basic-information' && } {setting === 'guide-line' && } {setting === 'story' && } - {setting === 'quillsense' && } + {setting === 'quillsense' && } {(setting === 'world' || setting === 'worlds') && ( - + )} {setting === 'locations' && ( - + )} {setting === 'characters' && ( - + )} {setting === 'spells' && ( - - )} - {setting === 'export' && ( - + )} + {setting === 'export' && } {!['basic-information', 'guide-line', 'story', 'world', 'worlds', 'locations', 'characters', 'spells', 'quillsense', 'export'].includes(setting) && (
    {t("bookSettingOption.notAvailable")} diff --git a/components/book/settings/BookSettingSidebar.tsx b/components/book/settings/BookSettingSidebar.tsx index 5309c8a..1ef9fe5 100644 --- a/components/book/settings/BookSettingSidebar.tsx +++ b/components/book/settings/BookSettingSidebar.tsx @@ -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> }) { const t = useTranslations(); - const {isCurrentlyOffline} = useContext(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 ( -
    -
    ); } diff --git a/components/book/settings/ToolDetailHeader.tsx b/components/book/settings/ToolDetailHeader.tsx index 7a3fcd3..6c5ee6e 100644 --- a/components/book/settings/ToolDetailHeader.tsx +++ b/components/book/settings/ToolDetailHeader.tsx @@ -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 { @@ -36,82 +38,53 @@ interface SidebarHeaderProps { * - edit: Boutons Cancel, Save/Create */ export default function ToolDetailHeader({ - title, - defaultTitle, - viewMode, - isNew, - onBack, - onEdit, - onSave, - onCancel, - onDelete, - onExport, - showExport = false, - showDelete = true, -}: SidebarHeaderProps): React.JSX.Element | null { + title, + defaultTitle, + viewMode, + isNew, + onBack, + onEdit, + onSave, + onCancel, + onDelete, + onExport, + showExport = false, + showDelete = true, + }: SidebarHeaderProps): React.JSX.Element | null { const t = useTranslations(); if (viewMode === 'list') { 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 ( - + tooltip={button.title} + /> ); } - + function getActionButtons(): ActionButton[] { const buttons: ActionButton[] = []; - + 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,31 +109,28 @@ 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', }); } } - + return buttons; } - + return ( -
    - - +
    + + {title || defaultTitle} - +
    {getActionButtons().map(renderActionButton)}
    diff --git a/components/book/settings/characters/CharacterSectionElement.tsx b/components/book/settings/characters/CharacterSectionElement.tsx index 202355c..21e963a 100644 --- a/components/book/settings/characters/CharacterSectionElement.tsx +++ b/components/book/settings/characters/CharacterSectionElement.tsx @@ -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(''); - function handleAddNewElement() { + function handleAddNewElement(): void { handleAddElement(section, {id: '', name: element}); setElement(''); } - + return ( - -
    - {Array.isArray(selectedCharacter?.[section]) && - selectedCharacter?.[section].map((item, index: number) => ( + + {selectedCharacter[section].map((item: Attribute, index: number): React.JSX.Element => (
    + className="flex items-center gap-2 bg-secondary rounded-xl border-l-4 border-primary transition-colors duration-200"> { - const updatedSection = [...(selectedCharacter[section] as any[])]; - updatedSection[index].name = e.target.value; + value={item.name || ''} + onChange={(e: ChangeEvent): 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} /> - +
    + handleRemoveElement(section, index, item.id)} + /> +
    ))} - -
    + +
    setElement(e.target.value)} + setValue={(e: ChangeEvent): void => setElement(e.target.value)} placeholder={t("characterSectionElement.newItem", {item: title.toLowerCase()})} /> } - addButtonCallBack={async () => handleAddNewElement()} + addButtonCallBack={async (): Promise => handleAddNewElement()} />
    -
    - +
    ) } \ No newline at end of file diff --git a/components/book/settings/characters/editor/CharacterEditor.tsx b/components/book/settings/characters/editor/CharacterEditor.tsx index 97b453f..ff5d2f5 100644 --- a/components/book/settings/characters/editor/CharacterEditor.tsx +++ b/components/book/settings/characters/editor/CharacterEditor.tsx @@ -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,16 +21,16 @@ import CharacterEditorEdit from './CharacterEditorEdit'; */ export default function CharacterEditor(): React.JSX.Element { const t = useTranslations(); - const {book} = useContext(BookContext); + const {book}: BookContextProps = useContext(BookContext); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); - + const config: UseCharactersConfig = useMemo(function (): UseCharactersConfig { return { entityType: 'book', entityId: book?.bookId || '', }; }, [book?.bookId]); - + const { characters, seriesCharacters, @@ -58,7 +55,7 @@ export default function CharacterEditor(): React.JSX.Element { backToList, addNewCharacter, } = useCharacters(config); - + const availableSeriesCharacters = useMemo(function (): SeriesCharacterProps[] { return seriesCharacters.filter(function (sc: SeriesCharacterProps): boolean { return !characters.some(function (c: CharacterProps): boolean { @@ -66,19 +63,19 @@ export default function CharacterEditor(): React.JSX.Element { }); }); }, [seriesCharacters, characters]); - + const handleCharacterChange = useCallback(function (key: keyof CharacterProps, value: string | number | null): void { updateCharacterField(key, value); }, [updateCharacterField]); - + async function handleSave(): Promise { await exitEditMode(true); } - + function handleCancel(): void { exitEditMode(false); } - + async function handleDelete(): Promise { if (selectedCharacter?.id) { await deleteCharacter(selectedCharacter.id); @@ -86,25 +83,21 @@ export default function CharacterEditor(): React.JSX.Element { backToList(); } } - + function getSeriesCharacterForSelected(): SeriesCharacterProps | null { if (!selectedCharacter?.seriesCharacterId) return null; return seriesCharacters.find(function (sc: SeriesCharacterProps): boolean { return sc.id === selectedCharacter.seriesCharacterId; }) || null; } - + if (isLoading) { - return ( -
    - -
    - ); + return ; } - + const isNew: boolean = selectedCharacter?.id === null; const canExport: boolean = Boolean(bookSeriesId && selectedCharacter?.id && !selectedCharacter.seriesCharacterId); - + return (
    - +
    {viewMode === 'list' && (
    - {/* Toggle tool */} -
    - - } + {/* Import from series */} + {bookSeriesId && availableSeriesCharacters.length > 0 && ( + -
    - - {toolEnabled && ( - <> - {/* Import from series */} - {bookSeriesId && availableSeriesCharacters.length > 0 && ( - - )} - - - )} + +
    )} - + {viewMode === 'detail' && selectedCharacter && (
    )} - + {viewMode === 'edit' && selectedCharacter && (
    )}
    - + {showDeleteConfirm && selectedCharacter?.id && ( )}
    diff --git a/components/book/settings/characters/editor/CharacterEditorDetail.tsx b/components/book/settings/characters/editor/CharacterEditorDetail.tsx index 9fb6681..20708b4 100644 --- a/components/book/settings/characters/editor/CharacterEditorDetail.tsx +++ b/components/book/settings/characters/editor/CharacterEditorDetail.tsx @@ -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[] }[]; @@ -30,36 +26,29 @@ interface CharacterEditorDetailProps { * PAS de CollapsableArea, PAS de grids */ export default function CharacterEditorDetail({ - character, - seriesCharacter, - onLoadAttributes, -}: CharacterEditorDetailProps): React.JSX.Element { + character, + seriesCharacter, + onLoadAttributes, + }: CharacterEditorDetailProps): React.JSX.Element { const t = useTranslations(); - const {lang} = useContext(LangContext); - const {session} = useContext(SessionContext); - const {errorMessage} = useContext(AlertContext); - const {isCurrentlyOffline} = useContext(OfflineContext); - const {book} = useContext(BookContext); - + const {lang}: LangContextProps = useContext(LangContext); + const {session}: SessionContextProps = useContext(SessionContext); + const {errorMessage}: AlertContextProps = useContext(AlertContext); + useEffect(function (): void { if (character?.id !== null) { getAttributes().then(); } }, [character?.id]); - + async function getAttributes(): Promise { try { - let response: AttributeResponse; - if (isCurrentlyOffline() || book?.localBook) { - response = await tauri.getCharacterAttributes(character?.id!) as AttributeResponse; - } else { - response = await System.authGetQueryToServer( - 'character/attribute', - session.accessToken, - lang, - {characterId: character?.id} - ); - } + const response: AttributeResponse = await apiGet( + 'character/attribute', + session.accessToken, + lang, + {characterId: character?.id} + ); if (response && onLoadAttributes) { const attributes: CharacterAttribute = {}; response.forEach(function (item: { type: string; values: Attribute[] }): void { @@ -73,48 +62,34 @@ export default function CharacterEditorDetail({ } } } - - function renderField(label: string, value: string | number | null | undefined): React.JSX.Element | null { - if (!value) return null; - return ( -
    - {label} -

    {value}

    -
    - ); - } - + 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 (
    - {/* Image du personnage - version compacte */} {character.image && (
    -
    - {character.name} -
    +
    )} - +

    {character.name} {character.lastName}

    - - {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)} + + + + + + +
    ); } diff --git a/components/book/settings/characters/editor/CharacterEditorEdit.tsx b/components/book/settings/characters/editor/CharacterEditorEdit.tsx index b9a6978..647922d 100644 --- a/components/book/settings/characters/editor/CharacterEditorEdit.tsx +++ b/components/book/settings/characters/editor/CharacterEditorEdit.tsx @@ -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>; onCharacterChange: (key: keyof CharacterProps, value: string | number | null) => void; - onAddAttribute: (section: keyof CharacterProps, attr: Attribute) => Promise; - onRemoveAttribute: (section: keyof CharacterProps, idx: number, id: string) => Promise; + onAddAttribute: (section: CharacterAttributeSection, attr: Attribute) => Promise; + onRemoveAttribute: (section: CharacterAttributeSection, idx: number, id: string) => Promise; seriesCharacter?: SeriesCharacterProps | null; onSyncComplete?: () => void; } @@ -47,54 +47,34 @@ interface CharacterEditorEditProps { * Mêmes fonctionnalités que CharacterSettingsEdit, layout linéaire */ export default function CharacterEditorEdit({ - character, - setCharacter, - onCharacterChange, - onAddAttribute, - onRemoveAttribute, - seriesCharacter, - onSyncComplete, -}: CharacterEditorEditProps): React.JSX.Element { + character, + setCharacter, + onCharacterChange, + onAddAttribute, + onRemoveAttribute, + seriesCharacter, + onSyncComplete, + }: CharacterEditorEditProps): React.JSX.Element { const t = useTranslations(); - const {lang} = useContext(LangContext); - const {session} = useContext(SessionContext); - const {errorMessage} = useContext(AlertContext); - const {isCurrentlyOffline} = useContext(OfflineContext); - const {book} = useContext(BookContext); + const {lang}: LangContextProps = useContext(LangContext); + const {session}: SessionContextProps = useContext(SessionContext); + const {errorMessage}: AlertContextProps = useContext(AlertContext); const [showAdvanced, setShowAdvanced] = useState(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(); } }, [character?.id]); - + async function getAttributes(): Promise { try { - let response: AttributeResponse; - if (isCurrentlyOffline() || book?.localBook) { - response = await tauri.getCharacterAttributes(character?.id!) as AttributeResponse; - } else { - response = await System.authGetQueryToServer( - 'character/attribute', - session.accessToken, - lang, - {characterId: character?.id} - ); - } + const response: AttributeResponse = await apiGet( + 'character/attribute', + session.accessToken, + lang, + {characterId: character?.id} + ); if (response) { const attributes: CharacterAttribute = {}; response.forEach(function (item: { type: string; values: Attribute[] }): void { @@ -131,11 +111,11 @@ export default function CharacterEditorEdit({ } } } - + return (
    {/* Informations de base */} -
    +

    {t('characterDetail.basicInfo')}

    } /> - + } /> - + ): 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 /> } /> - + } /> - +
    - + {/* Histoire */} -
    +

    {t('characterDetail.historySection')}

    - ): void { onCharacterChange('biography', e.target.value); @@ -258,7 +249,7 @@ export default function CharacterEditorEdit({ } /> - + - ): void { onCharacterChange('role', e.target.value); @@ -284,7 +277,7 @@ export default function CharacterEditorEdit({ />
    - + {/* Attributs de base */} {basicCharacterElements.map(function (item: CharacterElement, index: number): React.JSX.Element { return ( @@ -301,29 +294,32 @@ export default function CharacterEditorEdit({ /> ); })} - + {/* Toggle Mode Avancé */} -
    +
    - + {t('characterDetail.advancedMode')}
    - + {/* Sections avancées */} {showAdvanced && ( <> -
    +

    {t('characterDetail.identitySection')}

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

    {t('characterDetail.authorSection')}

    ): void { onCharacterChange('notes', e.target.value); @@ -371,7 +372,7 @@ export default function CharacterEditorEdit({ } />
    - + {advancedCharacterElements.map(function (item: CharacterElement, index: number): React.JSX.Element { return ( (''); - + function getFilteredCharacters(): CharacterProps[] { return characters.filter(function (char: CharacterProps): boolean { return char.name.toLowerCase().includes(searchQuery.toLowerCase()) || (char.lastName?.toLowerCase().includes(searchQuery.toLowerCase()) ?? false); }); } - + const filteredCharacters: CharacterProps[] = getFilteredCharacters(); - + return (
    @@ -48,65 +50,33 @@ export default function CharacterEditorList({ placeholder={t('characterList.search')} /> } - actionIcon={faPlus} + actionIcon={Plus} actionLabel={t('characterList.add')} addButtonCallBack={async function (): Promise { onAddCharacter(); }} />
    - +
    {filteredCharacters.length === 0 ? ( -
    -
    - -
    -

    - {t('characterList.noCharacters')} -

    -

    - {t('characterList.noCharactersDescription')} -

    -
    + ) : ( filteredCharacters.map(function (char: CharacterProps): React.JSX.Element { return ( -
    -
    - {char.image ? ( - {char.name} - ) : ( -
    - {char.name?.charAt(0)?.toUpperCase() || '?'} -
    - )} -
    - -
    -
    - {char.name || t('characterList.unknown')} -
    -
    - {char.title || char.role || t('characterList.noRole')} -
    -
    - -
    - -
    -
    + size="sm" + onClick={function (): void { + onCharacterClick(char); + }} + avatar={} + title={char.name || t('characterList.unknown')} + subtitle={char.title || char.role || t('characterList.noRole')} + /> ); }) )} diff --git a/components/book/settings/characters/settings/CharacterSettings.tsx b/components/book/settings/characters/settings/CharacterSettings.tsx index a3884fb..79714ea 100644 --- a/components/book/settings/characters/settings/CharacterSettings.tsx +++ b/components/book/settings/characters/settings/CharacterSettings.tsx @@ -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; } /** @@ -30,23 +27,23 @@ interface CharacterSettingsProps { * Inclut: toggle tool, import from series, header avec actions */ export default function CharacterSettings({ - entityType = 'book', - entityId, - showToggle = true, -}: CharacterSettingsProps): React.JSX.Element { + entityType = 'book', + entityId, + toolEnabled: parentToolEnabled, + }: CharacterSettingsProps): React.JSX.Element { const t = useTranslations(); - const {book} = useContext(BookContext); + const {book}: BookContextProps = useContext(BookContext); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); - + const resolvedEntityId: string = entityId || book?.bookId || ''; - + const config: UseCharactersConfig = useMemo(function (): UseCharactersConfig { return { entityType, entityId: resolvedEntityId, }; }, [entityType, resolvedEntityId]); - + const { characters, seriesCharacters, @@ -72,7 +69,7 @@ export default function CharacterSettings({ backToList, addNewCharacter, } = useCharacters(config); - + const availableSeriesCharacters = useMemo(function (): SeriesCharacterProps[] { return seriesCharacters.filter(function (sc: SeriesCharacterProps): boolean { return !characters.some(function (c: CharacterProps): boolean { @@ -80,19 +77,19 @@ export default function CharacterSettings({ }); }); }, [seriesCharacters, characters]); - + const handleCharacterChange = useCallback(function (key: keyof CharacterProps, value: string | number | null): void { updateCharacterField(key, value); }, [updateCharacterField]); - + async function handleSave(): Promise { await exitEditMode(true); } - + function handleCancel(): void { exitEditMode(false); } - + async function handleDelete(): Promise { if (selectedCharacter?.id) { await deleteCharacter(selectedCharacter.id); @@ -100,25 +97,21 @@ export default function CharacterSettings({ backToList(); } } - + function getSeriesCharacterForSelected(): SeriesCharacterProps | null { if (!selectedCharacter?.seriesCharacterId) return null; return seriesCharacters.find(function (sc: SeriesCharacterProps): boolean { return sc.id === selectedCharacter.seriesCharacterId; }) || null; } - + if (isLoading) { - return ( -
    - -
    - ); + return ; } - + const isNew: boolean = selectedCharacter?.id === null; const canExport: boolean = Boolean(bookSeriesId && selectedCharacter?.id && !selectedCharacter.seriesCharacterId); - + return (
    {/* Header - uniquement pour detail/edit */} @@ -131,37 +124,19 @@ 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)} /> - + {/* Contenu principal */}
    {viewMode === 'list' && (
    - {/* Toggle tool */} - {showToggle && !isSeriesMode && ( -
    - - } - /> -

    - {t('characterComponent.enableToolDescription')} -

    -
    - )} - - {/* Contenu si outil activé */} - {(toolEnabled || isSeriesMode) && ( + {((parentToolEnabled !== undefined ? parentToolEnabled : toolEnabled) || isSeriesMode) && ( <> {/* Import from series */} {!isSeriesMode && bookSeriesId && availableSeriesCharacters.length > 0 && ( @@ -177,7 +152,7 @@ export default function CharacterSettings({ label={t('seriesImport.importFromSeries')} /> )} - + {/* Liste des personnages */} )} - + {viewMode === 'detail' && selectedCharacter && (
    )} - + {viewMode === 'edit' && selectedCharacter && (
    )}
    - + {/* Modal de confirmation de suppression */} {showDeleteConfirm && selectedCharacter?.id && ( )}
    diff --git a/components/book/settings/characters/settings/CharacterSettingsDetail.tsx b/components/book/settings/characters/settings/CharacterSettingsDetail.tsx index eede8bc..3094f54 100644 --- a/components/book/settings/characters/settings/CharacterSettingsDetail.tsx +++ b/components/book/settings/characters/settings/CharacterSettingsDetail.tsx @@ -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[] }[]; @@ -50,37 +44,30 @@ interface CharacterSettingsDetailProps { } export default function CharacterSettingsDetail({ - character, - seriesCharacter, - onLoadAttributes, -}: CharacterSettingsDetailProps): React.JSX.Element { + character, + seriesCharacter, + onLoadAttributes, + }: CharacterSettingsDetailProps): React.JSX.Element { const t = useTranslations(); - const {lang} = useContext(LangContext); - const {session} = useContext(SessionContext); - const {errorMessage} = useContext(AlertContext); - const {isCurrentlyOffline} = useContext(OfflineContext); - const {book} = useContext(BookContext); + const {lang}: LangContextProps = useContext(LangContext); + const {session}: SessionContextProps = useContext(SessionContext); + const {errorMessage}: AlertContextProps = useContext(AlertContext); const [showAdvanced, setShowAdvanced] = useState(false); - + useEffect(function (): void { if (character?.id !== null) { getAttributes().then(); } }, [character?.id]); - + async function getAttributes(): Promise { try { - let response: AttributeResponse; - if (isCurrentlyOffline() || book?.localBook) { - response = await tauri.getCharacterAttributes(character?.id!) as AttributeResponse; - } else { - response = await System.authGetQueryToServer( - 'character/attribute', - session.accessToken, - lang, - {characterId: character?.id} - ); - } + const response: AttributeResponse = await apiGet( + 'character/attribute', + session.accessToken, + lang, + {characterId: character?.id} + ); if (response && onLoadAttributes) { const attributes: CharacterAttribute = {}; response.forEach(function (item: { type: string; values: Attribute[] }): void { @@ -94,53 +81,56 @@ 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 ( - -
    - {attributes.map(function (attr: Attribute, index: number): React.JSX.Element { - return ( - - {attr.name} - - ); - })} -
    -
    + +
    + {attributes.map(function (attr: Attribute, index: number): React.JSX.Element { + return ( + + {attr.name} + + ); + })} +
    +
    ); } - + return (
    {/* Hero Section - Image + Infos principales */} -
    +
    {/* Image */}
    {character.image ? ( -
    +
    {character.name}
    ) : ( -
    - +
    +
    )}
    - + {/* Infos principales */}

    @@ -152,170 +142,170 @@ export default function CharacterSettingsDetail({ {character.title && (

    {character.title}

    )} - + {/* Badges */}
    - - - {getCategoryLabel()} - + {getCategoryLabel()} {character.gender && ( - - - {character.gender} - + {character.gender} )} {character.age && ( - - - {character.age} {t('characterDetail.yearsOld')} - + {character.age} {t('characterDetail.yearsOld')} )}

    - + {/* Histoire & Biographie */} - -
    + +

    {t('characterDetail.biography')}

    -

    +

    {character.biography || '—'}

    {t('characterDetail.history')}

    -

    +

    {character.history || '—'}

    {t('characterDetail.roleFull')}

    -

    +

    {character.role || '—'}

    - +
    {/* Attributs de base */} {basicCharacterElements.map(renderAttributeSection)} - + {/* Toggle Mode Avancé */} -
    +
    - + {t('characterDetail.advancedMode')}
    - + {/* Sections avancées */} {showAdvanced && ( <> {/* Identité étendue */} - -
    -
    + +
    +
    - - {t('characterDetail.species')} + + {t('characterDetail.species')}
    -

    +

    {character.species || '—'}

    -
    +
    - - {t('characterDetail.nationality')} + + {t('characterDetail.nationality')}
    -

    +

    {character.nationality || '—'}

    -
    +
    - - {t('characterDetail.status')} + + {t('characterDetail.status')}
    -

    +

    {getStatusLabel()}

    -
    +
    - - {t('characterDetail.residence')} + + {t('characterDetail.residence')}
    -

    +

    {character.residence || '—'}

    - + {/* Voix du personnage */} - -
    -
    + +
    +

    {t('characterDetail.speechPattern')}

    -

    +

    {character.speechPattern || '—'}

    -
    - +
    +

    {t('characterDetail.catchphrase')}

    -

    +

    {character.catchphrase ? `« ${character.catchphrase} »` : '—'}

    - + {/* Notes de l'auteur */} - -
    -
    + +
    +
    - - {t('characterDetail.notes')} + + {t('characterDetail.notes')}
    -

    +

    {character.notes || '—'}

    -
    +
    - - {t('characterDetail.colorLabel')} + + {t('characterDetail.colorLabel')}
    {character.color ? (
    {character.color}
    ) : ( -

    +

    )}
    - - + + {/* Attributs avancés */} {advancedCharacterElements.map(renderAttributeSection)} diff --git a/components/book/settings/characters/settings/CharacterSettingsEdit.tsx b/components/book/settings/characters/settings/CharacterSettingsEdit.tsx index a169d4f..2efc753 100644 --- a/components/book/settings/characters/settings/CharacterSettingsEdit.tsx +++ b/components/book/settings/characters/settings/CharacterSettingsEdit.tsx @@ -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>; onCharacterChange: (key: keyof CharacterProps, value: string | number | null) => void; - onAddAttribute: (section: keyof CharacterProps, attr: Attribute) => Promise; - onRemoveAttribute: (section: keyof CharacterProps, idx: number, id: string) => Promise; + onAddAttribute: (section: CharacterAttributeSection, attr: Attribute) => Promise; + onRemoveAttribute: (section: CharacterAttributeSection, idx: number, id: string) => Promise; seriesCharacter?: SeriesCharacterProps | null; onSyncComplete?: () => void; } @@ -57,54 +49,34 @@ interface CharacterSettingsEditProps { * PAS de scroll interne (géré par parent) */ export default function CharacterSettingsEdit({ - character, - setCharacter, - onCharacterChange, - onAddAttribute, - onRemoveAttribute, - seriesCharacter, - onSyncComplete, -}: CharacterSettingsEditProps): React.JSX.Element { + character, + setCharacter, + onCharacterChange, + onAddAttribute, + onRemoveAttribute, + seriesCharacter, + onSyncComplete, + }: CharacterSettingsEditProps): React.JSX.Element { const t = useTranslations(); - const {lang} = useContext(LangContext); - const {session} = useContext(SessionContext); - const {errorMessage} = useContext(AlertContext); - const {isCurrentlyOffline} = useContext(OfflineContext); - const {book} = useContext(BookContext); + const {lang}: LangContextProps = useContext(LangContext); + const {session}: SessionContextProps = useContext(SessionContext); + const {errorMessage}: AlertContextProps = useContext(AlertContext); const [showAdvanced, setShowAdvanced] = useState(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(); } }, [character?.id]); - + async function getAttributes(): Promise { try { - let response: AttributeResponse; - if (isCurrentlyOffline() || book?.localBook) { - response = await tauri.getCharacterAttributes(character?.id!) as AttributeResponse; - } else { - response = await System.authGetQueryToServer( - 'character/attribute', - session.accessToken, - lang, - {characterId: character?.id} - ); - } + const response: AttributeResponse = await apiGet( + 'character/attribute', + session.accessToken, + lang, + {characterId: character?.id} + ); if (response) { const attributes: CharacterAttribute = {}; response.forEach(function (item: { type: string; values: Attribute[] }): void { @@ -141,12 +113,11 @@ export default function CharacterSettingsEdit({ } } } - + return (
    {/* Informations de base */} - -
    + } /> - + } /> - + } /> - + ): 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 /> } /> - + } /> - + } /> - + } /> -
    -
    + {/* Histoire */} - -
    + - ): void { onCharacterChange('biography', e.target.value); @@ -356,10 +346,10 @@ export default function CharacterSettingsEdit({ } /> - + - ): void { onCharacterChange('history', e.target.value); @@ -381,10 +373,10 @@ export default function CharacterSettingsEdit({ } /> - + - ): void { onCharacterChange('role', e.target.value); @@ -406,8 +400,7 @@ export default function CharacterSettingsEdit({ } /> -
    -
    + {/* Attributs de base */} {basicCharacterElements.map(function (item: CharacterElement, index: number): React.JSX.Element { @@ -425,31 +418,33 @@ export default function CharacterSettingsEdit({ /> ); })} - + {/* Toggle Mode Avancé */} -
    +
    - + {t('characterDetail.advancedMode')}
    - + {/* Sections avancées */} {showAdvanced && ( <> {/* Identité étendue */} - -
    + } /> - + } /> - + ): 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 /> } /> - + } /> -
    -
    + {/* Voix du personnage */} - -
    + - ): void { onCharacterChange('speechPattern', e.target.value); @@ -580,7 +590,7 @@ export default function CharacterSettingsEdit({ } /> - + } /> -
    -
    - + + {/* Notes de l'auteur */} - -
    + - ): void { onCharacterChange('notes', e.target.value); @@ -633,7 +645,7 @@ export default function CharacterSettingsEdit({ } /> - + } /> -
    -
    - + + {/* Attributs avancés */} {advancedCharacterElements.map(function (item: CharacterElement, index: number): React.JSX.Element { return ( diff --git a/components/book/settings/characters/settings/CharacterSettingsList.tsx b/components/book/settings/characters/settings/CharacterSettingsList.tsx index f1512a0..c605c97 100644 --- a/components/book/settings/characters/settings/CharacterSettingsList.tsx +++ b/components/book/settings/characters/settings/CharacterSettingsList.tsx @@ -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[]; @@ -21,22 +24,22 @@ interface CharacterSettingsListProps { * PAS de scroll interne (géré par parent SettingsContainer) */ export default function CharacterSettingsList({ - characters, - onCharacterClick, - onAddCharacter, -}: CharacterSettingsListProps): React.JSX.Element { + characters, + onCharacterClick, + onAddCharacter, + }: CharacterSettingsListProps): React.JSX.Element { const t = useTranslations(); const [searchQuery, setSearchQuery] = useState(''); - + function getFilteredCharacters(): CharacterProps[] { return characters.filter(function (char: CharacterProps): boolean { return char.name.toLowerCase().includes(searchQuery.toLowerCase()) || (char.lastName?.toLowerCase().includes(searchQuery.toLowerCase()) ?? false); }); } - + const filteredCharacters: CharacterProps[] = getFilteredCharacters(); - + return (
    @@ -50,14 +53,14 @@ export default function CharacterSettingsList({ placeholder={t('characterList.search')} /> } - actionIcon={faPlus} + actionIcon={Plus} actionLabel={t('characterList.add')} addButtonCallBack={async function (): Promise { onAddCharacter(); }} />
    - +
    {characterCategories.map(function (category: SelectBoxProps): React.JSX.Element | null { const categoryCharacters: CharacterProps[] = filteredCharacters.filter( @@ -65,85 +68,57 @@ export default function CharacterSettingsList({ return char.category === category.value; } ); - + if (categoryCharacters.length === 0) { return null; } - + return ( -
    {categoryCharacters.map(function (char: CharacterProps): React.JSX.Element { return ( -
    -
    - {char.image ? ( - {char.name} - ) : ( -
    - {char.name?.charAt(0)?.toUpperCase() || '?'} -
    - )} -
    - -
    -
    - {char.name || t('characterList.unknown')} -
    -
    - {char.lastName || t('characterList.noLastName')} -
    -
    - -
    -
    - {char.title || t('characterList.noTitle')} -
    -
    - {char.role || t('characterList.noRole')} -
    -
    - -
    - -
    -
    + } + title={char.name || t('characterList.unknown')} + subtitle={char.lastName || t('characterList.noLastName')} + extra={ +
    +
    + {char.title || t('characterList.noTitle')} +
    +
    + {char.role || t('characterList.noRole')} +
    +
    + } + /> ); })}
    -
    + ); })} - + {filteredCharacters.length === 0 && ( -
    -
    - -
    -

    - {t('characterList.noCharacters')} -

    -

    - {t('characterList.noCharactersDescription')} -

    -
    + )}
    diff --git a/components/book/settings/goals/page.tsx b/components/book/settings/goals/page.tsx index f3a5f01..f7cf16f 100644 --- a/components/book/settings/goals/page.tsx +++ b/components/book/settings/goals/page.tsx @@ -1,181 +1,176 @@ -'use client' -import {useState} from 'react'; - -interface TimeGoal { - desiredReleaseDate: string; - maxReleaseDate: string; -} - -interface NumbersGoal { - minWordsCount: number; - maxWordsCount: number; - desiredWordsCountByChapter: number; - desiredChapterCount: number; -} - -interface Goal { - id: number; - name: string; - timeGoal: TimeGoal; - numbersGoal: NumbersGoal; -} - -export default function GoalsPage() { - const [goals, setGoals] = useState([ - { - id: 1, - name: 'First Goal', - timeGoal: { - desiredReleaseDate: '', - maxReleaseDate: '', - }, - numbersGoal: { - minWordsCount: 0, - maxWordsCount: 0, - desiredWordsCountByChapter: 0, - desiredChapterCount: 0, - }, - }, - ]); - - const [selectedGoalIndex, setSelectedGoalIndex] = useState(0); - const [newGoalName, setNewGoalName] = useState(''); - - const handleAddGoal = () => { - const newGoal: Goal = { - id: goals.length + 1, - name: newGoalName, - timeGoal: { - desiredReleaseDate: '', - maxReleaseDate: '', - }, - numbersGoal: { - minWordsCount: 0, - maxWordsCount: 0, - desiredWordsCountByChapter: 0, - desiredChapterCount: 0, - }, - }; - setGoals([...goals, newGoal]); - setNewGoalName(''); - }; - - const handleInputChange = (e: React.ChangeEvent, field: keyof Goal, subField?: keyof TimeGoal | keyof NumbersGoal) => { - const updatedGoals = [...goals]; - if (subField) { - if (field === 'timeGoal' && subField in updatedGoals[selectedGoalIndex].timeGoal) { - (updatedGoals[selectedGoalIndex].timeGoal[subField as keyof TimeGoal] as string) = e.target.value; - } else if (field === 'numbersGoal' && subField in updatedGoals[selectedGoalIndex].numbersGoal) { - (updatedGoals[selectedGoalIndex].numbersGoal[subField as keyof NumbersGoal] as number) = Number(e.target.value); - } - } else { - (updatedGoals[selectedGoalIndex][field] as string) = e.target.value; - } - setGoals(updatedGoals); - }; - - return ( -
    -
    -

    Goals

    -
    -
    - - setNewGoalName(e.target.value)} - className="w-full px-4 py-2.5 rounded-xl bg-secondary/50 text-text-primary border border-secondary/50 outline-none hover:bg-secondary hover:border-secondary focus:border-primary focus:ring-4 focus:ring-primary/20 transition-all duration-200" - placeholder="New Goal Name" - /> - -
    -
    - -

    {goals[selectedGoalIndex].name}

    -
    -

    Time Goal

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

    Numbers Goal

    - - handleInputChange(e, 'numbersGoal', 'minWordsCount')} - className="w-full p-2 rounded-lg bg-gray-800 text-white border-none outline-none" - /> - - handleInputChange(e, 'numbersGoal', 'maxWordsCount')} - className="w-full p-2 rounded-lg bg-gray-800 text-white border-none outline-none" - /> - - handleInputChange(e, 'numbersGoal', 'desiredWordsCountByChapter')} - className="w-full p-2 rounded-lg bg-gray-800 text-white border-none outline-none" - /> - - handleInputChange(e, 'numbersGoal', 'desiredChapterCount')} - className="w-full p-2 rounded-lg bg-gray-800 text-white border-none outline-none" - /> -
    - -
    - -
    -
    -
    - ); -} +'use client' +import {useState} from 'react'; +import {Plus, Save} from 'lucide-react'; +import Button from '@/components/ui/Button'; + +interface TimeGoal { + desiredReleaseDate: string; + maxReleaseDate: string; +} + +interface NumbersGoal { + minWordsCount: number; + maxWordsCount: number; + desiredWordsCountByChapter: number; + desiredChapterCount: number; +} + +interface Goal { + id: number; + name: string; + timeGoal: TimeGoal; + numbersGoal: NumbersGoal; +} + +export default function GoalsPage() { + const [goals, setGoals] = useState([ + { + id: 1, + name: 'First Goal', + timeGoal: { + desiredReleaseDate: '', + maxReleaseDate: '', + }, + numbersGoal: { + minWordsCount: 0, + maxWordsCount: 0, + desiredWordsCountByChapter: 0, + desiredChapterCount: 0, + }, + }, + ]); + + const [selectedGoalIndex, setSelectedGoalIndex] = useState(0); + const [newGoalName, setNewGoalName] = useState(''); + + const handleAddGoal = () => { + const newGoal: Goal = { + id: goals.length + 1, + name: newGoalName, + timeGoal: { + desiredReleaseDate: '', + maxReleaseDate: '', + }, + numbersGoal: { + minWordsCount: 0, + maxWordsCount: 0, + desiredWordsCountByChapter: 0, + desiredChapterCount: 0, + }, + }; + setGoals([...goals, newGoal]); + setNewGoalName(''); + }; + + const handleInputChange = (e: React.ChangeEvent, field: keyof Goal, subField?: keyof TimeGoal | keyof NumbersGoal) => { + const updatedGoals = [...goals]; + if (subField) { + if (field === 'timeGoal' && subField in updatedGoals[selectedGoalIndex].timeGoal) { + (updatedGoals[selectedGoalIndex].timeGoal[subField as keyof TimeGoal] as string) = e.target.value; + } else if (field === 'numbersGoal' && subField in updatedGoals[selectedGoalIndex].numbersGoal) { + (updatedGoals[selectedGoalIndex].numbersGoal[subField as keyof NumbersGoal] as number) = Number(e.target.value); + } + } else { + (updatedGoals[selectedGoalIndex][field] as string) = e.target.value; + } + setGoals(updatedGoals); + }; + + return ( +
    +
    +

    Goals

    +
    +
    + + setNewGoalName(e.target.value)} + className="input-base" + placeholder="New Goal Name" + /> + +
    +
    + +

    {goals[selectedGoalIndex].name}

    +
    +

    Time Goal

    + + handleInputChange(e, 'timeGoal', 'desiredReleaseDate')} + className="input-base" + /> + + handleInputChange(e, 'timeGoal', 'maxReleaseDate')} + className="input-base" + /> +
    + +
    +

    Numbers Goal

    + + handleInputChange(e, 'numbersGoal', 'minWordsCount')} + className="input-base" + /> + + handleInputChange(e, 'numbersGoal', 'maxWordsCount')} + className="input-base" + /> + + handleInputChange(e, 'numbersGoal', 'desiredWordsCountByChapter')} + className="input-base" + /> + + handleInputChange(e, 'numbersGoal', 'desiredChapterCount')} + className="input-base" + /> +
    + +
    + +
    +
    +
    + ); +} diff --git a/components/book/settings/guide-line/GuideLineSetting.tsx b/components/book/settings/guide-line/GuideLineSetting.tsx deleted file mode 100644 index ef1e9e5..0000000 --- a/components/book/settings/guide-line/GuideLineSetting.tsx +++ /dev/null @@ -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(OfflineContext); - const {addToQueue} = useContext(LocalSyncQueueContext); - const {localSyncedBooks} = useContext(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(''); - const [atmosphere, setAtmosphere] = useState(''); - const [writingStyle, setWritingStyle] = useState(''); - const [themes, setThemes] = useState(''); - const [symbolism, setSymbolism] = useState(''); - const [motifs, setMotifs] = useState(''); - const [narrativeVoice, setNarrativeVoice] = useState(''); - const [pacing, setPacing] = useState(''); - const [intendedAudience, setIntendedAudience] = useState(''); - const [keyMessages, setKeyMessages] = useState(''); - - const [plotSummary, setPlotSummary] = useState(''); - const [narrativeType, setNarrativeType] = useState(''); - const [verbTense, setVerbTense] = useState(''); - const [dialogueType, setDialogueType] = useState(''); - const [toneAtmosphere, setToneAtmosphere] = useState(''); - const [language, setLanguage] = useState(''); - - useEffect((): void => { - if (activeTab === 'personal') { - getGuideLine().then(); - } else { - getAIGuideLine().then(); - } - }, [activeTab]); - - useImperativeHandle(ref, () => { - { - if (activeTab === 'personal') { - return { - handleSave: savePersonal - }; - } else { - return { - handleSave: saveQuillSense - }; - } - } - }); - - async function getAIGuideLine(): Promise { - try { - let response: GuideLineAI; - if (isCurrentlyOffline() || book?.localBook) { - response = await tauri.getAIGuideLine(bookId); - } else { - response = await System.authGetQueryToServer(`book/ai/guideline`, userToken, lang, {id: bookId}); - } - if (response) { - setPlotSummary(response.globalResume || ''); - setVerbTense(response.verbeTense?.toString() || ''); - setNarrativeType(response.narrativeType?.toString() || ''); - setDialogueType(response.dialogueType?.toString() || ''); - setToneAtmosphere(response.atmosphere || ''); - setLanguage(response.langue?.toString() || ''); - setThemes(response.themes || ''); - } - } catch (e: unknown) { - if (e instanceof Error) { - errorMessage(e.message); - } else { - errorMessage(t("guideLineSetting.errorUnknown")); - } - } - } - - async function getGuideLine(): Promise { - try { - let response: GuideLine; - if (isCurrentlyOffline() || book?.localBook) { - response = await tauri.getGuideLine(bookId); - } else { - response = await System.authGetQueryToServer( - `book/guide-line`, - userToken, - lang, - {id: bookId}, - ); - } - if (response) { - setTone(response.tone); - setAtmosphere(response.atmosphere); - setWritingStyle(response.writingStyle); - setThemes(response.themes); - setSymbolism(response.symbolism); - setMotifs(response.motifs); - setNarrativeVoice(response.narrativeVoice); - setPacing(response.pacing); - setIntendedAudience(response.intendedAudience); - setKeyMessages(response.keyMessages); - } - } catch (error: unknown) { - if (error instanceof Error) { - errorMessage(error.message); - } else { - errorMessage(t("guideLineSetting.errorUnknown")); - } - } - } - - async function savePersonal(): Promise { - try { - 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( - '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 { - 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( - '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 ( -
    -
    - - -
    - - {activeTab === 'personal' && ( -
    -
    - ) => setTone(e.target.value)} - placeholder={t("guideLineSetting.tonePlaceholder")} - /> - }/> -
    - -
    - ) => setAtmosphere(e.target.value)} - placeholder={t("guideLineSetting.atmospherePlaceholder")} - /> - }/> -
    - -
    - ): void => setWritingStyle(e.target.value)} - placeholder={t("guideLineSetting.writingStylePlaceholder")} - /> - }/> -
    - -
    - ): void => setThemes(e.target.value)} - placeholder={t("guideLineSetting.themesPlaceholder")} - /> - }/> -
    - -
    - ): void => setSymbolism(e.target.value)} - placeholder={t("guideLineSetting.symbolismPlaceholder")} - /> - }/> -
    - -
    - ): void => setMotifs(e.target.value)} - placeholder={t("guideLineSetting.motifsPlaceholder")} - /> - }/> -
    - -
    - ): void => setNarrativeVoice(e.target.value)} - placeholder={t("guideLineSetting.narrativeVoicePlaceholder")} - /> - }/> -
    - -
    - ): void => setPacing(e.target.value)} - placeholder={t("guideLineSetting.pacingPlaceholder")} - /> - }/> -
    - -
    - ): void => setIntendedAudience(e.target.value)} - placeholder={t("guideLineSetting.intendedAudiencePlaceholder")} - /> - }/> -
    - -
    - ): void => setKeyMessages(e.target.value)} - placeholder={t("guideLineSetting.keyMessagesPlaceholder")} - /> - }/> -
    -
    - )} - - {activeTab === 'quillsense' && ( -
    -
    - ): void => setPlotSummary(e.target.value)} - placeholder={t("guideLineSetting.plotSummaryPlaceholder")} - /> - }/> -
    - -
    - ): void => setToneAtmosphere(e.target.value)} - placeholder={t("guideLineSetting.toneAtmospherePlaceholder")} - /> - }/> -
    - -
    - ) => setThemes(e.target.value)} - placeholder={t("guideLineSetting.themesPlaceholderQuill")} - /> - }/> -
    -
    - ): void => setVerbTense(event.target.value)} - data={verbalTime} - placeholder={t("guideLineSetting.verbTensePlaceholder")} - /> - }/> -
    -
    - ): void => { - setNarrativeType(event.target.value) - }} placeholder={t("guideLineSetting.narrativeTypePlaceholder")}/> - }/> -
    - -
    - ) => { - setDialogueType(event.target.value) - }} placeholder={t("guideLineSetting.dialogueTypePlaceholder")}/> - }/> -
    - -
    - ) => { - setLanguage(event.target.value) - }} placeholder={t("guideLineSetting.languagePlaceholder")}/> - }/> -
    -
    - )} -
    - ); -} - -export default forwardRef(GuideLineSetting); \ No newline at end of file diff --git a/components/book/settings/locations/LocationComponent.tsx b/components/book/settings/locations/LocationComponent.tsx index 066749e..c9aaf47 100644 --- a/components/book/settings/locations/LocationComponent.tsx +++ b/components/book/settings/locations/LocationComponent.tsx @@ -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,16 +49,11 @@ interface LocationComponentProps { export function LocationComponent(props: LocationComponentProps, ref: React.Ref<{ handleSave: () => Promise }>) { const {showToggle = true, entityType = 'book', entityId} = props; const t = useTranslations(); - const {lang} = useContext(LangContext); - const {isCurrentlyOffline} = useContext(OfflineContext); - const {addToQueue} = useContext(LocalSyncQueueContext); - const {localSyncedBooks} = useContext(BooksSyncContext); - const {session} = useContext(SessionContext); - const {successMessage, errorMessage} = useContext(AlertContext); - const {book, setBook} = useContext(BookContext); - const {seriesId, localSeries} = useContext(SeriesContext); - const {localSyncedSeries} = useContext(SeriesSyncContext); - + const {lang}: LangContextProps = useContext(LangContext); + const {session}: SessionContextProps = useContext(SessionContext); + const {successMessage, errorMessage}: AlertContextProps = useContext(AlertContext); + const {book, setBook}: BookContextProps = useContext(BookContext); + const currentEntityId: string = entityId || book?.bookId || ''; const isSeriesMode: boolean = entityType === 'series'; const token: string = session.accessToken; @@ -90,17 +77,17 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref< getAllLocations().then(); } }, [currentEntityId]); - + useEffect((): void => { if (bookSeriesId && !isSeriesMode) { getSeriesLocations().then(); } }, [bookSeriesId]); - + async function getSeriesLocations(): Promise { if (!bookSeriesId) return; try { - const response: SeriesLocationItem[] = await System.authGetQueryToServer( + const response: SeriesLocationItem[] = await apiGet( '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 { if (isSeriesMode) return; try { - let response: boolean; - if (isCurrentlyOffline() || book?.localBook) { - response = await tauri.updateBookToolSetting(currentEntityId, 'locations', enabled); - } else { - response = await System.authPatchToServer('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 - }}); - } - } + const response: boolean = await apiPatch('book/tool-setting', { + bookId: currentEntityId, + toolName: 'locations', + enabled: enabled + }, token, lang); 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 { try { if (isSeriesMode) { - let response: SeriesLocationItem[]; - if (isCurrentlyOffline() || localSeries) { - response = await tauri.getSeriesLocationList(currentEntityId) as SeriesLocationItem[]; - } else { - response = await System.authGetQueryToServer( - 'series/location/list', - token, - lang, - {seriesid: currentEntityId} - ); - } + const response: SeriesLocationItem[] = await apiGet( + '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(`location/all`, token, lang, { - bookid: currentEntityId, - }); - } + const response: LocationListResponse = await apiGet( + '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 } }); } @@ -217,7 +185,7 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref< } } } - + async function handleAddSection(): Promise { if (!newSectionName.trim()) { errorMessage(t('locationComponent.errorSectionNameEmpty')) @@ -226,47 +194,29 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref< try { let sectionId: string; if (isSeriesMode) { - const addData = { - seriesId: currentEntityId, - name: newSectionName, - }; - if (isCurrentlyOffline() || localSeries) { - sectionId = await tauri.addSeriesLocationSection(addData); - } else { - sectionId = await System.authPostToServer( - 'series/location/section/add', - addData, - token, - lang - ); - if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) { - addToQueue('add_series_location_section', {data: addData}); - } - } + sectionId = await apiPost( + 'series/location/section/add', + { + seriesId: currentEntityId, + name: newSectionName, + }, + token, + lang + ); if (!sectionId) { errorMessage(t('locationComponent.errorUnknownAddSection')); return; } - } else if (isCurrentlyOffline() || book?.localBook) { - sectionId = await tauri.addLocationSection(newSectionName, currentEntityId); } else { - sectionId = await System.authPostToServer(`location/section/add`, { + sectionId = await apiPost('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; } } - 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 = { - locationId: sectionId, - name: newElementNames[sectionId], - }; - if (isCurrentlyOffline() || localSeries) { - elementId = await tauri.addSeriesLocationElement(addData); - } else { - elementId = await System.authPostToServer( - 'series/location/element/add', - addData, - token, - lang - ); - if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) { - addToQueue('add_series_location_element', {data: addData}); - } - } + elementId = await apiPost( + 'series/location/element/add', + { + locationId: sectionId, + name: newElementNames[sectionId], + }, + token, + lang + ); if (!elementId) { errorMessage(t('locationComponent.errorUnknownAddElement')); return; } - } else if (isCurrentlyOffline() || book?.localBook) { - elementId = await tauri.addLocationElement(sectionId, newElementNames[sectionId]); } else { - elementId = await System.authPostToServer(`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], - }}); + elementId = await apiPost('location/element/add', { + bookId: currentEntityId, + locationId: sectionId, + elementName: newElementNames[sectionId], + }, token, lang); + if (!elementId) { + errorMessage(t('locationComponent.errorUnknownAddElement')); + return; } } - if (!elementId) { - errorMessage(t('locationComponent.errorUnknownAddElement')); - return; - } const updatedSections: LocationProps[] = [...sections]; const sectionIndex: number = updatedSections.findIndex( (section: LocationProps): boolean => section.id === sectionId, @@ -355,7 +285,7 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref< } } } - + function handleElementChange( sectionId: string, elementIndex: number, @@ -370,7 +300,7 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref< updatedSections[sectionIndex].elements[elementIndex][field] = value; setSections(updatedSections); } - + async function handleAddSubElement( sectionId: string, elementIndex: number, @@ -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( - 'series/location/sub-element/add', - addData, - token, - lang - ); - if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) { - addToQueue('add_series_location_sub_element', {data: addData}); - } - } + subElementId = await apiPost( + 'series/location/sub-element/add', + { + elementId: sections[sectionIndex].elements[elementIndex].id, + name: newSubElementNames[elementIndex], + }, + token, + lang + ); if (!subElementId) { errorMessage(t('locationComponent.errorUnknownAddSubElement')); return; } - } else if (isCurrentlyOffline() || book?.localBook) { - subElementId = await tauri.addLocationSubElement(elementId, newSubElementNames[elementIndex]); } else { - subElementId = await System.authPostToServer(`location/sub-element/add`, { - elementId: elementId, + subElementId = await apiPost('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; } } - if (!subElementId) { - errorMessage(t('locationComponent.errorUnknownAddSubElement')); - return; - } const updatedSections: LocationProps[] = [...sections]; updatedSections[sectionIndex].elements[elementIndex].subElements.push({ id: subElementId, @@ -460,45 +371,30 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref< ][field] = value; setSections(updatedSections); } - + async function handleRemoveElement( sectionId: string, elementIndex: number, ): Promise { 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('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(`location/element/delete`, { - elementId: elementId, bookId: currentEntityId, deletedAt, + success = await apiDelete('series/location/element/delete', { + elementId: elementId + }, token, lang); + } else { + success = await apiDelete('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 { 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('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(`location/sub-element/delete`, { - subElementId: subElementId, bookId: currentEntityId, deletedAt, + success = await apiDelete('series/location/sub-element/delete', { + subElementId: subElementId + }, token, lang); + } else { + success = await apiDelete('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 { 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('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(`location/delete`, { - locationId: sectionId, bookId: currentEntityId, deletedAt, + success = await apiDelete('series/location/delete', { + locationId: sectionId + }, token, lang); + } else { + success = await apiDelete('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 { try { - let response: boolean; - if (isCurrentlyOffline() || book?.localBook) { - response = await tauri.updateLocations(sections) as boolean; - } else { - response = await System.authPostToServer(`location/update`, { - locations: sections, - }, token, lang); - - if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) { - addToQueue('update_locations', {data: { - locations: sections, - }}); - } - } + const response: boolean = await apiPost(`location/update`, { + locations: sections, + }, token, lang); if (!response) { errorMessage(t('locationComponent.errorUnknownSave')); return; @@ -630,23 +486,23 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref< } } } - + async function handleExportToSeries(section: LocationProps): Promise { if (!bookSeriesId) return; - + try { - const seriesLocationId: string = await System.authPostToServer('series/location/section/add', { + const seriesLocationId: string = await apiPost('series/location/section/add', { seriesId: bookSeriesId, name: section.name, }, token, lang); - + if (seriesLocationId) { - const updateResponse: boolean = await System.authPostToServer('location/section/update', { + const updateResponse: boolean = await apiPost('location/section/update', { sectionId: section.id, sectionName: section.name, seriesLocationId: seriesLocationId, }, token, lang); - + if (updateResponse) { setSections(sections.map((s: LocationProps): LocationProps => s.id === section.id ? {...s, seriesLocationId: seriesLocationId} : s @@ -661,42 +517,42 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref< } } } - + async function handleImportFromSeries(seriesLocationId: string): Promise { const seriesLocation: SeriesLocationItem | undefined = seriesLocations.find((location: SeriesLocationItem): boolean => location.id === seriesLocationId); if (!seriesLocation) return; - + try { - const sectionId: string = await System.authPostToServer('location/section/add', { + const sectionId: string = await apiPost('location/section/add', { bookId: currentEntityId, locationName: seriesLocation.name, seriesLocationId: seriesLocationId, }, token, lang); - + if (!sectionId) { errorMessage(t('locationComponent.importError')); return; } - + const importedElements: Element[] = []; - + for (const seriesElement of seriesLocation.elements) { - const elementId: string = await System.authPostToServer('location/element/add', { + const elementId: string = await apiPost('location/element/add', { bookId: currentEntityId, locationId: sectionId, elementName: seriesElement.name, }, token, lang); - + if (!elementId) continue; - + const importedSubElements: SubElement[] = []; - + for (const seriesSubElement of seriesElement.subElements) { - const subElementId: string = await System.authPostToServer('location/sub-element/add', { + const subElementId: string = await apiPost('location/sub-element/add', { elementId: elementId, subElementName: seriesSubElement.name, }, token, lang); - + if (subElementId) { importedSubElements.push({ id: subElementId, @@ -705,7 +561,7 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref< }); } } - + importedElements.push({ id: elementId, name: seriesElement.name, @@ -713,7 +569,7 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref< subElements: importedSubElements, }); } - + const newLocation: LocationProps = { id: sectionId, name: seriesLocation.name, @@ -732,9 +588,9 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref< return (
    {showToggle && !isSeriesMode && ( -
    +
    !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")} /> )} -
    } - actionIcon={faPlus} + actionIcon={Plus} actionLabel={t("locationComponent.addSectionLabel")} addButtonCallBack={handleAddSection} />
    -
    {sections.length > 0 ? ( sections.map((section: LocationProps) => ( -
    +

    - + {section.name} + className="ml-2 text-sm bg-secondary text-text-secondary py-0.5 px-2 rounded-full"> {section.elements.length || 0}
    {!isSeriesMode && bookSeriesId && !section.seriesLocationId && ( - + => handleExportToSeries(section)}/> )} - + => handleRemoveSection(section.id)}/>

    {section.elements.length > 0 ? ( section.elements.map((element, elementIndex) => (
    + className="bg-secondary rounded-lg p-3 border-l-4 border-primary">
    => handleRemoveElement(section.id, elementIndex)} />
    - ): void => handleElementChange(section.id, elementIndex, 'description', e.target.value)} placeholder={t("locationComponent.elementDescriptionPlaceholder")} /> - -
    + +
    {element.subElements.length > 0 && (

    {t("locationComponent.subElementsHeading")}

    )} - + {element.subElements.map((subElement: SubElement, subElementIndex: number) => (
    @@ -852,7 +702,7 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref< removeButtonCallBack={(): Promise => handleRemoveSubElement(section.id, elementIndex, subElementIndex)} />
    - handleSubElementChange(section.id, elementIndex, subElementIndex, 'description', e.target.value) @@ -861,7 +711,7 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref< />
    ))} - + )} - + ) => - 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<
    )) ) : ( -
    -

    {t("locationComponent.noSectionAvailable")}

    +
    +

    {t("locationComponent.noSectionAvailable")}

    )} @@ -913,4 +765,4 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref< ); } -export default forwardRef(LocationComponent); +export default forwardRef(LocationComponent); \ No newline at end of file diff --git a/components/book/settings/locations/editor/LocationEditor.tsx b/components/book/settings/locations/editor/LocationEditor.tsx index f286521..a6a721c 100644 --- a/components/book/settings/locations/editor/LocationEditor.tsx +++ b/components/book/settings/locations/editor/LocationEditor.tsx @@ -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,17 +23,17 @@ import LocationEditorEdit from './LocationEditorEdit'; */ export default function LocationEditor(): React.JSX.Element { const t = useTranslations(); - const {book} = useContext(BookContext); + const {book}: BookContextProps = useContext(BookContext); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showAddForm, setShowAddForm] = useState(false); - + const config: UseLocationsConfig = useMemo(function (): UseLocationsConfig { return { entityType: 'book', entityId: book?.bookId || '', }; }, [book?.bookId]); - + const { sections, seriesLocations, @@ -66,7 +65,7 @@ export default function LocationEditor(): React.JSX.Element { exitEditMode, backToList, } = useLocations(config); - + const availableSeriesLocations = useMemo(function (): SeriesLocationItem[] { return seriesLocations.filter(function (sl: SeriesLocationItem): boolean { return !sections.some(function (s: LocationProps): boolean { @@ -74,12 +73,12 @@ export default function LocationEditor(): React.JSX.Element { }); }); }, [seriesLocations, sections]); - + // Wrapper pour convertir LocationProps en index const handleSectionClick = useCallback(function (section: LocationProps, index: number): void { enterDetailMode(index); }, [enterDetailMode]); - + // Gestion de l'ajout async function handleAddSection(): Promise { if (newSectionName.trim()) { @@ -89,15 +88,15 @@ export default function LocationEditor(): React.JSX.Element { setShowAddForm(true); } } - + async function handleSave(): Promise { await exitEditMode(true); } - + function handleCancel(): void { exitEditMode(false); } - + async function handleDelete(): Promise { if (selectedSectionIndex >= 0 && sections[selectedSectionIndex]) { await removeSection(sections[selectedSectionIndex].id); @@ -105,18 +104,14 @@ export default function LocationEditor(): React.JSX.Element { backToList(); } } - + if (isLoading) { - return ( -
    - -
    - ); + return ; } - + const selectedSection: LocationProps | undefined = sections[selectedSectionIndex]; const canExport: boolean = Boolean(bookSeriesId && selectedSection && !selectedSection.seriesLocationId); - + return (
    { return exportToSeries(selectedSection!); } : undefined} + onDelete={function (): void { + setShowDeleteConfirm(true); + }} + onExport={canExport ? function (): Promise { + return exportToSeries(selectedSection!); + } : undefined} showExport={canExport} showDelete={Boolean(selectedSection)} /> - +
    {viewMode === 'list' && (
    - {/* Toggle tool */} -
    - - } + {/* Import from series */} + {bookSeriesId && availableSeriesLocations.length > 0 && ( + -
    - - {toolEnabled && ( - <> - {/* Import from series */} - {bookSeriesId && availableSeriesLocations.length > 0 && ( - - )} - - {showAddForm && ( -
    - ): void { - setNewSectionName(e.target.value); - }} - placeholder={t('locationComponent.newSectionPlaceholder')} - /> - } - actionIcon={faPlus} - actionLabel={t('locationComponent.addSectionLabel')} - addButtonCallBack={async function (): Promise { - await addSection(); - setShowAddForm(false); - }} - /> -
    - )} - - - )} + + {showAddForm && ( +
    + ): void { + setNewSectionName(e.target.value); + }} + placeholder={t('locationComponent.newSectionPlaceholder')} + /> + } + actionIcon={Plus} + actionLabel={t('locationComponent.addSectionLabel')} + addButtonCallBack={async function (): Promise { + await addSection(); + setShowAddForm(false); + }} + /> +
    + )} + +
    )} - + {viewMode === 'detail' && selectedSection && (
    )} - + {viewMode === 'edit' && selectedSection && (
    )}
    - + {showDeleteConfirm && selectedSection && ( )}
    diff --git a/components/book/settings/locations/editor/LocationEditorDetail.tsx b/components/book/settings/locations/editor/LocationEditorDetail.tsx index b1701b7..598cf85 100644 --- a/components/book/settings/locations/editor/LocationEditorDetail.tsx +++ b/components/book/settings/locations/editor/LocationEditorDetail.tsx @@ -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; @@ -15,37 +14,39 @@ interface LocationEditorDetailProps { * PAS de CollapsableArea, PAS de grids */ export default function LocationEditorDetail({ - section, -}: LocationEditorDetailProps): React.JSX.Element { + section, + }: LocationEditorDetailProps): React.JSX.Element { const t = useTranslations(); - + return (

    {section.name}

    - + {section.elements.length === 0 ? (

    {t('locationComponent.noElementAvailable')}

    ) : (
    {section.elements.map(function (element: Element): React.JSX.Element { return ( -
    +
    - + {element.name}
    {element.description && (

    {element.description}

    )} - + {element.subElements.length > 0 && (
    {element.subElements.map(function (subElement: SubElement): React.JSX.Element { return (
    - +
    - {subElement.name} + {subElement.name} {subElement.description && (

    {subElement.description}

    )} diff --git a/components/book/settings/locations/editor/LocationEditorEdit.tsx b/components/book/settings/locations/editor/LocationEditorEdit.tsx index c9e5f7f..a0a0e7c 100644 --- a/components/book/settings/locations/editor/LocationEditorEdit.tsx +++ b/components/book/settings/locations/editor/LocationEditorEdit.tsx @@ -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; @@ -28,33 +27,33 @@ interface LocationEditorEditProps { * PAS de CollapsableArea, PAS de grids */ export default function LocationEditorEdit({ - section, - newElementNames, - newSubElementNames, - onAddElement, - onAddSubElement, - onRemoveElement, - onRemoveSubElement, - onUpdateElement, - onUpdateSubElement, - onNewElementNameChange, - onNewSubElementNameChange, -}: LocationEditorEditProps): React.JSX.Element { + section, + newElementNames, + newSubElementNames, + onAddElement, + onAddSubElement, + onRemoveElement, + onRemoveSubElement, + onUpdateElement, + onUpdateSubElement, + onNewElementNameChange, + onNewSubElementNameChange, + }: LocationEditorEditProps): React.JSX.Element { const t = useTranslations(); - + return (

    {section.name}

    - + {/* Éléments existants */} {section.elements.map(function (element: Element, elementIndex: number): React.JSX.Element { return ( -
    +
    - + {t('locationComponent.element')}
    - + - +
    - ): void { onUpdateElement(section.id, elementIndex, 'description', e.target.value); @@ -79,13 +78,13 @@ export default function LocationEditorEdit({ placeholder={t('locationComponent.elementDescriptionPlaceholder')} />
    - + {/* Sous-éléments */} {element.subElements.length > 0 && ( -
    +
    {element.subElements.map(function (subElement: SubElement, subElementIndex: number): React.JSX.Element { return ( -
    +
    )} - + {/* Ajouter sous-élément */}
    } - actionIcon={faPlus} + actionIcon={Plus} actionLabel={t('locationComponent.addSubElement')} addButtonCallBack={function (): Promise { return onAddSubElement(section.id, elementIndex); @@ -128,7 +127,7 @@ export default function LocationEditorEdit({
    ); })} - + {/* Ajouter élément */} } - actionIcon={faPlus} + actionIcon={Plus} addButtonCallBack={function (): Promise { return onAddElement(section.id); }} diff --git a/components/book/settings/locations/editor/LocationEditorList.tsx b/components/book/settings/locations/editor/LocationEditorList.tsx index 88d2f80..a89e88c 100644 --- a/components/book/settings/locations/editor/LocationEditorList.tsx +++ b/components/book/settings/locations/editor/LocationEditorList.tsx @@ -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[]; @@ -19,19 +21,19 @@ interface LocationEditorListProps { * PAS de scroll interne (géré par parent ComposerRightBar) */ export default function LocationEditorList({ - sections, - onSectionClick, - onAddSection, -}: LocationEditorListProps): React.JSX.Element { + sections, + onSectionClick, + onAddSection, + }: LocationEditorListProps): React.JSX.Element { const t = useTranslations(); const [searchQuery, setSearchQuery] = useState(''); - + function getFilteredSections(): LocationProps[] { return sections.filter(function (section: LocationProps): boolean { return section.name.toLowerCase().includes(searchQuery.toLowerCase()); }); } - + function countTotalElements(section: LocationProps): number { let count: number = section.elements.length; section.elements.forEach(function (element: Element): void { @@ -39,9 +41,9 @@ export default function LocationEditorList({ }); return count; } - + const filteredSections: LocationProps[] = getFilteredSections(); - + return (
    @@ -55,55 +57,31 @@ export default function LocationEditorList({ placeholder={t('locationComponent.search')} /> } - actionIcon={faPlus} + actionIcon={Plus} actionLabel={t('locationComponent.addSectionLabel')} addButtonCallBack={async function (): Promise { onAddSection(); }} />
    - +
    {filteredSections.length === 0 ? ( -
    -
    - -
    -

    - {t('locationComponent.noSectionAvailable')} -

    -

    - {t('locationComponent.noSectionDescription')} -

    -
    + ) : ( filteredSections.map(function (section: LocationProps, index: number): React.JSX.Element { return ( -
    -
    - -
    - -
    -
    - {section.name} -
    -
    - {t('locationComponent.elementsCount', {count: countTotalElements(section)})} -
    -
    - -
    - -
    -
    + size="sm" + onClick={function (): void { + onSectionClick(section, index); + }} + avatar={} + title={section.name} + subtitle={t('locationComponent.elementsCount', {count: countTotalElements(section)})} + /> ); }) )} diff --git a/components/book/settings/locations/settings/LocationSettings.tsx b/components/book/settings/locations/settings/LocationSettings.tsx index dcddaf4..4d68c54 100644 --- a/components/book/settings/locations/settings/LocationSettings.tsx +++ b/components/book/settings/locations/settings/LocationSettings.tsx @@ -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; } /** @@ -28,23 +25,23 @@ interface LocationSettingsProps { * Inclut: toggle tool, import from series, export to series */ export default function LocationSettings({ - entityType = 'book', - entityId, - showToggle = true, -}: LocationSettingsProps): React.JSX.Element { + entityType = 'book', + entityId, + toolEnabled: parentToolEnabled, + }: LocationSettingsProps): React.JSX.Element { const t = useTranslations(); - const {book} = useContext(BookContext); + const {book}: BookContextProps = useContext(BookContext); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); - + const resolvedEntityId: string = entityId || book?.bookId || ''; - + const config: UseLocationsConfig = useMemo(function (): UseLocationsConfig { return { entityType, entityId: resolvedEntityId, }; }, [entityType, resolvedEntityId]); - + const { sections, seriesLocations, @@ -77,7 +74,7 @@ export default function LocationSettings({ exitEditMode, backToList, } = useLocations(config); - + const availableSeriesLocations = useMemo(function (): SeriesLocationItem[] { return seriesLocations.filter(function (sl: SeriesLocationItem): boolean { return !sections.some(function (s: LocationProps): boolean { @@ -85,15 +82,15 @@ export default function LocationSettings({ }); }); }, [seriesLocations, sections]); - + async function handleSave(): Promise { await exitEditMode(true); } - + function handleCancel(): void { exitEditMode(false); } - + async function handleDelete(): Promise { if (selectedSectionIndex >= 0 && sections[selectedSectionIndex]) { await removeSection(sections[selectedSectionIndex].id); @@ -101,18 +98,14 @@ export default function LocationSettings({ backToList(); } } - + if (isLoading) { - return ( -
    - -
    - ); + return ; } - + const selectedSection: LocationProps | undefined = sections[selectedSectionIndex]; const canExport: boolean = Boolean(bookSeriesId && selectedSection && !selectedSection.seriesLocationId); - + return (
    {/* Header - uniquement pour detail/edit */} @@ -125,37 +118,21 @@ export default function LocationSettings({ onEdit={enterEditMode} onSave={handleSave} onCancel={handleCancel} - onDelete={function (): void { setShowDeleteConfirm(true); }} - onExport={canExport ? function (): Promise { return exportToSeries(selectedSection!); } : undefined} + onDelete={function (): void { + setShowDeleteConfirm(true); + }} + onExport={canExport ? function (): Promise { + return exportToSeries(selectedSection!); + } : undefined} showExport={canExport} showDelete={Boolean(selectedSection)} /> - + {/* Contenu principal */}
    {viewMode === 'list' && (
    - {/* Toggle tool */} - {showToggle && !isSeriesMode && ( -
    - - } - /> -

    - {t('locationComponent.enableToolDescription')} -

    -
    - )} - - {/* Contenu si outil activé */} - {(toolEnabled || isSeriesMode) && ( + {((parentToolEnabled !== undefined ? parentToolEnabled : toolEnabled) || isSeriesMode) && ( <> {/* Import from series */} {!isSeriesMode && bookSeriesId && availableSeriesLocations.length > 0 && ( @@ -168,7 +145,7 @@ export default function LocationSettings({ label={t("seriesImport.importFromSeries")} /> )} - + {/* Liste des sections */} )} - + {viewMode === 'detail' && selectedSection && (
    )} - + {viewMode === 'edit' && selectedSection && (
    )}
    - + {/* Modal de confirmation de suppression */} {showDeleteConfirm && selectedSection && ( )}
    diff --git a/components/book/settings/locations/settings/LocationSettingsDetail.tsx b/components/book/settings/locations/settings/LocationSettingsDetail.tsx index 6af7b12..6c22d75 100644 --- a/components/book/settings/locations/settings/LocationSettingsDetail.tsx +++ b/components/book/settings/locations/settings/LocationSettingsDetail.tsx @@ -1,72 +1,69 @@ '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; } export default function LocationSettingsDetail({ - section, -}: LocationSettingsDetailProps): React.JSX.Element { + section, + }: LocationSettingsDetailProps): React.JSX.Element { const t = useTranslations(); - + return (
    {/* Hero Section */} -
    -
    -
    - -
    -
    -

    {section.name}

    -

    - {t("locationComponent.elementsCount", {count: section.elements.length})} -

    -
    -
    -
    - + +

    + {t("locationComponent.elementsCount", {count: section.elements.length})} +

    +
    + {/* Éléments en grille */} {section.elements.length === 0 ? ( -
    - +
    +

    {t("locationComponent.noElementAvailable")}

    ) : (
    {section.elements.map(function (element: Element): React.JSX.Element { return ( -
    +
    {/* Element header */}
    -
    - +
    +

    {element.name}

    - + {/* Description */} -

    +

    {element.description || '—'}

    - + {/* Sub-elements */} {element.subElements.length > 0 && ( -
    +

    - + {t("locationComponent.subElementsHeading")} ({element.subElements.length})

    {element.subElements.map(function (subElement: SubElement): React.JSX.Element { return ( -
    - +
    +

    {subElement.name}

    {subElement.description && ( diff --git a/components/book/settings/locations/settings/LocationSettingsEdit.tsx b/components/book/settings/locations/settings/LocationSettingsEdit.tsx index 5d56d1c..d8faf6e 100644 --- a/components/book/settings/locations/settings/LocationSettingsEdit.tsx +++ b/components/book/settings/locations/settings/LocationSettingsEdit.tsx @@ -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; @@ -28,36 +28,34 @@ interface LocationSettingsEditProps { * PAS de scroll interne (géré par parent) */ export default function LocationSettingsEdit({ - section, - newElementNames, - newSubElementNames, - onAddElement, - onAddSubElement, - onRemoveElement, - onRemoveSubElement, - onUpdateElement, - onUpdateSubElement, - onNewElementNameChange, - onNewSubElementNameChange, -}: LocationSettingsEditProps): React.JSX.Element { + section, + newElementNames, + newSubElementNames, + onAddElement, + onAddSubElement, + onRemoveElement, + onRemoveSubElement, + onUpdateElement, + onUpdateSubElement, + onNewElementNameChange, + onNewSubElementNameChange, + }: LocationSettingsEditProps): React.JSX.Element { const t = useTranslations(); - + return (
    {/* Header de la section */}
    -
    - -
    +

    {section.name}

    - + {/* Éléments existants */} {section.elements.map(function (element: Element, elementIndex: number): React.JSX.Element { return ( -
    +
    - - ): void { onUpdateElement(section.id, elementIndex, 'description', e.target.value); }} placeholder={t("locationComponent.elementDescriptionPlaceholder")} /> - + {/* Sous-éléments */} -
    +
    {element.subElements.length > 0 && (

    {t("locationComponent.subElementsHeading")}

    )} - + {element.subElements.map(function (subElement: SubElement, subElementIndex: number): React.JSX.Element { return ( -
    +
    - ): void { onUpdateSubElement(section.id, elementIndex, subElementIndex, 'description', e.target.value); @@ -121,7 +119,7 @@ export default function LocationSettingsEdit({
    ); })} - + {/* Ajouter sous-élément */} } - actionIcon={faPlus} + actionIcon={Plus} actionLabel={t("locationComponent.addSubElement")} addButtonCallBack={function (): Promise { return onAddSubElement(section.id, elementIndex); @@ -143,9 +141,9 @@ export default function LocationSettingsEdit({
    ); })} - + {/* Ajouter élément */} -
    +
    } - actionIcon={faPlus} + actionIcon={Plus} actionLabel={t("locationComponent.addElement")} addButtonCallBack={function (): Promise { return onAddElement(section.id); diff --git a/components/book/settings/locations/settings/LocationSettingsList.tsx b/components/book/settings/locations/settings/LocationSettingsList.tsx index 9bed495..0298f76 100644 --- a/components/book/settings/locations/settings/LocationSettingsList.tsx +++ b/components/book/settings/locations/settings/LocationSettingsList.tsx @@ -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[]; @@ -21,14 +23,14 @@ interface LocationSettingsListProps { * PAS de scroll interne (géré par parent) */ export default function LocationSettingsList({ - sections, - newSectionName, - onSectionClick, - onAddSection, - onNewSectionNameChange, -}: LocationSettingsListProps): React.JSX.Element { + sections, + newSectionName, + onSectionClick, + onAddSection, + onNewSectionNameChange, + }: LocationSettingsListProps): React.JSX.Element { const t = useTranslations(); - + function countTotalElements(section: LocationProps): number { let count: number = section.elements.length; section.elements.forEach(function (element: Element): void { @@ -36,67 +38,40 @@ export default function LocationSettingsList({ }); return count; } - + return (
    -
    - ): void { - onNewSectionNameChange(e.target.value); - }} - placeholder={t("locationComponent.newSectionPlaceholder")} - /> - } - actionIcon={faPlus} - actionLabel={t("locationComponent.addSectionLabel")} - addButtonCallBack={onAddSection} - /> -
    - + ): void { + onNewSectionNameChange(e.target.value); + }} + placeholder={t("locationComponent.newSectionPlaceholder")} + /> + } + actionIcon={Plus} + actionLabel={t("locationComponent.addSectionLabel")} + addButtonCallBack={onAddSection} + /> +
    {sections.length === 0 ? ( -
    -
    - -
    -

    - {t("locationComponent.noSectionAvailable")} -

    -

    - {t("locationComponent.noSectionDescription")} -

    -
    + ) : ( sections.map(function (section: LocationProps, index: number): React.JSX.Element { return ( -
    -
    - -
    - -
    -
    - {section.name} -
    -
    - {t("locationComponent.elementsCount", {count: countTotalElements(section)})} -
    -
    - -
    - -
    -
    + onClick={function (): void { + onSectionClick(index); + }} + avatar={} + title={section.name} + subtitle={t("locationComponent.elementsCount", {count: countTotalElements(section)})} + /> ); }) )} diff --git a/components/book/settings/objects/page.tsx b/components/book/settings/objects/page.tsx index 8877750..04f2022 100644 --- a/components/book/settings/objects/page.tsx +++ b/components/book/settings/objects/page.tsx @@ -1,327 +1,317 @@ -'use client'; -import {useState} from 'react'; -import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; -import {faArrowLeft} from '@fortawesome/free-solid-svg-icons'; - -interface RelatedItem { - name: string; - type: string; - description: string; - history: string; -} - -interface Item { - id: number | null; - name: string; - description: string; - history: string; - location: string; - ownedBy: string; - functionality: string; - image: string; - relatedItems: RelatedItem[]; -} - -const initialItemState: Item = { - id: null, - name: '', - description: '', - history: '', - location: '', - ownedBy: '', - functionality: '', - image: '', - relatedItems: [], -}; - -export default function Items() { - const [items, setItems] = useState([ - { - id: 1, - name: 'Sword of Destiny', - description: 'A powerful sword', - history: 'Forged in the ancient times...', - location: 'Castle', - ownedBy: 'John Doe', - functionality: 'Cuts through anything', - image: 'https://via.placeholder.com/150', - relatedItems: [] - }, - { - id: 2, - name: 'Shield of Valor', - description: 'An unbreakable shield', - history: 'Used by the legendary hero...', - location: 'Fortress', - ownedBy: 'Jane Doe', - functionality: 'Deflects any attack', - image: 'https://via.placeholder.com/150', - relatedItems: [] - } - ]); - - const [selectedItem, setSelectedItem] = useState(null); - const [searchQuery, setSearchQuery] = useState(''); - const [newItem, setNewItem] = useState(initialItemState); - const [newRelatedItem, setNewRelatedItem] = useState({ - name: '', - type: '', - description: '', - history: '' - }); - - const filteredItems = items.filter( - (item) => - item.name.toLowerCase().includes(searchQuery.toLowerCase()) - ); - - const handleItemClick = (item: Item) => { - setSelectedItem(item); - }; - - const handleAddItem = () => { - setSelectedItem(newItem); - }; - - const handleSaveItem = () => { - if (selectedItem) { - if (selectedItem.id === null) { - setItems([...items, {...selectedItem, id: items.length + 1}]); - } else { - setItems(items.map((item) => (item.id === selectedItem.id ? selectedItem : item))); - } - setSelectedItem(null); - setNewItem(initialItemState); - } - }; - - const handleItemChange = (key: keyof Item, value: string) => { - if (selectedItem) { - setSelectedItem({...selectedItem, [key]: value}); - } - }; - - const handleElementChange = (section: keyof Item, index: number, key: keyof RelatedItem, value: string) => { - if (selectedItem) { - const updatedSection = [...(selectedItem[section] as RelatedItem[])]; - updatedSection[index][key] = value; - setSelectedItem({...selectedItem, [section]: updatedSection}); - } - }; - - const handleAddElement = (section: keyof Item, value: RelatedItem) => { - if (selectedItem) { - const updatedSection = [...(selectedItem[section] as RelatedItem[]), value]; - setSelectedItem({...selectedItem, [section]: updatedSection}); - } - }; - - const handleRemoveElement = (section: keyof Item, index: number) => { - if (selectedItem) { - const updatedSection = (selectedItem[section] as RelatedItem[]).filter((_, i) => i !== index); - setSelectedItem({...selectedItem, [section]: updatedSection}); - } - }; - - return ( -
    - {selectedItem ? ( -
    -
    - -

    {selectedItem.name}

    - -
    -
    -
    - - handleItemChange('name', e.target.value)} - className="w-full px-4 py-2.5 rounded-xl bg-secondary/50 text-text-primary border border-secondary/50 outline-none hover:bg-secondary hover:border-secondary focus:border-primary focus:ring-4 focus:ring-primary/20 transition-all duration-200" - /> -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - handleItemChange('image', e.target.value)} - className="w-full px-4 py-2.5 rounded-xl bg-secondary/50 text-text-primary border border-secondary/50 outline-none hover:bg-secondary hover:border-secondary focus:border-primary focus:ring-4 focus:ring-primary/20 transition-all duration-200" - /> -
    -
    -
    -

    Related Items

    -
    - {selectedItem.relatedItems.map((relatedItem, index) => ( -
    - {relatedItem.name} -
    - - - - - -
    -
    - ))} -
    - - - -
    -
    -
    -
    - ) : ( -
    -
    - setSearchQuery(e.target.value)} - className="w-full px-4 py-2.5 rounded-xl bg-secondary/50 text-text-primary border border-secondary/50 outline-none hover:bg-secondary hover:border-secondary focus:border-primary focus:ring-4 focus:ring-primary/20 transition-all duration-200" - placeholder="Search Items" - /> - -
    -
    -

    Items

    -
    - {filteredItems.map((item) => ( -
    handleItemClick(item)} - className="cursor-pointer bg-tertiary/90 backdrop-blur-sm p-4 rounded-xl shadow-lg hover:shadow-xl hover:scale-105 transition-all duration-200 border border-secondary/50"> - {item.name} -

    {item.name}

    -

    {item.description}

    -
    - ))} -
    -
    -
    - )} -
    - ); -} +'use client'; +import {useState} from 'react'; +import {ArrowLeft, Plus, Trash2} from 'lucide-react'; +import Button from '@/components/ui/Button'; + +interface RelatedItem { + name: string; + type: string; + description: string; + history: string; +} + +interface Item { + id: number | null; + name: string; + description: string; + history: string; + location: string; + ownedBy: string; + functionality: string; + image: string; + relatedItems: RelatedItem[]; +} + +const initialItemState: Item = { + id: null, + name: '', + description: '', + history: '', + location: '', + ownedBy: '', + functionality: '', + image: '', + relatedItems: [], +}; + +export default function Items() { + const [items, setItems] = useState([ + { + id: 1, + name: 'Sword of Destiny', + description: 'A powerful sword', + history: 'Forged in the ancient times...', + location: 'Castle', + ownedBy: 'John Doe', + functionality: 'Cuts through anything', + image: 'https://via.placeholder.com/150', + relatedItems: [] + }, + { + id: 2, + name: 'Shield of Valor', + description: 'An unbreakable shield', + history: 'Used by the legendary hero...', + location: 'Fortress', + ownedBy: 'Jane Doe', + functionality: 'Deflects any attack', + image: 'https://via.placeholder.com/150', + relatedItems: [] + } + ]); + + const [selectedItem, setSelectedItem] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [newItem, setNewItem] = useState(initialItemState); + const [newRelatedItem, setNewRelatedItem] = useState({ + name: '', + type: '', + description: '', + history: '' + }); + + const filteredItems = items.filter( + (item) => + item.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + const handleItemClick = (item: Item) => { + setSelectedItem(item); + }; + + const handleAddItem = () => { + setSelectedItem(newItem); + }; + + const handleSaveItem = () => { + if (selectedItem) { + if (selectedItem.id === null) { + setItems([...items, {...selectedItem, id: items.length + 1}]); + } else { + setItems(items.map((item) => (item.id === selectedItem.id ? selectedItem : item))); + } + setSelectedItem(null); + setNewItem(initialItemState); + } + }; + + const handleItemChange = (key: keyof Item, value: string) => { + if (selectedItem) { + setSelectedItem({...selectedItem, [key]: value}); + } + }; + + const handleElementChange = (section: keyof Item, index: number, key: keyof RelatedItem, value: string) => { + if (selectedItem) { + const updatedSection = [...(selectedItem[section] as RelatedItem[])]; + updatedSection[index][key] = value; + setSelectedItem({...selectedItem, [section]: updatedSection}); + } + }; + + const handleAddElement = (section: keyof Item, value: RelatedItem) => { + if (selectedItem) { + const updatedSection = [...(selectedItem[section] as RelatedItem[]), value]; + setSelectedItem({...selectedItem, [section]: updatedSection}); + } + }; + + const handleRemoveElement = (section: keyof Item, index: number) => { + if (selectedItem) { + const updatedSection = (selectedItem[section] as RelatedItem[]).filter((_, i) => i !== index); + setSelectedItem({...selectedItem, [section]: updatedSection}); + } + }; + + return ( +
    + {selectedItem ? ( +
    +
    + +

    {selectedItem.name}

    + +
    +
    +
    + + handleItemChange('name', e.target.value)} + className="input-base" + /> +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + handleItemChange('image', e.target.value)} + className="input-base" + /> +
    +
    +
    +

    Related Items

    +
    + {selectedItem.relatedItems.map((relatedItem, index) => ( +
    + {relatedItem.name} +
    + + + + +
    + +
    +
    +
    + ))} +
    + + + +
    +
    +
    +
    + ) : ( +
    +
    + setSearchQuery(e.target.value)} + className="input-base" + placeholder="Search Items" + /> +
    + +
    +
    +
    +

    Items

    +
    + {filteredItems.map((item) => ( +
    handleItemClick(item)} + className="cursor-pointer bg-tertiary/90 backdrop-blur-sm p-4 rounded-xl shadow-lg hover:shadow-xl hover:scale-105 transition-all duration-200 border border-secondary/50"> + {item.name} +

    {item.name}

    +

    {item.description}

    +
    + ))} +
    +
    +
    + )} +
    + ); +} diff --git a/components/book/settings/quillsense/QuillSenseSetting.tsx b/components/book/settings/quillsense/QuillSenseSetting.tsx index 607bcf0..4c91f3f 100644 --- a/components/book/settings/quillsense/QuillSenseSetting.tsx +++ b/components/book/settings/quillsense/QuillSenseSetting.tsx @@ -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(function QuillSenseSetting({toolEnabled}: QuillSenseSettingProps, ref: React.ForwardedRef): 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 [quillsenseEnabled, setQuillsenseEnabled] = useState(true); - const [advancedPrompt, setAdvancedPrompt] = useState(''); + const {book}: BookContextProps = useContext(BookContext); + const {session}: SessionContextProps = useContext(SessionContext); + const {errorMessage, successMessage}: AlertContextProps = useContext(AlertContext); + const {lang}: LangContextProps = useContext(LangContext); + const bookId: string = book?.bookId ?? ''; + const userToken: string = session?.accessToken ?? ''; + const authorLevel: string = session.user?.writingLevel?.toString() ?? '1'; + + const [activeTab, setActiveTab] = useState('ghostwriter'); const [isLoading, setIsLoading] = useState(true); - - useImperativeHandle(ref, () => ({ - handleSave + + // GhostWriter state + const [advancedPrompt, setAdvancedPrompt] = useState(''); + + // Guideline AI state + const [plotSummary, setPlotSummary] = useState(''); + const [narrativeType, setNarrativeType] = useState(''); + const [verbTense, setVerbTense] = useState(''); + const [dialogueType, setDialogueType] = useState(''); + const [toneAtmosphere, setToneAtmosphere] = useState(''); + const [language, setLanguage] = useState(''); + const [themes, setThemes] = useState(''); + + useImperativeHandle(ref, (): SettingRef => ({ + handleSave: activeTab === 'ghostwriter' ? handleSaveGhostWriter : handleSaveGuideline })); useEffect((): void => { @@ -32,97 +65,222 @@ const QuillSenseSetting = forwardRef(function QuillSenseSetting(props, ref) { fetchQuillSenseSettings(); } }, [book?.bookId]); + + useEffect((): void => { + if (activeTab === 'guideline' && !isLoading) { + fetchAIGuideline(); + } + }, [activeTab]); async function fetchQuillSenseSettings(): Promise { try { setIsLoading(true); - const settings: QuillSenseSettingsProps = await System.authGetQueryToServer( + const settings: QuillSenseSettingsProps = await apiGet( '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 { + + async function fetchAIGuideline(): Promise { try { - const updateResult: boolean = await System.authPutToServer( + const response: GuideLineAI = await apiGet(`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 { + try { + const updateResult: boolean = await apiPut( '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 { + try { + const response: boolean = await apiPost( + '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 ( -
    -
    -
    - ); + return ; + } + + if (!toolEnabled) { + return null; } return ( -
    -
    - setQuillsenseEnabled(checked)} - /> - } - /> -

    - {t('quillSenseSetting.enableDescription')} -

    +
    +
    + +
    - -
    - ): void => setAdvancedPrompt(e.target.value)} - placeholder={t('quillSenseSetting.advancedPromptPlaceholder')} + + {activeTab === 'ghostwriter' && ( +
    + ): void => setAdvancedPrompt(e.target.value)} + placeholder={t('quillsenseSetting.advancedPromptPlaceholder')} + /> + } + /> +

    + {t('quillsenseSetting.advancedPromptDescription')} +

    +
    + )} + + {activeTab === 'guideline' && ( +
    + ): void => setPlotSummary(e.target.value)} + placeholder={t("guideLineSetting.plotSummaryPlaceholder")} /> - } - /> -

    - {t('quillSenseSetting.advancedPromptHint')} -

    -
    + }/> + ): void => setToneAtmosphere(e.target.value)} + placeholder={t("guideLineSetting.toneAtmospherePlaceholder")} + /> + }/> + ): void => setThemes(e.target.value)} + placeholder={t("guideLineSetting.themesPlaceholderQuill")} + /> + }/> + ): void => setVerbTense(event.target.value)} + data={verbalTime} + placeholder={t("guideLineSetting.verbTensePlaceholder")} + /> + }/> + ): void => { + setNarrativeType(event.target.value) + }} placeholder={t("guideLineSetting.narrativeTypePlaceholder")}/> + }/> + ) => { + setDialogueType(event.target.value) + }} placeholder={t("guideLineSetting.dialogueTypePlaceholder")}/> + }/> + ) => { + setLanguage(event.target.value) + }} placeholder={t("guideLineSetting.languagePlaceholder")}/> + }/> +
    + )}
    ); }); diff --git a/components/book/settings/spells/SpellTagChip.tsx b/components/book/settings/spells/SpellTagChip.tsx index b6eba57..b9a4737 100644 --- a/components/book/settings/spells/SpellTagChip.tsx +++ b/components/book/settings/spells/SpellTagChip.tsx @@ -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,34 +29,25 @@ 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' : 'px-3 py-1 text-sm'; - + const chipClasses = ` 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 ( {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" > - + )} diff --git a/components/book/settings/spells/SpellTagManager.tsx b/components/book/settings/spells/SpellTagManager.tsx index ff576a3..8c4ca97 100644 --- a/components/book/settings/spells/SpellTagManager.tsx +++ b/components/book/settings/spells/SpellTagManager.tsx @@ -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; onUpdateTag: (tagId: string, name: string, color: string) => Promise; onDeleteTag: (tagId: string) => Promise; @@ -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(AlertContext); const [newTagName, setNewTagName] = useState(''); const [newTagColor, setNewTagColor] = useState(defaultTagColors[0]); @@ -87,25 +89,9 @@ export default function SpellTagManager( } return ( -
    -
    - - - - {t("spellTagManager.title")} - -
    -
    - -
    -
    +
    +
    +

    {t("spellTagManager.addTag")}

    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)}`} /> ))}
    - +
    {tags.length === 0 ? (
    -
    - -
    +

    {t("spellTagManager.noTags")}

    ) : ( @@ -156,22 +135,14 @@ export default function SpellTagManager( {tags.map((tag: SpellTagProps) => (
    - - + handleEditClick(tag)}/> + handleDeleteClick(tag)}/>
    ))} @@ -183,7 +154,7 @@ export default function SpellTagManager( {showEditModal && editingTag && ( setShowEditModal(false)} onConfirm={handleUpdateTag} confirmText={t("common.confirm")} @@ -208,8 +179,7 @@ export default function SpellTagManager(
    @@ -231,7 +201,7 @@ export default function SpellTagManager( {showDeleteConfirm && tagToDelete && ( setShowDeleteConfirm(false)} onConfirm={handleDeleteTag} confirmText={t("spellTagManager.delete")} diff --git a/components/book/settings/spells/editor/SpellEditor.tsx b/components/book/settings/spells/editor/SpellEditor.tsx index 0d05e9e..f090af9 100644 --- a/components/book/settings/spells/editor/SpellEditor.tsx +++ b/components/book/settings/spells/editor/SpellEditor.tsx @@ -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,16 +22,16 @@ import SpellEditorEdit from './SpellEditorEdit'; */ export default function SpellEditor(): React.JSX.Element { const t = useTranslations(); - const {book} = useContext(BookContext); + const {book}: BookContextProps = useContext(BookContext); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); - + const config: UseSpellsConfig = useMemo(function (): UseSpellsConfig { return { entityType: 'book', entityId: book?.bookId || '', }; }, [book?.bookId]); - + const { spells, seriesSpells, @@ -64,7 +61,7 @@ export default function SpellEditor(): React.JSX.Element { deleteTag, handleSyncComplete, } = useSpells(config); - + const availableSeriesSpells = useMemo(function (): SeriesSpellListItem[] { return seriesSpells.filter(function (ss: SeriesSpellListItem): boolean { return !spells.some(function (s: SpellListItem): boolean { @@ -72,19 +69,19 @@ export default function SpellEditor(): React.JSX.Element { }); }); }, [seriesSpells, spells]); - + const handleSpellChange = useCallback(function (key: keyof SpellEditState, value: string | string[] | null): void { updateSpellField(key, value); }, [updateSpellField]); - + async function handleSave(): Promise { await exitEditMode(true); } - + function handleCancel(): void { exitEditMode(false); } - + async function handleDelete(): Promise { if (selectedSpell?.id) { await deleteSpell(selectedSpell.id); @@ -92,82 +89,75 @@ export default function SpellEditor(): React.JSX.Element { backToList(); } } - + if (isLoading) { - return ( -
    - -
    - ); + return ; } - + const isNew: boolean = selectedSpell?.id === null; const canExport: boolean = Boolean(bookSeriesId && selectedSpell?.id && !selectedSpell.seriesSpellId); - + return (
    - +
    - {viewMode === 'list' && ( + {showTagManager && ( + + )} + + {!showTagManager && viewMode === 'list' && (
    - {/* Toggle tool */} -
    - - } + {/* Import from series */} + {bookSeriesId && availableSeriesSpells.length > 0 && ( + -
    - - {toolEnabled && ( - <> - {/* Import from series */} - {bookSeriesId && availableSeriesSpells.length > 0 && ( - - )} - - - )} + +
    )} - - {viewMode === 'detail' && selectedSpell && ( + + {!showTagManager && viewMode === 'detail' && selectedSpell && (
    )} - - {viewMode === 'edit' && selectedSpell && ( + + {!showTagManager && viewMode === 'edit' && selectedSpell && (
    )}
    - + {showDeleteConfirm && selectedSpell?.id && ( - )} - - {showTagManager && ( - )} +
    ); } diff --git a/components/book/settings/spells/editor/SpellEditorDetail.tsx b/components/book/settings/spells/editor/SpellEditorDetail.tsx index bc930b5..cac2656 100644 --- a/components/book/settings/spells/editor/SpellEditorDetail.tsx +++ b/components/book/settings/spells/editor/SpellEditorDetail.tsx @@ -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; @@ -18,18 +20,18 @@ interface SpellEditorDetailProps { * PAS de CollapsableArea, PAS de grids */ export default function SpellEditorDetail({ - spell, - availableTags, - seriesSpell, -}: SpellEditorDetailProps): React.JSX.Element { + spell, + availableTags, + seriesSpell, + }: SpellEditorDetailProps): React.JSX.Element { const t = useTranslations(); - + function getSelectedTags(): SpellTagProps[] { return availableTags.filter(function (tag: SpellTagProps): boolean { return spell.tags.includes(tag.id); }); } - + function getLocalizedPowerLevel(): string { if (!spell.powerLevel || spell.powerLevel === 'none') { return ''; @@ -39,31 +41,22 @@ 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 ( -
    - {label} -

    {value}

    -
    - ); - } - + const selectedTags: SpellTagProps[] = getSelectedTags(); const powerLevelText: string = getLocalizedPowerLevel(); return (

    {spell.name}

    - - {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)} - + + + + {powerLevelText && + } + + + + {selectedTags.length > 0 && (
    {t('spellDetail.tags')} diff --git a/components/book/settings/spells/editor/SpellEditorEdit.tsx b/components/book/settings/spells/editor/SpellEditorEdit.tsx index ad4d0ee..9fdbb27 100644 --- a/components/book/settings/spells/editor/SpellEditorEdit.tsx +++ b/components/book/settings/spells/editor/SpellEditorEdit.tsx @@ -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; @@ -28,32 +29,32 @@ interface SpellEditorEditProps { * Gestion des tags, SyncFieldWrapper, tous les champs */ export default function SpellEditorEdit({ - spell, - availableTags, - onSpellChange, - onCreateTag, - seriesSpell, - onSyncComplete, -}: SpellEditorEditProps): React.JSX.Element { + spell, + availableTags, + onSpellChange, + onCreateTag, + seriesSpell, + onSyncComplete, + }: SpellEditorEditProps): React.JSX.Element { const t = useTranslations(); - + const [tagSearchQuery, setTagSearchQuery] = useState(''); const [isCreatingTag, setIsCreatingTag] = useState(false); const [newTagColor, setNewTagColor] = useState(defaultTagColors[0]); - + function handleAddTag(tagId: string): void { if (!spell.tags.includes(tagId)) { onSpellChange('tags', [...spell.tags, tagId]); } setTagSearchQuery(''); } - + function handleRemoveTag(tagId: string): void { onSpellChange('tags', spell.tags.filter(function (id: string): boolean { return id !== tagId; })); } - + function getFilteredAvailableTags(): SpellTagProps[] { return availableTags.filter(function (tag: SpellTagProps): boolean { const notAlreadyAdded: boolean = !spell.tags.includes(tag.id); @@ -61,13 +62,13 @@ export default function SpellEditorEdit({ return notAlreadyAdded && matchesSearch; }); } - + function getSelectedTags(): SpellTagProps[] { return availableTags.filter(function (tag: SpellTagProps): boolean { return spell.tags.includes(tag.id); }); } - + async function handleCreateTag(): Promise { if (!tagSearchQuery.trim()) return; const newTag: SpellTagProps | null = await onCreateTag(tagSearchQuery.trim(), newTagColor); @@ -77,7 +78,7 @@ export default function SpellEditorEdit({ setNewTagColor(defaultTagColors[0]); } } - + function getLocalizedPowerLevels(): SelectBoxProps[] { return spellPowerLevels.map(function (level: SelectBoxProps): SelectBoxProps { return { @@ -86,7 +87,7 @@ export default function SpellEditorEdit({ }; }); } - + const filteredTags: SpellTagProps[] = getFilteredAvailableTags(); const selectedTags: SpellTagProps[] = getSelectedTags(); const showCreateOption: boolean = Boolean( @@ -95,11 +96,11 @@ export default function SpellEditorEdit({ return tag.name.toLowerCase() === tagSearchQuery.toLowerCase(); }) ); - + return (
    {/* Informations de base */} -
    +

    {t('spellDetail.basicInfo')}

    } /> - + - ): void { onSpellChange('description', e.target.value); @@ -149,7 +154,7 @@ export default function SpellEditorEdit({ } /> - + - ): void { onSpellChange('appearance', e.target.value); @@ -175,9 +182,9 @@ export default function SpellEditorEdit({ />
    - + {/* Tags */} -
    +

    {t('spellDetail.tags')}

    {selectedTags.length > 0 && ( @@ -187,13 +194,15 @@ export default function SpellEditorEdit({ ); })}
    )} - + ): void { @@ -201,43 +210,42 @@ export default function SpellEditorEdit({ }} placeholder={t('spellDetail.addTag')} /> - + {filteredTags.length > 0 && (
    {filteredTags.map(function (tag: SpellTagProps): React.JSX.Element { return ( ); })}
    )} - + {showCreateOption && !isCreatingTag && ( )} - + {isCreatingTag && ( -
    +

    {t('spellDetail.createTag', {name: tagSearchQuery})}

    @@ -246,34 +254,31 @@ export default function SpellEditorEdit({ return (
    - - + +
    )}
    - + {/* Niveau de puissance */} -
    +

    {t('spellDetail.powerLevel')}

    - + {/* Composants */} -
    +

    {t('spellDetail.components')}

    - ): void { onSpellChange('components', e.target.value || null); @@ -317,9 +326,9 @@ export default function SpellEditorEdit({ />
    - + {/* Limitations */} -
    +

    {t('spellDetail.limitations')}

    - ): void { onSpellChange('limitations', e.target.value || null); @@ -340,7 +351,7 @@ export default function SpellEditorEdit({ />
    - + {/* Notes */}

    {t('spellDetail.notes')}

    @@ -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} > - ): void { onSpellChange('notes', e.target.value || null); diff --git a/components/book/settings/spells/editor/SpellEditorList.tsx b/components/book/settings/spells/editor/SpellEditorList.tsx index 56ba6bf..681f9c3 100644 --- a/components/book/settings/spells/editor/SpellEditorList.tsx +++ b/components/book/settings/spells/editor/SpellEditorList.tsx @@ -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[]; @@ -21,16 +24,16 @@ interface SpellEditorListProps { * Mêmes fonctionnalités que SpellSettingsList, layout condensé */ export default function SpellEditorList({ - spells, - tags, - onSpellClick, - onAddSpell, - onManageTags, -}: SpellEditorListProps): React.JSX.Element { + spells, + tags, + onSpellClick, + onAddSpell, + onManageTags, + }: SpellEditorListProps): React.JSX.Element { const t = useTranslations(); const [searchQuery, setSearchQuery] = useState(''); const [selectedTagId, setSelectedTagId] = useState(null); - + function getFilteredSpells(): SpellListItem[] { return spells.filter(function (spell: SpellListItem): boolean { const matchesSearch: boolean = spell.name.toLowerCase().includes(searchQuery.toLowerCase()); @@ -40,9 +43,9 @@ export default function SpellEditorList({ return matchesSearch && matchesTag; }); } - + const filteredSpells: SpellListItem[] = getFilteredSpells(); - + return (
    @@ -56,22 +59,24 @@ export default function SpellEditorList({ placeholder={t('spellList.search')} /> } - actionIcon={faPlus} + actionIcon={Plus} actionLabel={t('spellList.add')} addButtonCallBack={async function (): Promise { onAddSpell(); }} />
    - + {/* Tag filter + manage button */}
    @@ -94,19 +100,17 @@ export default function SpellEditorList({ })}
    - +
    {filteredSpells.length === 0 ? (
    -
    - -
    +

    {t('spellList.noSpells')}

    @@ -117,43 +121,29 @@ export default function SpellEditorList({ ) : ( filteredSpells.map(function (spell: SpellListItem): React.JSX.Element { return ( -
    -
    - -
    - -
    -
    - {spell.name} + size="sm" + onClick={function (): void { + onSpellClick(spell); + }} + avatar={} + title={spell.name} + subtitle={spell.description} + extra={spell.tags.length > 0 ? ( +
    + {spell.tags.slice(0, 2).map(function (tag: SpellTagProps): React.JSX.Element { + return ; + })} + {spell.tags.length > 2 && ( + + +{spell.tags.length - 2} + + )}
    -
    - {spell.description} -
    - {spell.tags.length > 0 && ( -
    - {spell.tags.slice(0, 2).map(function (tag: SpellTagProps): React.JSX.Element { - return ; - })} - {spell.tags.length > 2 && ( - - +{spell.tags.length - 2} - - )} -
    - )} -
    - -
    - -
    -
    + ) : undefined} + /> ); }) )} diff --git a/components/book/settings/spells/settings/SpellSettings.tsx b/components/book/settings/spells/settings/SpellSettings.tsx index d0c7203..b9a8c28 100644 --- a/components/book/settings/spells/settings/SpellSettings.tsx +++ b/components/book/settings/spells/settings/SpellSettings.tsx @@ -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; } /** @@ -31,23 +28,23 @@ interface SpellSettingsProps { * Inclut: toggle tool, import from series, tag manager */ export default function SpellSettings({ - entityType = 'book', - entityId, - showToggle = true, -}: SpellSettingsProps): React.JSX.Element { + entityType = 'book', + entityId, + toolEnabled: parentToolEnabled, + }: SpellSettingsProps): React.JSX.Element { const t = useTranslations(); - const {book} = useContext(BookContext); + const {book}: BookContextProps = useContext(BookContext); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); - + const resolvedEntityId: string = entityId || book?.bookId || ''; - + const config: UseSpellsConfig = useMemo(function (): UseSpellsConfig { return { entityType, entityId: resolvedEntityId, }; }, [entityType, resolvedEntityId]); - + const { spells, seriesSpells, @@ -79,7 +76,7 @@ export default function SpellSettings({ deleteTag, handleSyncComplete, } = useSpells(config); - + const availableSeriesSpells = useMemo(function (): SeriesSpellListItem[] { return seriesSpells.filter(function (ss: SeriesSpellListItem): boolean { return !spells.some(function (s: SpellListItem): boolean { @@ -87,19 +84,19 @@ export default function SpellSettings({ }); }); }, [seriesSpells, spells]); - + const handleSpellChange = useCallback(function (key: keyof SpellEditState, value: string | string[] | null): void { updateSpellField(key, value); }, [updateSpellField]); - + async function handleSave(): Promise { await exitEditMode(true); } - + function handleCancel(): void { exitEditMode(false); } - + async function handleDelete(): Promise { if (selectedSpell?.id) { await deleteSpell(selectedSpell.id); @@ -107,61 +104,50 @@ export default function SpellSettings({ backToList(); } } - + if (isLoading) { - return ( -
    - -
    - ); + return ; } - + const isNew: boolean = selectedSpell?.id === null; const canExport: boolean = Boolean(bookSeriesId && selectedSpell?.id && !selectedSpell.seriesSpellId); - + return (
    - {/* Header - uniquement pour detail/edit */} + {/* Header */} - + {/* Contenu principal */}
    - {viewMode === 'list' && ( + {showTagManager && ( + + )} + + {!showTagManager && viewMode === 'list' && (
    - {/* Toggle tool */} - {showToggle && !isSeriesMode && ( -
    - - } - /> -

    - {t('spellComponent.enableToolDescription')} -

    -
    - )} - - {/* Contenu si outil activé */} - {(toolEnabled || isSeriesMode) && ( + {((parentToolEnabled !== undefined ? parentToolEnabled : toolEnabled) || isSeriesMode) && ( <> {/* Import from series */} {!isSeriesMode && bookSeriesId && availableSeriesSpells.length > 0 && ( @@ -177,21 +163,23 @@ export default function SpellSettings({ label={t('seriesImport.importFromSeries')} /> )} - + {/* Liste des sorts */} )}
    )} - - {viewMode === 'detail' && selectedSpell && ( + + {!showTagManager && viewMode === 'detail' && selectedSpell && (
    )} - - {viewMode === 'edit' && selectedSpell && ( + + {!showTagManager && viewMode === 'edit' && selectedSpell && (
    )}
    - + {/* Modal de confirmation de suppression */} {showDeleteConfirm && selectedSpell?.id && ( - )} - - {/* Tag Manager Modal */} - {showTagManager && ( - )} +
    ); } diff --git a/components/book/settings/spells/settings/SpellSettingsDetail.tsx b/components/book/settings/spells/settings/SpellSettingsDetail.tsx index 8a5e55c..0329369 100644 --- a/components/book/settings/spells/settings/SpellSettingsDetail.tsx +++ b/components/book/settings/spells/settings/SpellSettingsDetail.tsx @@ -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; @@ -24,18 +17,18 @@ interface SpellSettingsDetailProps { } export default function SpellSettingsDetail({ - spell, - availableTags, - seriesSpell, -}: SpellSettingsDetailProps): React.JSX.Element { + spell, + availableTags, + seriesSpell, + }: SpellSettingsDetailProps): React.JSX.Element { const t = useTranslations(); - + function getSelectedTags(): SpellTagProps[] { return availableTags.filter(function (tag: SpellTagProps): boolean { return spell.tags.includes(tag.id); }); } - + function getLocalizedPowerLevel(): string { if (!spell.powerLevel || spell.powerLevel === 'none') { return t('spellPowerLevels.none'); @@ -45,88 +38,57 @@ export default function SpellSettingsDetail({ }); return level ? t(level.label) : spell.powerLevel; } - + 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'; } } - + const selectedTags: SpellTagProps[] = getSelectedTags(); - + return (
    {/* Hero Section */} -
    -
    -
    - -
    -
    -

    {spell.name || '—'}

    - - {/* Power Level Badge */} -
    - - - {getLocalizedPowerLevel()} - -
    - - {/* Tags */} - {selectedTags.length > 0 && ( -
    - {selectedTags.map(function (tag: SpellTagProps): React.JSX.Element { - return ; - })} -
    - )} -
    + +
    + + + {getLocalizedPowerLevel()} +
    -
    - + {selectedTags.length > 0 && ( +
    + {selectedTags.map(function (tag: SpellTagProps): React.JSX.Element { + return ; + })} +
    + )} + + {/* Description & Appearance - Side by side */}
    -
    -
    - -

    {t('spellDetail.description')}

    -
    -

    - {spell.description || '—'} -

    -
    - -
    -
    - -

    {t('spellDetail.appearance')}

    -
    -

    - {spell.appearance || '—'} -

    -
    + +
    {/* Components & Limitations - Side by side */}
    -
    -
    - -

    {t('spellDetail.components')}

    -
    -

    - {spell.components || '—'} -

    -
    +
    - +

    {t('spellDetail.limitations')}

    @@ -134,17 +96,9 @@ export default function SpellSettingsDetail({

    - + {/* Notes - Full width */} -
    -
    - -

    {t('spellDetail.notes')}

    -
    -

    - {spell.notes || '—'} -

    -
    +
    ); } diff --git a/components/book/settings/spells/settings/SpellSettingsEdit.tsx b/components/book/settings/spells/settings/SpellSettingsEdit.tsx index e9eaa78..5d77667 100644 --- a/components/book/settings/spells/settings/SpellSettingsEdit.tsx +++ b/components/book/settings/spells/settings/SpellSettingsEdit.tsx @@ -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; @@ -40,32 +31,32 @@ interface SpellSettingsEditProps { * PAS de scroll interne (géré par parent) */ export default function SpellSettingsEdit({ - spell, - availableTags, - onSpellChange, - onCreateTag, - seriesSpell, - onSyncComplete, -}: SpellSettingsEditProps): React.JSX.Element { + spell, + availableTags, + onSpellChange, + onCreateTag, + seriesSpell, + onSyncComplete, + }: SpellSettingsEditProps): React.JSX.Element { const t = useTranslations(); - + const [tagSearchQuery, setTagSearchQuery] = useState(''); const [isCreatingTag, setIsCreatingTag] = useState(false); const [newTagColor, setNewTagColor] = useState(defaultTagColors[0]); - + function handleAddTag(tagId: string): void { if (!spell.tags.includes(tagId)) { onSpellChange('tags', [...spell.tags, tagId]); } setTagSearchQuery(''); } - + function handleRemoveTag(tagId: string): void { onSpellChange('tags', spell.tags.filter(function (id: string): boolean { return id !== tagId; })); } - + function getFilteredAvailableTags(): SpellTagProps[] { return availableTags.filter(function (tag: SpellTagProps): boolean { const notAlreadyAdded: boolean = !spell.tags.includes(tag.id); @@ -73,13 +64,13 @@ export default function SpellSettingsEdit({ return notAlreadyAdded && matchesSearch; }); } - + function getSelectedTags(): SpellTagProps[] { return availableTags.filter(function (tag: SpellTagProps): boolean { return spell.tags.includes(tag.id); }); } - + async function handleCreateTag(): Promise { if (!tagSearchQuery.trim()) return; const newTag: SpellTagProps | null = await onCreateTag(tagSearchQuery.trim(), newTagColor); @@ -89,7 +80,7 @@ export default function SpellSettingsEdit({ setNewTagColor(defaultTagColors[0]); } } - + function getLocalizedPowerLevels(): SelectBoxProps[] { return spellPowerLevels.map(function (level: SelectBoxProps): SelectBoxProps { return { @@ -98,7 +89,7 @@ export default function SpellSettingsEdit({ }; }); } - + const filteredTags: SpellTagProps[] = getFilteredAvailableTags(); const selectedTags: SpellTagProps[] = getSelectedTags(); const showCreateOption: boolean = Boolean( @@ -107,12 +98,11 @@ export default function SpellSettingsEdit({ return tag.name.toLowerCase() === tagSearchQuery.toLowerCase(); }) ); - + return (
    {/* Informations de base */} - -
    + } /> - + - ): void { onSpellChange('description', e.target.value); @@ -161,10 +155,10 @@ export default function SpellSettingsEdit({ } /> - + - ): void { onSpellChange('appearance', e.target.value); @@ -186,12 +182,10 @@ export default function SpellSettingsEdit({ } /> -
    -
    + {/* Tags */} - -
    + {selectedTags.length > 0 && (
    {selectedTags.map(function (tag: SpellTagProps): React.JSX.Element { @@ -199,13 +193,15 @@ export default function SpellSettingsEdit({ ); })}
    )} - + ): void { @@ -213,43 +209,42 @@ export default function SpellSettingsEdit({ }} placeholder={t('spellDetail.addTag')} /> - + {filteredTags.length > 0 && (
    {filteredTags.map(function (tag: SpellTagProps): React.JSX.Element { return ( ); })}
    )} - + {showCreateOption && !isCreatingTag && ( )} - + {isCreatingTag && ( -
    +

    {t('spellDetail.createTag', {name: tagSearchQuery})}

    @@ -258,35 +253,30 @@ export default function SpellSettingsEdit({ return (
    - - + +
    )} -
    -
    + {/* Niveau de puissance */} - -
    + -
    -
    + {/* Composants */} - -
    + - ): void { onSpellChange('components', e.target.value || null); @@ -329,12 +321,10 @@ export default function SpellSettingsEdit({ placeholder={t('spellDetail.componentsPlaceholder')} /> -
    -
    + {/* Limitations */} - -
    + - ): void { onSpellChange('limitations', e.target.value || null); @@ -353,12 +345,10 @@ export default function SpellSettingsEdit({ placeholder={t('spellDetail.limitationsPlaceholder')} /> -
    -
    - + + {/* Notes */} - -
    + - ): void { onSpellChange('notes', e.target.value || null); @@ -377,8 +369,7 @@ export default function SpellSettingsEdit({ placeholder={t('spellDetail.notesPlaceholder')} /> -
    -
    +
    ); } diff --git a/components/book/settings/spells/settings/SpellSettingsList.tsx b/components/book/settings/spells/settings/SpellSettingsList.tsx index ad4fedb..158cf22 100644 --- a/components/book/settings/spells/settings/SpellSettingsList.tsx +++ b/components/book/settings/spells/settings/SpellSettingsList.tsx @@ -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[]; @@ -22,16 +26,16 @@ interface SpellSettingsListProps { * PAS de scroll interne (géré par parent) */ export default function SpellSettingsList({ - spells, - tags, - onSpellClick, - onAddSpell, - onManageTags, -}: SpellSettingsListProps): React.JSX.Element { + spells, + tags, + onSpellClick, + onAddSpell, + onManageTags, + }: SpellSettingsListProps): React.JSX.Element { const t = useTranslations(); const [searchQuery, setSearchQuery] = useState(''); const [filterTag, setFilterTag] = useState('all'); - + function getFilteredSpells(): SpellListItem[] { return spells.filter(function (spell: SpellListItem): boolean { const matchesSearch: boolean = spell.name.toLowerCase().includes(searchQuery.toLowerCase()); @@ -41,9 +45,9 @@ export default function SpellSettingsList({ return matchesSearch && matchesTag; }); } - + const filteredSpells: SpellListItem[] = getFilteredSpells(); - + return (
    @@ -57,13 +61,13 @@ export default function SpellSettingsList({ placeholder={t('spellList.search')} /> } - actionIcon={faPlus} + actionIcon={Plus} actionLabel={t('spellList.add')} addButtonCallBack={async function (): Promise { onAddSpell(); }} /> - +
    - +
    - +
    {filteredSpells.length === 0 ? ( -
    -
    - -
    -

    - {t('spellList.noSpells')} -

    -

    - {t('spellList.noSpellsDescription')} -

    -
    + ) : (
    {filteredSpells.map(function (spell: SpellListItem): React.JSX.Element { return ( -
    -
    - -
    - -
    -
    - {spell.name} -
    -
    - {spell.description} -
    - {spell.tags.length > 0 && ( -
    + onClick={function (): void { + onSpellClick(spell); + }} + avatar={} + title={spell.name} + subtitle={spell.description} + extra={ + spell.tags.length > 0 ? ( +
    {spell.tags.slice(0, 3).map(function (tag: SpellTagProps): React.JSX.Element { return ; })} {spell.tags.length > 3 && ( - + +{spell.tags.length - 3} - + )}
    - )} -
    - -
    - -
    -
    + ) : undefined + } + /> ); })}
    diff --git a/components/book/settings/story/Act.tsx b/components/book/settings/story/Act.tsx index 9054c3d..0fb48a5 100644 --- a/components/book/settings/story/Act.tsx +++ b/components/book/settings/story/Act.tsx @@ -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,14 +25,12 @@ interface ActProps { export default function Act({acts, setActs, mainChapters}: ActProps) { const t = useTranslations('actComponent'); - const {lang} = useContext(LangContext); - const {isCurrentlyOffline} = useContext(OfflineContext); - const {addToQueue} = useContext(LocalSyncQueueContext); - const {localSyncedBooks} = useContext(BooksSyncContext); - const {book} = useContext(BookContext); - const {session} = useContext(SessionContext); - const {errorMessage, successMessage} = useContext(AlertContext); - + const {lang}: LangContextProps = useContext(LangContext); + const {book}: BookContextProps = useContext(BookContext); + const {session}: SessionContextProps = useContext(SessionContext); + const {errorMessage, successMessage}: AlertContextProps = useContext(AlertContext); + const {isCurrentlyOffline}: OfflineContextType = useContext(OfflineContext); + const bookId: string | undefined = book?.bookId; const token: string = session.accessToken; @@ -77,24 +66,16 @@ export default function Act({acts, setActs, mainChapters}: ActProps) { async function addIncident(actId: number): Promise { if (newIncidentTitle.trim() === '') return; - + 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('book/incident/new', { + incidentId = await apiPost('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')); @@ -108,7 +89,7 @@ export default function Act({acts, setActs, mainChapters}: ActProps) { summary: '', chapters: [], }; - + return { ...act, incidents: [...(act.incidents || []), newIncident], @@ -130,15 +111,13 @@ export default function Act({acts, setActs, mainChapters}: ActProps) { async function deleteIncident(actId: number, incidentId: string): Promise { 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('book/incident/remove', deleteData, token, lang); - - if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) { - addToQueue('remove_incident', {data: deleteData}); - } + response = await apiDelete('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 = { - bookId, - name: newPlotPointTitle, - incidentId: selectedIncidentId, - }; - if (isCurrentlyOffline() || book?.localBook) { - plotId = await tauri.addPlotPoint(plotData.bookId!, plotData.name, plotData.incidentId); + if (isDesktop && (isCurrentlyOffline() || book?.localBook)) { + plotId = await tauri.addPlotPoint(bookId ?? '', newPlotPointTitle, selectedIncidentId); } else { - plotId = await System.authPostToServer('book/plot/new', plotData, token, lang); - - if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) { - addToQueue('add_plot_point', {data: { - ...plotData, - plotId, - }}); - } + plotId = await apiPost('book/plot/new', { + bookId, + name: newPlotPointTitle, + incidentId: selectedIncidentId, + }, 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 { 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('book/plot/remove', deleteData, token, lang); - - if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) { - addToQueue('remove_plot_point', {data: deleteData}); - } + response = await apiDelete('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 = { - 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); + 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 System.authPostToServer('chapter/resume/add', linkData, token, lang); - - if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) { - addToQueue('add_chapter_information', {data: { - ...linkData, - chapterInfoId: linkId, - }}); - } + linkId = await apiPost('chapter/resume/add', { + bookId, + chapterId: chapterId, + actId: actId, + plotId: destination === 'plotPoint' ? itemId : null, + incidentId: destination === 'incident' ? itemId : null, + }, token, lang); } if (!linkId) { errorMessage(t('errorLinkChapter')); @@ -363,15 +329,12 @@ export default function Act({acts, setActs, mainChapters}: ActProps) { ): Promise { 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('chapter/resume/remove', unlinkData, token, lang); - - if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) { - addToQueue('remove_chapter_information', {data: unlinkData}); - } + response = await apiDelete('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 (
    {acts.map((act: ActType) => ( - (LangContext); - const {isCurrentlyOffline} = useContext(OfflineContext); - const {addToQueue} = useContext(LocalSyncQueueContext); - const {localSyncedBooks} = useContext(BooksSyncContext); - const {book} = useContext(BookContext); - const {session} = useContext(SessionContext); - const {errorMessage} = useContext(AlertContext); - + const {lang}: LangContextProps = useContext(LangContext); + const {book}: BookContextProps = useContext(BookContext); + const {session}: SessionContextProps = useContext(SessionContext); + const {errorMessage}: AlertContextProps = useContext(AlertContext); + const {isCurrentlyOffline}: OfflineContextType = useContext(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('book/issue/add', { + issueId = await apiPost('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")); @@ -66,7 +56,7 @@ export default function Issues({issues, setIssues}: IssuesProps) { name: newIssueName, id: issueId, }; - + setIssues([...issues, newIssue]); setNewIssueName(''); } catch (e: unknown) { @@ -81,33 +71,23 @@ export default function Issues({issues, setIssues}: IssuesProps) { async function deleteIssue(issueId: string): Promise { 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( + response = await apiDelete( '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 ( - + +
    {issues && issues.length > 0 ? ( - issues.map((item: Issue) => ( -
    -
    - updateIssueName(item.id, e.target.value)} - placeholder={t("issues.issueNamePlaceholder")} - /> - + issues.map(function (item: Issue): React.JSX.Element { + return ( +
    +
    + ): void { + updateIssueName(item.id, e.target.value); + }} + placeholder={t("issues.issueNamePlaceholder")} + /> +
    + { + return deleteIssue(item.id); + }}/>
    -
    - )) + ); + }) ) : (

    {t("issues.noIssue")}

    )} -
    - ) => setNewIssueName(e.target.value)} - placeholder={t("issues.newIssuePlaceholder")} - /> - -
    + ): void { + setNewIssueName(e.target.value); + }} + placeholder={t("issues.newIssuePlaceholder")} + /> + } + actionIcon={Plus} + addButtonCallBack={addNewIssue} + isAddButtonDisabled={newIssueName.trim() === ''} + />
    - } icon={faWarning}/> + ); } \ No newline at end of file diff --git a/components/book/settings/story/MainChapter.tsx b/components/book/settings/story/MainChapter.tsx index b8614f6..f33695e 100644 --- a/components/book/settings/story/MainChapter.tsx +++ b/components/book/settings/story/MainChapter.tsx @@ -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,14 +25,12 @@ interface MainChapterProps { export default function MainChapter({chapters, setChapters}: MainChapterProps) { const t = useTranslations(); - const {lang} = useContext(LangContext) - const {isCurrentlyOffline} = useContext(OfflineContext); - const {addToQueue} = useContext(LocalSyncQueueContext); - const {localSyncedBooks} = useContext(BooksSyncContext); - const {book} = useContext(BookContext); - const {session} = useContext(SessionContext); - const {errorMessage, successMessage} = useContext(AlertContext); - + const {lang}: LangContextProps = useContext(LangContext) + const {book}: BookContextProps = useContext(BookContext); + const {session}: SessionContextProps = useContext(SessionContext); + const {errorMessage, successMessage}: AlertContextProps = useContext(AlertContext); + const {isCurrentlyOffline}: OfflineContextType = useContext(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 = { - bookId, - chapterId: chapterIdToRemove, - deletedAt, - }; - if (isCurrentlyOffline() || book?.localBook) { - response = await tauri.removeChapter(deleteData.chapterId, deleteData.bookId!, deleteData.deletedAt); + if (isDesktop && (isCurrentlyOffline() || book?.localBook)) { + response = await tauri.removeChapter(chapterIdToRemove, bookId ?? '', Date.now()); } else { - response = await System.authDeleteToServer('chapter/remove', deleteData, token, lang); - - if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) { - addToQueue('remove_chapter', {data: deleteData}); - } + response = await apiDelete( + 'chapter/remove', + { + bookId, + chapterId: chapterIdToRemove, + }, + token, + lang, + ); } if (!response) { errorMessage(t("mainChapter.errorDelete")); @@ -120,33 +116,33 @@ export default function MainChapter({chapters, setChapters}: MainChapterProps) { if (newChapterTitle.trim() === '') { return; } - + try { let responseId: string; - const chapterData = { - bookId: bookId, - wordsCount: 0, - chapterOrder: newChapterOrder ? newChapterOrder : 0, - title: newChapterTitle, - }; - if (isCurrentlyOffline() || book?.localBook) { - responseId = await tauri.addChapter(chapterData); + if (isDesktop && (isCurrentlyOffline() || book?.localBook)) { + responseId = await tauri.addChapter({ + bookId: bookId ?? '', + title: newChapterTitle, + chapterOrder: newChapterOrder ? newChapterOrder : 0, + }); } else { - responseId = await System.authPostToServer('chapter/add', chapterData, token); - - if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) { - addToQueue('add_chapter', {data: { - ...chapterData, - chapterId: responseId, - }}); - } + responseId = await apiPost( + 'chapter/add', + { + bookId: bookId, + wordsCount: 0, + chapterOrder: newChapterOrder ? newChapterOrder : 0, + title: newChapterTitle, + }, + token, + ); } if (!responseId) { errorMessage(t("mainChapter.errorAdd")); return; } const newChapter: ChapterListProps = { - chapterId: responseId as string, + chapterId: responseId, title: newChapterTitle, chapterOrder: newChapterOrder, summary: '', @@ -154,7 +150,7 @@ export default function MainChapter({chapters, setChapters}: MainChapterProps) { }; setChapters([...chapters, newChapter]); setNewChapterTitle(''); - + setNewChapterOrder(newChapterOrder + 1); } catch (e: unknown) { if (e instanceof Error) { @@ -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 (
    - {visibleChapters.length > 0 ? ( -
    +
    {visibleChapters.map((item: ChapterListProps, index: number) => ( -
    -
    - - {item.chapterOrder !== undefined ? item.chapterOrder : index} - - + + {item.chapterOrder !== undefined ? item.chapterOrder : index} + +
    + ) => handleChapterTitleChange(item.chapterId, e.target.value)} + setValue={function (e: ChangeEvent): void { + handleChapterTitleChange(item.chapterId, e.target.value); + }} placeholder={t("mainChapter.chapterTitlePlaceholder")} /> -
    - - - -
    +
    +
    + moveChapterUp(index)} + disabled={item.chapterOrder === 0} + /> + moveChapterDown(index)} + /> + { + setChapterIdToRemove(item.chapterId); + setDeleteConfirmMessage(true); + }} + />
    ))} @@ -250,44 +234,18 @@ export default function MainChapter({chapters, setChapters}: MainChapterProps) {

    )} -
    -
    -
    - - - {newChapterOrder} - - -
    - setNewChapterTitle(e.target.value)} - placeholder={t("mainChapter.newChapterPlaceholder")} - /> - -
    +
    + ): void { + setNewChapterTitle(e.target.value); + }} + order={newChapterOrder} + setOrder={setNewChapterOrder} + placeholder={t("mainChapter.newChapterPlaceholder")} + onAdd={addNewChapter} + isAddDisabled={newChapterTitle.trim() === ''} + />
    }/> diff --git a/components/book/settings/story/StorySetting.tsx b/components/book/settings/story/StorySetting.tsx index d314416..6d77897 100644 --- a/components/book/settings/story/StorySetting.tsx +++ b/components/book/settings/story/StorySetting.tsx @@ -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): React.JSX.Element { const t = useTranslations(); - const {lang} = useContext(LangContext); - const {isCurrentlyOffline} = useContext(OfflineContext); - const {addToQueue} = useContext(LocalSyncQueueContext); - const {localSyncedBooks} = useContext(BooksSyncContext); - const {book} = useContext(BookContext); + const {lang}: LangContextProps = useContext(LangContext); + const {book}: BookContextProps = useContext(BookContext); const bookId: string = book?.bookId ? book.bookId.toString() : ''; - const {session} = useContext(SessionContext); + const {session}: SessionContextProps = useContext(SessionContext); const userToken: string = session.accessToken; - const {errorMessage, successMessage} = useContext(AlertContext); - + const {errorMessage, successMessage}: AlertContextProps = useContext(AlertContext); + const {isCurrentlyOffline}: OfflineContextType = useContext(OfflineContext); + const [acts, setActs] = useState([]); const [issues, setIssues] = useState([]); const [mainChapters, setMainChapters] = useState([]); const [isLoading, setIsLoading] = useState(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 { 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(`book/story`, userToken, lang, { + response = await apiGet(`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 { try { let response: boolean; - const storyData = { - bookId, - acts, - mainChapters, - issues, - }; - if (isCurrentlyOffline() || book?.localBook) { - response = await tauri.updateBookStory(storyData); + if (isDesktop && (isCurrentlyOffline() || book?.localBook)) { + response = await tauri.updateBookStory({ + bookId, + acts, + mainChapters, + issues, + }); } else { - response = await System.authPostToServer('book/story', storyData, userToken, lang); - - if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) { - addToQueue('update_book_story', {data: storyData}); - } + response = await apiPost('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); \ No newline at end of file +export default forwardRef(Story); \ No newline at end of file diff --git a/components/book/settings/story/act/ActChapter.tsx b/components/book/settings/story/act/ActChapter.tsx index 104dae0..8e320ab 100644 --- a/components/book/settings/story/act/ActChapter.tsx +++ b/components/book/settings/story/act/ActChapter.tsx @@ -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 ( -
    +
    ) => 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 => onUnlink(chapter.chapterInfoId, chapter.chapterId)} actionLabel={t('remove')} diff --git a/components/book/settings/story/act/ActChaptersSection.tsx b/components/book/settings/story/act/ActChaptersSection.tsx index 37f133c..f7c67cb 100644 --- a/components/book/settings/story/act/ActChaptersSection.tsx +++ b/components/book/settings/story/act/ActChaptersSection.tsx @@ -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,52 +40,45 @@ export default function ActChaptersSection({ } return ( -
    - - - {isExpanded && ( -
    - {chapters && chapters.length > 0 ? ( - chapters.map((chapter: ActChapter) => ( + +
    + {chapters && chapters.length > 0 ? ( + chapters.map(function (chapter: ActChapter): React.JSX.Element { + return ( - 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 { + return onUnlinkChapter(chapterInfoId, chapterId); + }} /> - )) - ) : ( -

    - {t('noLinkedChapter')} -

    - )} - => onLinkChapter(actId, selectedChapterId)} - input={ - setSelectedChapterId(e.target.value)} - data={mainChaptersData()} - placeholder={t('selectChapterPlaceholder')} - /> - } - isAddButtonDisabled={!selectedChapterId} - /> -
    - )} -
    + ); + }) + ) : ( +

    + {t('noLinkedChapter')} +

    + )} + { + return onLinkChapter(actId, selectedChapterId); + }} + input={ + ): void { + setSelectedChapterId(e.target.value); + }} + data={mainChaptersData()} + placeholder={t('selectChapterPlaceholder')} + /> + } + isAddButtonDisabled={!selectedChapterId} + /> +
    + ); } diff --git a/components/book/settings/story/act/ActDescription.tsx b/components/book/settings/story/act/ActDescription.tsx index 7a5f5c3..46813d0 100644 --- a/components/book/settings/story/act/ActDescription.tsx +++ b/components/book/settings/story/act/ActDescription.tsx @@ -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 ) => 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')} />
    diff --git a/components/book/settings/story/act/ActIncidents.tsx b/components/book/settings/story/act/ActIncidents.tsx index f20fc0c..d3a1f36 100644 --- a/components/book/settings/story/act/ActIncidents.tsx +++ b/components/book/settings/story/act/ActIncidents.tsx @@ -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({ })); } + function mainChaptersData(): SelectBoxProps[] { + return mainChapters.map(function (chapter: ChapterListProps): SelectBoxProps { + return { + value: chapter.chapterId, + label: `${chapter.chapterOrder}. ${chapter.title}`, + }; + }); + } + return ( -
    - - - {isExpanded && ( -
    - {incidents && incidents.length > 0 ? ( - <> - {incidents.map((item: Incident) => { - const itemKey = `incident_${item.incidentId}`; - const isItemExpanded: boolean = expandedItems[itemKey]; - - return ( -
    - -
    - - - {isItemExpanded && ( -
    - {item.chapters && item.chapters.length > 0 ? ( - <> - {item.chapters.map((chapter: ActChapter) => ( - - onUpdateChapterSummary(chapterId, summary, item.incidentId) - } - onUnlink={(chapterInfoId, chapterId) => - onUnlinkChapter(chapterInfoId, chapterId, item.incidentId) - } - /> - ))} - - ) : ( -

    - {t('noLinkedChapter')} -

    - )} - -
    - - -
    -
    - )} + +
    + {incidents && incidents.length > 0 ? ( + incidents.map(function (item: Incident): React.JSX.Element { + const itemKey: string = `incident_${item.incidentId}`; + const isItemExpanded: boolean = expandedItems[itemKey]; + + return ( +
    + -
    -
    - )} -
    + } + actionIcon={Plus} + addButtonCallBack={function (): Promise { + return onAddIncident(actId); + }} + isAddButtonDisabled={newIncidentTitle.trim() === ''} + /> +
    + ); } diff --git a/components/book/settings/story/act/ActPlotPoints.tsx b/components/book/settings/story/act/ActPlotPoints.tsx index 42060d1..2ce3fdd 100644 --- a/components/book/settings/story/act/ActPlotPoints.tsx +++ b/components/book/settings/story/act/ActPlotPoints.tsx @@ -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,138 +66,136 @@ export default function ActPlotPoints({ } return ( -
    - - - {isExpanded && ( -
    - {plotPoints && plotPoints.length > 0 ? ( - plotPoints.map((item: PlotPoint) => { - const itemKey = `plotpoint_${item.plotPointId}`; - const isItemExpanded: boolean = expandedItems[itemKey]; - const linkedIncident: Incident | undefined = incidents.find( - (inc: Incident): boolean => inc.incidentId === item.linkedIncidentId - ); - - return ( -
    +
    + {plotPoints && plotPoints.length > 0 ? ( + 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( + function (inc: Incident): boolean { + return inc.incidentId === item.linkedIncidentId; + } + ); + + return ( +
    + + />
    - - - {isItemExpanded && ( -
    - {item.chapters && item.chapters.length > 0 ? ( - item.chapters.map((chapter: ActChapter) => ( +
    + + + {isItemExpanded && ( +
    + {item.chapters && item.chapters.length > 0 ? ( + item.chapters.map(function (chapter: ActChapter): React.JSX.Element { + return ( - 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 { + return onUnlinkChapter(chapterInfoId, chapterId, item.plotPointId); + }} /> - )) - ) : ( -

    - {t('noLinkedChapter')} -

    - )} - -
    - - -
    -
    - )} -
    - ); - }) - ) : ( -

    - {t('noPlotPointAdded')} -

    - )} - -
    -
    - setNewPlotPointTitle(e.target.value)} - placeholder={t('newPlotPointPlaceholder')} + ); + }) + ) : ( +

    + {t('noLinkedChapter')} +

    + )} + + ): 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 { + return onLinkChapter(actId, selectedChapterId, item.plotPointId); + }} + isAddButtonDisabled={!selectedChapterId} + /> +
    + )} +
    + ); + }) + ) : ( +

    + {t('noPlotPointAdded')} +

    + )} + +
    + ): void { + setNewPlotPointTitle(e.target.value); + }} + placeholder={t('newPlotPointPlaceholder')} + /> + ): void { + setSelectedIncidentId(e.target.value); + }} + data={getIncidentData()} /> -
    - setSelectedIncidentId(e.target.value)} - data={getIncidentData()} - /> - } - addButtonCallBack={(): Promise => onAddPlotPoint(actId)} - isAddButtonDisabled={newPlotPointTitle.trim() === ''} - /> -
    + } + actionIcon={Plus} + addButtonCallBack={function (): Promise { + return onAddPlotPoint(actId); + }} + isAddButtonDisabled={newPlotPointTitle.trim() === ''} + />
    - )} -
    +
    + ); } diff --git a/components/book/settings/world/WorldElement.tsx b/components/book/settings/world/WorldElement.tsx index ce9bba1..7a2985c 100644 --- a/components/book/settings/world/WorldElement.tsx +++ b/components/book/settings/world/WorldElement.tsx @@ -1,225 +1,185 @@ -'use client'; - -import {ChangeEvent, useContext, useState} from "react"; -import {WorldContext} from "@/context/WorldContext"; -import TextInput from "@/components/form/TextInput"; -import TexteAreaInput from "@/components/form/TexteAreaInput"; -import {WorldElement, WorldProps} from "@/lib/models/World"; -import {AlertContext} from "@/context/AlertContext"; -import {SessionContext} from "@/context/SessionContext"; -import System from "@/lib/models/System"; -import InputField from "@/components/form/InputField"; -import {useTranslations} from "next-intl"; -import {LangContext, LangContextProps} from "@/context/LangContext"; -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 * as tauri from '@/lib/tauri'; - -interface WorldElementInputProps { - sectionLabel: string; - sectionType: string; -} - -function getElementTypeNumber(sectionType: string): number { - const typeMap: { [key: string]: number } = { - 'laws': 0, - 'biomes': 1, - 'issues': 2, - 'customs': 3, - 'kingdoms': 4, - 'climate': 5, - 'resources': 6, - 'wildlife': 7, - 'arts': 8, - 'ethnicGroups': 9, - 'socialClasses': 10, - 'importantCharacters': 11, - }; - return typeMap[sectionType] ?? 0; -} - -export default function WorldElementComponent({sectionLabel, sectionType}: WorldElementInputProps) { - const t = useTranslations(); - const {lang} = useContext(LangContext); - const {isCurrentlyOffline} = useContext(OfflineContext); - const {addToQueue} = useContext(LocalSyncQueueContext); - const {localSyncedBooks} = useContext(BooksSyncContext); - const {book} = useContext(BookContext); - const {worlds, setWorlds, selectedWorldIndex, isSeriesMode} = useContext(WorldContext); - const {errorMessage} = useContext(AlertContext); - const {session} = useContext(SessionContext); - const {seriesId, localSeries} = useContext(SeriesContext); - const {localSyncedSeries} = useContext(SeriesSyncContext); - - const [newElementName, setNewElementName] = useState(''); - - async function handleRemoveElement( - section: keyof WorldProps, - index: number, - ): Promise { - try { - 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); - } else { - response = await System.authDeleteToServer('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('book/world/element/delete', { - elementId, bookId: book?.bookId, deletedAt, - }, 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, - ); - setWorlds(updatedWorlds); - } catch (e: unknown) { - if (e instanceof Error) { - errorMessage(e.toString()); - } else { - errorMessage(t("worldElementComponent.errorUnknown")); - } - } - } - - async function handleAddElement(section: keyof WorldProps): Promise { - if (newElementName.trim() === '') { - errorMessage(t("worldElementComponent.emptyField", {section: sectionLabel})); - return; - } - try { - 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( - 'series/world/element/add', - addData, - 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 { - elementId = await System.authPostToServer('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({ - id: elementId, - name: newElementName, - description: '', - }); - setWorlds(updatedWorlds); - setNewElementName(''); - } catch (e: unknown) { - if (e instanceof Error) { - errorMessage(e.message); - } else { - errorMessage(t("worldElementComponent.errorUnknown")); - } - } - } - - function handleElementChange( - section: keyof WorldProps, - index: number, - field: keyof WorldElement, - value: string, - ): void { - const updatedWorlds: WorldProps[] = [...worlds]; - const sectionElements = updatedWorlds[selectedWorldIndex][ - section - ] as WorldElement[]; - sectionElements[index] = {...sectionElements[index], [field]: value}; - setWorlds(updatedWorlds); - } - - return ( -
    - {Array.isArray(worlds[selectedWorldIndex][sectionType as keyof WorldProps]) && - (worlds[selectedWorldIndex][sectionType as keyof WorldProps] as WorldElement[]).map( - (element: WorldElement, index: number) => ( -
    -
    - ) => handleElementChange(sectionType as keyof WorldProps, index, 'name', e.target.value)} - placeholder={t("worldElementComponent.namePlaceholder", {section: sectionLabel.toLowerCase()})} - />} - removeButtonCallBack={(): Promise => handleRemoveElement(sectionType as keyof WorldProps, index)}/> -
    - handleElementChange(sectionType as keyof WorldProps, index, 'description', e.target.value)} - placeholder={t("worldElementComponent.descriptionPlaceholder", {section: sectionLabel.toLowerCase()})} - /> -
    - ) - ) - } - ): void => setNewElementName(e.target.value)} - placeholder={t("worldElementComponent.newPlaceholder", {section: sectionLabel.toLowerCase()})} - />} addButtonCallBack={(): Promise => handleAddElement(sectionType as keyof WorldProps)}/> -
    - ); -} \ No newline at end of file +'use client'; + +import React, {ChangeEvent, useContext, useState} from "react"; +import {WorldContext, WorldContextProps} from "@/context/WorldContext"; +import TextInput from "@/components/form/TextInput"; +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: WorldElementSection; +} + +function getElementTypeNumber(sectionType: WorldElementSection): number { + const typeMap: Record = { + 'laws': 0, + 'biomes': 1, + 'issues': 2, + 'customs': 3, + 'kingdoms': 4, + 'climate': 5, + 'resources': 6, + 'wildlife': 7, + 'arts': 8, + 'ethnicGroups': 9, + 'socialClasses': 10, + 'importantCharacters': 11, + }; + return typeMap[sectionType] ?? 0; +} + +export default function WorldElementComponent({sectionLabel, sectionType}: WorldElementInputProps): React.JSX.Element { + const t = useTranslations(); + const {lang}: LangContextProps = useContext(LangContext); + const { + worlds, + setWorlds, + selectedWorldIndex, + isSeriesMode + }: WorldContextProps = useContext(WorldContext); + const {errorMessage}: AlertContextProps = useContext(AlertContext); + const {session}: SessionContextProps = useContext(SessionContext); + const {book}: BookContextProps = useContext(BookContext); + const {isCurrentlyOffline}: OfflineContextType = useContext(OfflineContext); + + const [newElementName, setNewElementName] = useState(''); + + async function handleRemoveElement( + section: WorldElementSection, + index: number, + ): Promise { + try { + const elements: WorldElement[] = worlds[selectedWorldIndex][section]; + let response: boolean; + if (!isSeriesMode && isDesktop && (isCurrentlyOffline() || book?.localBook)) { + response = await tauri.removeWorldElement(elements[index].id, book?.bookId ?? '', Date.now()); + } else { + const endpoint: string = isSeriesMode ? 'series/world/element/delete' : 'book/world/element/delete'; + response = await apiDelete(endpoint, { + elementId: elements[index].id, + }, session.accessToken, lang); + } + if (!response) { + errorMessage(t("worldSetting.unknownError")) + } + const updatedWorlds: WorldProps[] = [...worlds]; + updatedWorlds[selectedWorldIndex][section].splice(index, 1); + setWorlds(updatedWorlds); + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t("worldElementComponent.errorUnknown")); + } + } + } + + async function handleAddElement(section: WorldElementSection): Promise { + if (newElementName.trim() === '') { + errorMessage(t("worldElementComponent.emptyField", {section: sectionLabel})); + return; + } + try { + let elementId: string; + if (isSeriesMode) { + elementId = await apiPost( + 'series/world/element/add', + { + worldId: worlds[selectedWorldIndex].id, + elementType: getElementTypeNumber(section), + name: newElementName, + }, + session.accessToken, + lang + ); + if (!elementId) { + errorMessage(t("worldSetting.unknownError")) + return; + } + } 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 apiPost('book/world/element/add', { + elementType: section, + worldId: worlds[selectedWorldIndex].id, + elementName: newElementName, + }, session.accessToken, lang); + if (!elementId) { + errorMessage(t("worldSetting.unknownError")) + return; + } + } + const updatedWorlds: WorldProps[] = [...worlds]; + updatedWorlds[selectedWorldIndex][section].push({ + id: elementId, + name: newElementName, + description: '', + }); + setWorlds(updatedWorlds); + setNewElementName(''); + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t("worldElementComponent.errorUnknown")); + } + } + } + + function handleElementChange( + section: WorldElementSection, + index: number, + field: keyof WorldElement, + value: string, + ): void { + const updatedWorlds: WorldProps[] = [...worlds]; + const sectionElements: WorldElement[] = updatedWorlds[selectedWorldIndex][section]; + sectionElements[index] = {...sectionElements[index], [field]: value}; + setWorlds(updatedWorlds); + } + + return ( +
    + {worlds[selectedWorldIndex][sectionType].map( + (element: WorldElement, index: number): React.JSX.Element => ( +
    +
    + ): void => handleElementChange(sectionType, index, 'name', e.target.value)} + placeholder={t("worldElementComponent.namePlaceholder", {section: sectionLabel.toLowerCase()})} + />} + removeButtonCallBack={(): Promise => handleRemoveElement(sectionType, index)}/> +
    + ): void => handleElementChange(sectionType, index, 'description', e.target.value)} + placeholder={t("worldElementComponent.descriptionPlaceholder", {section: sectionLabel.toLowerCase()})} + /> +
    + ) + )} + ): void => setNewElementName(e.target.value)} + placeholder={t("worldElementComponent.newPlaceholder", {section: sectionLabel.toLowerCase()})} + />} addButtonCallBack={(): Promise => handleAddElement(sectionType)}/> +
    + ); +} diff --git a/components/book/settings/world/WorldSetting.tsx b/components/book/settings/world/WorldSetting.tsx index cc0b456..a1be506 100644 --- a/components/book/settings/world/WorldSetting.tsx +++ b/components/book/settings/world/WorldSetting.tsx @@ -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 }>) { +export function WorldSetting(props: WorldSettingProps, ref: React.ForwardedRef): React.JSX.Element { const {showToggle = true, entityType = 'book', entityId} = props; const t = useTranslations(); - const {lang} = useContext(LangContext); - const {isCurrentlyOffline} = useContext(OfflineContext); - const {addToQueue} = useContext(LocalSyncQueueContext); - const {localSyncedBooks} = useContext(BooksSyncContext); - const {errorMessage, successMessage} = useContext(AlertContext); - const {session} = useContext(SessionContext); - const {book, setBook} = useContext(BookContext); + const {lang}: LangContextProps = useContext(LangContext); + const {errorMessage, successMessage}: AlertContextProps = useContext(AlertContext); + const {session}: SessionContextProps = useContext(SessionContext); + const {book, setBook}: BookContextProps = useContext(BookContext); + const {isCurrentlyOffline}: OfflineContextType = useContext(OfflineContext); const currentEntityId: string = entityId || book?.bookId || ''; const isSeriesMode: boolean = entityType === 'series'; @@ -60,29 +51,27 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa const [showAddNewWorld, setShowAddNewWorld] = useState(false); const [toolEnabled, setToolEnabled] = useState(isSeriesMode ? true : (book?.tools?.worlds ?? false)); const bookSeriesId: string | null = book?.seriesId || null; - - useImperativeHandle(ref, function () { - return { - handleSave: handleUpdateWorld, - }; - }); + + useImperativeHandle(ref, (): SettingRef => ({ + handleSave: handleUpdateWorld, + })); useEffect((): void => { if (currentEntityId) { getWorlds().then(); } }, [currentEntityId]); - + useEffect((): void => { - if (bookSeriesId && !isSeriesMode && !isCurrentlyOffline()) { + if (bookSeriesId && !isSeriesMode) { getSeriesWorlds().then(); } }, [bookSeriesId]); - + async function getSeriesWorlds(): Promise { - if (!bookSeriesId || isCurrentlyOffline()) return; + if (!bookSeriesId) return; try { - const response: SeriesWorldProps[] = await System.authGetQueryToServer('series/world/list', session.accessToken, lang, { + const response: SeriesWorldProps[] = await apiGet('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('book/tool-setting', { + response = await apiPatch('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); @@ -132,12 +114,11 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa } } } - + async function getWorlds(): Promise { try { if (isSeriesMode) { - // Series mode: server only (series are not local) - const response: SeriesWorldProps[] = await System.authGetQueryToServer('series/world/list', session.accessToken, lang, { + const response: SeriesWorldProps[] = await apiGet('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('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('book/worlds', session.accessToken, lang, {bookid: currentEntityId}); if (response) { setWorlds(response.worlds); setToolEnabled(response.enabled); @@ -209,7 +205,7 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa } } } - + async function handleAddNewWorld(): Promise { if (newWorldName.trim() === '') { errorMessage(t("worldSetting.newWorldNameError")); @@ -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( + newWorldId = await apiPost( '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('book/world/add', { + const worldId: string = await apiPost('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('series/world/update', { + const response: boolean = await apiPatch('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); + 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 { - // Book mode: online - response = await System.authPatchToServer('book/world/update', { + const response: boolean = await apiPatch('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; } } - - if (!response) { - errorMessage(t("worldSetting.updateWorldError")); - return; - } successMessage(t("worldSetting.updateWorldSuccess")); } catch (e: unknown) { if (e instanceof Error) { @@ -341,27 +324,26 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa } } } - + function handleWorldSelect(worldId: string): void { const index: number = worlds.findIndex((world: WorldProps): boolean => world.id === worldId); if (index !== -1) { setSelectedWorldIndex(index); } } - - 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 { - if (isCurrentlyOffline()) return; const selectedWorld: WorldProps | undefined = worlds[selectedWorldIndex]; if (!selectedWorld || !bookSeriesId) return; - + try { - const seriesWorldId: string = await System.authPostToServer('series/world/add', { + const seriesWorldId: string = await apiPost('series/world/add', { seriesId: bookSeriesId, world: { name: selectedWorld.name, @@ -372,15 +354,15 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa languages: selectedWorld.languages || null, } }, session.accessToken, lang); - + if (seriesWorldId) { - const updateResponse: boolean = await System.authPostToServer('book/world/update', { + const updateResponse: boolean = await apiPost('book/world/update', { world: { ...selectedWorld, seriesWorldId: seriesWorldId }, }, session.accessToken, lang); - + if (updateResponse) { const updatedWorlds: WorldProps[] = [...worlds]; updatedWorlds[selectedWorldIndex] = {...selectedWorld, seriesWorldId: seriesWorldId}; @@ -395,33 +377,23 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa } } } - + async function handleImportFromSeries(seriesWorldId: string): Promise { - 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('book/world/add', { + const worldId: string = await apiPost('book/world/add', { worldName: seriesWorld.name, bookId: currentEntityId, seriesWorldId: seriesWorldId, }, session.accessToken, lang); - + if (!worldId) { errorMessage(t("worldSetting.importError")); 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, @@ -455,19 +427,19 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa } } } - + function getSeriesWorldForCurrentWorld(): SeriesWorldProps | null { const currentWorld: WorldProps = worlds[selectedWorldIndex]; if (!currentWorld?.seriesWorldId) return null; return seriesWorlds.find((world: SeriesWorldListItem): boolean => world.id === currentWorld.seriesWorldId) || null; } - + return (
    {showToggle && !isSeriesMode && ( -
    +
    - {!isSeriesMode && bookSeriesId && !isCurrentlyOffline() && + {!isSeriesMode && bookSeriesId && seriesWorlds.filter((seriesWorld: SeriesWorldProps): boolean => !worlds.some((world: WorldProps): boolean => world.seriesWorldId === seriesWorld.id)).length > 0 && ( !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")} /> )} -
    handleWorldSelect(e.target.value)} + onChangeCallBack={(e: ChangeEvent): 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 => setShowAddNewWorld(!showAddNewWorld)} /> {showAddNewWorld && ( @@ -520,31 +493,25 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa input={ ) => setNewWorldName(e.target.value)} + setValue={(e: ChangeEvent): 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 && ( - + {!isSeriesMode && bookSeriesId && worlds[selectedWorldIndex] && !worlds[selectedWorldIndex].seriesWorldId && ( + )}
    -
    {worlds.length > 0 && worlds[selectedWorldIndex] ? ( -
    +
    { - 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 > ) => { + setValue={(e: ChangeEvent): 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} > - handleInputChange(e.target.value, 'history')} + setValue={(e: ChangeEvent): void => handleInputChange(e.target.value, 'history')} placeholder={t("worldSetting.worldHistoryPlaceholder")} /> } /> -
    -
    { - const seriesWorld = getSeriesWorldForCurrentWorld(); + onDownload={(): void => { + const seriesWorld: SeriesWorldProps | null = getSeriesWorldForCurrentWorld(); if (seriesWorld) handleInputChange(seriesWorld.politics || '', 'politics'); }} onSyncComplete={getSeriesWorlds} > - handleInputChange(e.target.value, 'politics')} + setValue={(e: ChangeEvent): void => handleInputChange(e.target.value, 'politics')} placeholder={t("worldSetting.politicsPlaceholder")} /> @@ -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} > - handleInputChange(e.target.value, 'economy')} + setValue={(e: ChangeEvent): void => handleInputChange(e.target.value, 'economy')} placeholder={t("worldSetting.economyPlaceholder")} /> } />
    -
    -
    { - const seriesWorld = getSeriesWorldForCurrentWorld(); + onDownload={(): void => { + const seriesWorld: SeriesWorldProps | null = getSeriesWorldForCurrentWorld(); if (seriesWorld) handleInputChange(seriesWorld.religion || '', 'religion'); }} onSyncComplete={getSeriesWorlds} > - handleInputChange(e.target.value, 'religion')} + setValue={(e: ChangeEvent): void => handleInputChange(e.target.value, 'religion')} placeholder={t("worldSetting.religionPlaceholder")} /> @@ -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} > - handleInputChange(e.target.value, 'languages')} + setValue={(e: ChangeEvent): void => handleInputChange(e.target.value, 'languages')} placeholder={t("worldSetting.languagesPlaceholder")} /> } />
    -
    - - {elementSections.map((section, index) => ( -
    + + {elementSections.map((section: ElementSection, index: number): React.JSX.Element => { + const SectionIcon: LucideIcon = section.icon; + return ( +

    - + {section.title} + className="ml-2 text-sm bg-secondary text-text-secondary py-0.5 px-2 rounded-full"> {worlds[selectedWorldIndex][section.section]?.length || 0}

    @@ -728,13 +689,14 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa sectionLabel={section.title} sectionType={section.section} /> -
    - ))} +
    + ); + })} +
    ) : ( -
    -

    {t("worldSetting.noWorldAvailable")}

    +
    +

    {t("worldSetting.noWorldAvailable")}

    )} @@ -743,4 +705,4 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa ); } -export default forwardRef(WorldSetting); +export default forwardRef(WorldSetting); \ No newline at end of file diff --git a/components/book/settings/world/editor/WorldEditor.tsx b/components/book/settings/world/editor/WorldEditor.tsx index d3aa1c4..a9fe1c2 100644 --- a/components/book/settings/world/editor/WorldEditor.tsx +++ b/components/book/settings/world/editor/WorldEditor.tsx @@ -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,17 +24,17 @@ import WorldEditorEdit from './WorldEditorEdit'; */ export default function WorldEditor(): React.JSX.Element { const t = useTranslations(); - const {book} = useContext(BookContext); + const {book}: BookContextProps = useContext(BookContext); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showAddForm, setShowAddForm] = useState(false); - + const config: UseWorldsConfig = useMemo(function (): UseWorldsConfig { return { entityType: 'book', entityId: book?.bookId || '', }; }, [book?.bookId]); - + const { worlds, seriesWorlds, @@ -60,7 +59,7 @@ export default function WorldEditor(): React.JSX.Element { exitEditMode, backToList, } = useWorlds(config); - + const availableSeriesWorlds = useMemo(function (): SeriesWorldProps[] { return seriesWorlds.filter(function (sw: SeriesWorldProps): boolean { return !worlds.some(function (w: WorldProps): boolean { @@ -68,16 +67,16 @@ 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]); - + // Wrapper pour convertir WorldProps en worldId const handleWorldClick = useCallback(function (world: WorldProps): void { enterDetailMode(world.id); }, [enterDetailMode]); - + // Gestion de l'ajout async function handleAddWorld(): Promise { if (newWorldName.trim()) { @@ -87,26 +86,22 @@ export default function WorldEditor(): React.JSX.Element { setShowAddForm(true); } } - + async function handleSave(): Promise { await exitEditMode(true); } - + function handleCancel(): void { exitEditMode(false); } - + if (isLoading) { - return ( -
    - -
    - ); + return ; } - + const selectedWorld: WorldProps | undefined = worlds[selectedWorldIndex]; const canExport: boolean = Boolean(bookSeriesId && selectedWorld && !selectedWorld.seriesWorldId); - + return (
    - +
    {viewMode === 'list' && (
    - {/* Toggle tool */} -
    - - } + {/* Import from series */} + {bookSeriesId && availableSeriesWorlds.length > 0 && ( + -
    - - {toolEnabled && ( - <> - {/* Import from series */} - {bookSeriesId && availableSeriesWorlds.length > 0 && ( - - )} - - {showAddForm && ( -
    - ): void { - setNewWorldName(e.target.value); - }} - placeholder={t('worldSetting.newWorldPlaceholder')} - /> - } - actionIcon={faPlus} - actionLabel={t('worldSetting.createWorldLabel')} - addButtonCallBack={async function (): Promise { - await addNewWorld(); - setShowAddForm(false); - }} - /> -
    - )} - - - )} + + {showAddForm && ( +
    + ): void { + setNewWorldName(e.target.value); + }} + placeholder={t('worldSetting.newWorldPlaceholder')} + /> + } + actionIcon={Plus} + actionLabel={t('worldSetting.createWorldLabel')} + addButtonCallBack={async function (): Promise { + await addNewWorld(); + setShowAddForm(false); + }} + /> +
    + )} + +
    )} - + {viewMode === 'detail' && selectedWorld && (
    )} - + {viewMode === 'edit' && selectedWorld && (
    )}
    - + {showDeleteConfirm && selectedWorld && ( { setShowDeleteConfirm(false); }} - onCancel={function (): void { setShowDeleteConfirm(false); }} + onConfirm={async function (): Promise { + setShowDeleteConfirm(false); + }} + onCancel={function (): void { + setShowDeleteConfirm(false); + }} /> )}
    diff --git a/components/book/settings/world/editor/WorldEditorDetail.tsx b/components/book/settings/world/editor/WorldEditorDetail.tsx index 82c9694..f6df5dc 100644 --- a/components/book/settings/world/editor/WorldEditorDetail.tsx +++ b/components/book/settings/world/editor/WorldEditorDetail.tsx @@ -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; @@ -14,32 +16,22 @@ interface WorldEditorDetailProps { * Mêmes fonctionnalités que WorldSettingsDetail, layout linéaire */ export default function WorldEditorDetail({ - world, - seriesWorld, -}: WorldEditorDetailProps): React.JSX.Element { + world, + seriesWorld, + }: WorldEditorDetailProps): React.JSX.Element { const t = useTranslations(); - - function renderField(label: string, value: string | null | undefined): React.JSX.Element | null { - if (!value) return null; - return ( -
    - {label} -

    {value}

    -
    - ); - } - + 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 ( -
    +

    {section.title}

    {elements.map(function (element: WorldElement): React.JSX.Element { return ( -
    +

    {element.name}

    {element.description && (

    {element.description}

    @@ -51,33 +43,33 @@ export default function WorldEditorDetail({
    ); } - + return (
    {/* Informations de base */} -
    +

    {world.name}

    - {renderField(t('worldSetting.worldHistory'), world.history)} +
    - + {/* Politique et économie */} {(world.politics || world.economy) && ( -
    +

    {t('worldSetting.politicsEconomy')}

    - {renderField(t('worldSetting.politics'), world.politics)} - {renderField(t('worldSetting.economy'), world.economy)} + +
    )} - + {/* Religion et langues */} {(world.religion || world.languages) && ( -
    +

    {t('worldSetting.cultureLanguages')}

    - {renderField(t('worldSetting.religion'), world.religion)} - {renderField(t('worldSetting.languages'), world.languages)} + +
    )} - + {/* Sections d'éléments */} {elementSections.map(function (section: ElementSection): React.JSX.Element | null { return renderElementSection(section); diff --git a/components/book/settings/world/editor/WorldEditorEdit.tsx b/components/book/settings/world/editor/WorldEditorEdit.tsx index 5f07894..8c6aed0 100644 --- a/components/book/settings/world/editor/WorldEditorEdit.tsx +++ b/components/book/settings/world/editor/WorldEditorEdit.tsx @@ -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>; - onWorldFieldChange: (field: keyof WorldProps, value: string) => void; + onWorldFieldChange: (field: WorldTextField, value: string) => void; seriesWorld?: SeriesWorldProps | null; onSyncComplete?: () => void; } @@ -26,21 +27,21 @@ interface WorldEditorEditProps { * SyncFieldWrapper pour tous les champs */ export default function WorldEditorEdit({ - world, - worlds, - selectedWorldIndex, - setWorlds, - onWorldFieldChange, - seriesWorld, - onSyncComplete, -}: WorldEditorEditProps): React.JSX.Element { + world, + worlds, + selectedWorldIndex, + setWorlds, + onWorldFieldChange, + seriesWorld, + onSyncComplete, + }: WorldEditorEditProps): React.JSX.Element { const t = useTranslations(); - + return (
    {/* Informations de base */} -
    +

    {t('worldSetting.basicInfo')}

    } /> - + - ): void { onWorldFieldChange('history', e.target.value); @@ -102,9 +103,9 @@ export default function WorldEditorEdit({ />
    - + {/* Politique et économie */} -
    +

    {t('worldSetting.politicsEconomy')}

    - ): void { onWorldFieldChange('politics', e.target.value); @@ -132,7 +133,7 @@ export default function WorldEditorEdit({ } /> - + - ): void { onWorldFieldChange('economy', e.target.value); @@ -160,9 +161,9 @@ export default function WorldEditorEdit({ />
    - + {/* Religion et langues */} -
    +

    {t('worldSetting.cultureLanguages')}

    - ): void { onWorldFieldChange('religion', e.target.value); @@ -190,7 +191,7 @@ export default function WorldEditorEdit({ } /> - + - ): void { onWorldFieldChange('languages', e.target.value); @@ -218,11 +219,11 @@ export default function WorldEditorEdit({ />
    - + {/* Sections d'éléments */} {elementSections.map(function (section: ElementSection): React.JSX.Element { return ( -
    +

    {section.title}

    (''); - + function getFilteredWorlds(): WorldProps[] { return worlds.filter(function (world: WorldProps): boolean { return world.name.toLowerCase().includes(searchQuery.toLowerCase()); }); } - + const filteredWorlds: WorldProps[] = getFilteredWorlds(); - + return (
    @@ -47,57 +49,31 @@ export default function WorldEditorList({ placeholder={t('worldSetting.search')} /> } - actionIcon={faPlus} + actionIcon={Plus} actionLabel={t('worldSetting.addWorldLabel')} addButtonCallBack={async function (): Promise { onAddWorld(); }} />
    - +
    {filteredWorlds.length === 0 ? ( -
    -
    - -
    -

    - {t('worldSetting.noWorldAvailable')} -

    -

    - {t('worldSetting.noWorldDescription')} -

    -
    + ) : ( filteredWorlds.map(function (world: WorldProps): React.JSX.Element { return ( -
    -
    - -
    - -
    -
    - {world.name} -
    - {world.history && ( -
    - {world.history.substring(0, 50)}{world.history.length > 50 ? '...' : ''} -
    - )} -
    - -
    - -
    -
    + size="sm" + onClick={function (): void { + onWorldClick(world); + }} + avatar={} + title={world.name} + subtitle={world.history ? world.history.substring(0, 50) + (world.history.length > 50 ? '...' : '') : null} + /> ); }) )} diff --git a/components/book/settings/world/settings/WorldSettings.tsx b/components/book/settings/world/settings/WorldSettings.tsx index 5d34953..614ec30 100644 --- a/components/book/settings/world/settings/WorldSettings.tsx +++ b/components/book/settings/world/settings/WorldSettings.tsx @@ -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; } /** @@ -29,23 +25,23 @@ interface WorldSettingsProps { * Inclut: toggle tool, import from series, export to series */ export default function WorldSettings({ - entityType = 'book', - entityId, - showToggle = true, -}: WorldSettingsProps): React.JSX.Element { + entityType = 'book', + entityId, + toolEnabled: parentToolEnabled, + }: WorldSettingsProps): React.JSX.Element { const t = useTranslations(); - const {book} = useContext(BookContext); + const {book}: BookContextProps = useContext(BookContext); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); - + const resolvedEntityId: string = entityId || book?.bookId || ''; - + const config: UseWorldsConfig = useMemo(function (): UseWorldsConfig { return { entityType, entityId: resolvedEntityId, }; }, [entityType, resolvedEntityId]); - + const { worlds, seriesWorlds, @@ -71,7 +67,7 @@ export default function WorldSettings({ exitEditMode, backToList, } = useWorlds(config); - + const availableSeriesWorlds = useMemo(function (): SeriesWorldProps[] { return seriesWorlds.filter(function (sw: SeriesWorldProps): boolean { return !worlds.some(function (w: WorldProps): boolean { @@ -79,30 +75,26 @@ 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]); - + async function handleSave(): Promise { await exitEditMode(true); } - + function handleCancel(): void { exitEditMode(false); } - + if (isLoading) { - return ( -
    - -
    - ); + return ; } - + const selectedWorld: WorldProps | undefined = worlds[selectedWorldIndex]; const canExport: boolean = Boolean(bookSeriesId && selectedWorld && !selectedWorld.seriesWorldId); - + return (
    {/* Header - uniquement pour detail/edit */} @@ -119,32 +111,12 @@ export default function WorldSettings({ showExport={canExport} showDelete={false} /> - + {/* Contenu principal */}
    {viewMode === 'list' && (
    - {/* Toggle tool */} - {showToggle && !isSeriesMode && ( -
    - - } - /> -

    - {t('worldSetting.enableToolDescription')} -

    -
    - )} - - {/* Contenu si outil activé */} - {(toolEnabled || isSeriesMode) && ( + {((parentToolEnabled !== undefined ? parentToolEnabled : toolEnabled) || isSeriesMode) && ( <> {/* Import from series */} {!isSeriesMode && bookSeriesId && availableSeriesWorlds.length > 0 && ( @@ -157,7 +129,7 @@ export default function WorldSettings({ label={t("seriesImport.importFromSeries")} /> )} - + {/* Liste des mondes */} )} - + {viewMode === 'detail' && selectedWorld && (
    )} - + {viewMode === 'edit' && selectedWorld && (
    -
    - {elements.map(function (element: WorldElement): React.JSX.Element { - return ( -
    -

    {element.name}

    - {element.description && ( -

    {element.description}

    - )} -
    - ); - })} -
    - + +
    + {elements.map(function (element: WorldElement): React.JSX.Element { + return ( +
    +

    {element.name}

    + {element.description && ( +

    {element.description}

    + )} +
    + ); + })} +
    +
    ); } - + return (
    {/* Hero Section */} -
    -
    -
    - -
    -
    -

    {world.name || '—'}

    -
    -
    -
    - + + {/* Histoire du monde - Full width */} -
    -
    - -

    {t('worldSetting.worldHistory')}

    -
    -

    - {world.history || '—'} -

    -
    - + + {/* Politique & Économie - Side by side */}
    -
    -
    - -

    {t('worldSetting.politics')}

    -
    -

    - {world.politics || '—'} -

    -
    - -
    -
    - -

    {t('worldSetting.economy')}

    -
    -

    - {world.economy || '—'} -

    -
    + +
    - + {/* Religion & Langues - Side by side */}
    -
    -
    - -

    {t('worldSetting.religion')}

    -
    -

    - {world.religion || '—'} -

    -
    - -
    -
    - -

    {t('worldSetting.languages')}

    -
    -

    - {world.languages || '—'} -

    -
    + +
    - + {/* Sections d'éléments - Grille de cards */} {elementSections.map(function (section: ElementSection): React.JSX.Element | null { return renderElementSection(section); diff --git a/components/book/settings/world/settings/WorldSettingsEdit.tsx b/components/book/settings/world/settings/WorldSettingsEdit.tsx index 066040f..f2a8753 100644 --- a/components/book/settings/world/settings/WorldSettingsEdit.tsx +++ b/components/book/settings/world/settings/WorldSettingsEdit.tsx @@ -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>; - onWorldFieldChange: (field: keyof WorldProps, value: string) => void; + onWorldFieldChange: (field: WorldTextField, value: string) => void; seriesWorld?: SeriesWorldProps | null; isSeriesMode: boolean; onSyncComplete?: () => void; @@ -30,23 +30,22 @@ interface WorldSettingsEditProps { * PAS de scroll interne (géré par parent) */ export default function WorldSettingsEdit({ - world, - worlds, - selectedWorldIndex, - setWorlds, - onWorldFieldChange, - seriesWorld, - isSeriesMode, - onSyncComplete, -}: WorldSettingsEditProps): React.JSX.Element { + world, + worlds, + selectedWorldIndex, + setWorlds, + onWorldFieldChange, + seriesWorld, + isSeriesMode, + onSyncComplete, + }: WorldSettingsEditProps): React.JSX.Element { const t = useTranslations(); - + return (
    {/* Informations de base */} - -
    + } /> - + - ): void { onWorldFieldChange('history', e.target.value); @@ -104,12 +103,10 @@ export default function WorldSettingsEdit({ } /> -
    -
    + {/* Politique et économie */} - -
    + - ): void { onWorldFieldChange('politics', e.target.value); @@ -135,7 +132,7 @@ export default function WorldSettingsEdit({ } /> - + - ): void { onWorldFieldChange('economy', e.target.value); @@ -161,12 +158,10 @@ export default function WorldSettingsEdit({ } /> -
    -
    + {/* Religion et langues */} - -
    + - ): void { onWorldFieldChange('religion', e.target.value); @@ -192,7 +187,7 @@ export default function WorldSettingsEdit({ } /> - + - ): void { onWorldFieldChange('languages', e.target.value); @@ -218,20 +213,17 @@ export default function WorldSettingsEdit({ } /> -
    -
    - + + {/* Sections d'éléments */} {elementSections.map(function (section: ElementSection): React.JSX.Element { return ( - -
    + -
    -
    + ); })}
    diff --git a/components/book/settings/world/settings/WorldSettingsList.tsx b/components/book/settings/world/settings/WorldSettingsList.tsx index d5de93f..c51ba94 100644 --- a/components/book/settings/world/settings/WorldSettingsList.tsx +++ b/components/book/settings/world/settings/WorldSettingsList.tsx @@ -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[]; @@ -22,28 +24,27 @@ interface WorldSettingsListProps { * PAS de scroll interne (géré par parent) */ export default function WorldSettingsList({ - worlds, - onWorldClick, - onAddWorld, - newWorldName, - onNewWorldNameChange, -}: WorldSettingsListProps): React.JSX.Element { + worlds, + onWorldClick, + onAddWorld, + newWorldName, + onNewWorldNameChange, + }: WorldSettingsListProps): React.JSX.Element { const t = useTranslations(); const [searchQuery, setSearchQuery] = useState(''); - + function getFilteredWorlds(): WorldProps[] { return worlds.filter(function (world: WorldProps): boolean { return world.name.toLowerCase().includes(searchQuery.toLowerCase()); }); } - + const filteredWorlds: WorldProps[] = getFilteredWorlds(); - + return (
    {/* Recherche et ajout */} -
    -
    +
    } - actionIcon={faPlus} + actionIcon={Plus} actionLabel={t('worldSetting.createWorldLabel')} addButtonCallBack={onAddWorld} />
    -
    - + {/* Liste des mondes cliquables */}
    {filteredWorlds.length === 0 ? ( -
    -
    - -
    -

    - {t('worldSetting.noWorldAvailable')} -

    -

    - {t('worldSetting.noWorldDescription')} -

    -
    + ) : ( filteredWorlds.map(function (world: WorldProps): React.JSX.Element { return ( -
    -
    - -
    - -
    -
    - {world.name} -
    - {world.history && ( -
    - {world.history.substring(0, 60)}{world.history.length > 60 ? '...' : ''} -
    - )} -
    - -
    - -
    -
    + onClick={function (): void { + onWorldClick(world.id); + }} + avatar={} + title={world.name} + subtitle={world.history ? world.history.substring(0, 60) + (world.history.length > 60 ? '...' : '') : null} + /> ); }) )} diff --git a/components/editor/DraftCompanion.tsx b/components/editor/DraftCompanion.tsx index f9f5549..2f34842 100644 --- a/components/editor/DraftCompanion.tsx +++ b/components/editor/DraftCompanion.tsx @@ -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(AIUsageContext) - const {lang} = useContext(LangContext) - const {isCurrentlyOffline} = useContext(OfflineContext); + const {setTotalPrice, setTotalCredits}: AIUsageContextProps = useContext(AIUsageContext) + const {lang}: LangContextProps = useContext(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(EditorContext); + const {chapter}: ChapterContextProps = useContext(ChapterContext); + const {book}: BookContextProps = useContext(BookContext); + const {session}: SessionContextProps = useContext(SessionContext); + const {errorMessage, infoMessage}: AlertContextProps = useContext(AlertContext); + const {isCurrentlyOffline}: OfflineContextType = useContext(OfflineContext); const [draftVersion, setDraftVersion] = useState(0); const [draftWordCount, setDraftWordCount] = useState(0); @@ -100,10 +93,10 @@ export default function DraftCompanion() { const [useExplicit, setUseExplicit] = useState(false); const [useSmart, setUseSmart] = useState(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(); if (showEnhancer) { @@ -113,14 +106,11 @@ export default function DraftCompanion() { async function getDraftContent(): Promise { 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(`chapter/content/companion`, session.accessToken, lang, { + response = await apiGet(`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 { 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(`book/tags`, session.accessToken, lang, { + responseTags = await apiGet(`book/tags`, session.accessToken, lang, { bookId: book?.bookId }); } @@ -190,13 +180,13 @@ export default function DraftCompanion() { infoMessage(t("draftCompanion.abortSuccess")); } } - + async function handleQuillSenseRefined(): Promise { if (chapter && session?.accessToken) { setIsRefining(true); setShowRefinedText(false); setRefinedText(''); - + try { const response: Response = await fetch(`${configs.apiUrl}quillsense/refine`, { method: 'POST', @@ -242,9 +232,9 @@ export default function DraftCompanion() { while (true) { try { const {done, value}: ReadableStreamReadResult = await reader.read(); - + if (done) break; - + const chunk: string = decoder.decode(value, {stream: true}); const lines: string[] = chunk.split('\n'); @@ -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 (
    + className="flex items-center justify-between p-4 flex-shrink-0">

    Amélioration de texte

    - +
    handleCharacterSearch(e.target.value)} @@ -478,7 +468,7 @@ export default function DraftCompanion() { /> handleLocationSearch(e.target.value)} @@ -491,7 +481,7 @@ export default function DraftCompanion() { /> handleObjectSearch(e.target.value)} @@ -504,7 +494,7 @@ export default function DraftCompanion() { /> handleWorldElementSearch(e.target.value)} @@ -519,12 +509,12 @@ export default function DraftCompanion() { } /> -
    +
    ) => setSpecifications(e.target.value)} placeholder="Spécifications particulières pour l'amélioration..." @@ -543,15 +533,15 @@ export default function DraftCompanion() {
    + className="p-5 shrink-0">
    - + icon={Wand2} + >{t("draftCompanion.refine")}
    {(showRefinedText || isRefining) && ( @@ -571,21 +561,21 @@ export default function DraftCompanion() { return (
    -
    + className="flex items-center justify-between p-4 flex-shrink-0 font-['ADLaM_Display']"> +
    {t("draftCompanion.words")}: {draftWordCount}
    { - hasAccess && book?.quillsenseEnabled !== false && chapter?.chapterContent.version === 3 && ( + book?.quillsenseEnabled !== false && hasAccess && chapter?.chapterContent.version === 3 && (
    - setShowEnhancer(true)} +
    ) } diff --git a/components/editor/NoBookHome.tsx b/components/editor/NoBookHome.tsx index 715ac88..4e769a0 100644 --- a/components/editor/NoBookHome.tsx +++ b/components/editor/NoBookHome.tsx @@ -1,22 +1,21 @@ -import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; -import {faBookOpen} from "@fortawesome/free-solid-svg-icons"; -import {useTranslations} from "next-intl"; +import {BookOpen} from 'lucide-react'; +import React from "react"; +import {useTranslations} from '@/lib/i18n'; export default function NoBookHome() { const t = useTranslations(); return (
    -
    - +
    +

    {t("noBookHome.title")}

    {t("noBookHome.description")}

    - + className="flex items-center justify-center gap-3 text-sm text-muted bg-secondary p-4 rounded-xl border border-secondary"> + {t("noBookHome.hint")}
    diff --git a/components/editor/ScribeEditor.tsx b/components/editor/ScribeEditor.tsx index 40f5fa9..de71d58 100644 --- a/components/editor/ScribeEditor.tsx +++ b/components/editor/ScribeEditor.tsx @@ -1,15 +1,15 @@ -import {useContext, useState} from "react"; -import {ChapterContext} from "@/context/ChapterContext"; -import {BookContext} from "@/context/BookContext"; -import {SettingBookContext} from "@/context/SettingBookContext"; +import React, {useContext, useState} from "react"; +import {ChapterContext, ChapterContextProps} from "@/context/ChapterContext"; +import {BookContext, BookContextProps} from "@/context/BookContext"; +import {SettingBookContext, SettingBookContextProps} from "@/context/SettingBookContext"; import TextEditor from "./TextEditor"; import BookList from "@/components/book/BookList"; import BookSettingOption from "@/components/book/settings/BookSettingOption"; import NoBookHome from "@/components/editor/NoBookHome"; export default function ScribeEditor() { - const {chapter} = useContext(ChapterContext); - const {book} = useContext(BookContext); + const {chapter}: ChapterContextProps = useContext(ChapterContext); + const {book}: BookContextProps = useContext(BookContext); const [bookSettingId, setBookSettingId] = useState(''); diff --git a/components/editor/TextEditor.tsx b/components/editor/TextEditor.tsx index ec27839..f50b764 100644 --- a/components/editor/TextEditor.tsx +++ b/components/editor/TextEditor.tsx @@ -1,48 +1,44 @@ 'use client' -import {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; -import {EditorContent} from '@tiptap/react'; -import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import React, {useCallback, useContext, useEffect, useRef, useState} from 'react'; +import {EditorContent, JSONContent} from '@tiptap/react'; import { - faAlignCenter, - faAlignLeft, - faAlignRight, - faBold, - faCog, - faFloppyDisk, - faGhost, - faHeading, - faLayerGroup, - faListOl, - faListUl, - faParagraph, - faUnderline, - faXmark -} from '@fortawesome/free-solid-svg-icons'; -import {EditorContext} from "@/context/EditorContext"; -import {ChapterContext} from '@/context/ChapterContext'; -import System from '@/lib/models/System'; -import {AlertContext} from '@/context/AlertContext'; -import {SessionContext} from "@/context/SessionContext"; -import {BookContext} from '@/context/BookContext'; + AlignCenter, + AlignJustify, + AlignLeft, + AlignRight, + Bold, + Ghost, + Heading1, + Heading2, + Heading3, + Layers, + List, + ListOrdered, + LucideIcon, + Save, + SlidersHorizontal, + Underline +} from 'lucide-react'; +import {EditorContext, EditorContextProps} from "@/context/EditorContext"; +import {ChapterContext, ChapterContextProps} from '@/context/ChapterContext'; +import {BookContext, BookContextProps} from "@/context/BookContext"; +import {apiPost} from '@/lib/api/client'; +import {isDesktop} from '@/lib/configs'; +import * as tauri from '@/lib/tauri'; +import OfflineContext, {OfflineContextType} from '@/context/OfflineContext'; +import {AlertContext, AlertContextProps} from '@/context/AlertContext'; +import {SessionContext, SessionContextProps} from "@/context/SessionContext"; import DraftCompanion from "@/components/editor/DraftCompanion"; import GhostWriter from "@/components/ghostwriter/GhostWriter"; -import SubmitButtonWLoading from "@/components/form/SubmitButtonWLoading"; -import CollapsableButton from "@/components/CollapsableButton"; -import {IconDefinition} from "@fortawesome/fontawesome-svg-core"; +import IconButton from "@/components/ui/IconButton"; import UserEditorSettings, {EditorDisplaySettings} from "@/components/editor/UserEditorSetting"; -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 ToolbarButton { action: () => void; - icon: IconDefinition; + icon: LucideIcon; isActive: boolean; - label?: string; } interface EditorClasses { @@ -57,7 +53,7 @@ interface EditorClasses { listItems: string; } -const DEFAULT_EDITOR_SETTINGS: EditorDisplaySettings = { +const defaultEditorSettings: EditorDisplaySettings = { zoomLevel: 3, indent: 30, lineHeight: 1.5, @@ -67,53 +63,53 @@ const DEFAULT_EDITOR_SETTINGS: EditorDisplaySettings = { focusMode: false }; -const FONT_SIZE_CLASSES = { +const fontSizeClasses: Record = { 1: 'text-sm', 2: 'text-base', 3: 'text-lg', 4: 'text-xl', 5: 'text-2xl' -} as const; +}; -const H1_SIZE_CLASSES = { +const h1SizeClasses: Record = { 1: 'text-xl', 2: 'text-2xl', 3: 'text-3xl', 4: 'text-4xl', 5: 'text-5xl' -} as const; +}; -const H2_SIZE_CLASSES = { +const h2SizeClasses: Record = { 1: 'text-lg', 2: 'text-xl', 3: 'text-2xl', 4: 'text-3xl', 5: 'text-4xl' -} as const; +}; -const H3_SIZE_CLASSES = { +const h3SizeClasses: Record = { 1: 'text-base', 2: 'text-lg', 3: 'text-xl', 4: 'text-2xl', 5: 'text-3xl' -} as const; +}; -const FONT_FAMILY_CLASSES = { +const fontFamilyClasses: Record = { 'lora': 'Lora', 'serif': 'font-serif', 'sans-serif': 'font-sans', 'monospace': 'font-mono' -} as const; +}; -const LINE_HEIGHT_CLASSES = { +const lineHeightClasses: Record = { 1.2: 'leading-tight', 1.5: 'leading-normal', 1.75: 'leading-relaxed', 2: 'leading-loose' -} as const; +}; -const MAX_WIDTH_CLASSES = { +const maxWidthClasses: Record = { 600: 'max-w-xl', 650: 'max-w-2xl', 700: 'max-w-3xl', @@ -127,9 +123,9 @@ const MAX_WIDTH_CLASSES = { 1100: 'max-w-full', 1150: 'max-w-full', 1200: 'max-w-full' -} as const; +}; -function getClosestKey>(value: number, obj: T): keyof T { +function getClosestKey(value: number, obj: Record): number { const keys: number[] = Object.keys(obj).map(Number).sort((a: number, b: number): number => a - b); return keys.reduce((prev: number, curr: number): number => Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev @@ -138,30 +134,27 @@ function getClosestKey>(value: number, obj: T): ke export default function TextEditor() { const t = useTranslations(); - const {lang} = useContext(LangContext) - const {editor} = useContext(EditorContext); - const {chapter, setChapter} = useContext(ChapterContext); - const {book, setBook} = useContext(BookContext); - const {errorMessage, successMessage} = useContext(AlertContext); - const {session} = useContext(SessionContext); - const {isCurrentlyOffline} = useContext(OfflineContext); - const {addToQueue} = useContext(LocalSyncQueueContext); - const {localSyncedBooks} = useContext(BooksSyncContext); - + const {lang}: LangContextProps = useContext(LangContext) + const {editor}: EditorContextProps = useContext(EditorContext); + const {chapter}: ChapterContextProps = useContext(ChapterContext); + const {book}: BookContextProps = useContext(BookContext); + const {errorMessage, successMessage}: AlertContextProps = useContext(AlertContext); + const {session}: SessionContextProps = useContext(SessionContext); + const {isCurrentlyOffline}: OfflineContextType = useContext(OfflineContext); + const [mainTimer, setMainTimer] = useState(0); const [showDraftCompanion, setShowDraftCompanion] = useState(false); const [showGhostWriter, setShowGhostWriter] = useState(false); const [showUserSettings, setShowUserSettings] = useState(false); const [isSaving, setIsSaving] = useState(false); - const [isClosing, setIsClosing] = useState(false); - const [editorSettings, setEditorSettings] = useState(DEFAULT_EDITOR_SETTINGS); + const [editorSettings, setEditorSettings] = useState(defaultEditorSettings); const [editorClasses, setEditorClasses] = useState({ base: 'text-lg font-serif leading-normal', h1: 'text-3xl font-bold', h2: 'text-2xl font-bold', h3: 'text-xl font-bold', container: 'max-w-3xl', - theme: 'bg-tertiary/90 backdrop-blur-sm bg-opacity-25 text-text-primary', + theme: 'bg-tertiary text-text-primary', paragraph: 'indent-6', lists: 'pl-10', listItems: 'text-lg' @@ -171,33 +164,31 @@ export default function TextEditor() { const timeoutRef: React.RefObject = useRef(null); const updateEditorClasses: (settings: EditorDisplaySettings) => void = useCallback((settings: EditorDisplaySettings): void => { - const fontSizeKey = settings.zoomLevel as keyof typeof FONT_SIZE_CLASSES; - const h1SizeKey = settings.zoomLevel as keyof typeof H1_SIZE_CLASSES; - const h2SizeKey = settings.zoomLevel as keyof typeof H2_SIZE_CLASSES; - const h3SizeKey = settings.zoomLevel as keyof typeof H3_SIZE_CLASSES; - const fontFamilyKey = settings.fontFamily as keyof typeof FONT_FAMILY_CLASSES; - const lineHeightKey = settings.lineHeight as keyof typeof LINE_HEIGHT_CLASSES; - const maxWidthKey: number = getClosestKey(settings.maxWidth, MAX_WIDTH_CLASSES); + const zoomKey: number = getClosestKey(settings.zoomLevel, fontSizeClasses); + const lineHeightKey: number = getClosestKey(settings.lineHeight, lineHeightClasses); + const maxWidthKey: number = getClosestKey(settings.maxWidth, maxWidthClasses); - const indentClass = `indent-${Math.round(settings.indent / 4)}`; + const fontFamily: string = fontFamilyClasses[settings.fontFamily] || fontFamilyClasses['lora']; + const lineHeight: string = lineHeightClasses[lineHeightKey]; + const indentClass: string = `indent-${Math.round(settings.indent / 4)}`; - const baseClass = `${FONT_SIZE_CLASSES[fontSizeKey]} ${FONT_FAMILY_CLASSES[fontFamilyKey]} ${LINE_HEIGHT_CLASSES[lineHeightKey]}`; - const h1Class = `${H1_SIZE_CLASSES[h1SizeKey]} font-bold ${FONT_FAMILY_CLASSES[fontFamilyKey]} ${LINE_HEIGHT_CLASSES[lineHeightKey]}`; - const h2Class = `${H2_SIZE_CLASSES[h2SizeKey]} font-bold ${FONT_FAMILY_CLASSES[fontFamilyKey]} ${LINE_HEIGHT_CLASSES[lineHeightKey]}`; - const h3Class = `${H3_SIZE_CLASSES[h3SizeKey]} font-bold ${FONT_FAMILY_CLASSES[fontFamilyKey]} ${LINE_HEIGHT_CLASSES[lineHeightKey]}`; - const containerClass = MAX_WIDTH_CLASSES[maxWidthKey as keyof typeof MAX_WIDTH_CLASSES]; - const listsClass = `pl-${Math.round((settings.indent + 20) / 4)}`; + const baseClass: string = `${fontSizeClasses[zoomKey]} ${fontFamily} ${lineHeight}`; + const h1Class: string = `${h1SizeClasses[zoomKey]} font-bold ${fontFamily} ${lineHeight}`; + const h2Class: string = `${h2SizeClasses[zoomKey]} font-bold ${fontFamily} ${lineHeight}`; + const h3Class: string = `${h3SizeClasses[zoomKey]} font-bold ${fontFamily} ${lineHeight}`; + const containerClass: string = maxWidthClasses[maxWidthKey]; + const listsClass: string = `pl-${Math.round((settings.indent + 20) / 4)}`; let themeClass: string = ''; switch (settings.theme) { case 'clair': - themeClass = 'bg-white text-black'; + themeClass = 'bg-gray-light text-darkest-background'; break; case 'sépia': - themeClass = 'text-amber-900'; + themeClass = 'bg-editor-page-sepia text-darkest-background'; break; default: - themeClass = 'bg-tertiary/90 backdrop-blur-sm bg-opacity-25 text-text-primary'; + themeClass = 'bg-tertiary text-text-primary'; } setEditorClasses({ @@ -213,109 +204,99 @@ export default function TextEditor() { }); }, []); - const containerStyle = useMemo(() => { - if (editorSettings.theme === 'sépia') { - return {backgroundColor: '#f4f1e8'}; - } - return {}; - }, [editorSettings.theme]); + const editorContainerRef: React.RefObject = useRef(null); - const toolbarButtons: ToolbarButton[] = (() => { + const editorContainerBgClass: string = + editorSettings.theme === 'sépia' ? 'bg-editor-page-sepia' : ''; + + const toolbarButtons: ToolbarButton[] = ((): ToolbarButton[] => { if (!editor) return []; return [ { action: (): boolean => editor.chain().focus().setParagraph().run(), - icon: faParagraph, + icon: AlignJustify, isActive: editor.isActive('paragraph') }, { action: (): boolean => editor.chain().focus().toggleBold().run(), - icon: faBold, + icon: Bold, isActive: editor.isActive('bold') }, { action: (): boolean => editor.chain().focus().toggleUnderline().run(), - icon: faUnderline, + icon: Underline, isActive: editor.isActive('underline') }, { action: (): boolean => editor.chain().focus().setTextAlign('left').run(), - icon: faAlignLeft, + icon: AlignLeft, isActive: editor.isActive({textAlign: 'left'}) }, { action: (): boolean => editor.chain().focus().setTextAlign('center').run(), - icon: faAlignCenter, + icon: AlignCenter, isActive: editor.isActive({textAlign: 'center'}) }, { action: (): boolean => editor.chain().focus().setTextAlign('right').run(), - icon: faAlignRight, + icon: AlignRight, isActive: editor.isActive({textAlign: 'right'}) }, { action: (): boolean => editor.chain().focus().toggleBulletList().run(), - icon: faListUl, + icon: List, isActive: editor.isActive('bulletList') }, { action: (): boolean => editor.chain().focus().toggleOrderedList().run(), - icon: faListOl, + icon: ListOrdered, isActive: editor.isActive('orderedList') }, { action: (): boolean => editor.chain().focus().toggleHeading({level: 1}).run(), - icon: faHeading, - isActive: editor.isActive('heading', {level: 1}), - label: '1' + icon: Heading1, + isActive: editor.isActive('heading', {level: 1}) }, { action: (): boolean => editor.chain().focus().toggleHeading({level: 2}).run(), - icon: faHeading, - isActive: editor.isActive('heading', {level: 2}), - label: '2' + icon: Heading2, + isActive: editor.isActive('heading', {level: 2}) }, { action: (): boolean => editor.chain().focus().toggleHeading({level: 3}).run(), - icon: faHeading, - isActive: editor.isActive('heading', {level: 3}), - label: '3' + icon: Heading3, + isActive: editor.isActive('heading', {level: 3}) }, ]; })(); const saveContent: () => Promise = useCallback(async (): Promise => { if (!editor || !chapter) return; - + setIsSaving(true); - const content = editor.state.doc.toJSON(); + const content: Record = editor.state.doc.toJSON(); const chapterId: string = chapter.chapterId || ''; const version: number = chapter.chapterContent.version || 0; - + try { let response: boolean; - const saveData = { - chapterId, - version, - content, - totalWordCount: editor.getText().length, - currentTime: mainTimer - }; - if (isCurrentlyOffline() || book?.localBook){ + if (isDesktop && (isCurrentlyOffline() || book?.localBook)) { response = await tauri.saveChapterContent({ - chapterId: saveData.chapterId, - version: saveData.version, - content: saveData.content, - totalWordCount: saveData.totalWordCount, - contentId: saveData.chapterId, + chapterId, + version, + content, + totalWordCount: editor.getText().length, + contentId: '', }); } else { - response = await System.authPostToServer(`chapter/content`, saveData, session?.accessToken, lang); - - if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === book?.bookId)) { - addToQueue('save_chapter_content', {data: saveData}); - } + response = await apiPost(`chapter/content`, { + chapterId, + version, + content, + totalWordCount: editor.getText().length, + currentTime: mainTimer + }, session?.accessToken ?? ''); } if (!response) { errorMessage(t('editor.error.savedFailed')); @@ -333,7 +314,7 @@ export default function TextEditor() { } setIsSaving(false); } - }, [editor, chapter, mainTimer, session?.accessToken, successMessage, errorMessage, addToQueue, book?.localBook, isCurrentlyOffline]); + }, [editor, chapter, mainTimer, session?.accessToken, successMessage, errorMessage]); const handleShowDraftCompanion: () => void = useCallback((): void => { setShowDraftCompanion((prev: boolean): boolean => !prev); @@ -354,21 +335,13 @@ export default function TextEditor() { setShowDraftCompanion(false); setShowGhostWriter(false); }, []); - - const handleCloseBook: () => Promise = useCallback(async (): Promise => { - setIsClosing(true); - await saveContent(); - setBook && setBook(null); - setChapter && setChapter(undefined); - setIsClosing(false); - }, [saveContent, setBook, setChapter]); useEffect((): void => { if (!editor) return; const editorElement: HTMLElement = editor.view.dom; if (editorElement) { - const indentClasses: string[] = Array.from({length: 21}, (_, i) => `indent-${i}`); + const indentClasses: string[] = Array.from({length: 21}, (_: unknown, i: number): string => `indent-${i}`); editorElement.classList.remove(...indentClasses); if (editorClasses.paragraph) { @@ -384,8 +357,8 @@ export default function TextEditor() { useEffect((): () => void => { function startTimer(): void { if (timerRef.current === null) { - timerRef.current = window.setInterval(() => { - setMainTimer(prevTimer => prevTimer + 1); + timerRef.current = window.setInterval((): void => { + setMainTimer((prevTimer: number): number => prevTimer + 1); }, 1000); } } @@ -431,9 +404,10 @@ export default function TextEditor() { if (!editor) return; if (chapter?.chapterContent.content) { try { - const parsedContent = JSON.parse(chapter.chapterContent.content); + const parsedContent: JSONContent = JSON.parse(chapter.chapterContent.content); editor.commands.setContent(parsedContent); - } catch (error) { + } catch (e: unknown) { + errorMessage(t('editor.error.parsingContent')); editor.commands.setContent({ type: "doc", content: [{type: "paragraph", content: []}] @@ -463,102 +437,86 @@ export default function TextEditor() { } return ( -
    +
    -
    + className={`flex justify-between items-center gap-3 rounded-xl mx-1 mb-1 px-4 py-2 bg-darkest-background transition-opacity duration-300 ${editorSettings.focusMode ? 'opacity-70 hover:opacity-100' : ''}`}> +
    {toolbarButtons.map((button: ToolbarButton, index: number) => ( - + /> ))}
    -
    - + - {chapter?.chapterContent.version === 2 && !isCurrentlyOffline() && !book?.localBook && book?.quillsenseEnabled !== false && ( - )} {chapter?.chapterContent.version && chapter.chapterContent.version > 2 && ( - )} - - + tooltip={t("textEditor.save")} + />
    - -
    + +
    -
    + className={`flex-1 bg-background rounded-xl mx-1 overflow-hidden transition-all duration-300 ${editorSettings.focusMode ? 'bg-darkest-background' : ''}`}> +
    -
    - + ref={editorContainerRef} + className={`editor-container mx-auto p-4 rounded-xl min-h-full ${editorClasses.container} ${editorClasses.theme} ${editorContainerBgClass}`}> + +
    - + {(showDraftCompanion || showGhostWriter || showUserSettings) && (
    - {showDraftCompanion && } - {showGhostWriter && } - {showUserSettings && ( - - )} + className={`w-4/12 bg-darkest-background rounded-xl mx-1 overflow-hidden flex flex-col transition-opacity duration-300 ${editorSettings.focusMode ? 'opacity-50 hover:opacity-100' : ''}`}> +
    + {showDraftCompanion && } + {showGhostWriter && } + {showUserSettings && ( + + )} +
    )}
    diff --git a/components/editor/UserEditorSetting.tsx b/components/editor/UserEditorSetting.tsx index e656cd2..ed280de 100644 --- a/components/editor/UserEditorSetting.tsx +++ b/components/editor/UserEditorSetting.tsx @@ -1,9 +1,10 @@ 'use client' -import {ChangeEvent, useCallback, useEffect, useMemo} from 'react'; -import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; -import {faEye, faFont, faIndent, faPalette, faTextHeight, faTextWidth} from '@fortawesome/free-solid-svg-icons'; -import {useTranslations} from "next-intl"; +import React, {ChangeEvent, useCallback, useContext, useEffect, useMemo} from 'react'; +import {Baseline, CaseSensitive, Eye, Indent, Palette, Type} from 'lucide-react'; +import {useTranslations} from '@/lib/i18n'; import SelectBox from "@/components/form/SelectBox"; +import Button from "@/components/ui/Button"; +import {AlertContext, AlertContextProps} from "@/context/AlertContext"; interface UserEditorSettingsProps { settings: EditorDisplaySettings; @@ -20,11 +21,15 @@ export interface EditorDisplaySettings { focusMode: boolean; } -const ZOOM_LABELS = ['Très petit', 'Petit', 'Normal', 'Grand', 'Très grand'] as const; -const FONT_SIZES = [14, 16, 18, 20, 22] as const; -const THEMES = ['clair', 'sombre', 'sépia'] as const; +const zoomLabels = ['Très petit', 'Petit', 'Normal', 'Grand', 'Très grand'] as const; +const fontSizes = [14, 16, 18, 20, 22] as const; +const themes = ['clair', 'sombre', 'sépia'] as const; -const DEFAULT_SETTINGS: EditorDisplaySettings = { +function isValidFontFamily(value: string): value is EditorDisplaySettings['fontFamily'] { + return value === 'lora' || value === 'serif' || value === 'sans-serif' || value === 'monospace'; +} + +const defaultSettings: EditorDisplaySettings = { zoomLevel: 3, indent: 30, lineHeight: 1.5, @@ -34,82 +39,91 @@ const DEFAULT_SETTINGS: EditorDisplaySettings = { focusMode: false }; -export default function UserEditorSettings({settings, onSettingsChange}: UserEditorSettingsProps) { +export default function UserEditorSettings({settings, onSettingsChange}: UserEditorSettingsProps): React.JSX.Element { const t = useTranslations(); - + const {errorMessage}: AlertContextProps = useContext(AlertContext); + const handleSettingChange = useCallback(( key: K, value: EditorDisplaySettings[K] - ) => { + ): void => { onSettingsChange({...settings, [key]: value}); }, [settings, onSettingsChange]); - const resetToDefaults = useCallback(() => { - onSettingsChange(DEFAULT_SETTINGS); + const resetToDefaults = useCallback((): void => { + onSettingsChange(defaultSettings); }, [onSettingsChange]); - const zoomOptions = useMemo(() => - ZOOM_LABELS.map((label, index) => ({ + const zoomOptions = useMemo((): { value: string; label: string }[] => + zoomLabels.map((label: typeof zoomLabels[number], index: number): { value: string; label: string } => ({ value: (index + 1).toString(), - label: `${t(`userEditorSettings.zoom.${label}`)} (${FONT_SIZES[index]}px)` + label: `${t(`userEditorSettings.zoom.${label}`)} (${fontSizes[index]}px)` })) , [t]); - const themeButtons = useMemo(() => - THEMES.map(theme => ({ + const themeButtons = useMemo((): { key: typeof themes[number]; isActive: boolean; className: string }[] => + themes.map((theme: typeof themes[number]): { + key: typeof themes[number]; + isActive: boolean; + className: string + } => ({ key: theme, isActive: settings.theme === theme, - className: `p-2.5 rounded-xl border capitalize transition-all duration-200 font-medium ${ + className: `p-2.5 rounded-xl border capitalize transition-colors duration-150 font-medium ${ settings.theme === theme - ? 'bg-primary text-text-primary border-primary shadow-md scale-105' - : 'bg-secondary/50 border-secondary/50 text-muted hover:text-text-primary hover:border-secondary hover:bg-secondary hover:scale-102' + ? 'bg-secondary text-primary border-primary' + : 'bg-secondary border-secondary text-muted hover:text-text-primary' }` })) , [settings.theme]); - + useEffect((): void => { try { const savedSettings: string | null = localStorage.getItem('userEditorSettings'); if (savedSettings) { - const parsed = JSON.parse(savedSettings); + const parsed: Partial = JSON.parse(savedSettings); if (parsed && typeof parsed === 'object') { - onSettingsChange({...DEFAULT_SETTINGS, ...parsed}); + onSettingsChange({...defaultSettings, ...parsed}); } } } catch (e: unknown) { - onSettingsChange(DEFAULT_SETTINGS); + onSettingsChange(defaultSettings); } }, [onSettingsChange]); - + useEffect((): () => void => { - const timeoutId = setTimeout((): void => { + const timeoutId: ReturnType = setTimeout((): void => { try { localStorage.setItem('userEditorSettings', JSON.stringify(settings)); - } catch (error) { - console.error('Erreur lors de la sauvegarde des settings:', error); + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(t('userEditorSettings.saveError')); + } else { + errorMessage(t('userEditorSettings.unknownError')); + } } }, 100); - + return (): void => clearTimeout(timeoutId); }, [settings]); return (
    -
    - + className="p-5 h-full overflow-y-auto"> +
    +

    {t("userEditorSettings.displayPreferences")}

    ) => { + onChangeCallBack={(e: ChangeEvent): void => { handleSettingChange('zoomLevel', Number(e.target.value)) }} data={zoomOptions} @@ -118,7 +132,7 @@ export default function UserEditorSettings({settings, onSettingsChange}: UserEdi
    @@ -128,7 +142,7 @@ export default function UserEditorSettings({settings, onSettingsChange}: UserEdi max={50} step={5} value={settings.indent} - onChange={(e) => handleSettingChange('indent', Number(e.target.value))} + onChange={(e: ChangeEvent): void => handleSettingChange('indent', Number(e.target.value))} className="w-full accent-primary" />
    @@ -141,12 +155,12 @@ export default function UserEditorSettings({settings, onSettingsChange}: UserEdi
    ) => handleSettingChange('lineHeight', Number(e.target.value))} + onChangeCallBack={(e: ChangeEvent): void => handleSettingChange('lineHeight', Number(e.target.value))} data={[ {value: "1.2", label: t("userEditorSettings.lineHeightCompact")}, {value: "1.5", label: t("userEditorSettings.lineHeightNormal")}, @@ -158,12 +172,17 @@ export default function UserEditorSettings({settings, onSettingsChange}: UserEdi
    ) => handleSettingChange('fontFamily', e.target.value as EditorDisplaySettings['fontFamily'])} + onChangeCallBack={(e: ChangeEvent): void => { + const fontValue: string = e.target.value; + if (isValidFontFamily(fontValue)) { + handleSettingChange('fontFamily', fontValue); + } + }} data={[ {value: "lora", label: t("userEditorSettings.fontLora")}, {value: "serif", label: t("userEditorSettings.fontSerif")}, @@ -175,7 +194,7 @@ export default function UserEditorSettings({settings, onSettingsChange}: UserEdi
    @@ -185,7 +204,7 @@ export default function UserEditorSettings({settings, onSettingsChange}: UserEdi max={1200} step={50} value={settings.maxWidth} - onChange={(e) => handleSettingChange('maxWidth', Number(e.target.value))} + onChange={(e: ChangeEvent): void => handleSettingChange('maxWidth', Number(e.target.value))} className="w-full accent-primary" />
    @@ -198,14 +217,18 @@ export default function UserEditorSettings({settings, onSettingsChange}: UserEdi
    - {themeButtons.map((themeBtn) => ( + {themeButtons.map((themeBtn: { + key: typeof themes[number]; + isActive: boolean; + className: string + }) => (
    -
    - +
    diff --git a/components/form/AddActionButton.tsx b/components/form/AddActionButton.tsx deleted file mode 100644 index 87ce487..0000000 --- a/components/form/AddActionButton.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; -import {faPlus} from "@fortawesome/free-solid-svg-icons"; -import React from "react"; - -interface AddActionButtonProps { - callBackAction: () => Promise; -} - -export default function AddActionButton( - { - callBackAction - }: AddActionButtonProps) { - return ( - - ) -} diff --git a/components/form/AdvancedGenerationOptions.tsx b/components/form/AdvancedGenerationOptions.tsx index 1405574..ad19be1 100644 --- a/components/form/AdvancedGenerationOptions.tsx +++ b/components/form/AdvancedGenerationOptions.tsx @@ -1,61 +1,48 @@ -'use client' -import React from "react"; -import {faBrain, faTriangleExclamation} from "@fortawesome/free-solid-svg-icons"; -import InputField from "@/components/form/InputField"; -import ToggleWithConfirmation from "@/components/form/ToggleWithConfirmation"; -import {useTranslations} from "next-intl"; - -interface AdvancedGenerationOptionsProps { - useExplicit: boolean; - setUseExplicit: (value: boolean) => void; - useSmart: boolean; - setUseSmart: (value: boolean) => void; -} - -export default function AdvancedGenerationOptions({ - useExplicit, - setUseExplicit, - useSmart, - setUseSmart -}: AdvancedGenerationOptionsProps) { - const t = useTranslations(); - - return ( -
    -
    - - } - /> - - } - /> -
    -
    - ); -} +'use client' +import React from "react"; +import {AlertTriangle, Brain} from "lucide-react"; +import ToggleField from "@/components/form/ToggleField"; +import {useTranslations} from '@/lib/i18n'; + +interface AdvancedGenerationOptionsProps { + useExplicit: boolean; + setUseExplicit: (value: boolean) => void; + useSmart: boolean; + setUseSmart: (value: boolean) => void; +} + +export default function AdvancedGenerationOptions({ + useExplicit, + setUseExplicit, + useSmart, + setUseSmart + }: AdvancedGenerationOptionsProps) { + const t = useTranslations(); + + return ( +
    + + +
    + ); +} diff --git a/components/form/CancelButton.tsx b/components/form/CancelButton.tsx deleted file mode 100644 index 42bed3c..0000000 --- a/components/form/CancelButton.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import React from "react"; - -interface CancelButtonProps { - callBackFunction: () => void; - text?: string; -} - -export default function CancelButton( - { - callBackFunction, - text = "Annuler" - }: CancelButtonProps) { - return ( - - ); -} diff --git a/components/form/CheckBox.tsx b/components/form/CheckBox.tsx index 090597c..63fc7ee 100644 --- a/components/form/CheckBox.tsx +++ b/components/form/CheckBox.tsx @@ -1,4 +1,4 @@ -import {Dispatch, SetStateAction} from "react"; +import React, {Dispatch, SetStateAction} from "react"; interface CheckBoxProps { isChecked: boolean; @@ -27,15 +27,15 @@ export default function CheckBox( className="hidden" />
    diff --git a/components/form/ConfirmButton.tsx b/components/form/ConfirmButton.tsx deleted file mode 100644 index b619290..0000000 --- a/components/form/ConfirmButton.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import React from "react"; - -type ButtonType = 'alert' | 'danger' | 'informatif' | 'success'; - -interface ConfirmButtonProps { - text: string; - callBackFunction?: () => void; - buttonType?: ButtonType; -} - -export default function ConfirmButton( - { - text, - callBackFunction, - buttonType = 'success' - }: ConfirmButtonProps) { - function getButtonType(alertType: ButtonType): string { - switch (alertType) { - case 'alert': - return 'bg-warning'; - case 'danger': - return 'bg-error'; - case 'informatif': - return 'bg-info'; - case 'success': - default: - return 'bg-success'; - } - } - - const applyType: string = getButtonType(buttonType); - return ( - - ) -} diff --git a/components/form/DatePicker.tsx b/components/form/DatePicker.tsx index 4743d00..0474aa9 100644 --- a/components/form/DatePicker.tsx +++ b/components/form/DatePicker.tsx @@ -1,4 +1,4 @@ -import {ChangeEvent} from "react"; +import React, {ChangeEvent} from "react"; interface DatePickerProps { date: string; @@ -15,7 +15,7 @@ export default function DatePicker( type="date" value={date} onChange={setDate} - className="bg-secondary/50 text-text-primary px-4 py-2.5 rounded-xl border border-secondary/50 focus:border-primary focus:ring-4 focus:ring-primary/20 focus:bg-secondary hover:bg-secondary hover:border-secondary outline-none transition-all duration-200" + className="input-base" /> ) } diff --git a/components/form/DeleteButton.tsx b/components/form/DeleteButton.tsx deleted file mode 100644 index 6143fea..0000000 --- a/components/form/DeleteButton.tsx +++ /dev/null @@ -1,66 +0,0 @@ -'use client'; -import {useState} from 'react'; -import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; -import {faTrash} from '@fortawesome/free-solid-svg-icons'; -import AlertBox from '@/components/AlertBox'; - -interface DeleteButtonProps { - onDelete: () => void | Promise; - confirmTitle: string; - confirmMessage: string; - confirmButtonText: string; - cancelButtonText: string; - disabled?: boolean; - className?: string; -} - -export default function DeleteButton( - { - onDelete, - confirmTitle, - confirmMessage, - confirmButtonText, - cancelButtonText, - disabled = false, - className = '' - }: DeleteButtonProps -) { - const [showConfirm, setShowConfirm] = useState(false); - - function handlePress(): void { - if (disabled) return; - setShowConfirm(true); - } - - async function handleConfirm(): Promise { - setShowConfirm(false); - await onDelete(); - } - - function handleCancel(): void { - setShowConfirm(false); - } - - return ( - <> - - {showConfirm && ( - - )} - - ); -} diff --git a/components/form/InlineAddInput.tsx b/components/form/InlineAddInput.tsx index 1cc2802..86d012d 100644 --- a/components/form/InlineAddInput.tsx +++ b/components/form/InlineAddInput.tsx @@ -1,5 +1,6 @@ -import {ChangeEvent, KeyboardEvent, useRef, useState} from "react"; -import AddActionButton from "@/components/form/AddActionButton"; +import React, {ChangeEvent, KeyboardEvent, useRef, useState} from "react"; +import IconButton from "@/components/ui/IconButton"; +import {Plus} from "lucide-react"; interface InlineAddInputProps { value: string; @@ -37,11 +38,11 @@ export default function InlineAddInput( setIsAdding(false); } }} - className="relative flex items-center gap-1 h-[44px] px-3 bg-secondary/30 rounded-xl border-2 border-dashed border-secondary/50 hover:border-primary/60 hover:bg-secondary/50 cursor-pointer transition-colors duration-200" + className="relative flex items-center gap-1 h-[44px] px-3 bg-dark-background rounded-xl border border-secondary hover:border-primary/60 cursor-pointer transition-colors duration-200" > {showNumericalInput && numericalValue !== undefined && setNumericalValue && ( ) => setNumericalValue(parseInt(e.target.value))} @@ -63,7 +64,7 @@ export default function InlineAddInput( /> {isAdding && (
    - +
    )} diff --git a/components/form/InputField.tsx b/components/form/InputField.tsx index 189853c..c34492f 100644 --- a/components/form/InputField.tsx +++ b/components/form/InputField.tsx @@ -1,20 +1,21 @@ -import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; import React from "react"; -import {IconDefinition} from "@fortawesome/fontawesome-svg-core"; -import {faPlus, faTrash} from "@fortawesome/free-solid-svg-icons"; +import {LucideIcon, Plus, Trash2} from "lucide-react"; +import IconButton from "@/components/ui/IconButton"; +import Button from "@/components/ui/Button"; +import Badge from "@/components/ui/Badge"; interface InputFieldProps { - icon?: IconDefinition, - fieldName?: string, - input: React.ReactNode, - addButtonCallBack?: () => Promise - removeButtonCallBack?: () => Promise - isAddButtonDisabled?: boolean - action?: () => Promise - actionLabel?: string - actionIcon?: IconDefinition - hint?: string, - centered?: boolean, + icon?: LucideIcon; + fieldName?: string; + input: React.ReactNode; + addButtonCallBack?: () => Promise; + removeButtonCallBack?: () => Promise; + isAddButtonDisabled?: boolean; + action?: () => Promise; + actionLabel?: string; + actionIcon?: LucideIcon; + hint?: string; + centered?: boolean; } export default function InputField( @@ -31,63 +32,51 @@ export default function InputField( hint, centered = false }: InputFieldProps) { + + function renderIcon(): React.JSX.Element | null { + if (!icon) return null; + const Icon: LucideIcon = icon; + return ; + } + return (
    -
    - { - fieldName && ( -

    - { - icon && - } - {fieldName} -

    - ) - } - { - action && ( - - ) - } +
    + {fieldName && ( +

    + {renderIcon()} + {fieldName} +

    + )} + {action && actionIcon && ( + + )} {hint && ( - - {hint} - + {hint} )}
    {input} - { - addButtonCallBack && ( - - ) - } - { - removeButtonCallBack && ( - - ) - } + {addButtonCallBack && ( + + )} + {removeButtonCallBack && ( + + )}
    ) diff --git a/components/form/NumberInput.tsx b/components/form/NumberInput.tsx index dd0bfcb..10d89d4 100644 --- a/components/form/NumberInput.tsx +++ b/components/form/NumberInput.tsx @@ -42,13 +42,7 @@ export default function NumberInput( type="number" value={value ?? ''} onChange={handleChange} - className={`w-full bg-secondary/50 text-text-primary px-4 py-2.5 rounded-xl border border-secondary/50 - focus:border-primary focus:ring-4 focus:ring-primary/20 focus:bg-secondary - hover:bg-secondary hover:border-secondary - placeholder:text-muted/60 - outline-none transition-all duration-200 - ${disabled ? 'opacity-50 cursor-not-allowed' : ''} - ${readOnly ? 'cursor-default' : ''}`} + className="input-base" placeholder={placeholder} readOnly={readOnly} disabled={disabled} diff --git a/components/form/RadioBox.tsx b/components/form/RadioBox.tsx index b4e4aee..2445824 100644 --- a/components/form/RadioBox.tsx +++ b/components/form/RadioBox.tsx @@ -1,7 +1,6 @@ -import {storyStates} from "@/lib/models/Story"; -import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; -import {faBookOpen, faKeyboard, faMagicWandSparkles, faPalette, faPenNib} from "@fortawesome/free-solid-svg-icons"; -import {Dispatch, SetStateAction} from "react"; +import {storyStates} from "@/lib/constants/story"; +import {BookOpen, Keyboard, LucideIcon, Palette, PenLine, Wand2} from "lucide-react"; +import React, {Dispatch, SetStateAction} from "react"; export interface RadioBoxValue { label: string; @@ -14,6 +13,8 @@ interface RadioBoxProps { name: string; } +const storyStateIcons: LucideIcon[] = [PenLine, Keyboard, Palette, BookOpen, Wand2]; + export default function RadioBox( { selected, @@ -22,41 +23,36 @@ export default function RadioBox( }: RadioBoxProps) { return (
    - {storyStates.map((option: RadioBoxValue) => ( -
    - setSelected(option.value)} - className="hidden" - /> -
    + ); + })}
    ) } diff --git a/components/form/RadioGroup.tsx b/components/form/RadioGroup.tsx index e26c6bc..2ab8cce 100644 --- a/components/form/RadioGroup.tsx +++ b/components/form/RadioGroup.tsx @@ -36,10 +36,10 @@ export default function RadioGroup( />
    ); diff --git a/components/form/SelectBox.tsx b/components/form/SelectBox.tsx index ace97b5..c35a50c 100644 --- a/components/form/SelectBox.tsx +++ b/components/form/SelectBox.tsx @@ -1,12 +1,26 @@ -import {ChangeEvent} from "react"; -import {SelectBoxProps} from "@/shared/interface"; +import React, {ChangeEvent} from "react"; +import {useTranslations} from '@/lib/i18n'; + +export interface SelectBoxProps { + label: string; + value: string; +} + +type InputSize = 'sm' | 'md' | 'lg'; +const sizeClasses: Record = { + sm: 'px-3 py-1.5 text-xs rounded-lg', + md: 'px-4 py-2.5 text-sm rounded-xl', + lg: 'px-5 py-3 text-base rounded-xl', +}; export interface SelectBoxFormProps { onChangeCallBack: (event: ChangeEvent) => void, data: SelectBoxProps[], defaultValue: string | null | undefined, placeholder?: string, - disabled?: boolean + disabled?: boolean, + size?: InputSize, + translate?: boolean } export default function SelectBox( @@ -15,26 +29,24 @@ export default function SelectBox( data, defaultValue, placeholder, - disabled + disabled, + size = 'md', + translate = false }: SelectBoxFormProps) { + const t = useTranslations(); return ( ) } diff --git a/components/form/TexteAreaInput.tsx b/components/form/TexteAreaInput.tsx deleted file mode 100644 index b9d5b0b..0000000 --- a/components/form/TexteAreaInput.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import {ChangeEvent, useEffect, useState} from "react"; - -interface TextAreaInputProps { - value: string; - setValue: (e: ChangeEvent) => void; - placeholder: string; - maxLength?: number; -} - -export default function TextAreaInput( - { - value, - setValue, - placeholder, - maxLength - }: TextAreaInputProps) { - const [prevLength, setPrevLength] = useState(value.length); - const [isGrowing, setIsGrowing] = useState(false); - - useEffect(() => { - if (value.length > prevLength) { - setIsGrowing(true); - setTimeout(() => setIsGrowing(false), 200); - } - setPrevLength(value.length); - }, [value.length, prevLength]); - - const getProgressPercentage = () => { - if (!maxLength) return 0; - return Math.min((value.length / maxLength) * 100, 100); - }; - - const getStatusStyles = () => { - if (!maxLength) return {}; - const percentage = getProgressPercentage(); - - if (percentage >= 100) return { - textColor: 'text-error', - bgColor: 'bg-error/10', - borderColor: 'border-error/30', - progressColor: 'bg-error' - }; - - if (percentage >= 90) return { - textColor: 'text-warning', - bgColor: 'bg-warning/10', - borderColor: 'border-warning/30', - progressColor: 'bg-warning' - }; - - if (percentage >= 75) return { - textColor: 'text-warning', - bgColor: 'bg-warning/10', - borderColor: 'border-warning/30', - progressColor: 'bg-warning' - }; - - return { - textColor: 'text-success', - bgColor: 'bg-success/10', - borderColor: 'border-success/30', - progressColor: 'bg-success' - }; - }; - - const styles = getStatusStyles(); - - return ( -
    -