Remove unused components and models for improved maintainability

- Deleted redundant components (`AddActionButton`, `AlertBox`, `AlertStack`, `BackButton`, `CancelButton`, and `CollapsableArea`) and related files.
- Removed unused models (`Book`, `BookSerie`, `BookTables`, `Character`, and `Chapter`) to reduce codebase clutter.
- Updated project structure and references to reflect these removals.
This commit is contained in:
natreex
2026-03-22 22:37:31 -04:00
parent e8aaef108b
commit 64ed90d993
229 changed files with 15091 additions and 21289 deletions

View File

@@ -1,36 +1,26 @@
'use client'
import React, {ChangeEvent, Dispatch, SetStateAction, useContext, useState} from "react";
import {AlertContext, AlertContextProps} from "@/context/AlertContext";
import {apiPost} from "@/lib/api/client";
import {isDesktop} from '@/lib/configs';
import * as tauri from '@/lib/tauri';
import {ChangeEvent, Dispatch, RefObject, SetStateAction, useContext, useEffect, useRef, useState} from "react";
import {AlertContext} from "@/context/AlertContext";
import System from "@/lib/models/System";
import {SessionContext} from "@/context/SessionContext";
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {
faBook,
faBookOpen,
faCalendarAlt,
faFileWord,
faInfo,
faPencilAlt,
faX
} from "@fortawesome/free-solid-svg-icons";
import {SelectBoxProps} from "@/shared/interface";
import {BookProps, bookTypes} from "@/lib/models/Book";
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
import {SessionContext, SessionContextProps} from "@/context/SessionContext";
import {Book, BookOpen, Calendar, FileText, Info, Pencil} from "lucide-react";
import SelectBox, {SelectBoxProps} from "@/components/form/SelectBox";
import {bookTypes} from "@/lib/constants/book";
import InputField from "@/components/form/InputField";
import TextInput from "@/components/form/TextInput";
import SelectBox from "@/components/form/SelectBox";
import DatePicker from "@/components/form/DatePicker";
import NumberInput from "@/components/form/NumberInput";
import TexteAreaInput from "@/components/form/TexteAreaInput";
import CancelButton from "@/components/form/CancelButton";
import SubmitButtonWLoading from "@/components/form/SubmitButtonWLoading";
import TextAreaInput from "@/components/form/TextAreaInput";
import Button from "@/components/ui/Button";
import Modal from "@/components/ui/Modal";
import GuideTour, {GuideStep} from "@/components/GuideTour";
import {UserProps} from "@/lib/models/User";
import {useTranslations} from "next-intl";
import {useTranslations} from '@/lib/i18n';
import {LangContext, LangContextProps} from "@/context/LangContext";
import OfflineContext, {OfflineContextType} from "@/context/OfflineContext";
import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext";
import {SyncedBook} from "@/lib/models/SyncedBook";
import {SyncedBook} from "@/lib/types/synced-book";
interface MinMax {
min: number;
@@ -39,13 +29,11 @@ interface MinMax {
export default function AddNewBookForm({setCloseForm}: { setCloseForm: Dispatch<SetStateAction<boolean>> }) {
const t = useTranslations();
const {lang} = useContext<LangContextProps>(LangContext);
const {session} = useContext(SessionContext);
const {errorMessage} = useContext(AlertContext);
const {setServerOnlyBooks, setLocalOnlyBooks} = useContext<BooksSyncContextProps>(BooksSyncContext)
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
const modalRef: RefObject<HTMLDivElement | null> = useRef<HTMLDivElement>(null);
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext);
const {session, setSession}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
const {errorMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
const {setServerSyncedBooks}: BooksSyncContextProps = useContext<BooksSyncContextProps>(BooksSyncContext)
const {isCurrentlyOffline}: OfflineContextType = useContext<OfflineContextType>(OfflineContext);
const [title, setTitle] = useState<string>('');
const [subtitle, setSubtitle] = useState<string>('');
const [summary, setSummary] = useState<string>('');
@@ -66,27 +54,27 @@ export default function AddNewBookForm({setCloseForm}: { setCloseForm: Dispatch<
content: (
<div className="space-y-4 max-h-96 overflow-y-auto custom-scrollbar">
<div className="space-y-3">
<div className="border-l-4 border-primary pl-4 bg-secondary/10 p-3 rounded-r-xl">
<div className="border-l-4 border-primary pl-4 bg-secondary p-3 rounded-r-xl">
<h4 className="font-semibold text-lg text-text-primary mb-1">{t("addNewBookForm.bookTypeHint.nouvelle.title")}</h4>
<p className="text-sm text-muted mb-2">{t("addNewBookForm.bookTypeHint.nouvelle.range")}</p>
<p className="text-sm text-text-secondary">{t("addNewBookForm.bookTypeHint.nouvelle.description")}</p>
</div>
<div className="border-l-4 border-primary pl-4 bg-secondary/10 p-3 rounded-r-xl">
<div className="border-l-4 border-primary pl-4 bg-secondary p-3 rounded-r-xl">
<h4 className="font-semibold text-lg text-text-primary mb-1">{t("addNewBookForm.bookTypeHint.novelette.title")}</h4>
<p className="text-sm text-muted mb-2">{t("addNewBookForm.bookTypeHint.novelette.range")}</p>
<p className="text-sm text-text-secondary">{t("addNewBookForm.bookTypeHint.novelette.description")}</p>
</div>
<div className="border-l-4 border-primary pl-4 bg-secondary/10 p-3 rounded-r-xl">
<div className="border-l-4 border-primary pl-4 bg-secondary p-3 rounded-r-xl">
<h4 className="font-semibold text-lg text-text-primary mb-1">{t("addNewBookForm.bookTypeHint.novella.title")}</h4>
<p className="text-sm text-muted mb-2">{t("addNewBookForm.bookTypeHint.novella.range")}</p>
<p className="text-sm text-text-secondary">{t("addNewBookForm.bookTypeHint.novella.description")}</p>
</div>
<div className="border-l-4 border-primary pl-4 bg-secondary/10 p-3 rounded-r-xl">
<div className="border-l-4 border-primary pl-4 bg-secondary p-3 rounded-r-xl">
<h4 className="font-semibold text-lg text-text-primary mb-1">{t("addNewBookForm.bookTypeHint.chapbook.title")}</h4>
<p className="text-sm text-muted mb-2">{t("addNewBookForm.bookTypeHint.chapbook.range")}</p>
<p className="text-sm text-text-secondary">{t("addNewBookForm.bookTypeHint.chapbook.description")}</p>
</div>
<div className="border-l-4 border-primary pl-4 bg-secondary/10 p-3 rounded-r-xl">
<div className="border-l-4 border-primary pl-4 bg-secondary p-3 rounded-r-xl">
<h4 className="font-semibold text-lg text-text-primary mb-1">{t("addNewBookForm.bookTypeHint.roman.title")}</h4>
<p className="text-sm text-muted mb-2">{t("addNewBookForm.bookTypeHint.roman.range")}</p>
<p className="text-sm text-text-secondary">{t("addNewBookForm.bookTypeHint.roman.description")}</p>
@@ -101,13 +89,6 @@ export default function AddNewBookForm({setCloseForm}: { setCloseForm: Dispatch<
),
}]
useEffect((): () => void => {
document.body.style.overflow = 'hidden';
return (): void => {
document.body.style.overflow = 'auto';
};
}, []);
async function handleAddBook(): Promise<void> {
if (!title) {
errorMessage(t('addNewBookForm.error.titleMissing'));
@@ -128,85 +109,60 @@ export default function AddNewBookForm({setCloseForm}: { setCloseForm: Dispatch<
}
setIsAddingBook(true);
try {
const bookData = {
title,
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<string>('book/add', bookData, token, lang);
bookId = await apiPost<string>('book/add', {
title: title,
subTitle: subtitle,
type: selectedBookType,
summary: summary,
serie: 0,
publicationDate: publicationDate,
desiredWordCount: wordCount,
}, token, lang);
}
if (!bookId) {
errorMessage(t('addNewBookForm.error.addingBook'))
errorMessage(t('addNewBookForm.error.addingBook'));
setIsAddingBook(false);
return;
}
const book: BookProps = {
bookId: bookId,
...bookData
const book: SyncedBook = {
id: bookId,
type: selectedBookType,
title: title,
subTitle: subtitle,
seriesId: null,
lastUpdate: new Date().getTime() / 1000,
chapters: [],
characters: [],
locations: [],
worlds: [],
incidents: [],
plotPoints: [],
issues: [],
actSummaries: [],
guideLine: null,
aiGuideLine: null
};
if (isCurrentlyOffline()){
setLocalOnlyBooks((prevBooks: SyncedBook[]): SyncedBook[] => [...prevBooks, {
id: book.bookId,
type: selectedBookType,
title: title,
subTitle: subtitle,
lastUpdate: new Date().getTime()/1000,
chapters: [],
characters: [],
locations: [],
worlds: [],
incidents: [],
plotPoints: [],
issues: [],
actSummaries: [],
guideLine: null,
aiGuideLine: null,
bookTools: null,
seriesId: null,
spells: [],
spellTags: []
}]);
}
else {
setServerOnlyBooks((prevBooks: SyncedBook[]): SyncedBook[] => [...prevBooks, {
id: book.bookId,
type: selectedBookType,
title: title,
subTitle: subtitle,
lastUpdate: new Date().getTime()/1000,
chapters: [],
characters: [],
locations: [],
worlds: [],
incidents: [],
plotPoints: [],
issues: [],
actSummaries: [],
guideLine: null,
aiGuideLine: null,
bookTools: null,
seriesId: null,
spells: [],
spellTags: []
}]);
}
setServerSyncedBooks((prev: SyncedBook[]): SyncedBook[] => [...prev, book])
setIsAddingBook(false);
setCloseForm(false)
} catch (e: unknown) {
console.error('[AddBook] Raw error:', e, typeof e, JSON.stringify(e));
const msg = e instanceof Error ? e.message : typeof e === 'object' && e !== null && 'message' in e ? String((e as {message:string}).message) : typeof e === 'string' ? e : t('addNewBookForm.error.addingBook');
errorMessage(msg);
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t('addNewBookForm.error.addingBook'));
}
setIsAddingBook(false);
}
}
@@ -247,96 +203,83 @@ export default function AddNewBookForm({setCloseForm}: { setCloseForm: Dispatch<
}
return (
<div
className="fixed inset-0 flex items-center justify-center bg-black/60 z-40 backdrop-blur-md animate-fadeIn">
<div ref={modalRef}
className="bg-tertiary/95 backdrop-blur-sm text-text-primary rounded-2xl border border-secondary/50 shadow-2xl md:w-3/4 xl:w-1/4 lg:w-2/4 sm:w-11/12 max-h-[85vh] flex flex-col">
<div className="flex justify-between items-center bg-primary px-6 py-4 rounded-t-2xl shadow-lg">
<h2 className="flex items-center gap-3 font-['ADLaM_Display'] text-2xl text-text-primary">
<FontAwesomeIcon icon={faBook} className="w-6 h-6"/>
{t("addNewBookForm.title")}
</h2>
<button
className="text-background hover:text-background w-10 h-10 rounded-xl hover:bg-white/20 transition-all duration-200 flex items-center justify-center hover:scale-110"
onClick={(): void => setCloseForm(false)}
>
<FontAwesomeIcon icon={faX} className={'w-5 h-5'}/>
</button>
</div>
<>
<Modal
icon={Book}
title={t("addNewBookForm.title")}
onClose={(): void => setCloseForm(false)}
size="sm"
footer={
<>
<Button variant="secondary" onClick={() => setCloseForm(false)}>{t("common.cancel")}</Button>
<Button
variant="primary"
onClick={handleAddBook}
isLoading={isAddingBook}
loadingText={t("addNewBookForm.adding")}
icon={Book}
>{t("addNewBookForm.add")}</Button>
</>
}
>
<InputField icon={BookOpen} fieldName={t("addNewBookForm.type")} input={
<SelectBox
onChangeCallBack={(e: ChangeEvent<HTMLSelectElement>): void => setSelectedBookType(e.target.value)}
data={bookTypes.map((types: SelectBoxProps): SelectBoxProps => {
return {
value: types.value,
label: t(types.label)
}
})} defaultValue={selectedBookType}
placeholder={t("addNewBookForm.typePlaceholder")}/>
} action={async (): Promise<void> => setBookTypeHint(true)} actionIcon={Info}/>
<InputField icon={Pencil} fieldName={t("addNewBookForm.bookTitle")} input={
<TextInput value={title}
setValue={(e: ChangeEvent<HTMLInputElement>): void => setTitle(e.target.value)}
placeholder={t("addNewBookForm.bookTitlePlaceholder")}/>
}/>
{
selectedBookType !== 'lyric' && (
<InputField icon={Pencil} fieldName={t("addNewBookForm.subtitle")} input={
<TextInput value={subtitle}
setValue={(e: ChangeEvent<HTMLInputElement>): void => setSubtitle(e.target.value)}
placeholder={t("addNewBookForm.subtitlePlaceholder")}/>
}/>
)
}
<div className="p-5 overflow-y-auto flex-grow custom-scrollbar">
<div className="space-y-6">
<InputField icon={faBookOpen} fieldName={t("addNewBookForm.type")} input={
<SelectBox
onChangeCallBack={(e: ChangeEvent<HTMLSelectElement>): void => setSelectedBookType(e.target.value)}
data={bookTypes.map((types: SelectBoxProps): SelectBoxProps => {
return {
value: types.value,
label: t(types.label)
}
})} defaultValue={selectedBookType}
placeholder={t("addNewBookForm.typePlaceholder")}/>
} action={async (): Promise<void> => setBookTypeHint(true)} actionIcon={faInfo}/>
<InputField icon={faPencilAlt} fieldName={t("addNewBookForm.bookTitle")} input={
<TextInput value={title}
setValue={(e: ChangeEvent<HTMLInputElement>): void => setTitle(e.target.value)}
placeholder={t("addNewBookForm.bookTitlePlaceholder")}/>
}/>
{
selectedBookType !== 'lyric' && (
<InputField icon={faPencilAlt} fieldName={t("addNewBookForm.subtitle")} input={
<TextInput value={subtitle}
setValue={(e: ChangeEvent<HTMLInputElement>): void => setSubtitle(e.target.value)}
placeholder={t("addNewBookForm.subtitlePlaceholder")}/>
}/>
)
}
<InputField icon={faCalendarAlt} fieldName={t("addNewBookForm.publicationDate")} input={
<DatePicker date={publicationDate}
setDate={(e: React.ChangeEvent<HTMLInputElement>): void => setPublicationDate(e.target.value)}/>
}/>
{
selectedBookType !== 'lyric' && (
<>
<InputField icon={faFileWord} fieldName={t("addNewBookForm.wordGoal")}
hint={selectedBookType && `${maxWordsCountHint().min.toLocaleString('fr-FR')} - ${maxWordsCountHint().max > 0 ? maxWordsCountHint().max.toLocaleString('fr-FR') : '∞'} ${t("addNewBookForm.words")}`}
input={
<NumberInput value={wordCount} setValue={setWordCount}
placeholder={t("addNewBookForm.wordGoalPlaceholder")}/>
}/>
<InputField
icon={faFileWord}
fieldName={t("addNewBookForm.summary")}
<InputField icon={Calendar} fieldName={t("addNewBookForm.publicationDate")} input={
<DatePicker date={publicationDate}
setDate={(e: React.ChangeEvent<HTMLInputElement>): void => setPublicationDate(e.target.value)}/>
}/>
{
selectedBookType !== 'lyric' && (
<>
<InputField icon={FileText} fieldName={t("addNewBookForm.wordGoal")}
hint={selectedBookType && `${maxWordsCountHint().min.toLocaleString('fr-FR')} - ${maxWordsCountHint().max > 0 ? maxWordsCountHint().max.toLocaleString('fr-FR') : '∞'} ${t("addNewBookForm.words")}`}
input={
<TexteAreaInput
value={summary}
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setSummary(e.target.value)}
placeholder={t("addNewBookForm.summaryPlaceholder")}
/>
}
<NumberInput value={wordCount} setValue={setWordCount}
placeholder={t("addNewBookForm.wordGoalPlaceholder")}/>
}/>
<InputField
icon={FileText}
fieldName={t("addNewBookForm.summary")}
input={
<TextAreaInput
value={summary}
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setSummary(e.target.value)}
placeholder={t("addNewBookForm.summaryPlaceholder")}
/>
</>
)
}
</div>
</div>
<div
className="flex justify-between items-center p-5 border-t border-secondary/50 bg-secondary/20 rounded-b-2xl">
<div></div>
<div className="flex gap-3">
<CancelButton callBackFunction={() => setCloseForm(false)}/>
<SubmitButtonWLoading callBackAction={handleAddBook} isLoading={isAddingBook}
text={t("addNewBookForm.add")}
loadingText={t("addNewBookForm.adding")} icon={faBook}/>
</div>
</div>
</div>
}
/>
</>
)
}
</Modal>
{bookTypeHint && <GuideTour stepId={0} steps={bookTypesHint} onClose={(): void => setBookTypeHint(false)}
onComplete={async (): Promise<void> => setBookTypeHint(false)}/>}
</div>
</>
);
}

View File

@@ -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 (
<div
className="group bg-tertiary/90 backdrop-blur-sm rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-300 h-full border border-secondary/50 hover:border-primary/50 flex flex-col">
<div className="relative w-full aspect-[2/3] flex-shrink-0 overflow-hidden rounded-t-2xl">
<button onClick={(): void => onClickCallback(book.bookId)} className="w-full h-full text-left block" type="button">
{book.coverImage ? (
<img
src={book.coverImage}
alt={book.title || t("bookCard.noCoverAlt")}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
/>
) : (
<div
className="relative w-full h-full bg-gradient-to-br from-secondary via-secondary to-gray-dark flex items-center justify-center overflow-hidden">
<div className="absolute inset-0 bg-primary/5"></div>
<span
className="relative text-primary/80 text-4xl sm:text-5xl md:text-6xl font-['ADLaM_Display'] tracking-wider">
{book.title.charAt(0).toUpperCase()}{t("bookCard.initialsSeparator")}{book.subTitle ? book.subTitle.charAt(0).toUpperCase() : ''}
</span>
<div
className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-primary/30 to-transparent"></div>
<div
className="absolute bottom-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-primary/30 to-transparent"></div>
</div>
<Link href={`/book/${book.bookId}`}>
<div
className="group relative aspect-[2/3] rounded-xl overflow-hidden cursor-pointer transition-all duration-300 hover:ring-1 hover:ring-text-primary/20">
{book.coverImage ? (
<img
src={book.coverImage}
alt={book.title || t("bookCard.noCoverAlt")}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full bg-secondary flex items-center justify-center">
<span className="text-muted text-5xl font-['ADLaM_Display']">
{book.title.charAt(0).toUpperCase()}
</span>
</div>
)}
<div className="absolute inset-x-0 bottom-0 bg-darkest-background/70 p-3">
<h3 className="text-text-primary font-bold text-sm truncate">
{book.title}
</h3>
{book.subTitle && (
<p className="text-text-secondary text-xs truncate mt-0.5">
{book.subTitle}
</p>
)}
</button>
</div>
<div
className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-tertiary via-tertiary/50 to-transparent h-24"></div>
</div>
<div className="p-4 flex-1 flex flex-col justify-between">
<div className="flex-1">
<button onClick={(): void => onClickCallback(book.bookId)} className="w-full text-left" type="button">
<h3 className="text-text-primary text-center font-bold text-base mb-2 truncate group-hover:text-primary transition-colors tracking-wide">
{book.title}
</h3>
</button>
<div className="flex items-center justify-center mb-3 h-5">
{book.subTitle ? (
<>
<div className="h-px w-8 bg-primary/30"></div>
<p className="text-muted text-center mx-2 text-xs italic truncate px-2">
{book.subTitle}
</p>
<div className="h-px w-8 bg-primary/30"></div>
</>
) : null}
</div>
</div>
<div className="flex justify-between items-center pt-3 border-t border-secondary/30">
<SyncBook status={syncStatus} bookId={book.bookId}/>
<div className="flex items-center gap-1" {...index === 0 && {'data-guide': 'bottom-book-card'}}>
<DeleteBook bookId={book.bookId}/>
</div>
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200"
onClick={(e: React.MouseEvent): void => e.preventDefault()}
{...(index === 0 && {'data-guide': 'bottom-book-card'})}
>
<DeleteBook bookId={book.bookId}/>
</div>
</div>
</div>
</Link>
)
}
}

View File

@@ -1,26 +1,25 @@
export default function BookCardSkeleton() {
return (
<div
className="bg-tertiary/90 backdrop-blur-sm rounded-xl shadow-lg h-full border border-secondary/50 flex flex-col animate-pulse">
<div className="relative w-full h-[400px] sm:h-32 md:h-48 lg:h-64 xl:h-80 flex-shrink-0">
<div className="w-full h-full bg-secondary/30 rounded-t-xl"></div>
<div
className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-background to-transparent h-20"></div>
</div>
<div className="p-3 flex-1 flex flex-col justify-between">
<div>
<div className="h-4 bg-secondary/30 rounded-lg mb-2 w-3/4 mx-auto"></div>
<div className="h-3 bg-secondary/20 rounded-lg w-1/2 mx-auto"></div>
</div>
<div className="flex justify-between items-center mt-4">
<div className="h-6 bg-secondary/30 rounded-full w-16"></div>
<div className="flex items-center space-x-2">
<div className="h-8 w-8 bg-secondary/30 rounded-lg"></div>
<div className="h-8 w-8 bg-secondary/30 rounded-lg"></div>
</div>
</div>
</div>
</div>
);
}
export default function BookCardSkeleton() {
return (
<div className="bg-tertiary rounded-xl h-full flex flex-col animate-pulse">
<div className="relative w-full h-[400px] sm:h-32 md:h-48 lg:h-64 xl:h-80 flex-shrink-0">
<div className="w-full h-full bg-secondary rounded-t-xl"></div>
<div
className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-background to-transparent h-20"></div>
</div>
<div className="p-3 flex-1 flex flex-col justify-between">
<div>
<div className="h-4 bg-secondary rounded-lg mb-2 w-3/4 mx-auto"></div>
<div className="h-3 bg-tertiary rounded-lg w-1/2 mx-auto"></div>
</div>
<div className="flex justify-between items-center mt-4">
<div className="h-6 bg-secondary rounded-full w-16"></div>
<div className="flex items-center space-x-2">
<div className="h-8 w-8 bg-secondary rounded-lg"></div>
<div className="h-8 w-8 bg-secondary rounded-lg"></div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,25 +1,24 @@
import React, {useContext, useEffect, useRef, useState} from "react";
import {apiGet, apiPost} from "@/lib/api/client";
import {isDesktop} from '@/lib/configs';
import * as tauri from '@/lib/tauri';
import {useContext, useEffect, useRef, useState} from "react";
import System from "@/lib/models/System";
import {AlertContext} from "@/context/AlertContext";
import {BookContext} from "@/context/BookContext";
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
import {AlertContext, AlertContextProps} from "@/context/AlertContext";
import SearchBook from "./SearchBook";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {faBook, faChevronLeft, faChevronRight, faDownload, faGear, faTrash} from "@fortawesome/free-solid-svg-icons";
import {SessionContext} from "@/context/SessionContext";
import Book, {BookProps} from "@/lib/models/Book";
import {useRouter} from "@/lib/navigation";
import {Book, ChevronLeft, ChevronRight, Download, Settings, Trash2} from 'lucide-react';
import Badge from "@/components/ui/Badge";
import {SessionContext, SessionContextProps} from "@/context/SessionContext";
import {BookProps} from "@/lib/types/book";
import {getBookTypeLabel} from "@/lib/utils/book";
import BookCard from "@/components/book/BookCard";
import BookCardSkeleton from "@/components/book/BookCardSkeleton";
import GuideTour, {GuideStep} from "@/components/GuideTour";
import User from "@/lib/models/User";
import {useTranslations} from "next-intl";
import {guideTourDone, setNewGuideTour} from "@/lib/utils/user";
import {useTranslations} from '@/lib/i18n';
import {LangContext, LangContextProps} from "@/context/LangContext";
import OfflineContext, {OfflineContextType} from "@/context/OfflineContext";
import {BooksSyncContext, BooksSyncContextProps, SyncType} from "@/context/BooksSyncContext";
import {SeriesSyncContext, SeriesSyncContextProps, SeriesSyncType} from "@/context/SeriesSyncContext";
import {BookSyncCompare, SyncedBook} from "@/lib/models/SyncedBook";
import {SeriesSyncCompare, SyncedSeries} from "@/lib/models/SyncedSeries";
import {SeriesListItemProps} from "@/lib/models/Series";
import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext";
import {SeriesListItemProps} from "@/lib/types/series";
import SeriesCard, {SeriesCardProps} from "@/components/series/SeriesCard";
import SeriesSetting from "@/components/series/SeriesSetting";
@@ -30,32 +29,19 @@ interface CategoryItem {
}
export default function BookList() {
const {session, setSession} = useContext(SessionContext);
const {session, setSession}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
const accessToken: string = session?.accessToken || '';
const {errorMessage} = useContext(AlertContext);
const {setBook} = useContext(BookContext);
const {errorMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
const router = useRouter();
const t = useTranslations();
const {lang} = useContext<LangContextProps>(LangContext);
const {isCurrentlyOffline, offlineMode} = useContext<OfflineContextType>(OfflineContext);
const {
booksToSyncFromServer,
booksToSyncToServer,
serverOnlyBooks,
localOnlyBooks,
serverSyncedBooks
} = useContext<BooksSyncContextProps>(BooksSyncContext);
const {
seriesToSyncFromServer,
seriesToSyncToServer,
serverOnlySeries,
localOnlySeries
} = useContext<SeriesSyncContextProps>(SeriesSyncContext);
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext)
const {serverSyncedBooks}: BooksSyncContextProps = useContext<BooksSyncContextProps>(BooksSyncContext)
const {isCurrentlyOffline}: OfflineContextType = useContext<OfflineContextType>(OfflineContext);
const [searchQuery, setSearchQuery] = useState<string>('');
const [groupedItems, setGroupedItems] = useState<Record<string, CategoryItem[]>>({});
const [isLoadingBooks, setIsLoadingBooks] = useState<boolean>(true);
const [showSeriesSettingId, setShowSeriesSettingId] = useState<string | null>(null);
const [isLocalSeries, setIsLocalSeries] = useState<boolean>(false);
const carouselRefs = useRef<Record<string, HTMLDivElement | null>>({});
const [bookGuide, setBookGuide] = useState<boolean>(false);
@@ -92,240 +78,165 @@ export default function BookList() {
content: (
<div>
<p>
<FontAwesomeIcon icon={faGear} className="mr-2 text-primary w-5 h-5"/>
<Settings strokeWidth={1.75} className="mr-2 text-primary w-5 h-5 inline"/>
{t("bookList.guideStep2ContentGear")}
</p>
<p>
<FontAwesomeIcon icon={faDownload} className="mr-2 text-primary w-5 h-5"/>
<Download strokeWidth={1.75} className="mr-2 text-primary w-5 h-5 inline"/>
{t("bookList.guideStep2ContentDownload")}
</p>
<p>
<FontAwesomeIcon icon={faTrash} className="mr-2 text-primary w-5 h-5"/>
<Trash2 strokeWidth={1.75} className="mr-2 text-primary w-5 h-5 inline"/>
{t("bookList.guideStep2ContentTrash")}
</p>
</div>
),
},
];
]
useEffect((): void => {
if (groupedItems && Object.keys(groupedItems).length > 0 && User.guideTourDone(session.user?.guideTour || [], 'new-first-book')) {
if (groupedItems && Object.keys(groupedItems).length > 0 && guideTourDone(session.user?.guideTour || [], 'new-first-book')) {
setBookGuide(true);
}
}, [groupedItems]);
useEffect((): void => {
const shouldFetch: boolean | "" =
(session.isConnected || accessToken) &&
(!isCurrentlyOffline() || offlineMode.isDatabaseInitialized);
loadBooksAndSeries().then()
}, [serverSyncedBooks]);
if (shouldFetch) {
loadBooksAndSeries().then();
}
}, [
session.isConnected,
accessToken,
offlineMode.isDatabaseInitialized,
offlineMode.isOffline,
booksToSyncFromServer,
booksToSyncToServer,
serverOnlyBooks,
localOnlyBooks,
serverSyncedBooks
]);
useEffect((): void => {
if (accessToken) loadBooksAndSeries().then();
}, [accessToken]);
async function handleFirstBookGuide(): Promise<void> {
try {
if (!isCurrentlyOffline()) {
const response: boolean = await System.authPostToServer<boolean>(
'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<boolean>(
'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<void> {
setIsLoadingBooks(true);
try {
let booksResponse: (BookProps & { itIsLocal?: boolean })[] = [];
let seriesResponse: SeriesListItemProps[] = [];
// ═══════════════════════════════════════════════════════════════
// PARTIE 1 : FETCH DES DONNÉES (dual logic)
// ═══════════════════════════════════════════════════════════════
if (!isCurrentlyOffline()) {
// ONLINE : fetch serveur + local en parallèle
const [onlineBooks, localBooks, onlineSeries, localSeries] = await Promise.all([
System.authGetQueryToServer<BookProps[]>('books', accessToken, lang),
offlineMode.isDatabaseInitialized
? tauri.getBooks()
: Promise.resolve([]),
System.authGetQueryToServer<SeriesListItemProps[]>('series/list', accessToken, lang),
offlineMode.isDatabaseInitialized
? tauri.getSeriesList() as Promise<SeriesListItemProps[]>
: Promise.resolve([])
]);
// Merge des livres (serveur + locaux uniques)
const onlineBookIds = new Set(onlineBooks.map(b => b.bookId));
const uniqueLocalBooks = localBooks.filter(b => !onlineBookIds.has(b.bookId));
booksResponse = [
...onlineBooks.map(b => ({...b, itIsLocal: false})),
...uniqueLocalBooks.map(b => ({...b, itIsLocal: true}))
];
// Merge des séries (serveur + locales uniques)
// Pour les séries synced, on merge les bookIds (serveur + local-only)
const localSeriesMap = new Map(localSeries.map(s => [s.id, s]));
const mergedOnlineSeries = onlineSeries.map(serverSeries => {
const localVersion = localSeriesMap.get(serverSeries.id);
if (localVersion) {
// Merger les bookIds : serveur + ceux du local qui ne sont pas sur le serveur
const serverBookIds = new Set(serverSeries.bookIds);
const localOnlyBookIds = localVersion.bookIds.filter(id => !serverBookIds.has(id));
return {
...serverSeries,
bookIds: [...serverSeries.bookIds, ...localOnlyBookIds]
};
}
return serverSeries;
});
const onlineSeriesIds = new Set(onlineSeries.map(s => s.id));
const uniqueLocalSeries = localSeries.filter(s => !onlineSeriesIds.has(s.id));
seriesResponse = [...mergedOnlineSeries, ...uniqueLocalSeries];
let booksResponse: BookProps[];
let seriesResponse: SeriesListItemProps[];
if (isDesktop && isCurrentlyOffline()) {
booksResponse = await tauri.getBooks() as BookProps[];
seriesResponse = await tauri.getSeriesList() as SeriesListItemProps[];
} else {
// OFFLINE : local seulement
if (!offlineMode.isDatabaseInitialized) {
setIsLoadingBooks(false);
return;
}
const [localBooks, localSeries] = await Promise.all([
tauri.getBooks(),
tauri.getSeriesList() as Promise<SeriesListItemProps[]>
[booksResponse, seriesResponse] = await Promise.all([
apiGet<BookProps[]>('books', accessToken, lang),
apiGet<SeriesListItemProps[]>('series/list', accessToken, lang)
]);
booksResponse = localBooks.map(b => ({...b, itIsLocal: true}));
seriesResponse = localSeries;
}
// ═══════════════════════════════════════════════════════════════
// PARTIE 2 : CRÉATION DU MAPPING BOOK → SERIES
// ═══════════════════════════════════════════════════════════════
const bookToSeriesMap: Map<string, SeriesListItemProps> = 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<string, SeriesListItemProps> = 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<string, CategoryItem[]> = {};
const processedSeriesIds: Set<string> = 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<string, CategoryItem[]> = {};
const processedSeriesIds: Set<string> = 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<string, CategoryItem[]> {
if (!searchQuery) {
return groupedItems;
}
const filtered: Record<string, CategoryItem[]> = {};
Object.entries(groupedItems).forEach(([category, items]) => {
const filteredItems = items.filter((item): boolean => {
Object.entries(groupedItems).forEach(([category, items]: [string, CategoryItem[]]): void => {
const filteredItems: CategoryItem[] = items.filter((item: CategoryItem): boolean => {
if (item.type === 'book' && item.book) {
return item.book.title.toLowerCase().includes(searchQuery.toLowerCase());
}
if (item.type === 'series' && item.series) {
const matchesSeriesName = item.series.name.toLowerCase().includes(searchQuery.toLowerCase());
const matchesBookTitle = item.series.books.some(
book => book.title.toLowerCase().includes(searchQuery.toLowerCase())
// Recherche dans le nom de la série ou dans les titres des livres
const matchesSeriesName: boolean = item.series.name.toLowerCase().includes(searchQuery.toLowerCase());
const matchesBookTitle: boolean = item.series.books.some(
(book: BookProps): boolean => book.title.toLowerCase().includes(searchQuery.toLowerCase())
);
return matchesSeriesName || matchesBookTitle;
}
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<void> {
try {
let localBookOnly: boolean = false;
let bookResponse: BookProps | null = null;
// DUAL LOGIC
const isOfflineBook = localOnlyBooks.find((book: SyncedBook): boolean => book.id === bookId);
if (isCurrentlyOffline() || isOfflineBook) {
if (isCurrentlyOffline() && !offlineMode.isDatabaseInitialized) {
errorMessage(t("bookList.errorBookDetails"));
return;
}
bookResponse = await tauri.getBookBasicInformation(bookId);
if (bookResponse) localBookOnly = true;
}
if (!bookResponse) {
bookResponse = await System.authGetQueryToServer<BookProps>(
'book/basic-information', accessToken, lang, {id: bookId}
);
}
if (!bookResponse) {
errorMessage(t("bookList.errorBookDetails"));
return;
}
if (setBook) {
setBook({
bookId: bookId,
title: bookResponse.title || '',
subTitle: bookResponse.subTitle || '',
summary: bookResponse.summary || '',
type: bookResponse.type || '',
serie: bookResponse.serie,
seriesId: bookResponse.seriesId,
publicationDate: bookResponse.publicationDate || '',
desiredWordCount: bookResponse.desiredWordCount || 0,
totalWordCount: 0,
localBook: localBookOnly,
coverImage: bookResponse.coverImage ? 'data:image/jpeg;base64,' + bookResponse.coverImage : '',
quillsenseEnabled: bookResponse.quillsenseEnabled,
tools: bookResponse.tools,
});
}
} catch (error: unknown) {
if (error instanceof Error) {
errorMessage(error.message);
} else {
errorMessage(t("bookList.errorUnknown"));
}
}
}
function 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<string, CategoryItem[]> = getFilteredGroupedItems();
return (
<div
className="flex flex-col items-center h-full overflow-hidden w-full text-text-primary font-['Lora']">
{session?.user && (
<div data-guide="search-bar" className="w-full max-w-3xl px-4 pt-6 pb-4">
<SearchBook searchQuery={searchQuery} setSearchQuery={setSearchQuery}/>
</div>
)}
className="flex flex-col h-full overflow-hidden w-full text-text-primary font-['Lora']">
<div className="flex flex-col w-full overflow-y-auto overflow-x-hidden h-full min-h-0 flex-grow">
{session?.user && (
<div data-guide="search-bar" className="sticky top-0 z-10 w-full flex justify-center px-4 py-3">
<SearchBook searchQuery={searchQuery} setSearchQuery={setSearchQuery}/>
</div>
)}
{
isLoadingBooks ? (
<>
@@ -483,10 +331,10 @@ export default function BookList() {
<div className="w-full mb-10">
<div className="flex justify-between items-center w-full max-w-5xl mx-auto mb-4 px-6">
<div className="h-8 bg-secondary/30 rounded-xl w-32 animate-pulse"></div>
<div className="h-6 bg-secondary/20 rounded-lg w-24 animate-pulse"></div>
<div className="h-8 bg-secondary rounded-xl w-32 animate-pulse"></div>
<div className="h-6 bg-tertiary rounded-lg w-24 animate-pulse"></div>
</div>
<div className="flex flex-wrap justify-center items-start w-full px-4">
{Array.from({length: 6}).map((_, id: number) => (
<div key={id}
@@ -503,8 +351,8 @@ export default function BookList() {
<h1 className="font-['ADLaM_Display'] text-4xl mb-3 text-text-primary">{t("bookList.library")}</h1>
<p className="text-muted italic text-lg">{t("bookList.booksAreMirrors")}</p>
</div>
{Object.entries(filteredItems).map(([category, items], index) => (
{Object.entries(filteredItems).map(([category, items]: [string, CategoryItem[]], index: number) => (
<div key={category} {...(index === 0 && {'data-guide': 'book-category'})}
className="w-full mb-10">
<div
@@ -513,33 +361,36 @@ export default function BookList() {
<span className="w-1 h-8 bg-primary rounded-full"></span>
{category}
</h2>
<span
className="text-muted text-lg font-medium bg-secondary/30 px-4 py-1.5 rounded-full">
<Badge variant="muted" size="md">
{getTotalItemsCount(items)} {t("bookList.works")}
</span>
</Badge>
</div>
<div className="group relative w-full">
<button
onClick={() => scrollCarousel(category, 'left')}
className="absolute left-3 top-1/2 -translate-y-1/2 z-10 bg-primary/80 backdrop-blur-sm hover:bg-primary text-white rounded-2xl w-12 h-12 flex items-center justify-center shadow-xl border border-primary-light/30 transition-all duration-200 opacity-0 group-hover:opacity-100 hover:scale-110"
>
<FontAwesomeIcon icon={faChevronLeft} className="w-5 h-5"/>
</button>
<div className="group/carousel relative w-full">
<div
ref={(el: HTMLDivElement | null) => { 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">
<button
onClick={(): void => scrollCarousel(category, 'left')}
className="flex items-center justify-center w-11 h-11 rounded-xl bg-primary-dark hover:bg-primary text-text-primary transition-all duration-200"
>
<ChevronLeft strokeWidth={1.75} className="w-5 h-5"/>
</button>
</div>
<div
ref={(el: HTMLDivElement | null) => {
carouselRefs.current[category] = el;
}}
className="flex items-start w-full overflow-x-auto px-4 gap-2 scroll-smooth scrollbar-hide"
>
{items.map((item, idx) => {
{items.map((item: CategoryItem, idx: number) => {
if (item.type === 'book' && item.book) {
return (
<div key={item.book.bookId}
{...(idx === 0 && {'data-guide': 'book-card'})}
className={`flex-shrink-0 w-64 sm:w-52 md:w-48 lg:w-56 xl:w-64 p-2 box-border ${User.guideTourDone(session.user?.guideTour || [], 'new-first-book') && 'mb-[200px]'}`}>
className={`flex-shrink-0 w-64 sm:w-52 md:w-48 lg:w-56 xl:w-64 p-2 box-border ${guideTourDone(session.user?.guideTour || [], 'new-first-book') && 'mb-[200px]'}`}>
<BookCard
book={item.book}
syncStatus={detectBookSyncStatus(item.book.bookId)}
onClickCallback={handleBookClick}
index={idx}
/>
@@ -553,21 +404,22 @@ export default function BookList() {
series={item.series}
onBookClick={handleBookClick}
onSettingsClick={handleSeriesSettingsClick}
getSyncStatus={detectBookSyncStatus}
seriesSyncStatus={detectSeriesSyncStatus(item.series.id)}
/>
);
}
return null;
})}
</div>
<button
onClick={() => scrollCarousel(category, 'right')}
className="absolute right-3 top-1/2 -translate-y-1/2 z-10 bg-primary/80 backdrop-blur-sm hover:bg-primary text-white rounded-2xl w-12 h-12 flex items-center justify-center shadow-xl border border-primary-light/30 transition-all duration-200 opacity-0 group-hover:opacity-100 hover:scale-110"
>
<FontAwesomeIcon icon={faChevronRight} className="w-5 h-5"/>
</button>
<div
className="absolute right-3 top-1/2 -translate-y-1/2 z-10 opacity-0 group-hover/carousel:opacity-100">
<button
onClick={(): void => scrollCarousel(category, 'right')}
className="flex items-center justify-center w-11 h-11 rounded-xl bg-primary-dark hover:bg-primary text-text-primary transition-all duration-200"
>
<ChevronRight strokeWidth={1.75} className="w-5 h-5"/>
</button>
</div>
</div>
</div>
))}
@@ -575,9 +427,11 @@ export default function BookList() {
) : (
<div className="flex items-center justify-center h-full">
<div className="text-center p-8 max-w-lg">
<div
className="w-24 h-24 bg-primary/20 text-primary rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-lg animate-pulse">
<FontAwesomeIcon icon={faBook} className={'w-12 h-12'}/>
<div className="mx-auto mb-6 w-fit">
<div
className="bg-primary/10 flex items-center justify-center w-16 h-16 rounded-2xl">
<Book strokeWidth={1.75} className="text-primary w-8 h-8"/>
</div>
</div>
<h2 className="text-4xl font-['ADLaM_Display'] mb-4 text-text-primary">{t("bookList.welcomeWritingWorkshop")}</h2>
<p className="text-muted mb-6 text-lg leading-relaxed">
@@ -591,16 +445,8 @@ export default function BookList() {
bookGuide && <GuideTour stepId={0} steps={bookGuideSteps} onComplete={handleFirstBookGuide}
onClose={() => setBookGuide(false)}/>
}
{showSeriesSettingId && (
<SeriesSetting
seriesId={showSeriesSettingId}
localSeries={isLocalSeries}
onClose={() => {
setShowSeriesSettingId(null);
setIsLocalSeries(false);
}}
/>
)}
{showSeriesSettingId &&
<SeriesSetting seriesId={showSeriesSettingId} onClose={() => setShowSeriesSettingId(null)}/>}
</div>
);
}

View File

@@ -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<SetStateAction<boolean>> }) {
const t = useTranslations();
const {lang} = useContext<LangContextProps>(LangContext);
const {session} = useContext(SessionContext);
const {errorMessage, successMessage} = useContext(AlertContext);
const {setServerOnlyBooks} = useContext<BooksSyncContextProps>(BooksSyncContext);
const modalRef: RefObject<HTMLDivElement | null> = useRef<HTMLDivElement>(null);
const token: string = session?.accessToken ?? '';
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext);
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
const {errorMessage, successMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
const {setServerSyncedBooks}: BooksSyncContextProps = useContext<BooksSyncContextProps>(BooksSyncContext);
const fileInputRef: React.RefObject<HTMLInputElement | null> = useRef<HTMLInputElement>(null);
const [isParsing, setIsParsing] = useState<boolean>(false);
const [isImporting, setIsImporting] = useState<boolean>(false);
const [importId, setImportId] = useState<string>('');
@@ -52,47 +38,40 @@ export default function ImportBookForm({setCloseForm}: { setCloseForm: Dispatch<
const [summary, setSummary] = useState<string>('');
const [selectedBookType, setSelectedBookType] = useState<string>('short');
const [selectedVersion, setSelectedVersion] = useState<string>('2');
const token: string = session?.accessToken ?? '';
const hasParsedFile: boolean = importId.length > 0 && chapters.length > 0;
const selectedCount: number = chapters.filter((chapter: ImportChapterSelection): boolean => chapter.selected).length;
const canImport: boolean = !isImporting && hasParsedFile && selectedCount > 0 && title.trim().length > 0;
useEffect((): () => void => {
document.body.style.overflow = 'hidden';
return (): void => {
document.body.style.overflow = 'auto';
};
}, []);
const handleFileChange = useCallback(async (e: ChangeEvent<HTMLInputElement>): Promise<void> => {
const file: File | undefined = e.target.files?.[0];
async function handleFileChange(event: ChangeEvent<HTMLInputElement>): Promise<void> {
const file: File | undefined = event.target.files?.[0];
if (!file) return;
if (!file.name.endsWith('.docx')) {
errorMessage(t('importBook.error.invalidFormat'));
return;
}
setIsParsing(true);
setImportId('');
setChapters([]);
try {
const response: ParsedDocxResponse = await System.authUploadFileToServer<ParsedDocxResponse>(
const response: ParsedDocxResponse = await apiUpload<ParsedDocxResponse>(
'book/import/parse',
file,
token,
lang,
lang
);
setImportId(response.importId);
setChapters(
response.chapters.map((chapter: { index: number; title: string; wordCount: number }): ImportChapterSelection => ({
response.chapters.map((chapter: ParsedChapterPreview): ImportChapterSelection => ({
index: chapter.index,
title: chapter.title,
wordCount: chapter.wordCount,
selected: true,
})),
}))
);
} catch (parseError: unknown) {
if (parseError instanceof Error) {
@@ -103,26 +82,26 @@ export default function ImportBookForm({setCloseForm}: { setCloseForm: Dispatch<
} finally {
setIsParsing(false);
}
}, [token, lang, errorMessage, t]);
const toggleChapter = useCallback((chapterIndex: number): void => {
}
function toggleChapter(chapterIndex: number): void {
setChapters((previousChapters: ImportChapterSelection[]): ImportChapterSelection[] =>
previousChapters.map((chapter: ImportChapterSelection): ImportChapterSelection =>
chapter.index === chapterIndex ? {...chapter, selected: !chapter.selected} : chapter,
),
chapter.index === chapterIndex ? {...chapter, selected: !chapter.selected} : chapter
)
);
}, []);
const toggleAllChapters = useCallback((selectAll: boolean): void => {
}
function toggleAllChapters(selectAll: boolean): void {
setChapters((previousChapters: ImportChapterSelection[]): ImportChapterSelection[] =>
previousChapters.map((chapter: ImportChapterSelection): ImportChapterSelection => ({
...chapter,
selected: selectAll,
})),
}))
);
}, []);
const handleImport = useCallback(async (): Promise<void> => {
}
async function handleImport(): Promise<void> {
if (!title.trim()) {
errorMessage(t('importBook.error.titleRequired'));
return;
@@ -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 (
<div className="fixed inset-0 flex items-center justify-center bg-black/60 z-50 backdrop-blur-md animate-fadeIn">
<div ref={modalRef}
className="bg-tertiary/95 backdrop-blur-sm text-text-primary rounded-2xl border border-secondary/50 shadow-2xl md:w-3/4 xl:w-1/4 lg:w-2/4 sm:w-11/12 max-h-[85vh] flex flex-col">
<div className="flex justify-between items-center bg-primary px-6 py-4 rounded-t-2xl shadow-lg">
<h2 className="flex items-center gap-3 font-['ADLaM_Display'] text-2xl text-text-primary">
<FontAwesomeIcon icon={faFileImport} className="w-6 h-6"/>
{t("importBook.header.title")}
</h2>
<button
className="text-background hover:text-background w-10 h-10 rounded-xl hover:bg-white/20 transition-all duration-200 flex items-center justify-center hover:scale-110"
onClick={(): void => setCloseForm(false)}
>
<FontAwesomeIcon icon={faX} className={'w-5 h-5'}/>
</button>
</div>
<div className="p-5 overflow-y-auto flex-grow custom-scrollbar">
<div className="space-y-6">
<label
className={`flex items-center justify-center gap-3 py-4 border-2 border-dashed border-primary rounded-xl cursor-pointer
bg-secondary/20 hover:bg-secondary/40 transition-all duration-200
${isParsing ? 'opacity-70 pointer-events-none' : ''}`}
>
{isParsing ? (
<FontAwesomeIcon icon={faSpinner} className="w-5 h-5 text-primary animate-spin"/>
) : (
<FontAwesomeIcon icon={faFileWord} className="w-5 h-5 text-primary"/>
)}
<span className="font-['ADLaM_Display'] text-primary">
{isParsing ? t('importBook.parsing') : t('importBook.pickFile')}
</span>
<input
type="file"
accept={DOCX_ACCEPT}
onChange={handleFileChange}
className="hidden"
disabled={isParsing}
/>
</label>
{hasParsedFile && (
<>
<InputField icon={faBookOpen} fieldName={t("importBook.fields.type.label")} input={
<SelectBox
onChangeCallBack={(e: ChangeEvent<HTMLSelectElement>): void => setSelectedBookType(e.target.value)}
data={bookTypes.map((types: SelectBoxProps): SelectBoxProps => ({
value: types.value,
label: t(types.label)
}))}
defaultValue={selectedBookType}
/>
}/>
<InputField icon={faBook} fieldName={t("importBook.fields.title.label")} input={
<TextInput
value={title}
setValue={(e: ChangeEvent<HTMLInputElement>): void => setTitle(e.target.value)}
placeholder={t("importBook.fields.title.placeholder")}
/>
}/>
<InputField icon={faBookmark} fieldName={t("importBook.fields.subTitle.label")} input={
<TextInput
value={subTitle}
setValue={(e: ChangeEvent<HTMLInputElement>): void => setSubTitle(e.target.value)}
placeholder={t("importBook.fields.subTitle.placeholder")}
/>
}/>
<InputField icon={faBook} fieldName={t("importBook.fields.summary.label")} input={
<TexteAreaInput
value={summary}
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setSummary(e.target.value)}
placeholder={t("importBook.fields.summary.placeholder")}
/>
}/>
<InputField icon={faLayerGroup} fieldName={t("importBook.fields.version.label")} input={
<SelectBox
onChangeCallBack={(e: ChangeEvent<HTMLSelectElement>): void => setSelectedVersion(e.target.value)}
data={chapterVersions.map((version: SelectBoxProps): SelectBoxProps => ({
value: version.value,
label: t(version.label)
}))}
defaultValue={selectedVersion}
/>
}/>
<div className="mt-4">
<div className="flex justify-between items-center mb-3">
<h3 className="font-['ADLaM_Display'] text-lg text-text-primary">
{t('importBook.chapters.title')}
</h3>
<span className="text-sm text-muted">
{t('importBook.chaptersDetected', {count: chapters.length})}
<Modal
icon={FileInput}
title={t("importBook.header")}
onClose={(): void => setCloseForm(false)}
size="sm"
footer={hasParsedFile ? (
<>
<Button variant="secondary" onClick={() => setCloseForm(false)}>{t("common.cancel")}</Button>
<Button
variant="primary"
onClick={handleImport}
isLoading={isImporting}
loadingText={t("importBook.importing")}
icon={FileInput}
>{t("importBook.submit")}</Button>
</>
) : undefined}
>
<input
ref={fileInputRef}
type="file"
accept={docxAccept}
onChange={handleFileChange}
className="hidden"
/>
<Button
variant="dashed"
size="lg"
icon={isParsing ? undefined : FileText}
isLoading={isParsing}
loadingText={t('importBook.parsing')}
onClick={(): void => fileInputRef.current?.click()}
disabled={isParsing}
fullWidth
>
{t('importBook.pickFile')}
</Button>
{hasParsedFile && (
<>
<InputField icon={BookOpen} fieldName={t("importBook.fields.type")} input={
<SelectBox
onChangeCallBack={(e: ChangeEvent<HTMLSelectElement>): void => setSelectedBookType(e.target.value)}
data={bookTypes.map((types: SelectBoxProps): SelectBoxProps => ({
value: types.value,
label: t(types.label)
}))}
defaultValue={selectedBookType}
placeholder={t("addNewBookForm.typePlaceholder")}
/>
}/>
<InputField icon={Pencil} fieldName={t("importBook.fields.title")} input={
<TextInput
value={title}
setValue={(e: ChangeEvent<HTMLInputElement>): void => setTitle(e.target.value)}
placeholder={t("addNewBookForm.bookTitlePlaceholder")}
/>
}/>
<InputField icon={Pencil} fieldName={t("importBook.fields.subTitle")} input={
<TextInput
value={subTitle}
setValue={(e: ChangeEvent<HTMLInputElement>): void => setSubTitle(e.target.value)}
placeholder={t("addNewBookForm.subtitlePlaceholder")}
/>
}/>
<InputField icon={Book} fieldName={t("importBook.fields.summary")} input={
<TextAreaInput
value={summary}
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setSummary(e.target.value)}
placeholder={t("addNewBookForm.summaryPlaceholder")}
/>
}/>
<InputField icon={Layers} fieldName={t("importBook.fields.version")} input={
<SelectBox
onChangeCallBack={(e: ChangeEvent<HTMLSelectElement>): void => setSelectedVersion(e.target.value)}
data={chapterVersions.map((version: SelectBoxProps): SelectBoxProps => ({
value: version.value,
label: t(version.label)
}))}
defaultValue={selectedVersion}
placeholder={""}
/>
}/>
<div className="mt-2">
<div className="flex justify-between items-center mb-3">
<h3 className="text-text-primary text-xl font-['ADLaM_Display'] font-medium flex items-center gap-2">
<BookOpen className="text-primary w-5 h-5" strokeWidth={1.75}/>
{t('importBook.chapters.title')}
</h3>
<Badge variant="muted" size="sm">
{t('importBook.chapters.detected', {count: chapters.length})}
</Badge>
</div>
<div className="mb-3 hover:underline w-fit">
<Button variant="ghost" size="sm"
onClick={(): void => toggleAllChapters(selectedCount < chapters.length)}>
{selectedCount === chapters.length
? t('importBook.chapters.deselectAll')
: t('importBook.chapters.selectAll')}
</Button>
</div>
<div className="space-y-1">
{chapters.map((chapter: ImportChapterSelection) => (
<button
key={chapter.index}
onClick={(): void => toggleChapter(chapter.index)}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-secondary transition-all duration-150"
>
{chapter.selected
? <SquareCheck className="w-4 h-4 text-primary" strokeWidth={1.75}/>
: <Square className="w-4 h-4 text-muted" strokeWidth={1.75}/>
}
<div className="flex-1 text-left">
<span
className={`text-sm font-medium block truncate ${chapter.selected ? 'text-text-primary' : 'text-muted'}`}>
{chapter.title}
</span>
<span className="text-xs text-muted">
{chapter.wordCount.toLocaleString('fr-FR')} {t('importBook.chapters.words')}
</span>
</div>
<button
onClick={(): void => toggleAllChapters(selectedCount < chapters.length)}
className="text-sm text-primary hover:text-primary/80 mb-3 transition-colors"
>
{selectedCount === chapters.length
? t('importBook.chapters.deselectAll')
: t('importBook.chapters.selectAll')}
</button>
<div className="space-y-1">
{chapters.map((chapter: ImportChapterSelection) => (
<button
key={chapter.index}
onClick={(): void => toggleChapter(chapter.index)}
className="flex items-center gap-3 w-full py-2 px-3 rounded-lg hover:bg-secondary/30 transition-colors text-left"
>
<FontAwesomeIcon
icon={chapter.selected ? faSquareCheck : faSquare}
className={`w-4 h-4 ${chapter.selected ? 'text-primary' : 'text-muted'}`}
/>
<div className="flex-1 min-w-0">
<p className={`text-sm truncate ${chapter.selected ? 'text-text-primary' : 'text-muted'}`}>
{chapter.title}
</p>
<p className="text-xs text-muted">
{t('importBook.chapters.words', {count: chapter.wordCount})}
</p>
</div>
</button>
))}
</div>
</div>
</>
)}
</div>
</div>
{hasParsedFile && (
<div className="flex justify-between items-center p-5 border-t border-secondary/50 bg-secondary/20 rounded-b-2xl">
<div></div>
<div className="flex gap-3">
<CancelButton callBackFunction={() => setCloseForm(false)}/>
<SubmitButtonWLoading
callBackAction={handleImport}
isLoading={isImporting}
text={t("importBook.submit")}
loadingText={t("importBook.importing")}
icon={faFileImport}
/>
</button>
))}
</div>
</div>
)}
</div>
</div>
</>
)}
</Modal>
);
}

View File

@@ -1,8 +1,6 @@
import {faSearch} from "@fortawesome/free-solid-svg-icons";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {ChangeEvent, Dispatch, SetStateAction} from "react";
import {useTranslations} from "next-intl";
import TextInput from "@/components/form/TextInput";
import {Search} from 'lucide-react';
import React, {ChangeEvent, Dispatch, SetStateAction} from "react";
import {useTranslations} from '@/lib/i18n';
export default function SearchBook(
{
@@ -15,20 +13,16 @@ export default function SearchBook(
const t = useTranslations();
return (
<div className="flex items-center relative my-5 w-full max-w-3xl">
<div className="relative flex-grow">
<div className="relative">
<FontAwesomeIcon icon={faSearch}
className="absolute left-4 top-1/2 transform -translate-y-1/2 text-primary w-5 h-5 pointer-events-none z-10"/>
<div className="pl-11">
<TextInput
value={searchQuery}
setValue={(e: ChangeEvent<HTMLInputElement>) => setSearchQuery(e.target.value)}
placeholder={t("searchBook.placeholder")}
/>
</div>
</div>
</div>
<div
className="flex items-center gap-3 w-full max-w-3xl bg-secondary rounded-xl px-4 py-2.5 border border-secondary hover:bg-gray-dark hover:border-gray-dark focus-within:border-primary focus-within:ring-4 focus-within:ring-primary/20 transition-all duration-200">
<Search className="text-muted w-4 h-4 flex-shrink-0" strokeWidth={1.75}/>
<input
type="text"
value={searchQuery}
onChange={(e: ChangeEvent<HTMLInputElement>): void => setSearchQuery(e.target.value)}
placeholder={t("searchBook.placeholder")}
className="w-full bg-transparent text-text-primary text-sm placeholder:text-muted outline-none"
/>
</div>
)
}

View File

@@ -1,38 +1,35 @@
'use client'
import * as tauri from '@/lib/tauri';
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {faFeather, faTimes} from "@fortawesome/free-solid-svg-icons";
import {ChangeEvent, forwardRef, useContext, useImperativeHandle, useState} from "react";
import System from "@/lib/models/System";
import {X} from 'lucide-react';
import IconButton from "@/components/ui/IconButton";
import React, {ChangeEvent, forwardRef, useContext, useImperativeHandle, useState} from "react";
import {apiDelete, apiPost} from "@/lib/api/client";
import axios, {AxiosResponse} from "axios";
import {AlertContext} from "@/context/AlertContext";
import {BookContext} from "@/context/BookContext";
import {SessionContext} from "@/context/SessionContext";
import {AlertContext, AlertContextProps} from "@/context/AlertContext";
import {BookContext, BookContextProps} from "@/context/BookContext";
import {SessionContext, SessionContextProps} from "@/context/SessionContext";
import TextInput from "@/components/form/TextInput";
import TexteAreaInput from "@/components/form/TexteAreaInput";
import TextAreaInput from "@/components/form/TextAreaInput";
import InputField from "@/components/form/InputField";
import NumberInput from "@/components/form/NumberInput";
import DatePicker from "@/components/form/DatePicker";
import {configs} from "@/lib/configs";
import {useTranslations} from "next-intl";
import {configs, isDesktop} from "@/lib/configs";
import * as tauri from '@/lib/tauri';
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
import {useTranslations} from '@/lib/i18n';
import {LangContext, LangContextProps} from "@/context/LangContext";
import {BookProps} from "@/lib/models/Book";
import OfflineContext, {OfflineContextType} from "@/context/OfflineContext";
import {LocalSyncQueueContext, LocalSyncQueueContextProps} from "@/context/SyncQueueContext";
import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext";
import {SyncedBook} from "@/lib/models/SyncedBook";
import {BookProps} from "@/lib/types/book";
import {SettingRef} from "@/lib/types/settings";
import ImageDropZone from "@/components/form/ImageDropZone";
function BasicInformationSetting(props: any, ref: any) {
function BasicInformationSetting(_props: object, ref: React.ForwardedRef<SettingRef>): React.JSX.Element {
const t = useTranslations();
const {lang} = useContext<LangContextProps>(LangContext)
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
const {addToQueue} = useContext<LocalSyncQueueContextProps>(LocalSyncQueueContext);
const {localSyncedBooks} = useContext<BooksSyncContextProps>(BooksSyncContext);
const {session} = useContext(SessionContext);
const {book, setBook} = useContext(BookContext);
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext);
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
const {book, setBook}: BookContextProps = useContext<BookContextProps>(BookContext);
const userToken: string = session?.accessToken ? session?.accessToken : '';
const {errorMessage, successMessage} = useContext(AlertContext);
const {errorMessage, successMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
const {isCurrentlyOffline}: OfflineContextType = useContext<OfflineContextType>(OfflineContext);
const bookId: string = book?.bookId ? book?.bookId.toString() : '';
const [currentImage, setCurrentImage] = useState<string>(book?.coverImage ?? '');
@@ -42,21 +39,14 @@ function BasicInformationSetting(props: any, ref: any) {
const [publicationDate, setPublicationDate] = useState<string>(book?.publicationDate ? book?.publicationDate : '');
const [wordCount, setWordCount] = useState<number>(book?.desiredWordCount ? book?.desiredWordCount : 0);
useImperativeHandle(ref, function () {
useImperativeHandle(ref, function (): SettingRef {
return {
handleSave: handleSave
};
});
async function handleCoverImageChange(e: ChangeEvent<HTMLInputElement>): Promise<void> {
const file: File | undefined = e.target.files?.[0];
if (!file) {
errorMessage(t('basicInformationSetting.error.noFileSelected'));
return;
}
const formData = new FormData();
async function handleCoverImageChange(file: File): Promise<void> {
const formData: FormData = new FormData();
formData.append('bookId', bookId);
formData.append('picture', file);
@@ -69,15 +59,15 @@ function BasicInformationSetting(props: any, ref: any) {
},
params: {
lang: lang,
plateforme: 'desktop',
plateforme: 'web',
},
data: formData,
responseType: 'arraybuffer'
});
const contentType: string = query.headers['content-type'] || 'image/jpeg';
const blob = new Blob([query.data], {type: contentType});
const reader = new FileReader();
const blob: Blob = new Blob([query.data], {type: contentType});
const reader: FileReader = new FileReader();
reader.onloadend = function (): void {
if (typeof reader.result === 'string') {
@@ -87,20 +77,17 @@ function BasicInformationSetting(props: any, ref: any) {
reader.readAsDataURL(blob);
} catch (e: unknown) {
if (axios.isAxiosError(e)) {
const serverMessage: string = e.response?.data?.message || e.response?.data || e.message;
throw new Error(serverMessage as string);
} else if (e instanceof Error) {
throw new Error(e.message);
if (e instanceof Error) {
errorMessage(e.message);
} else {
throw new Error('An unexpected error occurred');
errorMessage(t('basicInformationSetting.error.unknown'));
}
}
}
async function handleRemoveCurrentImage(): Promise<void> {
try {
const response: boolean = await System.authDeleteToServer<boolean>(`book/cover/delete`, {
const response: boolean = await apiDelete<boolean>(`book/cover/delete`, {
bookId: bookId
}, userToken, lang);
if (!response) {
@@ -115,7 +102,7 @@ function BasicInformationSetting(props: any, ref: any) {
}
}
}
async function handleSave(): Promise<void> {
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<boolean>('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<boolean>('book/basic-information', {
title: title,
subTitle: subTitle,
summary: summary,
publicationDate: publicationDate,
wordCount: wordCount,
bookId: bookId
}, userToken, lang);
}
if (!response) {
errorMessage(t('basicInformationSetting.error.update'));
@@ -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 (
<div className="space-y-6">
<div className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<InputField fieldName={t('basicInformationSetting.fields.title')} input={<TextInput
value={title}
setValue={(e: ChangeEvent<HTMLInputElement>) => setTitle(e.target.value)}
placeholder={t('basicInformationSetting.fields.titlePlaceholder')}
/>}/>
<InputField fieldName={t('basicInformationSetting.fields.subtitle')} input={<TextInput
value={subTitle}
setValue={(e: ChangeEvent<HTMLInputElement>) => setSubTitle(e.target.value)}
placeholder={t('basicInformationSetting.fields.subtitlePlaceholder')}
/>}/>
</div>
</div>
<div className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
<InputField fieldName={t('basicInformationSetting.fields.summary')} input={<TexteAreaInput
value={summary}
setValue={(e: ChangeEvent<HTMLTextAreaElement>) => setSummary(e.target.value)}
placeholder={t('basicInformationSetting.fields.summaryPlaceholder')}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<InputField fieldName={t('basicInformationSetting.fields.title')} input={<TextInput
value={title}
setValue={(e: ChangeEvent<HTMLInputElement>): void => setTitle(e.target.value)}
placeholder={t('basicInformationSetting.fields.titlePlaceholder')}
/>}/>
<InputField fieldName={t('basicInformationSetting.fields.subtitle')} input={<TextInput
value={subTitle}
setValue={(e: ChangeEvent<HTMLInputElement>): void => setSubTitle(e.target.value)}
placeholder={t('basicInformationSetting.fields.subtitlePlaceholder')}
/>}/>
</div>
<div className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<InputField fieldName={t('basicInformationSetting.fields.publicationDate')} input={
<DatePicker
date={publicationDate}
setDate={(e: ChangeEvent<HTMLInputElement>) => setPublicationDate(e.target.value)}
/>
}/>
<InputField fieldName={t('basicInformationSetting.fields.wordCount')} input={
<NumberInput value={wordCount} setValue={setWordCount}
placeholder={t('basicInformationSetting.fields.wordCountPlaceholder')}/>
}/>
</div>
<InputField fieldName={t('basicInformationSetting.fields.summary')} input={<TextAreaInput
value={summary}
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setSummary(e.target.value)}
placeholder={t('basicInformationSetting.fields.summaryPlaceholder')}
/>}/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<InputField fieldName={t('basicInformationSetting.fields.publicationDate')} input={
<DatePicker
date={publicationDate}
setDate={(e: ChangeEvent<HTMLInputElement>): void => setPublicationDate(e.target.value)}
/>
}/>
<InputField fieldName={t('basicInformationSetting.fields.wordCount')} input={
<NumberInput value={wordCount} setValue={setWordCount}
placeholder={t('basicInformationSetting.fields.wordCountPlaceholder')}/>
}/>
</div>
<div className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
<div>
{currentImage ? (
<div className="flex justify-center">
<div className="relative w-40">
<img src={currentImage} alt={t('basicInformationSetting.fields.coverImageAlt')}
className="rounded-lg border border-secondary shadow-md w-full h-auto"/>
<button
type="button"
className="absolute -top-2 -right-2 bg-error/90 hover:bg-error text-white rounded-full w-8 h-8 flex items-center justify-center hover:scale-110 transition-all duration-200 shadow-lg"
onClick={handleRemoveCurrentImage}
>
<FontAwesomeIcon icon={faTimes} className={'w-5 h-5'}/>
</button>
</div>
</div>
) : (
<div className="flex justify-center">
<div className="w-full max-w-lg">
<div
className="p-6 border-2 border-dashed border-secondary/50 rounded-xl bg-secondary/20 hover:border-primary/60 hover:bg-secondary/30 transition-all duration-200 shadow-inner">
<InputField fieldName={t('basicInformationSetting.fields.coverImage')}
actionIcon={faFeather}
actionLabel={t('basicInformationSetting.fields.generateWithQuillSense')}
action={async () => {
}} input={<input
type="file"
id="coverImage"
accept="image/png, image/jpeg"
onChange={handleCoverImageChange}
className="w-full text-text-secondary focus:outline-none file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-primary file:text-background hover:file:bg-primary-dark file:cursor-pointer"
/>}/>
className="rounded-lg border border-secondary w-full h-auto"/>
<div className="absolute -top-2 -right-2">
<IconButton icon={X} variant="danger" size="sm"
onClick={handleRemoveCurrentImage}/>
</div>
</div>
</div>
) : (
<ImageDropZone
onFileSelect={handleCoverImageChange}
label={t('basicInformationSetting.fields.coverImage')}
/>
)}
</div>
</div>
);
}
export default forwardRef(BasicInformationSetting);
export default forwardRef<SettingRef, object>(BasicInformationSetting);

View File

@@ -2,8 +2,8 @@
import {useState} from "react";
import BookSettingSidebar from "@/components/book/settings/BookSettingSidebar";
import BookSettingOption from "@/components/book/settings/BookSettingOption";
import SettingsPanel from "@/components/SettingsPanel";
import {useTranslations} from "next-intl";
import SettingsPanel from "@/components/ui/SettingsPanel";
import {useTranslations} from '@/lib/i18n';
interface BookSettingProps {
onClose: () => void;
@@ -19,7 +19,7 @@ export default function BookSetting({onClose}: BookSettingProps) {
sidebar={<BookSettingSidebar selectedSetting={currentSetting} setSelectedSetting={setCurrentSetting}/>}
onClose={onClose}
>
<BookSettingOption setting={currentSetting}/>
<BookSettingOption key={currentSetting} setting={currentSetting}/>
</SettingsPanel>
);
}

View File

@@ -1,17 +1,24 @@
'use client'
import React, {lazy, Suspense, useRef} from 'react';
import {faPen, faSave} from '@fortawesome/free-solid-svg-icons';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faSpinner} from '@fortawesome/free-solid-svg-icons';
import {useTranslations} from 'next-intl';
import PanelHeader from '@/components/PanelHeader';
import React, {lazy, Suspense, useContext, useRef, useState} from 'react';
import {Save} from 'lucide-react';
import {useTranslations} from '@/lib/i18n';
import SectionHeader from "@/components/ui/SectionHeader";
import IconButton from "@/components/ui/IconButton";
import PulseLoader from '@/components/ui/PulseLoader';
import ToggleSwitch from "@/components/form/ToggleSwitch";
import {BookContext, BookContextProps} from "@/context/BookContext";
import {SessionContext, SessionContextProps} from "@/context/SessionContext";
import {AlertContext, AlertContextProps} from "@/context/AlertContext";
import {LangContext, LangContextProps} from "@/context/LangContext";
import {apiPatch} from "@/lib/api/client";
import {SettingRef} from "@/lib/types/settings";
import {BookProps} from "@/lib/types/book";
// Lazy loaded components - avec ref (anciens)
const BasicInformationSetting = lazy(function () {
return import('./BasicInformationSetting');
});
const GuideLineSetting = lazy(function () {
return import('./guide-line/GuideLineSetting');
return import('./guideline/GuideLineSetting');
});
const StorySetting = lazy(function () {
return import('./story/StorySetting');
@@ -19,8 +26,6 @@ const StorySetting = lazy(function () {
const QuillSenseSetting = lazy(function () {
return import('./quillsense/QuillSenseSetting');
});
// Lazy loaded components - sans ref (nouveaux avec leur propre header)
const WorldSettings = lazy(function () {
return import('./world/settings/WorldSettings');
});
@@ -37,28 +42,41 @@ const ExportSetting = lazy(function () {
return import('./ExportSetting');
});
function LoadingSpinner(): React.JSX.Element {
return (
<div className="flex items-center justify-center py-12">
<FontAwesomeIcon icon={faSpinner} className="w-8 h-8 text-primary animate-spin"/>
</div>
);
}
interface BookSettingOptionProps {
setting: string;
}
interface SettingRef {
handleSave: () => Promise<void>;
}
// Settings qui gèrent leur propre save (pas de bouton save parent)
const selfManagedSettings: string[] = ['characters', 'spells', 'world', 'worlds', 'locations', 'export'];
type ToolName = 'worlds' | 'locations' | 'characters' | 'spells' | 'quillsense';
const toggleableSettings: Record<string, ToolName> = {
'world': 'worlds',
'worlds': 'worlds',
'locations': 'locations',
'characters': 'characters',
'spells': 'spells',
'quillsense': 'quillsense',
};
function getInitialToolEnabled(setting: string, book: BookProps | null): boolean {
const toolName: ToolName | undefined = toggleableSettings[setting];
if (!toolName) return false;
if (toolName === 'quillsense') return book?.quillsenseEnabled ?? false;
return book?.tools?.[toolName] ?? false;
}
export default function BookSettingOption({setting}: BookSettingOptionProps): React.JSX.Element {
const t = useTranslations();
const settingRef = useRef<SettingRef>(null);
const settingRef: React.RefObject<SettingRef | null> = useRef<SettingRef>(null);
const {book, setBook}: BookContextProps = useContext<BookContextProps>(BookContext);
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
const {errorMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext);
const userToken: string = session?.accessToken ?? '';
const isToggleable: boolean = setting in toggleableSettings;
const [toolEnabled, setToolEnabled] = useState<boolean>(getInitialToolEnabled(setting, book));
const showSaveButton: boolean = !selfManagedSettings.includes(setting);
@@ -86,47 +104,91 @@ export default function BookSettingOption({setting}: BookSettingOptionProps): Re
return "";
}
}
async function handleToggleTool(enabled: boolean): Promise<void> {
const toolName: ToolName | undefined = toggleableSettings[setting];
if (!toolName) return;
try {
const result: boolean = await apiPatch<boolean>('book/tool-setting', {
bookId: book?.bookId,
toolName: toolName,
enabled: enabled
}, userToken, lang);
if (result && setBook && book) {
setToolEnabled(enabled);
if (toolName === 'quillsense') {
setBook({...book, quillsenseEnabled: enabled});
} else {
setBook({
...book,
tools: {
characters: toolName === 'characters' ? enabled : (book.tools?.characters ?? false),
worlds: toolName === 'worlds' ? enabled : (book.tools?.worlds ?? false),
locations: toolName === 'locations' ? enabled : (book.tools?.locations ?? false),
spells: toolName === 'spells' ? enabled : (book.tools?.spells ?? false),
}
});
}
}
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t('bookSettingOption.unknownError'));
}
}
}
async function handleSaveClick(): Promise<void> {
if (settingRef.current?.handleSave) {
await settingRef.current.handleSave();
}
}
function renderHeaderActions(): React.JSX.Element {
return (
<div className="flex items-center gap-3">
{isToggleable && (
<ToggleSwitch
checked={toolEnabled}
onChange={handleToggleTool}
size="sm"
/>
)}
{showSaveButton && (
<IconButton icon={Save} variant="primary" onClick={handleSaveClick}/>
)}
</div>
);
}
return (
<div className="px-6">
<div className="sticky top-0 z-10 bg-tertiary pt-6 pb-4">
<PanelHeader
icon={faPen}
badge="BI"
<div className="sticky top-0 z-10 bg-darkest-background pt-6 pb-4">
<SectionHeader
title={renderTitle()}
description=""
secondActionCallback={showSaveButton ? handleSaveClick : undefined}
callBackAction={showSaveButton ? handleSaveClick : undefined}
secondActionIcon={showSaveButton ? faSave : undefined}
actions={renderHeaderActions()}
/>
</div>
<div className="bg-secondary/10 rounded-xl p-1 mb-6">
<Suspense fallback={<LoadingSpinner/>}>
<div className="mb-6">
<Suspense fallback={<PulseLoader/>}>
{setting === 'basic-information' && <BasicInformationSetting ref={settingRef}/>}
{setting === 'guide-line' && <GuideLineSetting ref={settingRef}/>}
{setting === 'story' && <StorySetting ref={settingRef}/>}
{setting === 'quillsense' && <QuillSenseSetting ref={settingRef}/>}
{setting === 'quillsense' && <QuillSenseSetting ref={settingRef} toolEnabled={toolEnabled}/>}
{(setting === 'world' || setting === 'worlds') && (
<WorldSettings entityType="book" showToggle={true}/>
<WorldSettings entityType="book" toolEnabled={toolEnabled}/>
)}
{setting === 'locations' && (
<LocationSettings entityType="book" showToggle={true}/>
<LocationSettings entityType="book" toolEnabled={toolEnabled}/>
)}
{setting === 'characters' && (
<CharacterSettings entityType="book" showToggle={true}/>
<CharacterSettings entityType="book" toolEnabled={toolEnabled}/>
)}
{setting === 'spells' && (
<SpellSettings entityType="book" showToggle={true}/>
)}
{setting === 'export' && (
<ExportSetting/>
<SpellSettings entityType="book" toolEnabled={toolEnabled}/>
)}
{setting === 'export' && <ExportSetting/>}
{!['basic-information', 'guide-line', 'story', 'world', 'worlds', 'locations', 'characters', 'spells', 'quillsense', 'export'].includes(setting) && (
<div className="text-text-secondary py-4 text-center">
{t("bookSettingOption.notAvailable")}

View File

@@ -1,26 +1,12 @@
'use client'
// Removed Next.js Link import for Electron
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {
faBook,
faDownload,
faGlobe,
faHatWizard,
faListAlt,
faMapMarkedAlt,
faPencilAlt,
faUser,
faWandMagicSparkles
} from "@fortawesome/free-solid-svg-icons";
import {Dispatch, SetStateAction, useContext} from "react";
import {IconDefinition} from "@fortawesome/fontawesome-svg-core";
import {useTranslations} from "next-intl";
import OfflineContext, {OfflineContextType} from "@/context/OfflineContext";
import React, {Dispatch, SetStateAction} from "react";
import {Book, Download, Globe, List, LucideIcon, Map, Pencil, User, Wand2} from 'lucide-react';
import {useTranslations} from '@/lib/i18n';
interface BookSettingOption {
id: string;
name: string;
icon: IconDefinition;
icon: LucideIcon;
}
export default function BookSettingSidebar(
@@ -32,92 +18,44 @@ export default function BookSettingSidebar(
setSelectedSetting: Dispatch<SetStateAction<string>>
}) {
const t = useTranslations();
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
const settings: BookSettingOption[] = [
{
id: 'basic-information',
name: 'bookSetting.basicInformation',
icon: faPencilAlt
},
{
id: 'guide-line',
name: 'bookSetting.guideLine',
icon: faListAlt
},
{
id: 'story',
name: 'bookSetting.story',
icon: faBook
},
{
id: 'world',
name: 'bookSetting.world',
icon: faGlobe
},
{
id: 'locations',
name: 'bookSetting.locations',
icon: faMapMarkedAlt
},
{
id: 'characters',
name: 'bookSetting.characters',
icon: faUser
},
{
id: 'spells',
name: 'bookSetting.spells',
icon: faHatWizard
},
{
id: 'quillsense',
name: 'bookSetting.quillsense',
icon: faWandMagicSparkles
},
{
id: 'export',
name: 'bookSetting.export',
icon: faDownload
},
// {
// id: 'objects',
// name: t('bookSetting.objects'),
// icon: faLocationArrow
// },
// {
// id: 'goals',
// name: t('bookSetting.goals'),
// icon: faCogs
// },
]
// Filter out QuillSense when offline (requires server connection)
const availableSettings: BookSettingOption[] = isCurrentlyOffline()
? settings.filter((s: BookSettingOption) => s.id !== 'quillsense')
: settings;
{id: 'basic-information', name: 'bookSetting.basicInformation', icon: Pencil},
{id: 'guide-line', name: 'bookSetting.guideLine', icon: List},
{id: 'story', name: 'bookSetting.story', icon: Book},
{id: 'world', name: 'bookSetting.world', icon: Globe},
{id: 'locations', name: 'bookSetting.locations', icon: Map},
{id: 'characters', name: 'bookSetting.characters', icon: User},
{id: 'spells', name: 'bookSetting.spells', icon: Wand2},
{id: 'quillsense', name: 'bookSetting.quillsense', icon: Wand2},
{id: 'export', name: 'bookSetting.export', icon: Download},
];
return (
<div className="py-6 px-3">
<nav className="space-y-1">
{
availableSettings.map((setting: BookSettingOption) => (
<div className="py-4 px-2">
<nav className="space-y-0.5">
{settings.map((setting: BookSettingOption) => {
const Icon: LucideIcon = setting.icon;
const isActive: boolean = selectedSetting === setting.id;
return (
<button
key={setting.id}
onClick={(): void => setSelectedSetting(setting.id)}
type="button"
className={`flex items-center text-base rounded-xl transition-all duration-200 w-full ${
selectedSetting === setting.id
? 'bg-primary/20 text-text-primary border-l-4 border-primary font-semibold shadow-md scale-105'
: 'text-text-secondary hover:bg-secondary/50 hover:text-text-primary hover:scale-102'
} p-3 mb-1`}>
<FontAwesomeIcon icon={setting.icon}
className={`mr-3 ${selectedSetting === setting.id ? 'text-primary w-5 h-5' : 'text-text-secondary w-5 h-5'}`}/>
className={`flex items-center w-full text-sm rounded-lg transition-colors duration-150 px-3 py-2 ${
isActive
? 'bg-secondary text-text-primary font-medium'
: 'text-text-secondary hover:bg-secondary/50 hover:text-text-primary'
}`}
>
<Icon
className={`mr-2.5 w-4 h-4 flex-shrink-0 ${isActive ? 'text-primary' : 'text-muted'}`}
strokeWidth={1.75}
/>
{t(setting.name)}
</button>
))
}
);
})}
</nav>
</div>
)
);
}

View File

@@ -1,118 +1,71 @@
import {Trash2} from 'lucide-react';
import React, {useContext, useState} from "react";
import IconButton from "@/components/ui/IconButton";
import {apiDelete} from "@/lib/api/client";
import {isDesktop} from '@/lib/configs';
import * as tauri from '@/lib/tauri';
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {faTrash} from "@fortawesome/free-solid-svg-icons";
import {useContext, useState} from "react";
import System from "@/lib/models/System";
import {SessionContext} from "@/context/SessionContext";
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
import {SessionContext, SessionContextProps} from "@/context/SessionContext";
import {BookContext, BookContextProps} from "@/context/BookContext";
import {LangContext, LangContextProps} from "@/context/LangContext";
import {AlertContext, AlertContextProps} from "@/context/AlertContext";
import AlertBox from "@/components/AlertBox";
import OfflineContext, {OfflineContextType} from "@/context/OfflineContext";
import AlertBox from "@/components/ui/AlertBox";
import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext";
import {SyncedBook} from "@/lib/models/SyncedBook";
import {useTranslations} from "next-intl";
import {SyncedBook} from "@/lib/types/synced-book";
interface DeleteBookProps {
bookId: string;
}
export default function DeleteBook({bookId}: DeleteBookProps) {
const {session} = useContext(SessionContext);
const {lang} = useContext<LangContextProps>(LangContext)
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext)
const [showConfirmBox, setShowConfirmBox] = useState<boolean>(false);
const [deleteLocalToo, setDeleteLocalToo] = useState<boolean>(false);
const {errorMessage} = useContext<AlertContextProps>(AlertContext)
const {serverOnlyBooks,setServerOnlyBooks,localOnlyBooks,setLocalOnlyBooks,localSyncedBooks,setLocalSyncedBooks,setServerSyncedBooks} = useContext<BooksSyncContextProps>(BooksSyncContext);
const t = useTranslations('deleteBook');
const ifLocalOnlyBook: SyncedBook | undefined = localOnlyBooks.find((book: SyncedBook): boolean => book.id === bookId);
const ifSyncedBook: SyncedBook | undefined = localSyncedBooks.find((book: SyncedBook): boolean => book.id === bookId);
const {errorMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext)
const {setServerSyncedBooks}: BooksSyncContextProps = useContext<BooksSyncContextProps>(BooksSyncContext)
const {book}: BookContextProps = useContext<BookContextProps>(BookContext);
const {isCurrentlyOffline}: OfflineContextType = useContext<OfflineContextType>(OfflineContext);
function handleConfirmation(): void {
setDeleteLocalToo(false);
setShowConfirmBox(true);
}
async function handleDeleteBook(): Promise<void> {
try {
let response: boolean;
const deletedAt: number = System.timeStampInSeconds();
const deleteData = { id: bookId, deletedAt };
if (isCurrentlyOffline() || ifLocalOnlyBook) {
response = await tauri.deleteBook(deleteData.id, deleteData.deletedAt);
if (isDesktop && (isCurrentlyOffline() || book?.localBook)) {
response = await tauri.deleteBook(bookId, Date.now());
} else {
response = await System.authDeleteToServer<boolean>(
`book/delete`,
deleteData,
session.accessToken,
lang
);
// If synced book and user wants to delete local too
if (response && ifSyncedBook && deleteLocalToo) {
await tauri.deleteBook(deleteData.id, deleteData.deletedAt);
}
response = await apiDelete<boolean>('book/delete', {
id: bookId,
}, session.accessToken, lang);
}
if (response) {
setShowConfirmBox(false);
if (ifLocalOnlyBook) {
setLocalOnlyBooks(localOnlyBooks.filter((b: SyncedBook): boolean => b.id !== bookId));
} else if (ifSyncedBook) {
// Remove from synced lists
setLocalSyncedBooks(localSyncedBooks.filter((b: SyncedBook): boolean => b.id !== bookId));
setServerSyncedBooks((prev: SyncedBook[]): SyncedBook[] => prev.filter((b: SyncedBook): boolean => b.id !== bookId));
// If not deleting local, move to localOnlyBooks
if (!deleteLocalToo) {
setLocalOnlyBooks([...localOnlyBooks, ifSyncedBook]);
}
} else {
setServerOnlyBooks(serverOnlyBooks.filter((b: SyncedBook): boolean => b.id !== bookId));
if (!response) {
errorMessage("Une erreur est survenue lors de la suppression du livre.");
return;
}
setServerSyncedBooks((prev: SyncedBook[]): SyncedBook[] => prev.filter((book: SyncedBook): boolean => book.id !== bookId))
}
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message)
} else {
errorMessage(t('errorUnknown'));
errorMessage("Une erreur inconnue est survenue lors de la suppression du livre.");
}
}
}
return (
<>
<button onClick={handleConfirmation}
className="text-muted hover:text-error hover:bg-error/10 transition-all duration-200 p-2 rounded-lg hover:scale-110">
<FontAwesomeIcon icon={faTrash} className={'w-5 h-5'}/>
</button>
<IconButton icon={Trash2} variant="ghost" shape="square" onClick={handleConfirmation}/>
{
showConfirmBox && (
<AlertBox title={t('title')}
message={t('message')}
type={"danger"}
onConfirm={handleDeleteBook}
onCancel={() => setShowConfirmBox(false)}
confirmText={t('confirm')}
cancelText={t('cancel')}>
{ifSyncedBook && !isCurrentlyOffline() && (
<div className="mt-4 p-3 bg-error/10 border border-error/30 rounded-lg">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={deleteLocalToo}
onChange={(e) => setDeleteLocalToo(e.target.checked)}
className="w-5 h-5 accent-error cursor-pointer"
/>
<span className="text-sm text-text-primary">
{t('deleteLocalToo')}
</span>
</label>
<p className="text-xs text-error mt-2">
{t('deleteLocalWarning')}
</p>
</div>
)}
</AlertBox>
<AlertBox title={'Suppression du livre'}
message={'Vous être sur le point de supprimer votre livre définitivement.'} type={"danger"}
onConfirm={handleDeleteBook} onCancel={() => setShowConfirmBox(false)}
confirmText={'Supprimer'} cancelText={'Annuler'}/>
)
}
</>

View File

@@ -1,228 +1,288 @@
'use client'
import React, {useCallback, useContext, useEffect, useMemo, useState} from 'react';
import {Check} from 'lucide-react';
import Button from '@/components/ui/Button';
import PulseLoader from '@/components/ui/PulseLoader';
import {useTranslations} from '@/lib/i18n';
import {BookContext, BookContextProps} from '@/context/BookContext';
import {SessionContext, SessionContextProps} from '@/context/SessionContext';
import {AlertContext, AlertContextProps} from '@/context/AlertContext';
import {configs, isDesktop} from '@/lib/configs';
import {apiGet} from '@/lib/api/client';
import * as tauri from '@/lib/tauri';
import React, {useCallback, useContext, useEffect, useState} from 'react';
import {useTranslations} from 'next-intl';
import {BookContext} from '@/context/BookContext';
import {AlertContext} from '@/context/AlertContext';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faDownload, faSpinner} from '@fortawesome/free-solid-svg-icons';
import {
ChapterExportInfo,
ChapterExportSelection,
chapterVersions,
ExportFormat
} from '@/lib/models/Chapter';
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
import {LangContext, LangContextProps} from '@/context/LangContext';
import {ChapterExportInfo, ChapterExportSelection, ExportFormat,} from '@/lib/types/chapter';
import {chapterVersions} from '@/lib/constants/chapter';
import {SelectBoxProps} from '@/components/form/SelectBox';
const exportFormats: {value: ExportFormat; label: string}[] = [
{value: 'epub', label: 'EPUB'},
{value: 'pdf', label: 'PDF'},
{value: 'docx', label: 'DOCX'},
];
const formats: ExportFormat[] = ['epub', 'pdf', 'docx'];
const formatExtensions: Record<ExportFormat, string> = {
epub: '.epub',
pdf: '.pdf',
docx: '.docx',
};
export default function ExportSetting(): React.JSX.Element {
const t = useTranslations();
const {book} = useContext(BookContext);
const {errorMessage, successMessage} = useContext(AlertContext);
const {book}: BookContextProps = useContext<BookContextProps>(BookContext);
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
const {successMessage, errorMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext);
const {isCurrentlyOffline}: OfflineContextType = useContext<OfflineContextType>(OfflineContext);
const [format, setFormat] = useState<ExportFormat>('epub');
const [chapters, setChapters] = useState<ChapterExportInfo[]>([]);
const [selections, setSelections] = useState<ChapterExportSelection[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [selectedFormat, setSelectedFormat] = useState<ExportFormat>('epub');
const [chaptersExportInfo, setChaptersExportInfo] = useState<ChapterExportInfo[]>([]);
const [chapterSelections, setChapterSelections] = useState<ChapterExportSelection[]>([]);
const [isLoadingChapters, setIsLoadingChapters] = useState<boolean>(true);
const [isExporting, setIsExporting] = useState<boolean>(false);
const loadChapters = useCallback(async (): Promise<void> => {
if (!book) return;
setIsLoading(true);
try {
const chaptersInfo: ChapterExportInfo[] = await tauri.getBookExportInfo(book.bookId) as ChapterExportInfo[];
setChapters(chaptersInfo);
const initialSelections: ChapterExportSelection[] = chaptersInfo.map(
(ch: ChapterExportInfo): ChapterExportSelection => ({
chapterId: ch.chapterId,
version: ch.availableVersions[ch.availableVersions.length - 1],
selected: true
})
);
setSelections(initialSelections);
} catch {
errorMessage(t('exportOption.error'));
} finally {
setIsLoading(false);
const selectedCount: number = useMemo(
() => chapterSelections.filter((selection: ChapterExportSelection): boolean => selection.selected).length,
[chapterSelections],
);
const allSelected: boolean = useMemo(
() => chapterSelections.length > 0 && chapterSelections.every((selection: ChapterExportSelection): boolean => selection.selected),
[chapterSelections],
);
const canExport: boolean = useMemo(
() => !isExporting && selectedCount > 0,
[isExporting, selectedCount],
);
const fetchChaptersExportInfo = useCallback(async (): Promise<void> => {
if (!book?.bookId) {
setIsLoadingChapters(false);
return;
}
}, [book]);
useEffect((): void => {
loadChapters();
}, [loadChapters]);
function toggleChapter(chapterId: string): void {
setSelections((prev: ChapterExportSelection[]): ChapterExportSelection[] =>
prev.map((s: ChapterExportSelection): ChapterExportSelection =>
s.chapterId === chapterId ? {...s, selected: !s.selected} : s
)
);
}
function toggleAll(selectAll: boolean): void {
setSelections((prev: ChapterExportSelection[]): ChapterExportSelection[] =>
prev.map((s: ChapterExportSelection): ChapterExportSelection => ({...s, selected: selectAll}))
);
}
function updateVersion(chapterId: string, version: number): void {
setSelections((prev: ChapterExportSelection[]): ChapterExportSelection[] =>
prev.map((s: ChapterExportSelection): ChapterExportSelection =>
s.chapterId === chapterId ? {...s, version} : s
)
);
}
function getVersionLabel(version: number): string {
const found = chapterVersions.find((v) => v.value === String(version));
return found ? t(found.label) : `v${version}`;
}
async function handleExport(): Promise<void> {
if (!book) return;
setIsExporting(true);
setIsLoadingChapters(true);
try {
const selectedChapters = selections
.filter((s: ChapterExportSelection): boolean => s.selected)
.map((s: ChapterExportSelection) => ({chapterId: s.chapterId, version: s.version}));
const result: boolean = await tauri.exportBook({
bookId: book.bookId,
format,
selections: selectedChapters.length === chapters.length ? null : selectedChapters
});
if (result) {
successMessage(t('exportOption.success'));
let data: ChapterExportInfo[];
if (isDesktop && (isCurrentlyOffline() || book?.localBook)) {
data = await tauri.getBookExportInfo(book.bookId) as ChapterExportInfo[];
} else {
data = await apiGet<ChapterExportInfo[]>(
'book/chapters/export-info', session.accessToken, lang, {id: book.bookId}
);
}
setChaptersExportInfo(data);
setChapterSelections(
data.map((chapter: ChapterExportInfo): ChapterExportSelection => ({
chapterId: chapter.chapterId,
version: chapter.availableVersions.length > 0
? chapter.availableVersions[chapter.availableVersions.length - 1]
: 1,
selected: true,
})),
);
} catch {
errorMessage(t('exportOption.error'));
errorMessage(t('exportOption.serverError'));
} finally {
setIsLoadingChapters(false);
}
}, [book?.bookId, session.accessToken, lang, errorMessage, t]);
useEffect((): void => {
fetchChaptersExportInfo();
}, [fetchChaptersExportInfo]);
function toggleChapterSelection(chapterId: string): void {
setChapterSelections((previous: ChapterExportSelection[]): ChapterExportSelection[] =>
previous.map((selection: ChapterExportSelection): ChapterExportSelection =>
selection.chapterId === chapterId
? {...selection, selected: !selection.selected}
: selection,
),
);
}
function toggleAllChapters(): void {
const newSelected: boolean = !allSelected;
setChapterSelections((previous: ChapterExportSelection[]): ChapterExportSelection[] =>
previous.map((selection: ChapterExportSelection): ChapterExportSelection => ({
...selection,
selected: newSelected,
})),
);
}
function setChapterVersion(chapterId: string, version: number): void {
setChapterSelections((previous: ChapterExportSelection[]): ChapterExportSelection[] =>
previous.map((selection: ChapterExportSelection): ChapterExportSelection =>
selection.chapterId === chapterId
? {...selection, version}
: selection,
),
);
}
function getVersionLabel(version: number): string {
const match: SelectBoxProps | undefined = chapterVersions.find(
(chapterVersion: SelectBoxProps): boolean => chapterVersion.value === String(version),
);
return match ? t(match.label) : `V${version}`;
}
async function handleExport(): Promise<void> {
if (!book?.bookId) {
errorMessage(t('exportOption.noBookSelected'));
return;
}
if (selectedCount === 0) {
errorMessage(t('exportOption.noChaptersSelected'));
return;
}
setIsExporting(true);
const selectedChapters: { chapterId: string; version: number }[] = chapterSelections
.filter((selection: ChapterExportSelection): boolean => selection.selected)
.map((selection: ChapterExportSelection): { chapterId: string; version: number } => ({
chapterId: selection.chapterId,
version: selection.version,
}));
try {
const response: Response = await fetch(`${configs.apiUrl}book/export`, {
method: 'POST',
headers: {
Authorization: `Bearer ${session.accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
bookId: book.bookId,
format: selectedFormat,
chapters: selectedChapters,
}),
});
if (!response.ok) {
errorMessage(t('exportOption.downloadError'));
return;
}
const blob: Blob = await response.blob();
const bookName: string = book.subTitle ? `${book.title} - ${book.subTitle}` : book.title;
const fileName: string = `${bookName}${formatExtensions[selectedFormat]}`;
const virtualUrl: string = window.URL.createObjectURL(blob);
const downloadLink: HTMLAnchorElement = document.createElement('a');
downloadLink.href = virtualUrl;
downloadLink.download = fileName;
document.body.appendChild(downloadLink);
downloadLink.click();
downloadLink.remove();
window.URL.revokeObjectURL(virtualUrl);
successMessage(t('exportOption.downloadSuccess', {format: selectedFormat.toUpperCase()}));
} catch {
errorMessage(t('exportOption.unknownError'));
} finally {
setIsExporting(false);
}
}
const allSelected: boolean = selections.every((s: ChapterExportSelection): boolean => s.selected);
const hasSelection: boolean = selections.some((s: ChapterExportSelection): boolean => s.selected);
return (
<div className="space-y-6 p-4">
<div className="p-4 space-y-6">
<div>
<h3 className="text-text-primary font-semibold mb-2">{t('exportOption.format')}</h3>
<h3 className="text-sm font-semibold text-text-primary mb-3">
{t('exportOption.formatLabel')}
</h3>
<div className="flex gap-3">
{exportFormats.map(({value, label}) => (
<button
key={value}
type="button"
onClick={(): void => setFormat(value)}
className={`px-4 py-2 rounded-lg font-medium transition-all duration-200 ${
format === value
? 'bg-primary text-white shadow-md'
: 'bg-secondary/30 text-text-secondary hover:bg-secondary/50'
}`}
{formats.map((format: ExportFormat) => (
<Button
key={format}
variant={selectedFormat === format ? 'primary' : 'secondary'}
size="sm"
onClick={(): void => setSelectedFormat(format)}
>
{label}
</button>
{format.toUpperCase()}
</Button>
))}
</div>
</div>
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="text-text-primary font-semibold">{t('exportOption.chapters')}</h3>
{chapters.length > 0 && (
<button
type="button"
onClick={(): void => toggleAll(!allSelected)}
className="text-sm text-primary hover:text-primary/80 transition-colors"
>
{allSelected ? t('exportOption.deselectAll') : t('exportOption.selectAll')}
</button>
)}
</div>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<FontAwesomeIcon icon={faSpinner} className="w-6 h-6 text-primary animate-spin mr-2"/>
<span className="text-text-secondary">{t('exportOption.loadingChapters')}</span>
</div>
) : chapters.length === 0 ? (
<p className="text-text-secondary text-center py-8">{t('exportOption.noChapters')}</p>
<h3 className="text-sm font-semibold text-text-primary mb-3">
{t('exportOption.chapters')}
</h3>
{isLoadingChapters ? (
<PulseLoader text={t('exportOption.loadingChapters')} size="sm"/>
) : chaptersExportInfo.length === 0 ? (
<p className="text-text-secondary text-sm text-center py-8">
{t('exportOption.noChaptersAvailable')}
</p>
) : (
<div className="space-y-2 max-h-96 overflow-y-auto pr-1">
{chapters.map((chapter: ChapterExportInfo) => {
const selection: ChapterExportSelection | undefined = selections.find(
(s: ChapterExportSelection): boolean => s.chapterId === chapter.chapterId
);
if (!selection) return null;
<>
<div className="mb-3">
<Button variant="ghost" size="sm" onClick={toggleAllChapters}>
{allSelected ? t('exportOption.deselectAll') : t('exportOption.selectAll')}
</Button>
</div>
return (
<div
key={chapter.chapterId}
className={`flex items-center justify-between p-3 rounded-lg transition-all duration-200 ${
selection.selected
? 'bg-primary/10 border border-primary/30'
: 'bg-secondary/20 border border-transparent'
}`}
>
<label className="flex items-center gap-3 cursor-pointer flex-1">
<input
type="checkbox"
checked={selection.selected}
onChange={(): void => toggleChapter(chapter.chapterId)}
className="w-4 h-4 rounded accent-primary cursor-pointer"
/>
<span className={`text-sm ${
selection.selected ? 'text-text-primary' : 'text-text-secondary'
}`}>
{chapter.title}
</span>
</label>
{chapter.availableVersions.length > 1 && selection.selected && (
<select
value={selection.version}
onChange={(e: React.ChangeEvent<HTMLSelectElement>): void =>
updateVersion(chapter.chapterId, parseInt(e.target.value, 10))
}
className="text-xs bg-secondary/50 text-text-primary rounded-md px-2 py-1 border border-secondary/30 focus:outline-none focus:border-primary"
<div className="space-y-1">
{chaptersExportInfo.map((chapter: ChapterExportInfo, index: number) => {
const selection: ChapterExportSelection = chapterSelections[index];
return (
<div key={chapter.chapterId} className="border-b border-secondary pb-2">
<button
onClick={(): void => toggleChapterSelection(chapter.chapterId)}
className="flex items-center gap-3 w-full py-2 text-left hover:bg-tertiary rounded-lg px-2 transition-all"
>
{chapter.availableVersions.map((v: number) => (
<option key={v} value={v}>
{getVersionLabel(v)}
</option>
))}
</select>
)}
</div>
);
})}
</div>
<div
className={`w-5 h-5 rounded flex items-center justify-center border transition-colors ${
selection.selected
? 'bg-primary/20 border-primary'
: 'border-secondary bg-tertiary'
}`}>
{selection.selected && (
<Check className="w-3 h-3 text-text-primary" strokeWidth={1.75}/>
)}
</div>
<span className={`text-sm font-medium truncate ${
selection.selected ? 'text-text-primary' : 'text-text-secondary'
}`}>
{chapter.chapterOrder !== -1 ? `${chapter.chapterOrder}. ` : ''}
{chapter.title}
</span>
</button>
{selection.selected && chapter.availableVersions.length > 1 && (
<div className="flex flex-wrap gap-2 ml-10 mt-1 mb-1">
{chapter.availableVersions.map((version: number) => (
<button
key={version}
onClick={(): void => setChapterVersion(chapter.chapterId, version)}
className={`px-3 py-1 rounded-full text-xs font-medium transition-all border ${
selection.version === version
? 'bg-primary/20 text-primary border-primary/50'
: 'bg-tertiary text-text-secondary border-secondary hover:bg-secondary'
}`}
>
{getVersionLabel(version)}
</button>
))}
</div>
)}
</div>
);
})}
</div>
</>
)}
</div>
<button
type="button"
onClick={handleExport}
disabled={isExporting || !hasSelection || isLoading}
className="w-full flex items-center justify-center gap-2 bg-primary hover:bg-primary/90 disabled:bg-primary/50 text-white font-semibold py-3 px-6 rounded-xl transition-all duration-200 shadow-md hover:shadow-lg disabled:cursor-not-allowed"
>
{isExporting ? (
<>
<FontAwesomeIcon icon={faSpinner} className="w-4 h-4 animate-spin"/>
{t('exportOption.exporting')}
</>
) : (
<>
<FontAwesomeIcon icon={faDownload} className="w-4 h-4"/>
{t('exportOption.export')}
</>
)}
</button>
<Button variant="primary" size="lg" onClick={handleExport} disabled={!canExport}
isLoading={isExporting} loadingText={t('exportOption.exporting')}
fullWidth>
{t('exportOption.exportButton')} {selectedFormat.toUpperCase()}
{selectedCount > 0 ? ` (${selectedCount})` : ''}
</Button>
</div>
);
}

View File

@@ -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 (
<button
<IconButton
key={`action-${index}`}
icon={button.icon}
variant={button.variant || 'ghost'}
shape="square"
onClick={button.onClick}
title={button.title}
className={`group flex items-center justify-center w-10 h-10 rounded-lg border hover:shadow-md hover:scale-110 transition-all duration-200 ${getVariantClasses(variant)}`}
>
<FontAwesomeIcon icon={button.icon} className={`w-4 h-4 transition-transform group-hover:scale-110 ${getIconColorClass(variant)}`}/>
</button>
tooltip={button.title}
/>
);
}
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 (
<div className="flex justify-between items-center p-4 border-b border-secondary/50 bg-tertiary/50 backdrop-blur-sm">
<button
onClick={onBack}
className="flex items-center gap-2 bg-secondary/50 py-2 px-4 rounded-xl border border-secondary/50 hover:bg-secondary hover:border-secondary hover:shadow-md hover:scale-105 transition-all duration-200"
>
<FontAwesomeIcon icon={faArrowLeft} className="text-primary w-4 h-4"/>
<span className="text-text-primary font-medium">{t('common.back')}</span>
</button>
<div
className="flex justify-between items-center p-4 border-b border-secondary bg-darkest-background">
<Button variant="secondary" size="sm" icon={ArrowLeft} onClick={onBack}>
{t('common.back')}
</Button>
<span className="text-text-primary font-semibold text-lg">
{title || defaultTitle}
</span>
<div className="flex items-center gap-2">
{getActionButtons().map(renderActionButton)}
</div>

View File

@@ -1,23 +1,23 @@
import CollapsableArea from "@/components/CollapsableArea";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {useState} from "react";
import {faTrash} from "@fortawesome/free-solid-svg-icons";
import Collapse from "@/components/ui/Collapse";
import React, {ChangeEvent, useState} from "react";
import {LucideIcon, Trash2} from 'lucide-react';
import IconButton from "@/components/ui/IconButton";
import InputField from "@/components/form/InputField";
import TextInput from "@/components/form/TextInput";
import {Attribute, CharacterProps} from "@/lib/models/Character";
import {IconDefinition} from "@fortawesome/fontawesome-svg-core";
import {useTranslations} from "next-intl";
import {Attribute, CharacterAttributeSection, CharacterProps} from "@/lib/types/character";
import {useTranslations} from '@/lib/i18n';
interface CharacterSectionElementProps {
title: string;
section: keyof CharacterProps;
section: CharacterAttributeSection;
placeholder: string;
icon: IconDefinition;
icon: LucideIcon;
selectedCharacter: CharacterProps;
setSelectedCharacter: (character: CharacterProps) => void;
handleAddElement: (section: keyof CharacterProps, element: Attribute) => void;
handleAddElement: (section: CharacterAttributeSection, element: Attribute) => void;
handleRemoveElement: (
section: keyof CharacterProps,
section: CharacterAttributeSection,
index: number,
attrId: string,
) => void;
@@ -33,28 +33,26 @@ export default function CharacterSectionElement(
setSelectedCharacter,
handleAddElement,
handleRemoveElement,
}: CharacterSectionElementProps) {
}: CharacterSectionElementProps): React.JSX.Element {
const t = useTranslations();
const [element, setElement] = useState<string>('');
function handleAddNewElement() {
function handleAddNewElement(): void {
handleAddElement(section, {id: '', name: element});
setElement('');
}
return (
<CollapsableArea title={title} icon={icon}>
<div className="space-y-3 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
{Array.isArray(selectedCharacter?.[section]) &&
selectedCharacter?.[section].map((item, index: number) => (
<Collapse variant="card" title={title} icon={icon}>
{selectedCharacter[section].map((item: Attribute, index: number): React.JSX.Element => (
<div key={index}
className="flex items-center gap-2 bg-secondary/30 rounded-xl border-l-4 border-primary shadow-sm hover:shadow-md transition-all duration-200">
className="flex items-center gap-2 bg-secondary rounded-xl border-l-4 border-primary transition-colors duration-200">
<input
className="flex-1 bg-transparent text-text-primary px-3 py-2.5 focus:outline-none placeholder:text-muted/60"
value={item.name || item.type || item.description || item.history || ''}
onChange={(e) => {
const updatedSection = [...(selectedCharacter[section] as any[])];
updatedSection[index].name = e.target.value;
value={item.name || ''}
onChange={(e: ChangeEvent<HTMLInputElement>): void => {
const updatedSection: Attribute[] = [...selectedCharacter[section]];
updatedSection[index] = {...updatedSection[index], name: e.target.value};
setSelectedCharacter({
...selectedCharacter,
[section]: updatedSection,
@@ -62,28 +60,28 @@ export default function CharacterSectionElement(
}}
placeholder={placeholder}
/>
<button
onClick={() => handleRemoveElement(section, index, item.id)}
className="bg-error/90 hover:bg-error w-9 h-9 rounded-full flex items-center justify-center mr-2 shadow-md hover:shadow-lg hover:scale-110 transition-all duration-200"
>
<FontAwesomeIcon icon={faTrash} className="text-white w-4 h-4"/>
</button>
<div className="mr-2">
<IconButton
icon={Trash2}
variant="danger"
onClick={(): void => handleRemoveElement(section, index, item.id)}
/>
</div>
</div>
))}
<div className="border-t border-secondary/50 mt-4 pt-4">
<div className="border-t border-secondary mt-4 pt-4">
<InputField
input={
<TextInput
value={element}
setValue={(e) => setElement(e.target.value)}
setValue={(e: ChangeEvent<HTMLInputElement>): void => setElement(e.target.value)}
placeholder={t("characterSectionElement.newItem", {item: title.toLowerCase()})}
/>
}
addButtonCallBack={async () => handleAddNewElement()}
addButtonCallBack={async (): Promise<void> => handleAddNewElement()}
/>
</div>
</div>
</CollapsableArea>
</Collapse>
)
}

View File

@@ -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<BookContextProps>(BookContext);
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean>(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<void> {
await exitEditMode(true);
}
function handleCancel(): void {
exitEditMode(false);
}
async function handleDelete(): Promise<void> {
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 (
<div className="flex items-center justify-center py-8">
<FontAwesomeIcon icon={faSpinner} className="w-6 h-6 text-primary animate-spin"/>
</div>
);
return <PulseLoader size="sm"/>;
}
const isNew: boolean = selectedCharacter?.id === null;
const canExport: boolean = Boolean(bookSeriesId && selectedCharacter?.id && !selectedCharacter.seriesCharacterId);
return (
<div className="flex flex-col h-full">
<ToolDetailHeader
@@ -116,56 +109,40 @@ export default function CharacterEditor(): React.JSX.Element {
onEdit={enterEditMode}
onSave={handleSave}
onCancel={handleCancel}
onDelete={function (): void { setShowDeleteConfirm(true); }}
onDelete={function (): void {
setShowDeleteConfirm(true);
}}
onExport={canExport ? exportToSeries : undefined}
showExport={canExport}
showDelete={Boolean(selectedCharacter?.id)}
/>
<div className="flex-1 overflow-y-auto">
{viewMode === 'list' && (
<div className="space-y-3 p-2">
{/* Toggle tool */}
<div className="bg-secondary/20 rounded-lg p-3 border border-secondary/30">
<InputField
icon={faToggleOn}
fieldName={t('characterComponent.enableTool')}
input={
<ToggleSwitch
checked={toolEnabled}
onChange={toggleTool}
/>
}
{/* Import from series */}
{bookSeriesId && availableSeriesCharacters.length > 0 && (
<SeriesImportSelector
availableItems={availableSeriesCharacters.map(function (sc: SeriesCharacterProps) {
return {
id: sc.id,
name: `${sc.name}${sc.lastName ? ' ' + sc.lastName : ''}`
};
})}
onImport={importFromSeries}
placeholder={t('seriesImport.selectElement')}
label={t('seriesImport.importFromSeries')}
/>
</div>
{toolEnabled && (
<>
{/* Import from series */}
{bookSeriesId && availableSeriesCharacters.length > 0 && (
<SeriesImportSelector
availableItems={availableSeriesCharacters.map(function (sc: SeriesCharacterProps) {
return {
id: sc.id,
name: `${sc.name}${sc.lastName ? ' ' + sc.lastName : ''}`
};
})}
onImport={importFromSeries}
placeholder={t('seriesImport.selectElement')}
label={t('seriesImport.importFromSeries')}
/>
)}
<CharacterEditorList
characters={characters}
onCharacterClick={enterDetailMode}
onAddCharacter={addNewCharacter}
/>
</>
)}
<CharacterEditorList
characters={characters}
onCharacterClick={enterDetailMode}
onAddCharacter={addNewCharacter}
/>
</div>
)}
{viewMode === 'detail' && selectedCharacter && (
<div className="p-4">
<CharacterEditorDetail
@@ -174,7 +151,7 @@ export default function CharacterEditor(): React.JSX.Element {
/>
</div>
)}
{viewMode === 'edit' && selectedCharacter && (
<div className="p-4">
<CharacterEditorEdit
@@ -189,7 +166,7 @@ export default function CharacterEditor(): React.JSX.Element {
</div>
)}
</div>
{showDeleteConfirm && selectedCharacter?.id && (
<AlertBox
title={t('characterDetail.deleteTitle')}
@@ -198,7 +175,9 @@ export default function CharacterEditor(): React.JSX.Element {
confirmText={t('common.delete')}
cancelText={t('common.cancel')}
onConfirm={handleDelete}
onCancel={function (): void { setShowDeleteConfirm(false); }}
onCancel={function (): void {
setShowDeleteConfirm(false);
}}
/>
)}
</div>

View File

@@ -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<OfflineContextType>(OfflineContext);
const {book} = useContext(BookContext);
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext);
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
const {errorMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
useEffect(function (): void {
if (character?.id !== null) {
getAttributes().then();
}
}, [character?.id]);
async function getAttributes(): Promise<void> {
try {
let response: AttributeResponse;
if (isCurrentlyOffline() || book?.localBook) {
response = await tauri.getCharacterAttributes(character?.id!) as AttributeResponse;
} else {
response = await System.authGetQueryToServer<AttributeResponse>(
'character/attribute',
session.accessToken,
lang,
{characterId: character?.id}
);
}
const response: AttributeResponse = await apiGet<AttributeResponse>(
'character/attribute',
session.accessToken,
lang,
{characterId: character?.id}
);
if (response && onLoadAttributes) {
const attributes: CharacterAttribute = {};
response.forEach(function (item: { type: string; values: Attribute[] }): void {
@@ -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 (
<div className="mb-3">
<span className="text-text-secondary text-xs block mb-1">{label}</span>
<p className="text-text-primary text-sm">{value}</p>
</div>
);
}
function getCategoryLabel(category: string | null | undefined): string {
if (!category) return '';
const found = characterCategories.find(function (c): boolean { return c.value === category; });
const found: SelectBoxProps | undefined = characterCategories.find(function (c: SelectBoxProps): boolean {
return c.value === category;
});
return found ? t(found.label) : category;
}
return (
<div>
{/* Image du personnage - version compacte */}
{character.image && (
<div className="flex justify-center mb-4">
<div className="w-16 h-16 rounded-full border-2 border-primary overflow-hidden">
<img
src={character.image}
alt={character.name}
className="w-full h-full object-cover"
/>
</div>
<AvatarIcon size="xl" image={character.image} alt={character.name}/>
</div>
)}
<h3 className="text-text-primary font-semibold text-base mb-4">
{character.name} {character.lastName}
</h3>
{renderField(t('characterDetail.role'), getCategoryLabel(character.category))}
{renderField(t('characterDetail.title'), character.title)}
{renderField(t('characterDetail.gender'), character.gender)}
{renderField(t('characterDetail.age'), character.age)}
{renderField(t('characterDetail.biography'), character.biography)}
{renderField(t('characterDetail.roleFull'), character.role)}
<DetailField variant="compact" label={t('characterDetail.role')}
value={getCategoryLabel(character.category)}/>
<DetailField variant="compact" label={t('characterDetail.title')} value={character.title}/>
<DetailField variant="compact" label={t('characterDetail.gender')} value={character.gender}/>
<DetailField variant="compact" label={t('characterDetail.age')} value={character.age}/>
<DetailField variant="compact" label={t('characterDetail.biography')} value={character.biography}/>
<DetailField variant="compact" label={t('characterDetail.roleFull')} value={character.role}/>
</div>
);
}

View File

@@ -1,34 +1,34 @@
'use client';
import React, {useContext, useEffect, useMemo, useState} from 'react';
import React, {Dispatch, SetStateAction, useContext, useEffect, useState} from 'react';
import {
advancedCharacterElements,
Attribute,
basicCharacterElements,
CharacterAttribute,
characterCategories,
CharacterAttributeSection,
CharacterElement,
CharacterProps,
isCharacterCategory,
isCharacterStatus,
} from '@/lib/types/character';
import {
advancedCharacterElements,
basicCharacterElements,
characterCategories,
characterStatus
} from '@/lib/models/Character';
import {SeriesCharacterProps} from '@/lib/models/Series';
} from '@/lib/constants/character';
import {SeriesCharacterProps} from '@/lib/types/series';
import InputField from '@/components/form/InputField';
import TextInput from '@/components/form/TextInput';
import TexteAreaInput from '@/components/form/TexteAreaInput';
import TextAreaInput from '@/components/form/TextAreaInput';
import NumberInput from '@/components/form/NumberInput';
import SelectBox from '@/components/form/SelectBox';
import CharacterSectionElement from '@/components/book/settings/characters/CharacterSectionElement';
import SyncFieldWrapper from '@/components/form/SyncFieldWrapper';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faSliders} from '@fortawesome/free-solid-svg-icons';
import {useTranslations} from 'next-intl';
import {SessionContext} from '@/context/SessionContext';
import {AlertContext} from '@/context/AlertContext';
import {LangContext} from '@/context/LangContext';
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
import {BookContext} from '@/context/BookContext';
import System from '@/lib/models/System';
import {Dispatch, SetStateAction} from 'react';
import * as tauri from '@/lib/tauri';
import {SlidersHorizontal} from 'lucide-react';
import {useTranslations} from '@/lib/i18n';
import {SessionContext, SessionContextProps} from '@/context/SessionContext';
import {AlertContext, AlertContextProps} from '@/context/AlertContext';
import {LangContext, LangContextProps} from '@/context/LangContext';
import {apiGet} from '@/lib/api/client';
type AttributeResponse = { type: string; values: Attribute[] }[];
@@ -36,8 +36,8 @@ interface CharacterEditorEditProps {
character: CharacterProps;
setCharacter: Dispatch<SetStateAction<CharacterProps | null>>;
onCharacterChange: (key: keyof CharacterProps, value: string | number | null) => void;
onAddAttribute: (section: keyof CharacterProps, attr: Attribute) => Promise<void>;
onRemoveAttribute: (section: keyof CharacterProps, idx: number, id: string) => Promise<void>;
onAddAttribute: (section: CharacterAttributeSection, attr: Attribute) => Promise<void>;
onRemoveAttribute: (section: CharacterAttributeSection, idx: number, id: string) => Promise<void>;
seriesCharacter?: SeriesCharacterProps | null;
onSyncComplete?: () => void;
}
@@ -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<OfflineContextType>(OfflineContext);
const {book} = useContext(BookContext);
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext);
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
const {errorMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
const [showAdvanced, setShowAdvanced] = useState<boolean>(false);
// Traduire les données des SelectBox
const translatedCharacterCategories = useMemo(() =>
characterCategories.map((item) => ({
...item,
label: t(item.label)
})), [t]);
const translatedCharacterStatus = useMemo(() =>
characterStatus.map((item) => ({
...item,
label: t(item.label)
})), [t]);
useEffect(function (): void {
if (character?.id !== null) {
getAttributes().then();
}
}, [character?.id]);
async function getAttributes(): Promise<void> {
try {
let response: AttributeResponse;
if (isCurrentlyOffline() || book?.localBook) {
response = await tauri.getCharacterAttributes(character?.id!) as AttributeResponse;
} else {
response = await System.authGetQueryToServer<AttributeResponse>(
'character/attribute',
session.accessToken,
lang,
{characterId: character?.id}
);
}
const response: AttributeResponse = await apiGet<AttributeResponse>(
'character/attribute',
session.accessToken,
lang,
{characterId: character?.id}
);
if (response) {
const attributes: CharacterAttribute = {};
response.forEach(function (item: { type: string; values: Attribute[] }): void {
@@ -131,11 +111,11 @@ export default function CharacterEditorEdit({
}
}
}
return (
<div className="space-y-4">
{/* Informations de base */}
<div className="border-b border-secondary/30 pb-3">
<div className="border-b border-secondary pb-3">
<h4 className="text-text-primary font-medium text-sm mb-3">{t('characterDetail.basicInfo')}</h4>
<div className="space-y-3">
<InputField
@@ -148,7 +128,9 @@ export default function CharacterEditorEdit({
bookElementId={character.id || ''}
field="name"
elementType="character"
onDownload={function (): void { onCharacterChange('name', seriesCharacter?.name || ''); }}
onDownload={function (): void {
onCharacterChange('name', seriesCharacter?.name || '');
}}
onSyncComplete={onSyncComplete}
>
<TextInput
@@ -161,7 +143,7 @@ export default function CharacterEditorEdit({
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('characterDetail.lastName')}
input={
@@ -172,7 +154,9 @@ export default function CharacterEditorEdit({
bookElementId={character.id || ''}
field="lastName"
elementType="character"
onDownload={function (): void { onCharacterChange('lastName', seriesCharacter?.lastName || ''); }}
onDownload={function (): void {
onCharacterChange('lastName', seriesCharacter?.lastName || '');
}}
onSyncComplete={onSyncComplete}
>
<TextInput
@@ -185,22 +169,27 @@ export default function CharacterEditorEdit({
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('characterDetail.role')}
input={
<SelectBox
defaultValue={character.category || 'none'}
onChangeCallBack={function (e: React.ChangeEvent<HTMLSelectElement>): void {
const selectedCategory: string = e.target.value;
setCharacter(function (prev: CharacterProps | null): CharacterProps | null {
return prev ? {...prev, category: e.target.value as CharacterProps['category']} : prev;
return prev ? {
...prev,
category: isCharacterCategory(selectedCategory) ? selectedCategory : 'none'
} : prev;
});
}}
data={translatedCharacterCategories}
data={characterCategories}
translate
/>
}
/>
<InputField
fieldName={t('characterDetail.gender')}
input={
@@ -213,7 +202,7 @@ export default function CharacterEditorEdit({
/>
}
/>
<InputField
fieldName={t('characterDetail.age')}
input={
@@ -230,9 +219,9 @@ export default function CharacterEditorEdit({
/>
</div>
</div>
{/* Histoire */}
<div className="border-b border-secondary/30 pb-3">
<div className="border-b border-secondary pb-3">
<h4 className="text-text-primary font-medium text-sm mb-3">{t('characterDetail.historySection')}</h4>
<div className="space-y-3">
<InputField
@@ -245,10 +234,12 @@ export default function CharacterEditorEdit({
bookElementId={character.id || ''}
field="biography"
elementType="character"
onDownload={function (): void { onCharacterChange('biography', seriesCharacter?.biography || ''); }}
onDownload={function (): void {
onCharacterChange('biography', seriesCharacter?.biography || '');
}}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
<TextAreaInput
value={character.biography || ''}
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
onCharacterChange('biography', e.target.value);
@@ -258,7 +249,7 @@ export default function CharacterEditorEdit({
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('characterDetail.roleFull')}
input={
@@ -269,10 +260,12 @@ export default function CharacterEditorEdit({
bookElementId={character.id || ''}
field="role"
elementType="character"
onDownload={function (): void { onCharacterChange('role', seriesCharacter?.role || ''); }}
onDownload={function (): void {
onCharacterChange('role', seriesCharacter?.role || '');
}}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
<TextAreaInput
value={character.role || ''}
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
onCharacterChange('role', e.target.value);
@@ -284,7 +277,7 @@ export default function CharacterEditorEdit({
/>
</div>
</div>
{/* 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é */}
<div className="flex items-center justify-between p-3 bg-secondary/30 rounded-lg border border-secondary/50">
<div
className="flex items-center justify-between p-3 bg-secondary rounded-lg border border-secondary">
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faSliders} className="text-primary w-4 h-4"/>
<SlidersHorizontal className="text-primary w-4 h-4" strokeWidth={1.75}/>
<span className="text-text-primary font-medium text-sm">{t('characterDetail.advancedMode')}</span>
</div>
<button
onClick={function (): void { setShowAdvanced(!showAdvanced); }}
className={`px-3 py-1.5 rounded-lg text-sm transition-all duration-200 ${
onClick={function (): void {
setShowAdvanced(!showAdvanced);
}}
className={`px-3 py-1.5 rounded-lg text-sm transition-colors duration-150 ${
showAdvanced
? 'bg-primary text-white'
: 'bg-secondary/50 text-text-primary hover:bg-secondary'
? 'bg-secondary text-primary'
: 'bg-secondary text-text-secondary hover:text-text-primary'
}`}
>
{showAdvanced ? t('characterDetail.hideAdvanced') : t('characterDetail.showAdvanced')}
</button>
</div>
{/* Sections avancées */}
{showAdvanced && (
<>
<div className="border-b border-secondary/30 pb-3">
<div className="border-b border-secondary pb-3">
<h4 className="text-text-primary font-medium text-sm mb-3">{t('characterDetail.identitySection')}</h4>
<div className="space-y-3">
<InputField
@@ -338,30 +334,35 @@ export default function CharacterEditorEdit({
/>
}
/>
<InputField
fieldName={t('characterDetail.status')}
input={
<SelectBox
defaultValue={character.status || 'alive'}
onChangeCallBack={function (e: React.ChangeEvent<HTMLSelectElement>): void {
const selectedStatus: string = e.target.value;
setCharacter(function (prev: CharacterProps | null): CharacterProps | null {
return prev ? {...prev, status: e.target.value as CharacterProps['status']} : prev;
return prev ? {
...prev,
status: isCharacterStatus(selectedStatus) ? selectedStatus : 'alive'
} : prev;
});
}}
data={translatedCharacterStatus}
data={characterStatus}
translate
/>
}
/>
</div>
</div>
<div className="border-b border-secondary/30 pb-3">
<div className="border-b border-secondary pb-3">
<h4 className="text-text-primary font-medium text-sm mb-3">{t('characterDetail.authorSection')}</h4>
<InputField
fieldName={t('characterDetail.notes')}
input={
<TexteAreaInput
<TextAreaInput
value={character.notes || ''}
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
onCharacterChange('notes', e.target.value);
@@ -371,7 +372,7 @@ export default function CharacterEditorEdit({
}
/>
</div>
{advancedCharacterElements.map(function (item: CharacterElement, index: number): React.JSX.Element {
return (
<CharacterSectionElement

View File

@@ -1,11 +1,13 @@
'use client';
import React, {useState} from 'react';
import {CharacterProps} from '@/lib/models/Character';
import {CharacterProps} from '@/lib/types/character';
import InputField from '@/components/form/InputField';
import TextInput from '@/components/form/TextInput';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faChevronRight, faPlus, faUser} from '@fortawesome/free-solid-svg-icons';
import {useTranslations} from 'next-intl';
import {Plus, User} from 'lucide-react';
import EmptyState from '@/components/ui/EmptyState';
import EntityListItem from '@/components/ui/EntityListItem';
import AvatarIcon from '@/components/ui/AvatarIcon';
import {useTranslations} from '@/lib/i18n';
interface CharacterEditorListProps {
characters: CharacterProps[];
@@ -19,22 +21,22 @@ interface CharacterEditorListProps {
* PAS de scroll interne (géré par parent ComposerRightBar)
*/
export default function CharacterEditorList({
characters,
onCharacterClick,
onAddCharacter,
}: CharacterEditorListProps): React.JSX.Element {
characters,
onCharacterClick,
onAddCharacter,
}: CharacterEditorListProps): React.JSX.Element {
const t = useTranslations();
const [searchQuery, setSearchQuery] = useState<string>('');
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 (
<div className="space-y-3">
<div className="px-2">
@@ -48,65 +50,33 @@ export default function CharacterEditorList({
placeholder={t('characterList.search')}
/>
}
actionIcon={faPlus}
actionIcon={Plus}
actionLabel={t('characterList.add')}
addButtonCallBack={async function (): Promise<void> {
onAddCharacter();
}}
/>
</div>
<div className="px-2 space-y-2">
{filteredCharacters.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mb-3">
<FontAwesomeIcon icon={faUser} className="text-primary w-8 h-8"/>
</div>
<h3 className="text-text-primary font-semibold text-base mb-1">
{t('characterList.noCharacters')}
</h3>
<p className="text-muted text-sm max-w-xs">
{t('characterList.noCharactersDescription')}
</p>
</div>
<EmptyState icon={User} title={t('characterList.noCharacters')}
description={t('characterList.noCharactersDescription')}/>
) : (
filteredCharacters.map(function (char: CharacterProps): React.JSX.Element {
return (
<div
<EntityListItem
key={char.id}
onClick={function (): void { onCharacterClick(char); }}
className="group flex items-center p-3 bg-secondary/30 rounded-lg border-l-4 border-primary border border-secondary/50 cursor-pointer hover:bg-secondary hover:shadow-md transition-all duration-200 hover:border-primary/50"
>
<div className="w-10 h-10 rounded-full border-2 border-primary overflow-hidden bg-secondary shadow-sm group-hover:scale-110 transition-transform">
{char.image ? (
<img
src={char.image}
alt={char.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-primary/10 text-primary font-bold text-sm">
{char.name?.charAt(0)?.toUpperCase() || '?'}
</div>
)}
</div>
<div className="ml-3 flex-1 min-w-0">
<div className="text-text-primary font-semibold text-sm group-hover:text-primary transition-colors truncate">
{char.name || t('characterList.unknown')}
</div>
<div className="text-muted text-xs truncate">
{char.title || char.role || t('characterList.noRole')}
</div>
</div>
<div className="w-6 flex justify-center">
<FontAwesomeIcon
icon={faChevronRight}
className="text-muted group-hover:text-primary group-hover:translate-x-1 transition-all w-3 h-3"
/>
</div>
</div>
size="sm"
onClick={function (): void {
onCharacterClick(char);
}}
avatar={<AvatarIcon size="sm" image={char.image}
initial={char.name?.charAt(0)?.toUpperCase() || '?'}
alt={char.name}/>}
title={char.name || t('characterList.unknown')}
subtitle={char.title || char.role || t('characterList.noRole')}
/>
);
})
)}

View File

@@ -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<BookContextProps>(BookContext);
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean>(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<void> {
await exitEditMode(true);
}
function handleCancel(): void {
exitEditMode(false);
}
async function handleDelete(): Promise<void> {
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 (
<div className="flex items-center justify-center py-12">
<FontAwesomeIcon icon={faSpinner} className="w-8 h-8 text-primary animate-spin"/>
</div>
);
return <PulseLoader/>;
}
const isNew: boolean = selectedCharacter?.id === null;
const canExport: boolean = Boolean(bookSeriesId && selectedCharacter?.id && !selectedCharacter.seriesCharacterId);
return (
<div className="flex flex-col h-full">
{/* 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 */}
<div className="flex-1 overflow-y-auto">
{viewMode === 'list' && (
<div className="space-y-5 p-4">
{/* Toggle tool */}
{showToggle && !isSeriesMode && (
<div className="bg-secondary/20 rounded-xl p-5 shadow-inner border border-secondary/30">
<InputField
icon={faToggleOn}
fieldName={t('characterComponent.enableTool')}
input={
<ToggleSwitch
checked={toolEnabled}
onChange={toggleTool}
/>
}
/>
<p className="text-muted text-sm mt-2">
{t('characterComponent.enableToolDescription')}
</p>
</div>
)}
{/* Contenu si outil activé */}
{(toolEnabled || isSeriesMode) && (
{((parentToolEnabled !== undefined ? parentToolEnabled : toolEnabled) || isSeriesMode) && (
<>
{/* Import from series */}
{!isSeriesMode && bookSeriesId && availableSeriesCharacters.length > 0 && (
@@ -177,7 +152,7 @@ export default function CharacterSettings({
label={t('seriesImport.importFromSeries')}
/>
)}
{/* Liste des personnages */}
<CharacterSettingsList
characters={characters}
@@ -188,7 +163,7 @@ export default function CharacterSettings({
)}
</div>
)}
{viewMode === 'detail' && selectedCharacter && (
<div className="p-4">
<CharacterSettingsDetail
@@ -197,7 +172,7 @@ export default function CharacterSettings({
/>
</div>
)}
{viewMode === 'edit' && selectedCharacter && (
<div className="p-4">
<CharacterSettingsEdit
@@ -212,7 +187,7 @@ export default function CharacterSettings({
</div>
)}
</div>
{/* Modal de confirmation de suppression */}
{showDeleteConfirm && selectedCharacter?.id && (
<AlertBox
@@ -222,7 +197,9 @@ export default function CharacterSettings({
confirmText={t('common.delete')}
cancelText={t('common.cancel')}
onConfirm={handleDelete}
onCancel={function (): void { setShowDeleteConfirm(false); }}
onCancel={function (): void {
setShowDeleteConfirm(false);
}}
/>
)}
</div>

View File

@@ -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<OfflineContextType>(OfflineContext);
const {book} = useContext(BookContext);
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext);
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
const {errorMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
const [showAdvanced, setShowAdvanced] = useState<boolean>(false);
useEffect(function (): void {
if (character?.id !== null) {
getAttributes().then();
}
}, [character?.id]);
async function getAttributes(): Promise<void> {
try {
let response: AttributeResponse;
if (isCurrentlyOffline() || book?.localBook) {
response = await tauri.getCharacterAttributes(character?.id!) as AttributeResponse;
} else {
response = await System.authGetQueryToServer<AttributeResponse>(
'character/attribute',
session.accessToken,
lang,
{characterId: character?.id}
);
}
const response: AttributeResponse = await apiGet<AttributeResponse>(
'character/attribute',
session.accessToken,
lang,
{characterId: character?.id}
);
if (response && onLoadAttributes) {
const attributes: CharacterAttribute = {};
response.forEach(function (item: { type: string; values: Attribute[] }): void {
@@ -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 (
<CollapsableArea key={element.section} title={element.title} icon={element.icon}>
<div className="flex flex-wrap gap-2 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
{attributes.map(function (attr: Attribute, index: number): React.JSX.Element {
return (
<span key={index} className="px-3 py-1 bg-primary/20 text-primary rounded-full text-sm border border-primary/30">
{attr.name}
</span>
);
})}
</div>
</CollapsableArea>
<Collapse variant="card" key={element.section} title={element.title} icon={element.icon}>
<div className="flex flex-wrap gap-2">
{attributes.map(function (attr: Attribute, index: number): React.JSX.Element {
return (
<span key={index}
className="px-3 py-1 bg-primary/20 text-primary rounded-full text-sm border border-primary/30">
{attr.name}
</span>
);
})}
</div>
</Collapse>
);
}
return (
<div className="space-y-6 px-2 pb-4">
{/* Hero Section - Image + Infos principales */}
<div className="flex gap-6 p-6 bg-gradient-to-r from-secondary/30 to-transparent rounded-2xl border border-secondary/30">
<div
className="flex gap-6 p-6 bg-gradient-to-r from-secondary to-transparent rounded-2xl border border-secondary">
{/* Image */}
<div className="shrink-0">
{character.image ? (
<div className="w-32 h-32 rounded-2xl border-4 border-primary/50 overflow-hidden shadow-lg">
<div className="w-32 h-32 rounded-2xl border-4 border-primary overflow-hidden">
<img src={character.image} alt={character.name} className="w-full h-full object-cover"/>
</div>
) : (
<div className="w-32 h-32 rounded-2xl border-4 border-secondary/50 bg-secondary/30 flex items-center justify-center">
<FontAwesomeIcon icon={faUser} className="w-12 h-12 text-text-secondary/50"/>
<div
className="w-32 h-32 rounded-2xl border-4 border-secondary bg-secondary flex items-center justify-center">
<User className="w-12 h-12 text-text-secondary" strokeWidth={1.75}/>
</div>
)}
</div>
{/* Infos principales */}
<div className="flex-1 min-w-0">
<h2 className="text-2xl font-bold text-text-primary">
@@ -152,170 +142,170 @@ export default function CharacterSettingsDetail({
{character.title && (
<p className="text-text-secondary mt-2">{character.title}</p>
)}
{/* Badges */}
<div className="flex flex-wrap gap-2 mt-4">
<span className="inline-flex items-center gap-2 px-3 py-1 bg-primary/20 text-primary rounded-lg text-sm border border-primary/30">
<FontAwesomeIcon icon={faTag} className="w-3 h-3"/>
{getCategoryLabel()}
</span>
<Badge variant="primary" icon={Tag} shape="rounded">{getCategoryLabel()}</Badge>
{character.gender && (
<span className="inline-flex items-center gap-2 px-3 py-1 bg-secondary/50 text-text-primary rounded-lg text-sm border border-secondary/50">
<FontAwesomeIcon icon={faVenusMars} className="w-3 h-3"/>
{character.gender}
</span>
<Badge variant="muted" icon={Users} shape="rounded">{character.gender}</Badge>
)}
{character.age && (
<span className="inline-flex items-center gap-2 px-3 py-1 bg-secondary/50 text-text-primary rounded-lg text-sm border border-secondary/50">
<FontAwesomeIcon icon={faCakeCandles} className="w-3 h-3"/>
{character.age} {t('characterDetail.yearsOld')}
</span>
<Badge variant="muted" icon={Cake}
shape="rounded">{character.age} {t('characterDetail.yearsOld')}</Badge>
)}
</div>
</div>
</div>
{/* Histoire & Biographie */}
<CollapsableArea title={t('characterDetail.historySection')} icon={faBook}>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<Collapse variant="card" title={t('characterDetail.historySection')} icon={Book}>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="lg:col-span-2">
<h4 className="text-text-secondary text-xs uppercase tracking-wide mb-2">{t('characterDetail.biography')}</h4>
<p className={`${character.biography ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
<p className={`${character.biography ? 'text-text-primary' : 'text-text-dimmed italic'}`}>
{character.biography || '—'}
</p>
</div>
<div>
<h4 className="text-text-secondary text-xs uppercase tracking-wide mb-2">{t('characterDetail.history')}</h4>
<p className={`${character.history ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
<p className={`${character.history ? 'text-text-primary' : 'text-text-dimmed italic'}`}>
{character.history || '—'}
</p>
</div>
<div>
<h4 className="text-text-secondary text-xs uppercase tracking-wide mb-2">{t('characterDetail.roleFull')}</h4>
<p className={`${character.role ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
<p className={`${character.role ? 'text-text-primary' : 'text-text-dimmed italic'}`}>
{character.role || '—'}
</p>
</div>
</div>
</CollapsableArea>
</Collapse>
{/* Attributs de base */}
{basicCharacterElements.map(renderAttributeSection)}
{/* Toggle Mode Avancé */}
<div className="flex items-center justify-between p-4 bg-secondary/30 rounded-xl border border-secondary/50">
<div
className="flex items-center justify-between p-4 bg-secondary rounded-xl border border-secondary">
<div className="flex items-center gap-3">
<FontAwesomeIcon icon={faSliders} className="text-primary w-5 h-5"/>
<SlidersHorizontal className="text-primary w-5 h-5" strokeWidth={1.75}/>
<span className="text-text-primary font-medium">{t('characterDetail.advancedMode')}</span>
</div>
<button
onClick={function (): void { setShowAdvanced(!showAdvanced); }}
onClick={function (): void {
setShowAdvanced(!showAdvanced);
}}
className={`px-4 py-2 rounded-lg transition-all duration-200 ${
showAdvanced
? 'bg-primary text-white'
: 'bg-secondary/50 text-text-primary hover:bg-secondary'
? 'bg-primary text-text-primary'
: 'bg-secondary text-text-primary hover:bg-gray-dark'
}`}
>
{showAdvanced ? t('characterDetail.hideAdvanced') : t('characterDetail.showAdvanced')}
</button>
</div>
{/* Sections avancées */}
{showAdvanced && (
<>
{/* Identité étendue */}
<CollapsableArea title={t('characterDetail.identitySection')} icon={faGlobe}>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<div className="p-3 bg-dark-background/30 rounded-lg">
<Collapse variant="card" title={t('characterDetail.identitySection')} icon={Globe}>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="p-3 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<FontAwesomeIcon icon={faDna} className="w-3 h-3 text-primary"/>
<span className="text-text-secondary text-xs uppercase">{t('characterDetail.species')}</span>
<Dna className="w-3 h-3 text-primary" strokeWidth={1.75}/>
<span
className="text-text-secondary text-xs uppercase">{t('characterDetail.species')}</span>
</div>
<p className={character.species ? 'text-text-primary' : 'text-text-secondary/50 italic'}>
<p className={character.species ? 'text-text-primary' : 'text-text-dimmed italic'}>
{character.species || '—'}
</p>
</div>
<div className="p-3 bg-dark-background/30 rounded-lg">
<div className="p-3 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<FontAwesomeIcon icon={faFlag} className="w-3 h-3 text-primary"/>
<span className="text-text-secondary text-xs uppercase">{t('characterDetail.nationality')}</span>
<Flag className="w-3 h-3 text-primary" strokeWidth={1.75}/>
<span
className="text-text-secondary text-xs uppercase">{t('characterDetail.nationality')}</span>
</div>
<p className={character.nationality ? 'text-text-primary' : 'text-text-secondary/50 italic'}>
<p className={character.nationality ? 'text-text-primary' : 'text-text-dimmed italic'}>
{character.nationality || '—'}
</p>
</div>
<div className="p-3 bg-dark-background/30 rounded-lg">
<div className="p-3 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<FontAwesomeIcon icon={faSkull} className="w-3 h-3 text-primary"/>
<span className="text-text-secondary text-xs uppercase">{t('characterDetail.status')}</span>
<Skull className="w-3 h-3 text-primary" strokeWidth={1.75}/>
<span
className="text-text-secondary text-xs uppercase">{t('characterDetail.status')}</span>
</div>
<p className={character.status ? 'text-text-primary' : 'text-text-secondary/50 italic'}>
<p className={character.status ? 'text-text-primary' : 'text-text-dimmed italic'}>
{getStatusLabel()}
</p>
</div>
<div className="p-3 bg-dark-background/30 rounded-lg">
<div className="p-3 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<FontAwesomeIcon icon={faHouse} className="w-3 h-3 text-primary"/>
<span className="text-text-secondary text-xs uppercase">{t('characterDetail.residence')}</span>
<Home className="w-3 h-3 text-primary" strokeWidth={1.75}/>
<span
className="text-text-secondary text-xs uppercase">{t('characterDetail.residence')}</span>
</div>
<p className={character.residence ? 'text-text-primary' : 'text-text-secondary/50 italic'}>
<p className={character.residence ? 'text-text-primary' : 'text-text-dimmed italic'}>
{character.residence || '—'}
</p>
</div>
</div>
</CollapsableArea>
</Collapse>
{/* Voix du personnage */}
<CollapsableArea title={t('characterDetail.voiceSection')} icon={faCommentDots}>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<div className="p-4 bg-dark-background/30 rounded-lg">
<Collapse variant="card" title={t('characterDetail.voiceSection')} icon={MessageCircle}>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="p-4 rounded-lg">
<h4 className="text-text-secondary text-xs uppercase tracking-wide mb-2">{t('characterDetail.speechPattern')}</h4>
<p className={`${character.speechPattern ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
<p className={`${character.speechPattern ? 'text-text-primary' : 'text-text-dimmed italic'}`}>
{character.speechPattern || '—'}
</p>
</div>
<div className="p-4 bg-dark-background/30 rounded-lg relative">
<FontAwesomeIcon icon={faQuoteLeft} className="absolute top-2 left-2 w-6 h-6 text-primary/20"/>
<div className="p-4 rounded-lg relative">
<Quote className="absolute top-2 left-2 w-6 h-6 text-primary/20" strokeWidth={1.75}/>
<h4 className="text-text-secondary text-xs uppercase tracking-wide mb-2">{t('characterDetail.catchphrase')}</h4>
<p className={`italic ${character.catchphrase ? 'text-text-primary' : 'text-text-secondary/50'}`}>
<p className={`italic ${character.catchphrase ? 'text-text-primary' : 'text-text-dimmed'}`}>
{character.catchphrase ? `« ${character.catchphrase} »` : '—'}
</p>
</div>
</div>
</CollapsableArea>
</Collapse>
{/* Notes de l'auteur */}
<CollapsableArea title={t('characterDetail.authorSection')} icon={faStickyNote}>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<div className="lg:col-span-2 p-4 bg-dark-background/30 rounded-lg">
<Collapse variant="card" title={t('characterDetail.authorSection')} icon={StickyNote}>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div className="lg:col-span-2 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<FontAwesomeIcon icon={faNoteSticky} className="w-3 h-3 text-primary"/>
<span className="text-text-secondary text-xs uppercase">{t('characterDetail.notes')}</span>
<StickyNote className="w-3 h-3 text-primary" strokeWidth={1.75}/>
<span
className="text-text-secondary text-xs uppercase">{t('characterDetail.notes')}</span>
</div>
<p className={`${character.notes ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
<p className={`${character.notes ? 'text-text-primary' : 'text-text-dimmed italic'}`}>
{character.notes || '—'}
</p>
</div>
<div className="p-4 bg-dark-background/30 rounded-lg">
<div className="p-4 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<FontAwesomeIcon icon={faPalette} className="w-3 h-3 text-primary"/>
<span className="text-text-secondary text-xs uppercase">{t('characterDetail.colorLabel')}</span>
<Palette className="w-3 h-3 text-primary" strokeWidth={1.75}/>
<span
className="text-text-secondary text-xs uppercase">{t('characterDetail.colorLabel')}</span>
</div>
{character.color ? (
<div className="flex items-center gap-3">
<div
className="w-8 h-8 rounded-lg border-2 border-white/20"
style={{backgroundColor: character.color}}
className={`w-8 h-8 rounded-lg border-2 border-secondary ${character.color ? dynamicBg(character.color) : ''}`}
/>
<span className="text-text-primary font-mono">{character.color}</span>
</div>
) : (
<p className="text-text-secondary/50 italic"></p>
<p className="text-text-dimmed italic"></p>
)}
</div>
</div>
</CollapsableArea>
</Collapse>
{/* Attributs avancés */}
{advancedCharacterElements.map(renderAttributeSection)}
</>

View File

@@ -1,43 +1,35 @@
'use client';
import React, {useContext, useEffect, useMemo, useState} from 'react';
import React, {Dispatch, SetStateAction, useContext, useEffect, useState} from 'react';
import {
advancedCharacterElements,
Attribute,
basicCharacterElements,
CharacterAttribute,
characterCategories,
CharacterAttributeSection,
CharacterElement,
CharacterProps,
isCharacterCategory,
isCharacterStatus,
} from '@/lib/types/character';
import {
advancedCharacterElements,
basicCharacterElements,
characterCategories,
characterStatus
} from '@/lib/models/Character';
import {SeriesCharacterProps} from '@/lib/models/Series';
import CollapsableArea from '@/components/CollapsableArea';
} from '@/lib/constants/character';
import {SeriesCharacterProps} from '@/lib/types/series';
import Collapse from '@/components/ui/Collapse';
import InputField from '@/components/form/InputField';
import TextInput from '@/components/form/TextInput';
import TexteAreaInput from '@/components/form/TexteAreaInput';
import TextAreaInput from '@/components/form/TextAreaInput';
import NumberInput from '@/components/form/NumberInput';
import SelectBox from '@/components/form/SelectBox';
import CharacterSectionElement from '@/components/book/settings/characters/CharacterSectionElement';
import SyncFieldWrapper from '@/components/form/SyncFieldWrapper';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {
faBook,
faCommentDots,
faGlobe,
faScroll,
faSliders,
faStickyNote,
faUser
} from '@fortawesome/free-solid-svg-icons';
import {useTranslations} from 'next-intl';
import {SessionContext} from '@/context/SessionContext';
import {AlertContext} from '@/context/AlertContext';
import {LangContext} from '@/context/LangContext';
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
import {BookContext} from '@/context/BookContext';
import System from '@/lib/models/System';
import {Dispatch, SetStateAction} from 'react';
import * as tauri from '@/lib/tauri';
import {Book, Globe, MessageCircle, ScrollText, SlidersHorizontal, StickyNote, User} from 'lucide-react';
import {useTranslations} from '@/lib/i18n';
import {SessionContext, SessionContextProps} from '@/context/SessionContext';
import {AlertContext, AlertContextProps} from '@/context/AlertContext';
import {LangContext, LangContextProps} from '@/context/LangContext';
import {apiGet} from '@/lib/api/client';
type AttributeResponse = { type: string; values: Attribute[] }[];
@@ -45,8 +37,8 @@ interface CharacterSettingsEditProps {
character: CharacterProps;
setCharacter: Dispatch<SetStateAction<CharacterProps | null>>;
onCharacterChange: (key: keyof CharacterProps, value: string | number | null) => void;
onAddAttribute: (section: keyof CharacterProps, attr: Attribute) => Promise<void>;
onRemoveAttribute: (section: keyof CharacterProps, idx: number, id: string) => Promise<void>;
onAddAttribute: (section: CharacterAttributeSection, attr: Attribute) => Promise<void>;
onRemoveAttribute: (section: CharacterAttributeSection, idx: number, id: string) => Promise<void>;
seriesCharacter?: SeriesCharacterProps | null;
onSyncComplete?: () => void;
}
@@ -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<OfflineContextType>(OfflineContext);
const {book} = useContext(BookContext);
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext);
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
const {errorMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
const [showAdvanced, setShowAdvanced] = useState<boolean>(false);
// Traduire les données des SelectBox
const translatedCharacterCategories = useMemo(() =>
characterCategories.map((item) => ({
...item,
label: t(item.label)
})), [t]);
const translatedCharacterStatus = useMemo(() =>
characterStatus.map((item) => ({
...item,
label: t(item.label)
})), [t]);
useEffect(function (): void {
if (character?.id !== null) {
getAttributes().then();
}
}, [character?.id]);
async function getAttributes(): Promise<void> {
try {
let response: AttributeResponse;
if (isCurrentlyOffline() || book?.localBook) {
response = await tauri.getCharacterAttributes(character?.id!) as AttributeResponse;
} else {
response = await System.authGetQueryToServer<AttributeResponse>(
'character/attribute',
session.accessToken,
lang,
{characterId: character?.id}
);
}
const response: AttributeResponse = await apiGet<AttributeResponse>(
'character/attribute',
session.accessToken,
lang,
{characterId: character?.id}
);
if (response) {
const attributes: CharacterAttribute = {};
response.forEach(function (item: { type: string; values: Attribute[] }): void {
@@ -141,12 +113,11 @@ export default function CharacterSettingsEdit({
}
}
}
return (
<div className="space-y-4 px-2 pb-4">
{/* Informations de base */}
<CollapsableArea title={t('characterDetail.basicInfo')} icon={faUser}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<Collapse variant="card" title={t('characterDetail.basicInfo')} icon={User}>
<InputField
fieldName={t('characterDetail.name')}
input={
@@ -157,7 +128,9 @@ export default function CharacterSettingsEdit({
bookElementId={character.id || ''}
field="name"
elementType="character"
onDownload={function (): void { onCharacterChange('name', seriesCharacter?.name || ''); }}
onDownload={function (): void {
onCharacterChange('name', seriesCharacter?.name || '');
}}
onSyncComplete={onSyncComplete}
>
<TextInput
@@ -170,7 +143,7 @@ export default function CharacterSettingsEdit({
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('characterDetail.lastName')}
input={
@@ -181,7 +154,9 @@ export default function CharacterSettingsEdit({
bookElementId={character.id || ''}
field="lastName"
elementType="character"
onDownload={function (): void { onCharacterChange('lastName', seriesCharacter?.lastName || ''); }}
onDownload={function (): void {
onCharacterChange('lastName', seriesCharacter?.lastName || '');
}}
onSyncComplete={onSyncComplete}
>
<TextInput
@@ -194,7 +169,7 @@ export default function CharacterSettingsEdit({
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('characterDetail.nickname')}
input={
@@ -205,7 +180,9 @@ export default function CharacterSettingsEdit({
bookElementId={character.id || ''}
field="nickname"
elementType="character"
onDownload={function (): void { onCharacterChange('nickname', seriesCharacter?.nickname || ''); }}
onDownload={function (): void {
onCharacterChange('nickname', seriesCharacter?.nickname || '');
}}
onSyncComplete={onSyncComplete}
>
<TextInput
@@ -218,7 +195,7 @@ export default function CharacterSettingsEdit({
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('characterDetail.role')}
input={
@@ -230,8 +207,12 @@ export default function CharacterSettingsEdit({
field="category"
elementType="character"
onDownload={function (): void {
const categoryValue: string = seriesCharacter?.category || 'none';
setCharacter(function (prev: CharacterProps | null): CharacterProps | null {
return prev ? {...prev, category: (seriesCharacter?.category || 'none') as CharacterProps['category']} : prev;
return prev ? {
...prev,
category: isCharacterCategory(categoryValue) ? categoryValue : 'none'
} : prev;
});
}}
onSyncComplete={onSyncComplete}
@@ -239,16 +220,21 @@ export default function CharacterSettingsEdit({
<SelectBox
defaultValue={character.category || 'none'}
onChangeCallBack={function (e: React.ChangeEvent<HTMLSelectElement>): void {
const selectedCategory: string = e.target.value;
setCharacter(function (prev: CharacterProps | null): CharacterProps | null {
return prev ? {...prev, category: e.target.value as CharacterProps['category']} : prev;
return prev ? {
...prev,
category: isCharacterCategory(selectedCategory) ? selectedCategory : 'none'
} : prev;
});
}}
data={translatedCharacterCategories}
data={characterCategories}
translate
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('characterDetail.title')}
input={
@@ -259,7 +245,9 @@ export default function CharacterSettingsEdit({
bookElementId={character.id || ''}
field="title"
elementType="character"
onDownload={function (): void { onCharacterChange('title', seriesCharacter?.title || ''); }}
onDownload={function (): void {
onCharacterChange('title', seriesCharacter?.title || '');
}}
onSyncComplete={onSyncComplete}
>
<TextInput
@@ -272,7 +260,7 @@ export default function CharacterSettingsEdit({
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('characterDetail.gender')}
input={
@@ -283,7 +271,9 @@ export default function CharacterSettingsEdit({
bookElementId={character.id || ''}
field="gender"
elementType="character"
onDownload={function (): void { onCharacterChange('gender', seriesCharacter?.gender || ''); }}
onDownload={function (): void {
onCharacterChange('gender', seriesCharacter?.gender || '');
}}
onSyncComplete={onSyncComplete}
>
<TextInput
@@ -296,7 +286,7 @@ export default function CharacterSettingsEdit({
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('characterDetail.age')}
input={
@@ -326,15 +316,13 @@ export default function CharacterSettingsEdit({
</SyncFieldWrapper>
}
/>
</div>
</CollapsableArea>
</Collapse>
{/* Histoire */}
<CollapsableArea title={t('characterDetail.historySection')} icon={faBook}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<Collapse variant="card" title={t('characterDetail.historySection')} icon={Book}>
<InputField
fieldName={t('characterDetail.biography')}
icon={faBook}
icon={Book}
input={
<SyncFieldWrapper
seriesElementId={character.seriesCharacterId}
@@ -343,10 +331,12 @@ export default function CharacterSettingsEdit({
bookElementId={character.id || ''}
field="biography"
elementType="character"
onDownload={function (): void { onCharacterChange('biography', seriesCharacter?.biography || ''); }}
onDownload={function (): void {
onCharacterChange('biography', seriesCharacter?.biography || '');
}}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
<TextAreaInput
value={character.biography || ''}
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
onCharacterChange('biography', e.target.value);
@@ -356,10 +346,10 @@ export default function CharacterSettingsEdit({
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('characterDetail.history')}
icon={faScroll}
icon={ScrollText}
input={
<SyncFieldWrapper
seriesElementId={character.seriesCharacterId}
@@ -368,10 +358,12 @@ export default function CharacterSettingsEdit({
bookElementId={character.id || ''}
field="history"
elementType="character"
onDownload={function (): void { onCharacterChange('history', seriesCharacter?.history || ''); }}
onDownload={function (): void {
onCharacterChange('history', seriesCharacter?.history || '');
}}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
<TextAreaInput
value={character.history || ''}
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
onCharacterChange('history', e.target.value);
@@ -381,10 +373,10 @@ export default function CharacterSettingsEdit({
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('characterDetail.roleFull')}
icon={faScroll}
icon={ScrollText}
input={
<SyncFieldWrapper
seriesElementId={character.seriesCharacterId}
@@ -393,10 +385,12 @@ export default function CharacterSettingsEdit({
bookElementId={character.id || ''}
field="role"
elementType="character"
onDownload={function (): void { onCharacterChange('role', seriesCharacter?.role || ''); }}
onDownload={function (): void {
onCharacterChange('role', seriesCharacter?.role || '');
}}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
<TextAreaInput
value={character.role || ''}
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
onCharacterChange('role', e.target.value);
@@ -406,8 +400,7 @@ export default function CharacterSettingsEdit({
</SyncFieldWrapper>
}
/>
</div>
</CollapsableArea>
</Collapse>
{/* Attributs de base */}
{basicCharacterElements.map(function (item: CharacterElement, index: number): React.JSX.Element {
@@ -425,31 +418,33 @@ export default function CharacterSettingsEdit({
/>
);
})}
{/* Toggle Mode Avancé */}
<div className="flex items-center justify-between p-4 bg-secondary/30 rounded-xl border border-secondary/50">
<div
className="flex items-center justify-between p-4 bg-secondary rounded-xl border border-secondary">
<div className="flex items-center gap-3">
<FontAwesomeIcon icon={faSliders} className="text-primary w-5 h-5"/>
<SlidersHorizontal className="text-primary w-5 h-5" strokeWidth={1.75}/>
<span className="text-text-primary font-medium">{t('characterDetail.advancedMode')}</span>
</div>
<button
onClick={function (): void { setShowAdvanced(!showAdvanced); }}
className={`px-4 py-2 rounded-lg transition-all duration-200 ${
onClick={function (): void {
setShowAdvanced(!showAdvanced);
}}
className={`px-4 py-2 rounded-lg transition-colors duration-150 ${
showAdvanced
? 'bg-primary text-white'
: 'bg-secondary/50 text-text-primary hover:bg-secondary'
? 'bg-secondary text-primary'
: 'bg-secondary text-text-secondary hover:text-text-primary'
}`}
>
{showAdvanced ? t('characterDetail.hideAdvanced') : t('characterDetail.showAdvanced')}
</button>
</div>
{/* Sections avancées */}
{showAdvanced && (
<>
{/* Identité étendue */}
<CollapsableArea title={t('characterDetail.identitySection')} icon={faGlobe}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<Collapse variant="card" title={t('characterDetail.identitySection')} icon={Globe}>
<InputField
fieldName={t('characterDetail.species')}
input={
@@ -460,7 +455,9 @@ export default function CharacterSettingsEdit({
bookElementId={character.id || ''}
field="species"
elementType="character"
onDownload={function (): void { onCharacterChange('species', seriesCharacter?.species || ''); }}
onDownload={function (): void {
onCharacterChange('species', seriesCharacter?.species || '');
}}
onSyncComplete={onSyncComplete}
>
<TextInput
@@ -473,7 +470,7 @@ export default function CharacterSettingsEdit({
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('characterDetail.nationality')}
input={
@@ -484,7 +481,9 @@ export default function CharacterSettingsEdit({
bookElementId={character.id || ''}
field="nationality"
elementType="character"
onDownload={function (): void { onCharacterChange('nationality', seriesCharacter?.nationality || ''); }}
onDownload={function (): void {
onCharacterChange('nationality', seriesCharacter?.nationality || '');
}}
onSyncComplete={onSyncComplete}
>
<TextInput
@@ -497,7 +496,7 @@ export default function CharacterSettingsEdit({
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('characterDetail.status')}
input={
@@ -509,8 +508,12 @@ export default function CharacterSettingsEdit({
field="status"
elementType="character"
onDownload={function (): void {
const statusValue: string = seriesCharacter?.status || 'alive';
setCharacter(function (prev: CharacterProps | null): CharacterProps | null {
return prev ? {...prev, status: (seriesCharacter?.status || 'alive') as CharacterProps['status']} : prev;
return prev ? {
...prev,
status: isCharacterStatus(statusValue) ? statusValue : 'alive'
} : prev;
});
}}
onSyncComplete={onSyncComplete}
@@ -518,16 +521,21 @@ export default function CharacterSettingsEdit({
<SelectBox
defaultValue={character.status || 'alive'}
onChangeCallBack={function (e: React.ChangeEvent<HTMLSelectElement>): void {
const selectedStatus: string = e.target.value;
setCharacter(function (prev: CharacterProps | null): CharacterProps | null {
return prev ? {...prev, status: e.target.value as CharacterProps['status']} : prev;
return prev ? {
...prev,
status: isCharacterStatus(selectedStatus) ? selectedStatus : 'alive'
} : prev;
});
}}
data={translatedCharacterStatus}
data={characterStatus}
translate
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('characterDetail.residence')}
input={
@@ -538,7 +546,9 @@ export default function CharacterSettingsEdit({
bookElementId={character.id || ''}
field="residence"
elementType="character"
onDownload={function (): void { onCharacterChange('residence', seriesCharacter?.residence || ''); }}
onDownload={function (): void {
onCharacterChange('residence', seriesCharacter?.residence || '');
}}
onSyncComplete={onSyncComplete}
>
<TextInput
@@ -551,12 +561,10 @@ export default function CharacterSettingsEdit({
</SyncFieldWrapper>
}
/>
</div>
</CollapsableArea>
</Collapse>
{/* Voix du personnage */}
<CollapsableArea title={t('characterDetail.voiceSection')} icon={faCommentDots}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<Collapse variant="card" title={t('characterDetail.voiceSection')} icon={MessageCircle}>
<InputField
fieldName={t('characterDetail.speechPattern')}
input={
@@ -567,10 +575,12 @@ export default function CharacterSettingsEdit({
bookElementId={character.id || ''}
field="speechPattern"
elementType="character"
onDownload={function (): void { onCharacterChange('speechPattern', seriesCharacter?.speechPattern || ''); }}
onDownload={function (): void {
onCharacterChange('speechPattern', seriesCharacter?.speechPattern || '');
}}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
<TextAreaInput
value={character.speechPattern || ''}
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
onCharacterChange('speechPattern', e.target.value);
@@ -580,7 +590,7 @@ export default function CharacterSettingsEdit({
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('characterDetail.catchphrase')}
input={
@@ -591,7 +601,9 @@ export default function CharacterSettingsEdit({
bookElementId={character.id || ''}
field="catchphrase"
elementType="character"
onDownload={function (): void { onCharacterChange('catchphrase', seriesCharacter?.catchphrase || ''); }}
onDownload={function (): void {
onCharacterChange('catchphrase', seriesCharacter?.catchphrase || '');
}}
onSyncComplete={onSyncComplete}
>
<TextInput
@@ -604,12 +616,10 @@ export default function CharacterSettingsEdit({
</SyncFieldWrapper>
}
/>
</div>
</CollapsableArea>
</Collapse>
{/* Notes de l'auteur */}
<CollapsableArea title={t('characterDetail.authorSection')} icon={faStickyNote}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<Collapse variant="card" title={t('characterDetail.authorSection')} icon={StickyNote}>
<InputField
fieldName={t('characterDetail.notes')}
input={
@@ -620,10 +630,12 @@ export default function CharacterSettingsEdit({
bookElementId={character.id || ''}
field="notes"
elementType="character"
onDownload={function (): void { onCharacterChange('notes', seriesCharacter?.notes || ''); }}
onDownload={function (): void {
onCharacterChange('notes', seriesCharacter?.notes || '');
}}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
<TextAreaInput
value={character.notes || ''}
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
onCharacterChange('notes', e.target.value);
@@ -633,7 +645,7 @@ export default function CharacterSettingsEdit({
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('characterDetail.colorLabel')}
input={
@@ -644,7 +656,9 @@ export default function CharacterSettingsEdit({
bookElementId={character.id || ''}
field="color"
elementType="character"
onDownload={function (): void { onCharacterChange('color', seriesCharacter?.color || ''); }}
onDownload={function (): void {
onCharacterChange('color', seriesCharacter?.color || '');
}}
onSyncComplete={onSyncComplete}
>
<TextInput
@@ -657,9 +671,8 @@ export default function CharacterSettingsEdit({
</SyncFieldWrapper>
}
/>
</div>
</CollapsableArea>
</Collapse>
{/* Attributs avancés */}
{advancedCharacterElements.map(function (item: CharacterElement, index: number): React.JSX.Element {
return (

View File

@@ -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<string>('');
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 (
<div className="space-y-4">
<div className="px-4 mb-4">
@@ -50,14 +53,14 @@ export default function CharacterSettingsList({
placeholder={t('characterList.search')}
/>
}
actionIcon={faPlus}
actionIcon={Plus}
actionLabel={t('characterList.add')}
addButtonCallBack={async function (): Promise<void> {
onAddCharacter();
}}
/>
</div>
<div className="px-2">
{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 (
<CollapsableArea
<Collapse variant="card"
key={category.value}
title={t(category.label)}
icon={faUser}
icon={User}
>
<div className="space-y-2 p-2">
{categoryCharacters.map(function (char: CharacterProps): React.JSX.Element {
return (
<div
<EntityListItem
key={char.id}
variant="transparent"
onClick={function (): void {
onCharacterClick(char);
}}
className="group flex items-center p-4 bg-secondary/30 rounded-xl border-l-4 border-primary border border-secondary/50 cursor-pointer hover:bg-secondary hover:shadow-md hover:scale-102 transition-all duration-200 hover:border-primary/50"
>
<div className="w-14 h-14 rounded-full border-2 border-primary overflow-hidden bg-secondary shadow-md group-hover:scale-110 transition-transform">
{char.image ? (
<img
src={char.image}
alt={char.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-primary/10 text-primary font-bold text-lg">
{char.name?.charAt(0)?.toUpperCase() || '?'}
</div>
)}
</div>
<div className="ml-4 flex-1">
<div className="text-text-primary font-bold text-base group-hover:text-primary transition-colors">
{char.name || t('characterList.unknown')}
</div>
<div className="text-text-secondary text-sm mt-0.5">
{char.lastName || t('characterList.noLastName')}
</div>
</div>
<div className="w-28 px-3">
<div className="text-primary text-sm font-semibold truncate">
{char.title || t('characterList.noTitle')}
</div>
<div className="text-muted text-xs truncate mt-0.5">
{char.role || t('characterList.noRole')}
</div>
</div>
<div className="w-8 flex justify-center">
<FontAwesomeIcon
icon={faChevronRight}
className="text-muted group-hover:text-primary group-hover:translate-x-1 transition-all w-4 h-4"
avatar={
<AvatarIcon
size="lg"
image={char.image}
initial={char.name?.charAt(0)?.toUpperCase() || '?'}
alt={char.name}
/>
</div>
</div>
}
title={char.name || t('characterList.unknown')}
subtitle={char.lastName || t('characterList.noLastName')}
extra={
<div className="w-28">
<div className="text-primary text-sm font-semibold truncate">
{char.title || t('characterList.noTitle')}
</div>
<div className="text-muted text-xs truncate mt-0.5">
{char.role || t('characterList.noRole')}
</div>
</div>
}
/>
);
})}
</div>
</CollapsableArea>
</Collapse>
);
})}
{filteredCharacters.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="w-20 h-20 rounded-full bg-primary/10 flex items-center justify-center mb-4">
<FontAwesomeIcon icon={faUser} className="text-primary w-10 h-10"/>
</div>
<h3 className="text-text-primary font-semibold text-lg mb-2">
{t('characterList.noCharacters')}
</h3>
<p className="text-muted text-sm max-w-xs">
{t('characterList.noCharactersDescription')}
</p>
</div>
<EmptyState icon={User} title={t('characterList.noCharacters')}
description={t('characterList.noCharactersDescription')}/>
)}
</div>
</div>

View File

@@ -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<Goal[]>([
{
id: 1,
name: 'First Goal',
timeGoal: {
desiredReleaseDate: '',
maxReleaseDate: '',
},
numbersGoal: {
minWordsCount: 0,
maxWordsCount: 0,
desiredWordsCountByChapter: 0,
desiredChapterCount: 0,
},
},
]);
const [selectedGoalIndex, setSelectedGoalIndex] = useState(0);
const [newGoalName, setNewGoalName] = useState('');
const handleAddGoal = () => {
const newGoal: Goal = {
id: goals.length + 1,
name: newGoalName,
timeGoal: {
desiredReleaseDate: '',
maxReleaseDate: '',
},
numbersGoal: {
minWordsCount: 0,
maxWordsCount: 0,
desiredWordsCountByChapter: 0,
desiredChapterCount: 0,
},
};
setGoals([...goals, newGoal]);
setNewGoalName('');
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>, field: keyof Goal, subField?: keyof TimeGoal | keyof NumbersGoal) => {
const updatedGoals = [...goals];
if (subField) {
if (field === 'timeGoal' && subField in updatedGoals[selectedGoalIndex].timeGoal) {
(updatedGoals[selectedGoalIndex].timeGoal[subField as keyof TimeGoal] as string) = e.target.value;
} else if (field === 'numbersGoal' && subField in updatedGoals[selectedGoalIndex].numbersGoal) {
(updatedGoals[selectedGoalIndex].numbersGoal[subField as keyof NumbersGoal] as number) = Number(e.target.value);
}
} else {
(updatedGoals[selectedGoalIndex][field] as string) = e.target.value;
}
setGoals(updatedGoals);
};
return (
<main className="flex-grow p-8 overflow-y-auto">
<section id="goals">
<h2 className="text-4xl font-['ADLaM_Display'] text-text-primary mb-6">Goals</h2>
<div className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-6 shadow-lg mb-6">
<div className="flex space-x-4 items-center">
<select
className="w-full px-4 py-2.5 rounded-xl bg-secondary/50 text-text-primary border border-secondary/50 outline-none hover:bg-secondary hover:border-secondary focus:border-primary focus:ring-4 focus:ring-primary/20 transition-all duration-200"
value={selectedGoalIndex}
onChange={(e) => setSelectedGoalIndex(parseInt(e.target.value))}
>
{goals.map((goal, index) => (
<option key={goal.id} value={index}>{goal.name}</option>
))}
</select>
<input
type="text"
value={newGoalName}
onChange={(e) => setNewGoalName(e.target.value)}
className="w-full px-4 py-2.5 rounded-xl bg-secondary/50 text-text-primary border border-secondary/50 outline-none hover:bg-secondary hover:border-secondary focus:border-primary focus:ring-4 focus:ring-primary/20 transition-all duration-200"
placeholder="New Goal Name"
/>
<button
type="button"
onClick={handleAddGoal}
className="bg-primary hover:bg-primary-dark text-text-primary font-semibold py-2.5 px-5 rounded-xl shadow-md hover:shadow-lg hover:scale-105 transition-all duration-200"
>
Add Goal
</button>
</div>
</div>
<h2 className="text-3xl font-['ADLaM_Display'] text-text-primary mb-6">{goals[selectedGoalIndex].name}</h2>
<div className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-6 shadow-lg mb-6">
<h3 className="text-2xl font-bold text-text-primary mb-4">Time Goal</h3>
<label className="block text-text-primary font-medium mb-2" htmlFor="desiredReleaseDate">Desired
Release Date</label>
<input
type="date"
id="desiredReleaseDate"
value={goals[selectedGoalIndex].timeGoal.desiredReleaseDate}
onChange={(e) => handleInputChange(e, 'timeGoal', 'desiredReleaseDate')}
className="w-full p-2 rounded-lg bg-gray-800 text-white border-none outline-none"
/>
<label className="block text-white mb-2 mt-4" htmlFor="maxReleaseDate">Max Release Date</label>
<input
type="date"
id="maxReleaseDate"
value={goals[selectedGoalIndex].timeGoal.maxReleaseDate}
onChange={(e) => handleInputChange(e, 'timeGoal', 'maxReleaseDate')}
className="w-full p-2 rounded-lg bg-gray-800 text-white border-none outline-none"
/>
</div>
<div className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-6 shadow-lg mb-6">
<h3 className="text-2xl font-bold text-text-primary mb-4">Numbers Goal</h3>
<label className="block text-text-primary font-medium mb-2" htmlFor="minWordsCount">Min Words
Count</label>
<input
type="number"
id="minWordsCount"
value={goals[selectedGoalIndex].numbersGoal.minWordsCount}
onChange={(e) => handleInputChange(e, 'numbersGoal', 'minWordsCount')}
className="w-full p-2 rounded-lg bg-gray-800 text-white border-none outline-none"
/>
<label className="block text-white mb-2 mt-4" htmlFor="maxWordsCount">Max Words Count</label>
<input
type="number"
id="maxWordsCount"
value={goals[selectedGoalIndex].numbersGoal.maxWordsCount}
onChange={(e) => handleInputChange(e, 'numbersGoal', 'maxWordsCount')}
className="w-full p-2 rounded-lg bg-gray-800 text-white border-none outline-none"
/>
<label className="block text-white mb-2 mt-4" htmlFor="desiredWordsCountByChapter">Desired Words
Count by Chapter</label>
<input
type="number"
id="desiredWordsCountByChapter"
value={goals[selectedGoalIndex].numbersGoal.desiredWordsCountByChapter}
onChange={(e) => handleInputChange(e, 'numbersGoal', 'desiredWordsCountByChapter')}
className="w-full p-2 rounded-lg bg-gray-800 text-white border-none outline-none"
/>
<label className="block text-white mb-2 mt-4" htmlFor="desiredChapterCount">Desired Chapter
Count</label>
<input
type="number"
id="desiredChapterCount"
value={goals[selectedGoalIndex].numbersGoal.desiredChapterCount}
onChange={(e) => handleInputChange(e, 'numbersGoal', 'desiredChapterCount')}
className="w-full p-2 rounded-lg bg-gray-800 text-white border-none outline-none"
/>
</div>
<div className="text-center mt-8">
<button
type="button"
className="bg-primary hover:bg-primary-dark text-text-primary font-semibold py-2.5 px-6 rounded-xl shadow-md hover:shadow-lg hover:scale-105 transition-all duration-200"
>
Update
</button>
</div>
</section>
</main>
);
}
'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<Goal[]>([
{
id: 1,
name: 'First Goal',
timeGoal: {
desiredReleaseDate: '',
maxReleaseDate: '',
},
numbersGoal: {
minWordsCount: 0,
maxWordsCount: 0,
desiredWordsCountByChapter: 0,
desiredChapterCount: 0,
},
},
]);
const [selectedGoalIndex, setSelectedGoalIndex] = useState(0);
const [newGoalName, setNewGoalName] = useState('');
const handleAddGoal = () => {
const newGoal: Goal = {
id: goals.length + 1,
name: newGoalName,
timeGoal: {
desiredReleaseDate: '',
maxReleaseDate: '',
},
numbersGoal: {
minWordsCount: 0,
maxWordsCount: 0,
desiredWordsCountByChapter: 0,
desiredChapterCount: 0,
},
};
setGoals([...goals, newGoal]);
setNewGoalName('');
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>, field: keyof Goal, subField?: keyof TimeGoal | keyof NumbersGoal) => {
const updatedGoals = [...goals];
if (subField) {
if (field === 'timeGoal' && subField in updatedGoals[selectedGoalIndex].timeGoal) {
(updatedGoals[selectedGoalIndex].timeGoal[subField as keyof TimeGoal] as string) = e.target.value;
} else if (field === 'numbersGoal' && subField in updatedGoals[selectedGoalIndex].numbersGoal) {
(updatedGoals[selectedGoalIndex].numbersGoal[subField as keyof NumbersGoal] as number) = Number(e.target.value);
}
} else {
(updatedGoals[selectedGoalIndex][field] as string) = e.target.value;
}
setGoals(updatedGoals);
};
return (
<main className="flex-grow p-8 overflow-y-auto">
<section id="goals">
<h2 className="text-4xl font-['ADLaM_Display'] text-text-primary mb-6">Goals</h2>
<div className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-6 shadow-lg mb-6">
<div className="flex space-x-4 items-center">
<select
className="input-base"
value={selectedGoalIndex}
onChange={(e) => setSelectedGoalIndex(parseInt(e.target.value))}
>
{goals.map((goal, index) => (
<option key={goal.id} value={index}>{goal.name}</option>
))}
</select>
<input
type="text"
value={newGoalName}
onChange={(e) => setNewGoalName(e.target.value)}
className="input-base"
placeholder="New Goal Name"
/>
<Button variant="primary" icon={Plus} onClick={handleAddGoal}>
Add Goal
</Button>
</div>
</div>
<h2 className="text-3xl font-['ADLaM_Display'] text-text-primary mb-6">{goals[selectedGoalIndex].name}</h2>
<div className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-6 shadow-lg mb-6">
<h3 className="text-2xl font-bold text-text-primary mb-4">Time Goal</h3>
<label className="block text-text-primary font-medium mb-2" htmlFor="desiredReleaseDate">Desired
Release Date</label>
<input
type="date"
id="desiredReleaseDate"
value={goals[selectedGoalIndex].timeGoal.desiredReleaseDate}
onChange={(e) => handleInputChange(e, 'timeGoal', 'desiredReleaseDate')}
className="input-base"
/>
<label className="block text-text-primary mb-2 mt-4" htmlFor="maxReleaseDate">Max Release Date</label>
<input
type="date"
id="maxReleaseDate"
value={goals[selectedGoalIndex].timeGoal.maxReleaseDate}
onChange={(e) => handleInputChange(e, 'timeGoal', 'maxReleaseDate')}
className="input-base"
/>
</div>
<div className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-6 shadow-lg mb-6">
<h3 className="text-2xl font-bold text-text-primary mb-4">Numbers Goal</h3>
<label className="block text-text-primary font-medium mb-2" htmlFor="minWordsCount">Min Words
Count</label>
<input
type="number"
id="minWordsCount"
value={goals[selectedGoalIndex].numbersGoal.minWordsCount}
onChange={(e) => handleInputChange(e, 'numbersGoal', 'minWordsCount')}
className="input-base"
/>
<label className="block text-text-primary mb-2 mt-4" htmlFor="maxWordsCount">Max Words Count</label>
<input
type="number"
id="maxWordsCount"
value={goals[selectedGoalIndex].numbersGoal.maxWordsCount}
onChange={(e) => handleInputChange(e, 'numbersGoal', 'maxWordsCount')}
className="input-base"
/>
<label className="block text-text-primary mb-2 mt-4" htmlFor="desiredWordsCountByChapter">Desired Words
Count by Chapter</label>
<input
type="number"
id="desiredWordsCountByChapter"
value={goals[selectedGoalIndex].numbersGoal.desiredWordsCountByChapter}
onChange={(e) => handleInputChange(e, 'numbersGoal', 'desiredWordsCountByChapter')}
className="input-base"
/>
<label className="block text-text-primary mb-2 mt-4" htmlFor="desiredChapterCount">Desired Chapter
Count</label>
<input
type="number"
id="desiredChapterCount"
value={goals[selectedGoalIndex].numbersGoal.desiredChapterCount}
onChange={(e) => handleInputChange(e, 'numbersGoal', 'desiredChapterCount')}
className="input-base"
/>
</div>
<div className="text-center mt-8">
<Button variant="primary" icon={Save}>
Update
</Button>
</div>
</section>
</main>
);
}

View File

@@ -1,457 +0,0 @@
'use client'
import * as tauri from '@/lib/tauri';
import {ChangeEvent, forwardRef, useContext, useEffect, useImperativeHandle, useState} from 'react';
import System from '@/lib/models/System';
import {AlertContext} from "@/context/AlertContext";
import {BookContext} from '@/context/BookContext';
import {SessionContext} from "@/context/SessionContext";
import {GuideLine, GuideLineAI} from "@/lib/models/Book";
import TexteAreaInput from "@/components/form/TexteAreaInput";
import InputField from "@/components/form/InputField";
import TextInput from "@/components/form/TextInput";
import SelectBox from "@/components/form/SelectBox";
import {
advancedDialogueTypes,
advancedNarrativePersons,
beginnerDialogueTypes,
beginnerNarrativePersons,
intermediateDialogueTypes,
intermediateNarrativePersons,
langues,
verbalTime
} from "@/lib/models/Story";
import {useTranslations} from "next-intl";
import {LangContext} from "@/context/LangContext";
import OfflineContext, {OfflineContextType} from "@/context/OfflineContext";
import {LocalSyncQueueContext, LocalSyncQueueContextProps} from "@/context/SyncQueueContext";
import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext";
import {SyncedBook} from "@/lib/models/SyncedBook";
function GuideLineSetting(props: any, ref: any) {
const t = useTranslations();
const {lang} = useContext(LangContext);
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
const {addToQueue} = useContext<LocalSyncQueueContextProps>(LocalSyncQueueContext);
const {localSyncedBooks} = useContext<BooksSyncContextProps>(BooksSyncContext);
const {book} = useContext(BookContext);
const {session} = useContext(SessionContext);
const userToken: string = session?.accessToken ? session?.accessToken : '';
const {errorMessage, successMessage} = useContext(AlertContext);
const bookId = book?.bookId as string;
const [activeTab, setActiveTab] = useState('personal');
const authorLevel: string = session.user?.writingLevel?.toString() ?? '1';
const [tone, setTone] = useState<string>('');
const [atmosphere, setAtmosphere] = useState<string>('');
const [writingStyle, setWritingStyle] = useState<string>('');
const [themes, setThemes] = useState<string>('');
const [symbolism, setSymbolism] = useState<string>('');
const [motifs, setMotifs] = useState<string>('');
const [narrativeVoice, setNarrativeVoice] = useState<string>('');
const [pacing, setPacing] = useState<string>('');
const [intendedAudience, setIntendedAudience] = useState<string>('');
const [keyMessages, setKeyMessages] = useState<string>('');
const [plotSummary, setPlotSummary] = useState<string>('');
const [narrativeType, setNarrativeType] = useState<string>('');
const [verbTense, setVerbTense] = useState<string>('');
const [dialogueType, setDialogueType] = useState<string>('');
const [toneAtmosphere, setToneAtmosphere] = useState<string>('');
const [language, setLanguage] = useState<string>('');
useEffect((): void => {
if (activeTab === 'personal') {
getGuideLine().then();
} else {
getAIGuideLine().then();
}
}, [activeTab]);
useImperativeHandle(ref, () => {
{
if (activeTab === 'personal') {
return {
handleSave: savePersonal
};
} else {
return {
handleSave: saveQuillSense
};
}
}
});
async function getAIGuideLine(): Promise<void> {
try {
let response: GuideLineAI;
if (isCurrentlyOffline() || book?.localBook) {
response = await tauri.getAIGuideLine(bookId);
} else {
response = await System.authGetQueryToServer<GuideLineAI>(`book/ai/guideline`, userToken, lang, {id: bookId});
}
if (response) {
setPlotSummary(response.globalResume || '');
setVerbTense(response.verbeTense?.toString() || '');
setNarrativeType(response.narrativeType?.toString() || '');
setDialogueType(response.dialogueType?.toString() || '');
setToneAtmosphere(response.atmosphere || '');
setLanguage(response.langue?.toString() || '');
setThemes(response.themes || '');
}
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t("guideLineSetting.errorUnknown"));
}
}
}
async function getGuideLine(): Promise<void> {
try {
let response: GuideLine;
if (isCurrentlyOffline() || book?.localBook) {
response = await tauri.getGuideLine(bookId);
} else {
response = await System.authGetQueryToServer<GuideLine>(
`book/guide-line`,
userToken,
lang,
{id: bookId},
);
}
if (response) {
setTone(response.tone);
setAtmosphere(response.atmosphere);
setWritingStyle(response.writingStyle);
setThemes(response.themes);
setSymbolism(response.symbolism);
setMotifs(response.motifs);
setNarrativeVoice(response.narrativeVoice);
setPacing(response.pacing);
setIntendedAudience(response.intendedAudience);
setKeyMessages(response.keyMessages);
}
} catch (error: unknown) {
if (error instanceof Error) {
errorMessage(error.message);
} else {
errorMessage(t("guideLineSetting.errorUnknown"));
}
}
}
async function savePersonal(): Promise<void> {
try {
let response: boolean;
const guidelineData = {
bookId: bookId,
tone: tone,
atmosphere: atmosphere,
writingStyle: writingStyle,
themes: themes,
symbolism: symbolism,
motifs: motifs,
narrativeVoice: narrativeVoice,
pacing: pacing,
intendedAudience: intendedAudience,
keyMessages: keyMessages,
};
if (isCurrentlyOffline() || book?.localBook) {
response = await tauri.updateGuideLine(guidelineData);
} else {
response = await System.authPostToServer<boolean>(
'book/guide-line',
guidelineData,
userToken,
lang,
);
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) {
addToQueue('update_guideline', {data: guidelineData});
}
}
if (!response) {
errorMessage(t("guideLineSetting.saveError"));
return;
}
successMessage(t("guideLineSetting.saveSuccess"));
} catch (error: unknown) {
if (error instanceof Error) {
errorMessage(error.message);
} else {
errorMessage(t("guideLineSetting.errorUnknown"));
}
}
}
async function saveQuillSense(): Promise<void> {
try {
let response: boolean;
const aiGuidelineData = {
bookId: bookId,
plotSummary: plotSummary,
verbTense: verbTense,
narrativeType: narrativeType,
dialogueType: dialogueType,
toneAtmosphere: toneAtmosphere,
language: language,
themes: themes,
};
if (isCurrentlyOffline() || book?.localBook) {
response = await tauri.updateAIGuideLine(aiGuidelineData);
} else {
response = await System.authPostToServer<boolean>(
'quillsense/book/guide-line',
aiGuidelineData,
userToken,
lang,
);
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) {
addToQueue('update_ai_guideline', {data: aiGuidelineData});
}
}
if (response) {
successMessage(t("guideLineSetting.saveSuccess"));
} else {
errorMessage(t("guideLineSetting.saveError"));
}
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t("guideLineSetting.errorUnknown"));
}
}
}
return (
<div className="space-y-6">
<div className="flex gap-2 border-b border-secondary/50 mb-6">
<button
className={`px-5 py-2.5 font-medium rounded-t-xl transition-all duration-200 ${
activeTab === 'personal'
? 'border-b-2 border-primary text-primary bg-primary/10 shadow-md'
: 'text-text-secondary hover:text-text-primary hover:bg-secondary/30'
}`}
onClick={(): void => setActiveTab('personal')}
>
{t("guideLineSetting.personal")}
</button>
<button
className={`px-5 py-2.5 font-medium rounded-t-xl transition-all duration-200 ${
activeTab === 'quillsense'
? 'border-b-2 border-primary text-primary bg-primary/10 shadow-md'
: 'text-text-secondary hover:text-text-primary hover:bg-secondary/30'
}`}
onClick={() => setActiveTab('quillsense')}
>
{t("guideLineSetting.quillsense")}
</button>
</div>
{activeTab === 'personal' && (
<div className="space-y-4">
<div
className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
<InputField fieldName={t("guideLineSetting.tone")} input={
<TexteAreaInput
value={tone}
setValue={(e: ChangeEvent<HTMLTextAreaElement>) => setTone(e.target.value)}
placeholder={t("guideLineSetting.tonePlaceholder")}
/>
}/>
</div>
<div
className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
<InputField fieldName={t("guideLineSetting.atmosphere")} input={
<TexteAreaInput
value={atmosphere}
setValue={(e: ChangeEvent<HTMLTextAreaElement>) => setAtmosphere(e.target.value)}
placeholder={t("guideLineSetting.atmospherePlaceholder")}
/>
}/>
</div>
<div
className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
<InputField fieldName={t("guideLineSetting.writingStyle")} input={
<TexteAreaInput
value={writingStyle}
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setWritingStyle(e.target.value)}
placeholder={t("guideLineSetting.writingStylePlaceholder")}
/>
}/>
</div>
<div
className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
<InputField fieldName={t("guideLineSetting.themes")} input={
<TexteAreaInput
value={themes}
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setThemes(e.target.value)}
placeholder={t("guideLineSetting.themesPlaceholder")}
/>
}/>
</div>
<div
className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
<InputField fieldName={t("guideLineSetting.symbolism")} input={
<TexteAreaInput
value={symbolism}
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setSymbolism(e.target.value)}
placeholder={t("guideLineSetting.symbolismPlaceholder")}
/>
}/>
</div>
<div
className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
<InputField fieldName={t("guideLineSetting.motifs")} input={
<TexteAreaInput
value={motifs}
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setMotifs(e.target.value)}
placeholder={t("guideLineSetting.motifsPlaceholder")}
/>
}/>
</div>
<div
className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
<InputField fieldName={t("guideLineSetting.narrativeVoice")} input={
<TexteAreaInput
value={narrativeVoice}
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setNarrativeVoice(e.target.value)}
placeholder={t("guideLineSetting.narrativeVoicePlaceholder")}
/>
}/>
</div>
<div
className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
<InputField fieldName={t("guideLineSetting.pacing")} input={
<TexteAreaInput
value={pacing}
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setPacing(e.target.value)}
placeholder={t("guideLineSetting.pacingPlaceholder")}
/>
}/>
</div>
<div
className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
<InputField fieldName={t("guideLineSetting.intendedAudience")} input={
<TexteAreaInput
value={intendedAudience}
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setIntendedAudience(e.target.value)}
placeholder={t("guideLineSetting.intendedAudiencePlaceholder")}
/>
}/>
</div>
<div
className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
<InputField fieldName={t("guideLineSetting.keyMessages")} input={
<TexteAreaInput
value={keyMessages}
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setKeyMessages(e.target.value)}
placeholder={t("guideLineSetting.keyMessagesPlaceholder")}
/>
}/>
</div>
</div>
)}
{activeTab === 'quillsense' && (
<div className="space-y-4">
<div
className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
<InputField fieldName={t("guideLineSetting.plotSummary")} input={
<TexteAreaInput
value={plotSummary}
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setPlotSummary(e.target.value)}
placeholder={t("guideLineSetting.plotSummaryPlaceholder")}
/>
}/>
</div>
<div
className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
<InputField fieldName={t("guideLineSetting.toneAtmosphere")} input={
<TextInput
value={toneAtmosphere}
setValue={(e: ChangeEvent<HTMLInputElement>): void => setToneAtmosphere(e.target.value)}
placeholder={t("guideLineSetting.toneAtmospherePlaceholder")}
/>
}/>
</div>
<div
className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
<InputField fieldName={t("guideLineSetting.themes")} input={
<TextInput
value={themes}
setValue={(e: ChangeEvent<HTMLInputElement>) => setThemes(e.target.value)}
placeholder={t("guideLineSetting.themesPlaceholderQuill")}
/>
}/>
</div>
<div
className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
<InputField fieldName={t("guideLineSetting.verbTense")} input={
<SelectBox
defaultValue={verbTense}
onChangeCallBack={(event: ChangeEvent<HTMLSelectElement>): void => setVerbTense(event.target.value)}
data={verbalTime}
placeholder={t("guideLineSetting.verbTensePlaceholder")}
/>
}/>
</div>
<div
className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
<InputField fieldName={t("guideLineSetting.narrativeType")} input={
<SelectBox defaultValue={narrativeType} data={
authorLevel === '1'
? beginnerNarrativePersons
: authorLevel === '2'
? intermediateNarrativePersons
: advancedNarrativePersons
} onChangeCallBack={(event: ChangeEvent<HTMLSelectElement>): void => {
setNarrativeType(event.target.value)
}} placeholder={t("guideLineSetting.narrativeTypePlaceholder")}/>
}/>
</div>
<div
className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
<InputField fieldName={t("guideLineSetting.dialogueType")} input={
<SelectBox defaultValue={dialogueType} data={authorLevel === '1'
? beginnerDialogueTypes
: authorLevel === '2'
? intermediateDialogueTypes
: advancedDialogueTypes}
onChangeCallBack={(event: ChangeEvent<HTMLSelectElement>) => {
setDialogueType(event.target.value)
}} placeholder={t("guideLineSetting.dialogueTypePlaceholder")}/>
}/>
</div>
<div
className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
<InputField fieldName={t("guideLineSetting.language")} input={
<SelectBox defaultValue={language} data={langues}
onChangeCallBack={(event: ChangeEvent<HTMLSelectElement>) => {
setLanguage(event.target.value)
}} placeholder={t("guideLineSetting.languagePlaceholder")}/>
}/>
</div>
</div>
)}
</div>
);
}
export default forwardRef(GuideLineSetting);

View File

@@ -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<void> }>) {
const {showToggle = true, entityType = 'book', entityId} = props;
const t = useTranslations();
const {lang} = useContext<LangContextProps>(LangContext);
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
const {addToQueue} = useContext<LocalSyncQueueContextProps>(LocalSyncQueueContext);
const {localSyncedBooks} = useContext<BooksSyncContextProps>(BooksSyncContext);
const {session} = useContext(SessionContext);
const {successMessage, errorMessage} = useContext(AlertContext);
const {book, setBook} = useContext(BookContext);
const {seriesId, localSeries} = useContext<SeriesContextProps>(SeriesContext);
const {localSyncedSeries} = useContext<SeriesSyncContextProps>(SeriesSyncContext);
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext);
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
const {successMessage, errorMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
const {book, setBook}: BookContextProps = useContext<BookContextProps>(BookContext);
const currentEntityId: string = entityId || book?.bookId || '';
const isSeriesMode: boolean = entityType === 'series';
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<void> {
if (!bookSeriesId) return;
try {
const response: SeriesLocationItem[] = await System.authGetQueryToServer<SeriesLocationItem[]>(
const response: SeriesLocationItem[] = await apiGet<SeriesLocationItem[]>(
'series/location/list',
token,
lang,
@@ -111,7 +98,7 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref<
}
} catch (e: unknown) {
if (e instanceof Error) {
console.error('Error loading series locations:', e.message);
errorMessage(e.message);
}
}
}
@@ -119,31 +106,19 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref<
async function handleToggleTool(enabled: boolean): Promise<void> {
if (isSeriesMode) return;
try {
let response: boolean;
if (isCurrentlyOffline() || book?.localBook) {
response = await tauri.updateBookToolSetting(currentEntityId, 'locations', enabled);
} else {
response = await System.authPatchToServer<boolean>('book/tool-setting', {
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<boolean>('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<void> {
try {
if (isSeriesMode) {
let response: SeriesLocationItem[];
if (isCurrentlyOffline() || localSeries) {
response = await tauri.getSeriesLocationList(currentEntityId) as SeriesLocationItem[];
} else {
response = await System.authGetQueryToServer<SeriesLocationItem[]>(
'series/location/list',
token,
lang,
{seriesid: currentEntityId}
);
}
const response: SeriesLocationItem[] = await apiGet<SeriesLocationItem[]>(
'series/location/list',
token,
lang,
{seriesid: currentEntityId}
);
if (response) {
const mappedLocations: LocationProps[] = response.map((loc: SeriesLocationItem): LocationProps => ({
id: loc.id,
@@ -186,14 +156,12 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref<
setSections(mappedLocations);
}
} else {
let response: LocationListResponse;
if (isCurrentlyOffline() || book?.localBook) {
response = await tauri.getAllLocations(currentEntityId, true) as LocationListResponse;
} else {
response = await System.authGetQueryToServer<LocationListResponse>(`location/all`, token, lang, {
bookid: currentEntityId,
});
}
const response: LocationListResponse = await apiGet<LocationListResponse>(
'location/all',
token,
lang,
{bookid: currentEntityId}
);
if (response) {
setSections(response.locations);
setToolEnabled(response.enabled);
@@ -202,8 +170,8 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref<
...book, tools: {
characters: book.tools?.characters ?? false,
worlds: book.tools?.worlds ?? false,
spells: book.tools?.spells ?? false,
locations: response.enabled
locations: response.enabled,
spells: book.tools?.spells ?? false
}
});
}
@@ -217,7 +185,7 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref<
}
}
}
async function handleAddSection(): Promise<void> {
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<string>(
'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<string>(
'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<string>(`location/section/add`, {
sectionId = await apiPost<string>('location/section/add', {
bookId: currentEntityId,
locationName: newSectionName,
}, token, lang);
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) {
addToQueue('add_location_section', {data: {
bookId: currentEntityId,
sectionId,
locationName: newSectionName,
}});
if (!sectionId) {
errorMessage(t('locationComponent.errorUnknownAddSection'));
return;
}
}
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<string>(
'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<string>(
'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<string>(`location/element/add`, {
bookId: currentEntityId,
locationId: sectionId,
elementName: newElementNames[sectionId],
},
token, lang);
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) {
addToQueue('add_location_element', {data: {
bookId: currentEntityId,
locationId: sectionId,
elementId,
elementName: newElementNames[sectionId],
}});
elementId = await apiPost<string>('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<string>(
'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<string>(
'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<string>(`location/sub-element/add`, {
elementId: elementId,
subElementId = await apiPost<string>('location/sub-element/add', {
elementId: sections[sectionIndex].elements[elementIndex].id,
subElementName: newSubElementNames[elementIndex],
}, token, lang);
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) {
addToQueue('add_location_sub_element', {data: {
elementId: elementId,
subElementId,
subElementName: newSubElementNames[elementIndex],
}});
if (!subElementId) {
errorMessage(t('locationComponent.errorUnknownAddSubElement'));
return;
}
}
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<void> {
try {
let response: boolean;
const elementId = sections.find((section: LocationProps): boolean => section.id === sectionId)
const elementId: string | undefined = sections.find((section: LocationProps): boolean => section.id === sectionId)
?.elements[elementIndex].id;
const deletedAt: number = System.timeStampInSeconds();
let success: boolean;
if (isSeriesMode) {
const deleteData = {elementId: elementId, deletedAt};
if (isCurrentlyOffline() || localSeries) {
response = await tauri.deleteSeriesLocationElement(deleteData.elementId!, deleteData.deletedAt);
} else {
response = await System.authDeleteToServer<boolean>('series/location/element/delete', deleteData, token, lang);
if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) {
addToQueue('delete_series_location_element', {data: deleteData});
}
}
} else if (isCurrentlyOffline() || book?.localBook) {
response = await tauri.deleteLocationElement(elementId!, currentEntityId, deletedAt);
} else {
response = await System.authDeleteToServer<boolean>(`location/element/delete`, {
elementId: elementId, bookId: currentEntityId, deletedAt,
success = await apiDelete<boolean>('series/location/element/delete', {
elementId: elementId
}, token, lang);
} else {
success = await apiDelete<boolean>('location/element/delete', {
elementId: elementId,
}, token, lang);
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) {
addToQueue('delete_location_element', {data: {
elementId: elementId, bookId: currentEntityId, deletedAt,
}});
}
}
if (!response) {
if (!success) {
errorMessage(t('locationComponent.errorUnknownDeleteElement'));
return;
}
const updatedSections: LocationProps[] = [...sections];
const sectionIndex: number = updatedSections.findIndex((section: LocationProps): boolean => section.id === sectionId,);
const sectionIndex: number = updatedSections.findIndex((section: LocationProps): boolean => section.id === sectionId);
updatedSections[sectionIndex].elements.splice(elementIndex, 1);
setSections(updatedSections);
} catch (e: unknown) {
@@ -516,39 +412,25 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref<
subElementIndex: number,
): Promise<void> {
try {
let response: boolean;
const subElementId = sections.find((section: LocationProps): boolean => section.id === sectionId)?.elements[elementIndex].subElements[subElementIndex].id;
const deletedAt: number = System.timeStampInSeconds();
const subElementId: string | undefined = sections.find((section: LocationProps): boolean => section.id === sectionId)
?.elements[elementIndex].subElements[subElementIndex].id;
let success: boolean;
if (isSeriesMode) {
const deleteData = {subElementId: subElementId, deletedAt};
if (isCurrentlyOffline() || localSeries) {
response = await tauri.deleteSeriesLocationSubElement(deleteData.subElementId!, deleteData.deletedAt);
} else {
response = await System.authDeleteToServer<boolean>('series/location/sub-element/delete', deleteData, token, lang);
if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) {
addToQueue('delete_series_location_sub_element', {data: deleteData});
}
}
} else if (isCurrentlyOffline() || book?.localBook) {
response = await tauri.deleteLocationSubElement(subElementId!, currentEntityId, deletedAt);
} else {
response = await System.authDeleteToServer<boolean>(`location/sub-element/delete`, {
subElementId: subElementId, bookId: currentEntityId, deletedAt,
success = await apiDelete<boolean>('series/location/sub-element/delete', {
subElementId: subElementId
}, token, lang);
} else {
success = await apiDelete<boolean>('location/sub-element/delete', {
subElementId: subElementId,
}, token, lang);
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) {
addToQueue('delete_location_sub_element', {data: {
subElementId: subElementId, bookId: currentEntityId, deletedAt,
}});
}
}
if (!response) {
if (!success) {
errorMessage(t('locationComponent.errorUnknownDeleteSubElement'));
return;
}
const updatedSections: LocationProps[] = [...sections];
const sectionIndex: number = updatedSections.findIndex((section: LocationProps): boolean => section.id === sectionId,);
updatedSections[sectionIndex].elements[elementIndex].subElements.splice(subElementIndex, 1,);
const sectionIndex: number = updatedSections.findIndex((section: LocationProps): boolean => section.id === sectionId);
updatedSections[sectionIndex].elements[elementIndex].subElements.splice(subElementIndex, 1);
setSections(updatedSections);
} catch (e: unknown) {
if (e instanceof Error) {
@@ -561,36 +443,21 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref<
async function handleRemoveSection(sectionId: string): Promise<void> {
try {
let response: boolean;
const deletedAt: number = System.timeStampInSeconds();
let success: boolean;
if (isSeriesMode) {
const deleteData = {locationId: sectionId, deletedAt};
if (isCurrentlyOffline() || localSeries) {
response = await tauri.deleteSeriesLocation(deleteData.locationId, deleteData.deletedAt);
} else {
response = await System.authDeleteToServer<boolean>('series/location/delete', deleteData, token, lang);
if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) {
addToQueue('delete_series_location', {data: deleteData});
}
}
} else if (isCurrentlyOffline() || book?.localBook) {
response = await tauri.deleteLocationSection(sectionId, currentEntityId, deletedAt);
} else {
response = await System.authDeleteToServer<boolean>(`location/delete`, {
locationId: sectionId, bookId: currentEntityId, deletedAt,
success = await apiDelete<boolean>('series/location/delete', {
locationId: sectionId
}, token, lang);
} else {
success = await apiDelete<boolean>('location/delete', {
locationId: sectionId,
}, token, lang);
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) {
addToQueue('delete_location_section', {data: {
locationId: sectionId, bookId: currentEntityId, deletedAt,
}});
}
}
if (!response) {
if (!success) {
errorMessage(t('locationComponent.errorUnknownDeleteSection'));
return;
}
const updatedSections: LocationProps[] = sections.filter((section: LocationProps): boolean => section.id !== sectionId,);
const updatedSections: LocationProps[] = sections.filter((section: LocationProps): boolean => section.id !== sectionId);
setSections(updatedSections);
} catch (e: unknown) {
if (e instanceof Error) {
@@ -603,20 +470,9 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref<
async function handleSave(): Promise<void> {
try {
let response: boolean;
if (isCurrentlyOffline() || book?.localBook) {
response = await tauri.updateLocations(sections) as boolean;
} else {
response = await System.authPostToServer<boolean>(`location/update`, {
locations: sections,
}, token, lang);
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) {
addToQueue('update_locations', {data: {
locations: sections,
}});
}
}
const response: boolean = await apiPost<boolean>(`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<void> {
if (!bookSeriesId) return;
try {
const seriesLocationId: string = await System.authPostToServer<string>('series/location/section/add', {
const seriesLocationId: string = await apiPost<string>('series/location/section/add', {
seriesId: bookSeriesId,
name: section.name,
}, token, lang);
if (seriesLocationId) {
const updateResponse: boolean = await System.authPostToServer<boolean>('location/section/update', {
const updateResponse: boolean = await apiPost<boolean>('location/section/update', {
sectionId: section.id,
sectionName: section.name,
seriesLocationId: seriesLocationId,
}, 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<void> {
const seriesLocation: SeriesLocationItem | undefined = seriesLocations.find((location: SeriesLocationItem): boolean => location.id === seriesLocationId);
if (!seriesLocation) return;
try {
const sectionId: string = await System.authPostToServer<string>('location/section/add', {
const sectionId: string = await apiPost<string>('location/section/add', {
bookId: currentEntityId,
locationName: seriesLocation.name,
seriesLocationId: seriesLocationId,
}, token, lang);
if (!sectionId) {
errorMessage(t('locationComponent.importError'));
return;
}
const importedElements: Element[] = [];
for (const seriesElement of seriesLocation.elements) {
const elementId: string = await System.authPostToServer<string>('location/element/add', {
const elementId: string = await apiPost<string>('location/element/add', {
bookId: currentEntityId,
locationId: sectionId,
elementName: seriesElement.name,
}, token, lang);
if (!elementId) continue;
const importedSubElements: SubElement[] = [];
for (const seriesSubElement of seriesElement.subElements) {
const subElementId: string = await System.authPostToServer<string>('location/sub-element/add', {
const subElementId: string = await apiPost<string>('location/sub-element/add', {
elementId: elementId,
subElementName: seriesSubElement.name,
}, token, lang);
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 (
<div className="space-y-6">
{showToggle && !isSeriesMode && (
<div className="bg-secondary/20 rounded-xl p-5 shadow-inner border border-secondary/30">
<div className="bg-secondary rounded-xl p-4">
<InputField
icon={faToggleOn}
icon={ToggleRight}
fieldName={t('locationComponent.enableTool')}
input={
<ToggleSwitch
@@ -755,13 +611,15 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref<
<SeriesImportSelector
availableItems={seriesLocations
.filter((seriesLocation: SeriesLocationItem): boolean => !sections.some((section: LocationProps): boolean => section.seriesLocationId === seriesLocation.id))
.map((seriesLocation: SeriesLocationItem) => ({id: seriesLocation.id, name: seriesLocation.name}))}
.map((seriesLocation: SeriesLocationItem) => ({
id: seriesLocation.id,
name: seriesLocation.name
}))}
onImport={handleImportFromSeries}
placeholder={t("seriesImport.selectElement")}
label={t("seriesImport.importFromSeries")}
/>
)}
<div className="bg-tertiary/90 backdrop-blur-sm rounded-xl shadow-lg p-4 border border-secondary/50">
<div className="grid grid-cols-1 gap-4 mb-4">
<InputField
input={
@@ -771,45 +629,37 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref<
placeholder={t("locationComponent.newSectionPlaceholder")}
/>
}
actionIcon={faPlus}
actionIcon={Plus}
actionLabel={t("locationComponent.addSectionLabel")}
addButtonCallBack={handleAddSection}
/>
</div>
</div>
{sections.length > 0 ? (
sections.map((section: LocationProps) => (
<div key={section.id}
className="bg-tertiary/90 backdrop-blur-sm rounded-xl shadow-lg p-4 border border-secondary/50">
<div key={section.id} className="space-y-4">
<h3 className="text-lg font-semibold text-text-primary mb-4 flex items-center">
<FontAwesomeIcon icon={faMapMarkerAlt} className="mr-2 w-5 h-5"/>
<MapPin className="mr-2 w-5 h-5" strokeWidth={1.75}/>
{section.name}
<span
className="ml-2 text-sm bg-dark-background text-text-secondary py-0.5 px-2 rounded-full">
className="ml-2 text-sm bg-secondary text-text-secondary py-0.5 px-2 rounded-full">
{section.elements.length || 0}
</span>
<div className="ml-auto flex items-center gap-2">
{!isSeriesMode && bookSeriesId && !section.seriesLocationId && (
<button
onClick={(): Promise<void> => handleExportToSeries(section)}
title={t("locationComponent.exportToSeries")}
className="bg-blue-500/90 text-text-primary rounded-full p-1.5 hover:bg-blue-500 transition-colors shadow-md"
>
<FontAwesomeIcon icon={faShare} className={'w-5 h-5'}/>
</button>
<IconButton icon={Share2} variant="ghost" size="sm" shape="square"
tooltip={t("locationComponent.exportToSeries")}
onClick={(): Promise<void> => handleExportToSeries(section)}/>
)}
<button onClick={(): Promise<void> => handleRemoveSection(section.id)}
className="bg-dark-background text-text-primary rounded-full p-1.5 hover:bg-secondary transition-colors shadow-md">
<FontAwesomeIcon icon={faTrash} className={'w-5 h-5'}/>
</button>
<IconButton icon={Trash2} variant="danger" size="sm" shape="square"
onClick={(): Promise<void> => handleRemoveSection(section.id)}/>
</div>
</h3>
<div className="space-y-4">
{section.elements.length > 0 ? (
section.elements.map((element, elementIndex) => (
<div key={element.id}
className="bg-dark-background rounded-lg p-3 border-l-4 border-primary">
className="bg-secondary rounded-lg p-3 border-l-4 border-primary">
<div className="mb-2">
<InputField
input={
@@ -824,17 +674,17 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref<
removeButtonCallBack={(): Promise<void> => handleRemoveElement(section.id, elementIndex)}
/>
</div>
<TexteAreaInput
<TextAreaInput
value={element.description}
setValue={(e: React.ChangeEvent<HTMLTextAreaElement>): void => handleElementChange(section.id, elementIndex, 'description', e.target.value)}
placeholder={t("locationComponent.elementDescriptionPlaceholder")}
/>
<div className="mt-4 pt-4 border-t border-secondary/50">
<div className="mt-4 pt-4 border-t border-secondary">
{element.subElements.length > 0 && (
<h4 className="text-sm italic text-text-secondary mb-3">{t("locationComponent.subElementsHeading")}</h4>
)}
{element.subElements.map((subElement: SubElement, subElementIndex: number) => (
<div key={subElement.id}
className="bg-darkest-background rounded-lg p-3 mb-3">
@@ -852,7 +702,7 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref<
removeButtonCallBack={(): Promise<void> => handleRemoveSubElement(section.id, elementIndex, subElementIndex)}
/>
</div>
<TexteAreaInput
<TextAreaInput
value={subElement.description}
setValue={(e) =>
handleSubElementChange(section.id, elementIndex, subElementIndex, 'description', e.target.value)
@@ -861,7 +711,7 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref<
/>
</div>
))}
<InputField
input={
<TextInput
@@ -885,13 +735,16 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref<
{t("locationComponent.noElementAvailable")}
</div>
)}
<InputField
input={
<TextInput
value={newElementNames[section.id] || ''}
setValue={(e: ChangeEvent<HTMLInputElement>) =>
setNewElementNames({...newElementNames, [section.id]: e.target.value})
setNewElementNames({
...newElementNames,
[section.id]: e.target.value
})
}
placeholder={t("locationComponent.newElementPlaceholder")}
/>
@@ -902,9 +755,8 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref<
</div>
))
) : (
<div
className="bg-tertiary/90 backdrop-blur-sm rounded-xl shadow-lg p-8 border border-secondary/50 text-center">
<p className="text-text-secondary mb-4">{t("locationComponent.noSectionAvailable")}</p>
<div className="text-center py-8">
<p className="text-text-secondary">{t("locationComponent.noSectionAvailable")}</p>
</div>
)}
</>
@@ -913,4 +765,4 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref<
);
}
export default forwardRef(LocationComponent);
export default forwardRef(LocationComponent);

View File

@@ -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<BookContextProps>(BookContext);
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean>(false);
const [showAddForm, setShowAddForm] = useState<boolean>(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<void> {
if (newSectionName.trim()) {
@@ -89,15 +88,15 @@ export default function LocationEditor(): React.JSX.Element {
setShowAddForm(true);
}
}
async function handleSave(): Promise<void> {
await exitEditMode(true);
}
function handleCancel(): void {
exitEditMode(false);
}
async function handleDelete(): Promise<void> {
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 (
<div className="flex items-center justify-center py-8">
<FontAwesomeIcon icon={faSpinner} className="w-6 h-6 text-primary animate-spin"/>
</div>
);
return <PulseLoader size="sm"/>;
}
const selectedSection: LocationProps | undefined = sections[selectedSectionIndex];
const canExport: boolean = Boolean(bookSeriesId && selectedSection && !selectedSection.seriesLocationId);
return (
<div className="flex flex-col h-full">
<ToolDetailHeader
@@ -128,81 +123,67 @@ export default function LocationEditor(): React.JSX.Element {
onEdit={enterEditMode}
onSave={handleSave}
onCancel={handleCancel}
onDelete={function (): void { setShowDeleteConfirm(true); }}
onExport={canExport ? function (): Promise<void> { return exportToSeries(selectedSection!); } : undefined}
onDelete={function (): void {
setShowDeleteConfirm(true);
}}
onExport={canExport ? function (): Promise<void> {
return exportToSeries(selectedSection!);
} : undefined}
showExport={canExport}
showDelete={Boolean(selectedSection)}
/>
<div className="flex-1 overflow-y-auto">
{viewMode === 'list' && (
<div className="space-y-3 p-2">
{/* Toggle tool */}
<div className="bg-secondary/20 rounded-lg p-3 border border-secondary/30">
<InputField
icon={faToggleOn}
fieldName={t('locationComponent.enableTool')}
input={
<ToggleSwitch
checked={toolEnabled}
onChange={toggleTool}
/>
}
{/* Import from series */}
{bookSeriesId && availableSeriesLocations.length > 0 && (
<SeriesImportSelector
availableItems={availableSeriesLocations.map(function (sl: SeriesLocationItem) {
return {id: sl.id, name: sl.name};
})}
onImport={importFromSeries}
placeholder={t('seriesImport.selectElement')}
label={t('seriesImport.importFromSeries')}
/>
</div>
{toolEnabled && (
<>
{/* Import from series */}
{bookSeriesId && availableSeriesLocations.length > 0 && (
<SeriesImportSelector
availableItems={availableSeriesLocations.map(function (sl: SeriesLocationItem) {
return {id: sl.id, name: sl.name};
})}
onImport={importFromSeries}
placeholder={t('seriesImport.selectElement')}
label={t('seriesImport.importFromSeries')}
/>
)}
{showAddForm && (
<div className="px-2">
<InputField
input={
<TextInput
value={newSectionName}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
setNewSectionName(e.target.value);
}}
placeholder={t('locationComponent.newSectionPlaceholder')}
/>
}
actionIcon={faPlus}
actionLabel={t('locationComponent.addSectionLabel')}
addButtonCallBack={async function (): Promise<void> {
await addSection();
setShowAddForm(false);
}}
/>
</div>
)}
<LocationEditorList
sections={sections}
onSectionClick={handleSectionClick}
onAddSection={handleAddSection}
/>
</>
)}
{showAddForm && (
<div className="px-2">
<InputField
input={
<TextInput
value={newSectionName}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
setNewSectionName(e.target.value);
}}
placeholder={t('locationComponent.newSectionPlaceholder')}
/>
}
actionIcon={Plus}
actionLabel={t('locationComponent.addSectionLabel')}
addButtonCallBack={async function (): Promise<void> {
await addSection();
setShowAddForm(false);
}}
/>
</div>
)}
<LocationEditorList
sections={sections}
onSectionClick={handleSectionClick}
onAddSection={handleAddSection}
/>
</div>
)}
{viewMode === 'detail' && selectedSection && (
<div className="p-4">
<LocationEditorDetail section={selectedSection}/>
</div>
)}
{viewMode === 'edit' && selectedSection && (
<div className="p-4">
<LocationEditorEdit
@@ -225,7 +206,7 @@ export default function LocationEditor(): React.JSX.Element {
</div>
)}
</div>
{showDeleteConfirm && selectedSection && (
<AlertBox
title={t('locationComponent.deleteTitle')}
@@ -234,7 +215,9 @@ export default function LocationEditor(): React.JSX.Element {
confirmText={t('common.delete')}
cancelText={t('common.cancel')}
onConfirm={handleDelete}
onCancel={function (): void { setShowDeleteConfirm(false); }}
onCancel={function (): void {
setShowDeleteConfirm(false);
}}
/>
)}
</div>

View File

@@ -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 (
<div>
<h3 className="text-text-primary font-semibold text-base mb-4">{section.name}</h3>
{section.elements.length === 0 ? (
<p className="text-muted text-sm">{t('locationComponent.noElementAvailable')}</p>
) : (
<div className="space-y-4">
{section.elements.map(function (element: Element): React.JSX.Element {
return (
<div key={element.id} className="border-b border-secondary/30 pb-3 last:border-b-0">
<div key={element.id} className="border-b border-secondary pb-3 last:border-b-0">
<div className="flex items-center gap-2 mb-1">
<FontAwesomeIcon icon={faMapPin} className="text-primary w-3 h-3"/>
<MapPin className="text-primary w-3 h-3" strokeWidth={1.75}/>
<span className="text-text-primary font-medium text-sm">{element.name}</span>
</div>
{element.description && (
<p className="text-text-secondary text-xs ml-5 mb-2">{element.description}</p>
)}
{element.subElements.length > 0 && (
<div className="ml-5 mt-2 space-y-1">
{element.subElements.map(function (subElement: SubElement): React.JSX.Element {
return (
<div key={subElement.id} className="flex items-start gap-2">
<FontAwesomeIcon icon={faLocationDot} className="text-muted w-2 h-2 mt-1.5"/>
<Navigation className="text-muted w-2 h-2 mt-1.5"
strokeWidth={1.75}/>
<div>
<span className="text-text-primary text-xs">{subElement.name}</span>
<span
className="text-text-primary text-xs">{subElement.name}</span>
{subElement.description && (
<p className="text-muted text-xs">{subElement.description}</p>
)}

View File

@@ -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 (
<div className="space-y-4">
<h3 className="text-text-primary font-semibold text-base">{section.name}</h3>
{/* Éléments existants */}
{section.elements.map(function (element: Element, elementIndex: number): React.JSX.Element {
return (
<div key={element.id} className="bg-secondary/20 rounded-lg p-3 border border-secondary/30">
<div key={element.id} className="bg-tertiary rounded-lg p-3 border border-secondary">
<div className="flex items-center gap-2 mb-2">
<FontAwesomeIcon icon={faMapPin} className="text-primary w-3 h-3"/>
<MapPin className="text-primary w-3 h-3" strokeWidth={1.75}/>
<span className="text-text-secondary text-xs">{t('locationComponent.element')}</span>
</div>
<InputField
input={
<TextInput
@@ -69,9 +68,9 @@ export default function LocationEditorEdit({
return onRemoveElement(section.id, elementIndex);
}}
/>
<div className="mt-2">
<TexteAreaInput
<TextAreaInput
value={element.description}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onUpdateElement(section.id, elementIndex, 'description', e.target.value);
@@ -79,13 +78,13 @@ export default function LocationEditorEdit({
placeholder={t('locationComponent.elementDescriptionPlaceholder')}
/>
</div>
{/* Sous-éléments */}
{element.subElements.length > 0 && (
<div className="mt-3 pt-3 border-t border-secondary/30 space-y-2">
<div className="mt-3 pt-3 border-t border-secondary space-y-2">
{element.subElements.map(function (subElement: SubElement, subElementIndex: number): React.JSX.Element {
return (
<div key={subElement.id} className="bg-dark-background/50 rounded p-2">
<div key={subElement.id} className="bg-secondary rounded p-2">
<InputField
input={
<TextInput
@@ -105,7 +104,7 @@ export default function LocationEditorEdit({
})}
</div>
)}
{/* Ajouter sous-élément */}
<div className="mt-2">
<InputField
@@ -118,7 +117,7 @@ export default function LocationEditorEdit({
placeholder={t('locationComponent.newSubElementPlaceholder')}
/>
}
actionIcon={faPlus}
actionIcon={Plus}
actionLabel={t('locationComponent.addSubElement')}
addButtonCallBack={function (): Promise<void> {
return onAddSubElement(section.id, elementIndex);
@@ -128,7 +127,7 @@ export default function LocationEditorEdit({
</div>
);
})}
{/* Ajouter élément */}
<InputField
fieldName={t('locationComponent.addElement')}
@@ -141,7 +140,7 @@ export default function LocationEditorEdit({
placeholder={t('locationComponent.newElementPlaceholder')}
/>
}
actionIcon={faPlus}
actionIcon={Plus}
addButtonCallBack={function (): Promise<void> {
return onAddElement(section.id);
}}

View File

@@ -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<string>('');
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 (
<div className="space-y-3">
<div className="px-2">
@@ -55,55 +57,31 @@ export default function LocationEditorList({
placeholder={t('locationComponent.search')}
/>
}
actionIcon={faPlus}
actionIcon={Plus}
actionLabel={t('locationComponent.addSectionLabel')}
addButtonCallBack={async function (): Promise<void> {
onAddSection();
}}
/>
</div>
<div className="px-2 space-y-2">
{filteredSections.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mb-3">
<FontAwesomeIcon icon={faMapMarkerAlt} className="text-primary w-8 h-8"/>
</div>
<h3 className="text-text-primary font-semibold text-base mb-1">
{t('locationComponent.noSectionAvailable')}
</h3>
<p className="text-muted text-sm max-w-xs">
{t('locationComponent.noSectionDescription')}
</p>
</div>
<EmptyState icon={MapPin} title={t('locationComponent.noSectionAvailable')}
description={t('locationComponent.noSectionDescription')}/>
) : (
filteredSections.map(function (section: LocationProps, index: number): React.JSX.Element {
return (
<div
<EntityListItem
key={section.id}
onClick={function (): void { onSectionClick(section, index); }}
className="group flex items-center p-3 bg-secondary/30 rounded-lg border-l-4 border-primary border border-secondary/50 cursor-pointer hover:bg-secondary hover:shadow-md transition-all duration-200 hover:border-primary/50"
>
<div className="w-10 h-10 rounded-full border-2 border-primary overflow-hidden bg-secondary shadow-sm group-hover:scale-110 transition-transform flex items-center justify-center">
<FontAwesomeIcon icon={faMapMarkerAlt} className="text-primary w-5 h-5"/>
</div>
<div className="ml-3 flex-1 min-w-0">
<div className="text-text-primary font-semibold text-sm group-hover:text-primary transition-colors truncate">
{section.name}
</div>
<div className="text-muted text-xs truncate">
{t('locationComponent.elementsCount', {count: countTotalElements(section)})}
</div>
</div>
<div className="w-6 flex justify-center">
<FontAwesomeIcon
icon={faChevronRight}
className="text-muted group-hover:text-primary group-hover:translate-x-1 transition-all w-3 h-3"
/>
</div>
</div>
size="sm"
onClick={function (): void {
onSectionClick(section, index);
}}
avatar={<AvatarIcon size="sm" icon={MapPin}/>}
title={section.name}
subtitle={t('locationComponent.elementsCount', {count: countTotalElements(section)})}
/>
);
})
)}

View File

@@ -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<BookContextProps>(BookContext);
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean>(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<void> {
await exitEditMode(true);
}
function handleCancel(): void {
exitEditMode(false);
}
async function handleDelete(): Promise<void> {
if (selectedSectionIndex >= 0 && sections[selectedSectionIndex]) {
await removeSection(sections[selectedSectionIndex].id);
@@ -101,18 +98,14 @@ export default function LocationSettings({
backToList();
}
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<FontAwesomeIcon icon={faSpinner} className="w-8 h-8 text-primary animate-spin"/>
</div>
);
return <PulseLoader/>;
}
const selectedSection: LocationProps | undefined = sections[selectedSectionIndex];
const canExport: boolean = Boolean(bookSeriesId && selectedSection && !selectedSection.seriesLocationId);
return (
<div className="flex flex-col h-full">
{/* 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<void> { return exportToSeries(selectedSection!); } : undefined}
onDelete={function (): void {
setShowDeleteConfirm(true);
}}
onExport={canExport ? function (): Promise<void> {
return exportToSeries(selectedSection!);
} : undefined}
showExport={canExport}
showDelete={Boolean(selectedSection)}
/>
{/* Contenu principal */}
<div className="flex-1 overflow-y-auto">
{viewMode === 'list' && (
<div className="space-y-5 p-4">
{/* Toggle tool */}
{showToggle && !isSeriesMode && (
<div className="bg-secondary/20 rounded-xl p-5 shadow-inner border border-secondary/30">
<InputField
icon={faToggleOn}
fieldName={t('locationComponent.enableTool')}
input={
<ToggleSwitch
checked={toolEnabled}
onChange={toggleTool}
/>
}
/>
<p className="text-muted text-sm mt-2">
{t('locationComponent.enableToolDescription')}
</p>
</div>
)}
{/* Contenu si outil activé */}
{(toolEnabled || isSeriesMode) && (
{((parentToolEnabled !== undefined ? parentToolEnabled : toolEnabled) || isSeriesMode) && (
<>
{/* Import from series */}
{!isSeriesMode && bookSeriesId && availableSeriesLocations.length > 0 && (
@@ -168,7 +145,7 @@ export default function LocationSettings({
label={t("seriesImport.importFromSeries")}
/>
)}
{/* Liste des sections */}
<LocationSettingsList
sections={sections}
@@ -181,13 +158,13 @@ export default function LocationSettings({
)}
</div>
)}
{viewMode === 'detail' && selectedSection && (
<div className="p-4">
<LocationSettingsDetail section={selectedSection}/>
</div>
)}
{viewMode === 'edit' && selectedSection && (
<div className="p-4">
<LocationSettingsEdit
@@ -210,7 +187,7 @@ export default function LocationSettings({
</div>
)}
</div>
{/* Modal de confirmation de suppression */}
{showDeleteConfirm && selectedSection && (
<AlertBox
@@ -220,7 +197,9 @@ export default function LocationSettings({
confirmText={t('common.delete')}
cancelText={t('common.cancel')}
onConfirm={handleDelete}
onCancel={function (): void { setShowDeleteConfirm(false); }}
onCancel={function (): void {
setShowDeleteConfirm(false);
}}
/>
)}
</div>

View File

@@ -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 (
<div className="space-y-6 px-2 pb-4">
{/* Hero Section */}
<div className="p-6 bg-gradient-to-r from-primary/10 via-secondary/20 to-transparent rounded-2xl border border-secondary/30">
<div className="flex items-start gap-4">
<div className="w-16 h-16 rounded-xl bg-primary/20 flex items-center justify-center shrink-0">
<FontAwesomeIcon icon={faMapMarkerAlt} className="w-8 h-8 text-primary"/>
</div>
<div className="flex-1 min-w-0">
<h2 className="text-2xl font-bold text-text-primary">{section.name}</h2>
<p className="text-text-secondary mt-2">
{t("locationComponent.elementsCount", {count: section.elements.length})}
</p>
</div>
</div>
</div>
<DetailHeroSection icon={MapPin} title={section.name}>
<p className="text-text-secondary mt-2">
{t("locationComponent.elementsCount", {count: section.elements.length})}
</p>
</DetailHeroSection>
{/* Éléments en grille */}
{section.elements.length === 0 ? (
<div className="text-center py-12 text-text-secondary bg-secondary/10 rounded-xl border border-secondary/20">
<FontAwesomeIcon icon={faMapPin} className="w-8 h-8 mb-3 opacity-50"/>
<div
className="text-center py-12 text-text-secondary">
<MapPin className="w-8 h-8 mb-3 opacity-50" strokeWidth={1.75}/>
<p>{t("locationComponent.noElementAvailable")}</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{section.elements.map(function (element: Element): React.JSX.Element {
return (
<div key={element.id} className="p-5 bg-secondary/20 rounded-xl border border-secondary/30 hover:border-primary/30 transition-colors">
<div key={element.id}
className="p-5 bg-tertiary rounded-xl">
{/* Element header */}
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 rounded-lg bg-primary/20 flex items-center justify-center">
<FontAwesomeIcon icon={faMapPin} className="w-5 h-5 text-primary"/>
<div
className="w-10 h-10 rounded-lg bg-primary/20 flex items-center justify-center">
<MapPin className="w-5 h-5 text-primary" strokeWidth={1.75}/>
</div>
<h3 className="text-text-primary font-semibold text-lg">{element.name}</h3>
</div>
{/* Description */}
<p className={`mb-4 ${element.description ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
<p className={`mb-4 ${element.description ? 'text-text-primary' : 'text-text-dimmed italic'}`}>
{element.description || '—'}
</p>
{/* Sub-elements */}
{element.subElements.length > 0 && (
<div className="pt-4 border-t border-secondary/30">
<div className="pt-4 border-t border-secondary">
<h4 className="text-text-secondary text-xs uppercase tracking-wide mb-3 flex items-center gap-2">
<FontAwesomeIcon icon={faLocationDot} className="w-3 h-3"/>
<Navigation className="w-3 h-3" strokeWidth={1.75}/>
{t("locationComponent.subElementsHeading")} ({element.subElements.length})
</h4>
<div className="space-y-2">
{element.subElements.map(function (subElement: SubElement): React.JSX.Element {
return (
<div key={subElement.id} className="flex items-start gap-2 p-2 bg-dark-background/30 rounded-lg">
<FontAwesomeIcon icon={faChevronRight} className="w-3 h-3 text-primary mt-1 shrink-0"/>
<div key={subElement.id}
className="flex items-start gap-2 p-2 bg-secondary rounded-lg">
<ChevronRight className="w-3 h-3 text-primary mt-1 shrink-0"
strokeWidth={1.75}/>
<div className="min-w-0">
<p className="text-text-primary font-medium">{subElement.name}</p>
{subElement.description && (

View File

@@ -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 (
<div className="space-y-4 px-2 pb-4">
{/* Header de la section */}
<div className="flex items-center gap-3 mb-4">
<div className="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center">
<FontAwesomeIcon icon={faMapMarkerAlt} className="text-primary w-6 h-6"/>
</div>
<IconContainer icon={MapPin} size="md" shape="circle"/>
<div>
<h2 className="text-text-primary font-bold text-xl">{section.name}</h2>
</div>
</div>
{/* Éléments existants */}
{section.elements.map(function (element: Element, elementIndex: number): React.JSX.Element {
return (
<div key={element.id} className="bg-secondary/30 rounded-xl p-4 border border-secondary/50">
<div key={element.id} className="space-y-3">
<div className="mb-3">
<InputField
fieldName={t("locationComponent.elementName")}
@@ -75,26 +73,26 @@ export default function LocationSettingsEdit({
}}
/>
</div>
<TexteAreaInput
<TextAreaInput
value={element.description}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onUpdateElement(section.id, elementIndex, 'description', e.target.value);
}}
placeholder={t("locationComponent.elementDescriptionPlaceholder")}
/>
{/* Sous-éléments */}
<div className="mt-4 pt-4 border-t border-secondary/50">
<div className="mt-4 pt-4 border-t border-secondary">
{element.subElements.length > 0 && (
<h4 className="text-sm italic text-text-secondary mb-3">
{t("locationComponent.subElementsHeading")}
</h4>
)}
{element.subElements.map(function (subElement: SubElement, subElementIndex: number): React.JSX.Element {
return (
<div key={subElement.id} className="bg-dark-background rounded-lg p-3 mb-3">
<div key={subElement.id} className="mb-3">
<div className="mb-2">
<InputField
input={
@@ -111,7 +109,7 @@ export default function LocationSettingsEdit({
}}
/>
</div>
<TexteAreaInput
<TextAreaInput
value={subElement.description}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onUpdateSubElement(section.id, elementIndex, subElementIndex, 'description', e.target.value);
@@ -121,7 +119,7 @@ export default function LocationSettingsEdit({
</div>
);
})}
{/* Ajouter sous-élément */}
<InputField
input={
@@ -133,7 +131,7 @@ export default function LocationSettingsEdit({
placeholder={t("locationComponent.newSubElementPlaceholder")}
/>
}
actionIcon={faPlus}
actionIcon={Plus}
actionLabel={t("locationComponent.addSubElement")}
addButtonCallBack={function (): Promise<void> {
return onAddSubElement(section.id, elementIndex);
@@ -143,9 +141,9 @@ export default function LocationSettingsEdit({
</div>
);
})}
{/* Ajouter élément */}
<div className="bg-secondary/20 rounded-xl p-4 border border-secondary/30">
<div>
<InputField
fieldName={t("locationComponent.addElement")}
input={
@@ -157,7 +155,7 @@ export default function LocationSettingsEdit({
placeholder={t("locationComponent.newElementPlaceholder")}
/>
}
actionIcon={faPlus}
actionIcon={Plus}
actionLabel={t("locationComponent.addElement")}
addButtonCallBack={function (): Promise<void> {
return onAddElement(section.id);

View File

@@ -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 (
<div className="space-y-4">
<div className="bg-tertiary/90 backdrop-blur-sm rounded-xl shadow-lg p-4 border border-secondary/50">
<InputField
input={
<TextInput
value={newSectionName}
setValue={function (e: ChangeEvent<HTMLInputElement>): void {
onNewSectionNameChange(e.target.value);
}}
placeholder={t("locationComponent.newSectionPlaceholder")}
/>
}
actionIcon={faPlus}
actionLabel={t("locationComponent.addSectionLabel")}
addButtonCallBack={onAddSection}
/>
</div>
<InputField
input={
<TextInput
value={newSectionName}
setValue={function (e: ChangeEvent<HTMLInputElement>): void {
onNewSectionNameChange(e.target.value);
}}
placeholder={t("locationComponent.newSectionPlaceholder")}
/>
}
actionIcon={Plus}
actionLabel={t("locationComponent.addSectionLabel")}
addButtonCallBack={onAddSection}
/>
<div className="space-y-2 px-2">
{sections.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="w-20 h-20 rounded-full bg-primary/10 flex items-center justify-center mb-4">
<FontAwesomeIcon icon={faMapMarkerAlt} className="text-primary w-10 h-10"/>
</div>
<h3 className="text-text-primary font-semibold text-lg mb-2">
{t("locationComponent.noSectionAvailable")}
</h3>
<p className="text-muted text-sm max-w-xs">
{t("locationComponent.noSectionDescription")}
</p>
</div>
<EmptyState icon={MapPin} title={t("locationComponent.noSectionAvailable")}
description={t("locationComponent.noSectionDescription")}/>
) : (
sections.map(function (section: LocationProps, index: number): React.JSX.Element {
return (
<div
<EntityListItem
key={section.id}
onClick={function (): void { onSectionClick(index); }}
className="group flex items-center p-4 bg-secondary/30 rounded-xl border-l-4 border-primary border border-secondary/50 cursor-pointer hover:bg-secondary hover:shadow-md hover:scale-102 transition-all duration-200 hover:border-primary/50"
>
<div className="w-12 h-12 rounded-full border-2 border-primary overflow-hidden bg-secondary shadow-md group-hover:scale-110 transition-transform flex items-center justify-center">
<FontAwesomeIcon icon={faMapMarkerAlt} className="text-primary w-6 h-6"/>
</div>
<div className="ml-4 flex-1 min-w-0">
<div className="text-text-primary font-bold text-base group-hover:text-primary transition-colors">
{section.name}
</div>
<div className="text-text-secondary text-sm mt-0.5">
{t("locationComponent.elementsCount", {count: countTotalElements(section)})}
</div>
</div>
<div className="w-8 flex justify-center">
<FontAwesomeIcon
icon={faChevronRight}
className="text-muted group-hover:text-primary group-hover:translate-x-1 transition-all w-4 h-4"
/>
</div>
</div>
onClick={function (): void {
onSectionClick(index);
}}
avatar={<AvatarIcon icon={MapPin}/>}
title={section.name}
subtitle={t("locationComponent.elementsCount", {count: countTotalElements(section)})}
/>
);
})
)}

View File

@@ -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<Item[]>([
{
id: 1,
name: 'Sword of Destiny',
description: 'A powerful sword',
history: 'Forged in the ancient times...',
location: 'Castle',
ownedBy: 'John Doe',
functionality: 'Cuts through anything',
image: 'https://via.placeholder.com/150',
relatedItems: []
},
{
id: 2,
name: 'Shield of Valor',
description: 'An unbreakable shield',
history: 'Used by the legendary hero...',
location: 'Fortress',
ownedBy: 'Jane Doe',
functionality: 'Deflects any attack',
image: 'https://via.placeholder.com/150',
relatedItems: []
}
]);
const [selectedItem, setSelectedItem] = useState<Item | null>(null);
const [searchQuery, setSearchQuery] = useState<string>('');
const [newItem, setNewItem] = useState<Item>(initialItemState);
const [newRelatedItem, setNewRelatedItem] = useState<RelatedItem>({
name: '',
type: '',
description: '',
history: ''
});
const filteredItems = items.filter(
(item) =>
item.name.toLowerCase().includes(searchQuery.toLowerCase())
);
const handleItemClick = (item: Item) => {
setSelectedItem(item);
};
const handleAddItem = () => {
setSelectedItem(newItem);
};
const handleSaveItem = () => {
if (selectedItem) {
if (selectedItem.id === null) {
setItems([...items, {...selectedItem, id: items.length + 1}]);
} else {
setItems(items.map((item) => (item.id === selectedItem.id ? selectedItem : item)));
}
setSelectedItem(null);
setNewItem(initialItemState);
}
};
const handleItemChange = (key: keyof Item, value: string) => {
if (selectedItem) {
setSelectedItem({...selectedItem, [key]: value});
}
};
const handleElementChange = (section: keyof Item, index: number, key: keyof RelatedItem, value: string) => {
if (selectedItem) {
const updatedSection = [...(selectedItem[section] as RelatedItem[])];
updatedSection[index][key] = value;
setSelectedItem({...selectedItem, [section]: updatedSection});
}
};
const handleAddElement = (section: keyof Item, value: RelatedItem) => {
if (selectedItem) {
const updatedSection = [...(selectedItem[section] as RelatedItem[]), value];
setSelectedItem({...selectedItem, [section]: updatedSection});
}
};
const handleRemoveElement = (section: keyof Item, index: number) => {
if (selectedItem) {
const updatedSection = (selectedItem[section] as RelatedItem[]).filter((_, i) => i !== index);
setSelectedItem({...selectedItem, [section]: updatedSection});
}
};
return (
<main className="flex-grow p-8 overflow-y-auto">
{selectedItem ? (
<div>
<div className="flex justify-between sticky top-0 z-10 bg-gray-900 py-4">
<button onClick={() => setSelectedItem(null)}
className="flex items-center gap-2 text-text-primary bg-secondary/50 hover:bg-secondary px-4 py-2 rounded-xl transition-all duration-200 hover:scale-105 shadow-md">
<FontAwesomeIcon icon={faArrowLeft} className="mr-2 w-5 h-5"/> Back
</button>
<h2 className="text-3xl font-['ADLaM_Display'] text-center text-text-primary">{selectedItem.name}</h2>
<button onClick={handleSaveItem}
className="bg-primary hover:bg-primary-dark text-text-primary font-semibold py-2.5 px-6 rounded-xl shadow-md hover:shadow-lg hover:scale-105 transition-all duration-200">
Save Item
</button>
</div>
<div className="bg-gray-700 rounded-lg p-8 shadow-lg space-y-4">
<div>
<label className="block text-white mb-2" htmlFor="name">Name</label>
<input
type="text"
id="name"
value={selectedItem.name}
onChange={(e) => handleItemChange('name', e.target.value)}
className="w-full px-4 py-2.5 rounded-xl bg-secondary/50 text-text-primary border border-secondary/50 outline-none hover:bg-secondary hover:border-secondary focus:border-primary focus:ring-4 focus:ring-primary/20 transition-all duration-200"
/>
</div>
<div>
<label className="block text-white mb-2" htmlFor="description">Description</label>
<textarea
id="description"
rows={4}
value={selectedItem.description}
onChange={(e) => handleItemChange('description', e.target.value)}
className="w-full px-4 py-2.5 rounded-xl bg-secondary/50 text-text-primary border border-secondary/50 outline-none hover:bg-secondary hover:border-secondary focus:border-primary focus:ring-4 focus:ring-primary/20 transition-all duration-200"
></textarea>
</div>
<div>
<label className="block text-white mb-2" htmlFor="history">History</label>
<textarea
id="history"
rows={4}
value={selectedItem.history}
onChange={(e) => handleItemChange('history', e.target.value)}
className="w-full px-4 py-2.5 rounded-xl bg-secondary/50 text-text-primary border border-secondary/50 outline-none hover:bg-secondary hover:border-secondary focus:border-primary focus:ring-4 focus:ring-primary/20 transition-all duration-200"
></textarea>
</div>
<div>
<label className="block text-white mb-2" htmlFor="location">Location</label>
<select
id="location"
value={selectedItem.location}
onChange={(e) => handleItemChange('location', e.target.value)}
className="w-full px-4 py-2.5 rounded-xl bg-secondary/50 text-text-primary border border-secondary/50 outline-none hover:bg-secondary hover:border-secondary focus:border-primary focus:ring-4 focus:ring-primary/20 transition-all duration-200"
>
<option value="">Select Location</option>
<option value="Castle">Castle</option>
<option value="Fortress">Fortress</option>
</select>
</div>
<div>
<label className="block text-white mb-2" htmlFor="ownedBy">Owned By</label>
<select
id="ownedBy"
value={selectedItem.ownedBy}
onChange={(e) => handleItemChange('ownedBy', e.target.value)}
className="w-full px-4 py-2.5 rounded-xl bg-secondary/50 text-text-primary border border-secondary/50 outline-none hover:bg-secondary hover:border-secondary focus:border-primary focus:ring-4 focus:ring-primary/20 transition-all duration-200"
>
<option value="">Select Owner</option>
{items.map((item) => (
<option key={item.id} value={item.name}>{item.name}</option>
))}
</select>
</div>
<div>
<label className="block text-white mb-2" htmlFor="functionality">Functionality</label>
<textarea
id="functionality"
rows={4}
value={selectedItem.functionality}
onChange={(e) => handleItemChange('functionality', e.target.value)}
className="w-full px-4 py-2.5 rounded-xl bg-secondary/50 text-text-primary border border-secondary/50 outline-none hover:bg-secondary hover:border-secondary focus:border-primary focus:ring-4 focus:ring-primary/20 transition-all duration-200"
></textarea>
</div>
<div>
<label className="block text-white mb-2" htmlFor="image">Image URL</label>
<input
type="text"
id="image"
value={selectedItem.image}
onChange={(e) => handleItemChange('image', e.target.value)}
className="w-full px-4 py-2.5 rounded-xl bg-secondary/50 text-text-primary border border-secondary/50 outline-none hover:bg-secondary hover:border-secondary focus:border-primary focus:ring-4 focus:ring-primary/20 transition-all duration-200"
/>
</div>
</div>
<div className="bg-gray-700 rounded-lg p-8 shadow-lg space-y-4 mt-4">
<h3 className="text-2xl font-['ADLaM_Display'] text-text-primary">Related Items</h3>
<div className="space-y-2">
{selectedItem.relatedItems.map((relatedItem, index) => (
<details key={index}
className="bg-secondary/30 rounded-xl mb-4 p-4 shadow-sm hover:shadow-md transition-all duration-200">
<summary className="text-lg text-white cursor-pointer">{relatedItem.name}</summary>
<div className="mt-2">
<label className="block text-white mb-2"
htmlFor={`related-item-description-${relatedItem.name}`}>Description</label>
<textarea
id={`related-item-description-${relatedItem.name}`}
rows={3}
value={relatedItem.description}
onChange={(e) => handleElementChange('relatedItems', index, 'description', e.target.value)}
className="w-full px-4 py-2.5 rounded-xl bg-secondary/50 text-text-primary border border-secondary/50 outline-none hover:bg-secondary hover:border-secondary focus:border-primary focus:ring-4 focus:ring-primary/20 transition-all duration-200"
></textarea>
<label className="block text-white mb-2 mt-4"
htmlFor={`related-item-history-${relatedItem.name}`}>History</label>
<textarea
id={`related-item-history-${relatedItem.name}`}
rows={3}
value={relatedItem.history}
onChange={(e) => handleElementChange('relatedItems', index, 'history', e.target.value)}
className="w-full px-4 py-2.5 rounded-xl bg-secondary/50 text-text-primary border border-secondary/50 outline-none hover:bg-secondary hover:border-secondary focus:border-primary focus:ring-4 focus:ring-primary/20 transition-all duration-200"
></textarea>
<button
type="button"
onClick={() => handleRemoveElement('relatedItems', index)}
className="bg-error/90 hover:bg-error text-text-primary font-semibold py-2 px-4 rounded-xl shadow-md hover:shadow-lg hover:scale-105 transition-all duration-200 mt-2"
>
Remove
</button>
</div>
</details>
))}
<div className="flex space-x-2 items-center mt-2">
<select
className="w-full px-4 py-2.5 rounded-xl bg-secondary/50 text-text-primary border border-secondary/50 outline-none hover:bg-secondary hover:border-secondary focus:border-primary focus:ring-4 focus:ring-primary/20 transition-all duration-200"
onChange={(e) => setNewRelatedItem({...newRelatedItem, name: e.target.value})}
value={newRelatedItem.name}
>
<option value="">Select Related Item</option>
{items.map((item) => (
<option key={item.id} value={item.name}>{item.name}</option>
))}
</select>
<select
className="w-full px-4 py-2.5 rounded-xl bg-secondary/50 text-text-primary border border-secondary/50 outline-none hover:bg-secondary hover:border-secondary focus:border-primary focus:ring-4 focus:ring-primary/20 transition-all duration-200"
onChange={(e) => setNewRelatedItem({...newRelatedItem, type: e.target.value})}
value={newRelatedItem.type}
>
<option value="">Relation Type</option>
<option value="Related">Related</option>
<option value="Similar">Similar</option>
{/* Add more relation types as needed */}
</select>
<button
type="button"
onClick={() => {
handleAddElement('relatedItems', {...newRelatedItem});
setNewRelatedItem({name: '', type: '', description: '', history: ''});
}}
className="bg-primary hover:bg-primary-dark text-text-primary font-semibold py-2.5 px-5 rounded-xl shadow-md hover:shadow-lg hover:scale-105 transition-all duration-200"
>
Add Related Item
</button>
</div>
</div>
</div>
</div>
) : (
<div>
<div className="flex justify-between sticky top-0 z-10 bg-gray-900 py-4">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full px-4 py-2.5 rounded-xl bg-secondary/50 text-text-primary border border-secondary/50 outline-none hover:bg-secondary hover:border-secondary focus:border-primary focus:ring-4 focus:ring-primary/20 transition-all duration-200"
placeholder="Search Items"
/>
<button
type="button"
onClick={handleAddItem}
className="bg-primary hover:bg-primary-dark text-text-primary font-semibold py-2.5 px-5 rounded-xl ml-4 shadow-md hover:shadow-lg hover:scale-105 transition-all duration-200"
>
Add New Item
</button>
</div>
<div>
<h2 className="text-4xl font-['ADLaM_Display'] text-text-primary mb-6">Items</h2>
<div className="flex flex-wrap space-x-4">
{filteredItems.map((item) => (
<div key={item.id} onClick={() => handleItemClick(item)}
className="cursor-pointer bg-tertiary/90 backdrop-blur-sm p-4 rounded-xl shadow-lg hover:shadow-xl hover:scale-105 transition-all duration-200 border border-secondary/50">
<img src={item.image || 'https://via.placeholder.com/150'} alt={item.name}
className="w-full h-32 object-cover rounded-lg mb-2"/>
<h3 className="text-lg font-bold text-text-primary">{item.name}</h3>
<p className="text-muted">{item.description}</p>
</div>
))}
</div>
</div>
</div>
)}
</main>
);
}
'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<Item[]>([
{
id: 1,
name: 'Sword of Destiny',
description: 'A powerful sword',
history: 'Forged in the ancient times...',
location: 'Castle',
ownedBy: 'John Doe',
functionality: 'Cuts through anything',
image: 'https://via.placeholder.com/150',
relatedItems: []
},
{
id: 2,
name: 'Shield of Valor',
description: 'An unbreakable shield',
history: 'Used by the legendary hero...',
location: 'Fortress',
ownedBy: 'Jane Doe',
functionality: 'Deflects any attack',
image: 'https://via.placeholder.com/150',
relatedItems: []
}
]);
const [selectedItem, setSelectedItem] = useState<Item | null>(null);
const [searchQuery, setSearchQuery] = useState<string>('');
const [newItem, setNewItem] = useState<Item>(initialItemState);
const [newRelatedItem, setNewRelatedItem] = useState<RelatedItem>({
name: '',
type: '',
description: '',
history: ''
});
const filteredItems = items.filter(
(item) =>
item.name.toLowerCase().includes(searchQuery.toLowerCase())
);
const handleItemClick = (item: Item) => {
setSelectedItem(item);
};
const handleAddItem = () => {
setSelectedItem(newItem);
};
const handleSaveItem = () => {
if (selectedItem) {
if (selectedItem.id === null) {
setItems([...items, {...selectedItem, id: items.length + 1}]);
} else {
setItems(items.map((item) => (item.id === selectedItem.id ? selectedItem : item)));
}
setSelectedItem(null);
setNewItem(initialItemState);
}
};
const handleItemChange = (key: keyof Item, value: string) => {
if (selectedItem) {
setSelectedItem({...selectedItem, [key]: value});
}
};
const handleElementChange = (section: keyof Item, index: number, key: keyof RelatedItem, value: string) => {
if (selectedItem) {
const updatedSection = [...(selectedItem[section] as RelatedItem[])];
updatedSection[index][key] = value;
setSelectedItem({...selectedItem, [section]: updatedSection});
}
};
const handleAddElement = (section: keyof Item, value: RelatedItem) => {
if (selectedItem) {
const updatedSection = [...(selectedItem[section] as RelatedItem[]), value];
setSelectedItem({...selectedItem, [section]: updatedSection});
}
};
const handleRemoveElement = (section: keyof Item, index: number) => {
if (selectedItem) {
const updatedSection = (selectedItem[section] as RelatedItem[]).filter((_, i) => i !== index);
setSelectedItem({...selectedItem, [section]: updatedSection});
}
};
return (
<main className="flex-grow p-8 overflow-y-auto">
{selectedItem ? (
<div>
<div className="flex justify-between sticky top-0 z-10 bg-darkest-background py-4">
<Button variant="secondary" icon={ArrowLeft} onClick={() => setSelectedItem(null)}>
Back
</Button>
<h2 className="text-3xl font-['ADLaM_Display'] text-center text-text-primary">{selectedItem.name}</h2>
<Button variant="primary" onClick={handleSaveItem}>
Save Item
</Button>
</div>
<div className="bg-secondary rounded-lg p-8 shadow-lg space-y-4">
<div>
<label className="block text-text-primary mb-2" htmlFor="name">Name</label>
<input
type="text"
id="name"
value={selectedItem.name}
onChange={(e) => handleItemChange('name', e.target.value)}
className="input-base"
/>
</div>
<div>
<label className="block text-text-primary mb-2" htmlFor="description">Description</label>
<textarea
id="description"
rows={4}
value={selectedItem.description}
onChange={(e) => handleItemChange('description', e.target.value)}
className="input-base"
></textarea>
</div>
<div>
<label className="block text-text-primary mb-2" htmlFor="history">History</label>
<textarea
id="history"
rows={4}
value={selectedItem.history}
onChange={(e) => handleItemChange('history', e.target.value)}
className="input-base"
></textarea>
</div>
<div>
<label className="block text-text-primary mb-2" htmlFor="location">Location</label>
<select
id="location"
value={selectedItem.location}
onChange={(e) => handleItemChange('location', e.target.value)}
className="input-base"
>
<option value="">Select Location</option>
<option value="Castle">Castle</option>
<option value="Fortress">Fortress</option>
</select>
</div>
<div>
<label className="block text-text-primary mb-2" htmlFor="ownedBy">Owned By</label>
<select
id="ownedBy"
value={selectedItem.ownedBy}
onChange={(e) => handleItemChange('ownedBy', e.target.value)}
className="input-base"
>
<option value="">Select Owner</option>
{items.map((item) => (
<option key={item.id} value={item.name}>{item.name}</option>
))}
</select>
</div>
<div>
<label className="block text-text-primary mb-2" htmlFor="functionality">Functionality</label>
<textarea
id="functionality"
rows={4}
value={selectedItem.functionality}
onChange={(e) => handleItemChange('functionality', e.target.value)}
className="input-base"
></textarea>
</div>
<div>
<label className="block text-text-primary mb-2" htmlFor="image">Image URL</label>
<input
type="text"
id="image"
value={selectedItem.image}
onChange={(e) => handleItemChange('image', e.target.value)}
className="input-base"
/>
</div>
</div>
<div className="bg-secondary rounded-lg p-8 shadow-lg space-y-4 mt-4">
<h3 className="text-2xl font-['ADLaM_Display'] text-text-primary">Related Items</h3>
<div className="space-y-2">
{selectedItem.relatedItems.map((relatedItem, index) => (
<details key={index}
className="bg-secondary/30 rounded-xl mb-4 p-4 shadow-sm hover:shadow-md transition-all duration-200">
<summary className="text-lg text-text-primary cursor-pointer">{relatedItem.name}</summary>
<div className="mt-2">
<label className="block text-text-primary mb-2"
htmlFor={`related-item-description-${relatedItem.name}`}>Description</label>
<textarea
id={`related-item-description-${relatedItem.name}`}
rows={3}
value={relatedItem.description}
onChange={(e) => handleElementChange('relatedItems', index, 'description', e.target.value)}
className="input-base"
></textarea>
<label className="block text-text-primary mb-2 mt-4"
htmlFor={`related-item-history-${relatedItem.name}`}>History</label>
<textarea
id={`related-item-history-${relatedItem.name}`}
rows={3}
value={relatedItem.history}
onChange={(e) => handleElementChange('relatedItems', index, 'history', e.target.value)}
className="input-base"
></textarea>
<div className="mt-2">
<Button variant="danger" icon={Trash2}
onClick={() => handleRemoveElement('relatedItems', index)}>
Remove
</Button>
</div>
</div>
</details>
))}
<div className="flex space-x-2 items-center mt-2">
<select
className="input-base"
onChange={(e) => setNewRelatedItem({...newRelatedItem, name: e.target.value})}
value={newRelatedItem.name}
>
<option value="">Select Related Item</option>
{items.map((item) => (
<option key={item.id} value={item.name}>{item.name}</option>
))}
</select>
<select
className="input-base"
onChange={(e) => setNewRelatedItem({...newRelatedItem, type: e.target.value})}
value={newRelatedItem.type}
>
<option value="">Relation Type</option>
<option value="Related">Related</option>
<option value="Similar">Similar</option>
</select>
<Button variant="primary" icon={Plus} onClick={() => {
handleAddElement('relatedItems', {...newRelatedItem});
setNewRelatedItem({name: '', type: '', description: '', history: ''});
}}>
Add Related Item
</Button>
</div>
</div>
</div>
</div>
) : (
<div>
<div className="flex justify-between sticky top-0 z-10 bg-darkest-background py-4">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="input-base"
placeholder="Search Items"
/>
<div className="ml-4">
<Button variant="primary" icon={Plus} onClick={handleAddItem}>
Add New Item
</Button>
</div>
</div>
<div>
<h2 className="text-4xl font-['ADLaM_Display'] text-text-primary mb-6">Items</h2>
<div className="flex flex-wrap space-x-4">
{filteredItems.map((item) => (
<div key={item.id} onClick={() => handleItemClick(item)}
className="cursor-pointer bg-tertiary/90 backdrop-blur-sm p-4 rounded-xl shadow-lg hover:shadow-xl hover:scale-105 transition-all duration-200 border border-secondary/50">
<img src={item.image || 'https://via.placeholder.com/150'} alt={item.name}
className="w-full h-32 object-cover rounded-lg mb-2"/>
<h3 className="text-lg font-bold text-text-primary">{item.name}</h3>
<p className="text-muted">{item.description}</p>
</div>
))}
</div>
</div>
</div>
)}
</main>
);
}

View File

@@ -1,30 +1,63 @@
'use client'
import React, {ChangeEvent, forwardRef, useContext, useEffect, useImperativeHandle, useState} from "react";
import {BookContext} from "@/context/BookContext";
import {SessionContext} from "@/context/SessionContext";
import {AlertContext} from "@/context/AlertContext";
import {LangContext} from "@/context/LangContext";
import System from "@/lib/models/System";
import {QuillSenseSettingsProps} from "@/lib/models/QuillSenseSettings";
import {useTranslations} from "next-intl";
import ToggleSwitch from "@/components/form/ToggleSwitch";
import TexteAreaInput from "@/components/form/TexteAreaInput";
import {BookContext, BookContextProps} from "@/context/BookContext";
import {SessionContext, SessionContextProps} from "@/context/SessionContext";
import {AlertContext, AlertContextProps} from "@/context/AlertContext";
import {LangContext, LangContextProps} from "@/context/LangContext";
import {apiGet, apiPost, apiPut} from "@/lib/api/client";
import {QuillSenseSettingsProps} from "@/lib/types/quillsense";
import {GuideLineAI} from "@/lib/types/book";
import {useTranslations} from '@/lib/i18n';
import TextAreaInput from "@/components/form/TextAreaInput";
import TextInput from "@/components/form/TextInput";
import SelectBox from "@/components/form/SelectBox";
import InputField from "@/components/form/InputField";
import {faMagicWandSparkles, faToggleOn} from "@fortawesome/free-solid-svg-icons";
import PulseLoader from '@/components/ui/PulseLoader';
import {SettingRef} from "@/lib/types/settings";
import {
advancedDialogueTypes,
advancedNarrativePersons,
beginnerDialogueTypes,
beginnerNarrativePersons,
intermediateDialogueTypes,
intermediateNarrativePersons,
langues,
verbalTime
} from "@/lib/constants/story";
const QuillSenseSetting = forwardRef(function QuillSenseSetting(props, ref) {
type QuillSenseTab = 'ghostwriter' | 'guideline';
interface QuillSenseSettingProps {
toolEnabled?: boolean;
}
const QuillSenseSetting = forwardRef<SettingRef, QuillSenseSettingProps>(function QuillSenseSetting({toolEnabled}: QuillSenseSettingProps, ref: React.ForwardedRef<SettingRef>): React.JSX.Element | null {
const t = useTranslations();
const {book, setBook} = useContext(BookContext);
const {session} = useContext(SessionContext);
const {errorMessage, successMessage} = useContext(AlertContext);
const {lang} = useContext(LangContext);
const [quillsenseEnabled, setQuillsenseEnabled] = useState<boolean>(true);
const [advancedPrompt, setAdvancedPrompt] = useState<string>('');
const {book}: BookContextProps = useContext<BookContextProps>(BookContext);
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
const {errorMessage, successMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext);
const bookId: string = book?.bookId ?? '';
const userToken: string = session?.accessToken ?? '';
const authorLevel: string = session.user?.writingLevel?.toString() ?? '1';
const [activeTab, setActiveTab] = useState<QuillSenseTab>('ghostwriter');
const [isLoading, setIsLoading] = useState<boolean>(true);
useImperativeHandle(ref, () => ({
handleSave
// GhostWriter state
const [advancedPrompt, setAdvancedPrompt] = useState<string>('');
// Guideline AI state
const [plotSummary, setPlotSummary] = useState<string>('');
const [narrativeType, setNarrativeType] = useState<string>('');
const [verbTense, setVerbTense] = useState<string>('');
const [dialogueType, setDialogueType] = useState<string>('');
const [toneAtmosphere, setToneAtmosphere] = useState<string>('');
const [language, setLanguage] = useState<string>('');
const [themes, setThemes] = useState<string>('');
useImperativeHandle(ref, (): SettingRef => ({
handleSave: activeTab === 'ghostwriter' ? handleSaveGhostWriter : handleSaveGuideline
}));
useEffect((): void => {
@@ -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<void> {
try {
setIsLoading(true);
const settings: QuillSenseSettingsProps = await System.authGetQueryToServer<QuillSenseSettingsProps>(
const settings: QuillSenseSettingsProps = await apiGet<QuillSenseSettingsProps>(
'book/quillsense-settings',
session.accessToken,
lang,
{bookId: book?.bookId}
);
setQuillsenseEnabled(settings.quillsenseEnabled);
setAdvancedPrompt(settings.advancedPrompt ?? '');
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t('quillsenseSetting.unknownError'));
}
} finally {
setIsLoading(false);
}
}
async function handleSave(): Promise<void> {
async function fetchAIGuideline(): Promise<void> {
try {
const updateResult: boolean = await System.authPutToServer<boolean>(
const response: GuideLineAI = await apiGet<GuideLineAI>(`book/ai/guideline`, userToken, lang, {id: bookId});
if (response) {
setPlotSummary(response.globalResume || '');
setVerbTense(response.verbeTense?.toString() || '');
setNarrativeType(response.narrativeType?.toString() || '');
setDialogueType(response.dialogueType?.toString() || '');
setToneAtmosphere(response.atmosphere || '');
setLanguage(response.langue?.toString() || '');
setThemes(response.themes || '');
}
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t("guideLineSetting.errorUnknown"));
}
}
}
async function handleSaveGhostWriter(): Promise<void> {
try {
const updateResult: boolean = await apiPut<boolean>(
'book/quillsense-settings',
{
bookId: book?.bookId,
quillsenseEnabled: quillsenseEnabled,
advancedPrompt: advancedPrompt
},
session.accessToken,
lang
);
if (updateResult) {
successMessage(t('quillSenseSetting.successSave'));
// Mettre a jour le contexte du livre
if (setBook && book) {
setBook({...book, quillsenseEnabled: quillsenseEnabled});
}
successMessage(t('quillsenseSetting.saveSuccess'));
} else {
errorMessage(t('quillSenseSetting.errorSave'));
errorMessage(t('quillsenseSetting.saveError'));
}
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t('quillsenseSetting.unknownError'));
}
}
}
async function handleSaveGuideline(): Promise<void> {
try {
const response: boolean = await apiPost<boolean>(
'quillsense/book/guide-line',
{
bookId: bookId,
plotSummary: plotSummary,
verbTense: verbTense,
narrativeType: narrativeType,
dialogueType: dialogueType,
toneAtmosphere: toneAtmosphere,
language: language,
themes: themes,
},
userToken,
lang,
);
if (response) {
successMessage(t("guideLineSetting.saveSuccess"));
} else {
errorMessage(t("guideLineSetting.saveError"));
}
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t("guideLineSetting.errorUnknown"));
}
}
}
if (isLoading) {
return (
<div className="flex items-center justify-center p-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
);
return <PulseLoader/>;
}
if (!toolEnabled) {
return null;
}
return (
<div className="space-y-6 p-4">
<div className="bg-secondary/20 rounded-xl p-5 shadow-inner border border-secondary/30">
<InputField
icon={faToggleOn}
fieldName={t('quillSenseSetting.enableLabel')}
input={
<ToggleSwitch
checked={quillsenseEnabled}
onChange={(checked: boolean): void => setQuillsenseEnabled(checked)}
/>
}
/>
<p className="text-muted text-sm mt-2">
{t('quillSenseSetting.enableDescription')}
</p>
<div className="space-y-6">
<div className="flex gap-4 border-b border-secondary">
<button
className={`pb-2 text-sm font-medium transition-colors duration-150 ${
activeTab === 'ghostwriter'
? 'border-b-2 border-primary text-text-primary'
: 'text-muted hover:text-text-primary'
}`}
onClick={(): void => setActiveTab('ghostwriter')}
>
GhostWriter
</button>
<button
className={`pb-2 text-sm font-medium transition-colors duration-150 ${
activeTab === 'guideline'
? 'border-b-2 border-primary text-text-primary'
: 'text-muted hover:text-text-primary'
}`}
onClick={(): void => setActiveTab('guideline')}
>
{t("bookSetting.guideLine")}
</button>
</div>
<div className="bg-secondary/20 rounded-xl p-5 shadow-inner border border-secondary/30">
<InputField
icon={faMagicWandSparkles}
fieldName={t('quillSenseSetting.advancedPromptLabel')}
input={
<TexteAreaInput
value={advancedPrompt}
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setAdvancedPrompt(e.target.value)}
placeholder={t('quillSenseSetting.advancedPromptPlaceholder')}
{activeTab === 'ghostwriter' && (
<div className="space-y-4">
<InputField
fieldName={t('quillsenseSetting.advancedPrompt')}
input={
<TextAreaInput
value={advancedPrompt}
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setAdvancedPrompt(e.target.value)}
placeholder={t('quillsenseSetting.advancedPromptPlaceholder')}
/>
}
/>
<p className="text-muted text-xs">
{t('quillsenseSetting.advancedPromptDescription')}
</p>
</div>
)}
{activeTab === 'guideline' && (
<div className="space-y-4">
<InputField fieldName={t("guideLineSetting.plotSummary")} input={
<TextAreaInput
value={plotSummary}
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setPlotSummary(e.target.value)}
placeholder={t("guideLineSetting.plotSummaryPlaceholder")}
/>
}
/>
<p className="text-muted text-sm mt-2">
{t('quillSenseSetting.advancedPromptHint')}
</p>
</div>
}/>
<InputField fieldName={t("guideLineSetting.toneAtmosphere")} input={
<TextInput
value={toneAtmosphere}
setValue={(e: ChangeEvent<HTMLInputElement>): void => setToneAtmosphere(e.target.value)}
placeholder={t("guideLineSetting.toneAtmospherePlaceholder")}
/>
}/>
<InputField fieldName={t("guideLineSetting.themes")} input={
<TextInput
value={themes}
setValue={(e: ChangeEvent<HTMLInputElement>): void => setThemes(e.target.value)}
placeholder={t("guideLineSetting.themesPlaceholderQuill")}
/>
}/>
<InputField fieldName={t("guideLineSetting.verbTense")} input={
<SelectBox
defaultValue={verbTense}
onChangeCallBack={(event: ChangeEvent<HTMLSelectElement>): void => setVerbTense(event.target.value)}
data={verbalTime}
placeholder={t("guideLineSetting.verbTensePlaceholder")}
/>
}/>
<InputField fieldName={t("guideLineSetting.narrativeType")} input={
<SelectBox defaultValue={narrativeType} data={
authorLevel === '1'
? beginnerNarrativePersons
: authorLevel === '2'
? intermediateNarrativePersons
: advancedNarrativePersons
} onChangeCallBack={(event: ChangeEvent<HTMLSelectElement>): void => {
setNarrativeType(event.target.value)
}} placeholder={t("guideLineSetting.narrativeTypePlaceholder")}/>
}/>
<InputField fieldName={t("guideLineSetting.dialogueType")} input={
<SelectBox defaultValue={dialogueType} data={authorLevel === '1'
? beginnerDialogueTypes
: authorLevel === '2'
? intermediateDialogueTypes
: advancedDialogueTypes}
onChangeCallBack={(event: ChangeEvent<HTMLSelectElement>) => {
setDialogueType(event.target.value)
}} placeholder={t("guideLineSetting.dialogueTypePlaceholder")}/>
}/>
<InputField fieldName={t("guideLineSetting.language")} input={
<SelectBox defaultValue={language} data={langues}
onChangeCallBack={(event: ChangeEvent<HTMLSelectElement>) => {
setLanguage(event.target.value)
}} placeholder={t("guideLineSetting.languagePlaceholder")}/>
}/>
</div>
)}
</div>
);
});

View File

@@ -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 (
<span
className={chipClasses}
style={{
backgroundColor: tag.color || '#3B82F6',
color: getContrastColor(tag.color)
}}
onClick={onClick}
>
{tag.name}
@@ -56,9 +57,9 @@ export default function SpellTagChip(
e.stopPropagation();
onRemove();
}}
className="ml-0.5 hover:bg-white/20 rounded-full p-0.5 transition-all duration-200 hover:scale-110"
className="ml-0.5 hover:bg-text-primary/20 rounded-full p-0.5 transition-colors duration-200"
>
<FontAwesomeIcon icon={faX} className={size === 'sm' ? 'w-2 h-2' : 'w-2.5 h-2.5'}/>
<X className={size === 'sm' ? 'w-2 h-2' : 'w-2.5 h-2.5'} strokeWidth={1.75}/>
</button>
)}
</span>

View File

@@ -1,18 +1,21 @@
'use client';
import React, {useContext, useState} from 'react';
import {defaultTagColors, SpellTagProps} from "@/lib/models/Spell";
import {SpellTagProps} from "@/lib/types/spell";
import {defaultTagColors} from "@/lib/constants/spell";
import SpellTagChip from "@/components/book/settings/spells/SpellTagChip";
import InputField from "@/components/form/InputField";
import TextInput from "@/components/form/TextInput";
import Modal from "@/components/Modal";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {faArrowLeft, faEdit, faPlus, faTags, faTrash} from "@fortawesome/free-solid-svg-icons";
import {useTranslations} from "next-intl";
import {AlertContext} from "@/context/AlertContext";
import Modal from "@/components/ui/Modal";
import {Pencil, Plus, Tag, Trash2} from 'lucide-react';
import {useTranslations} from '@/lib/i18n';
import {AlertContext, AlertContextProps} from "@/context/AlertContext";
import IconContainer from '@/components/ui/IconContainer';
import Button from '@/components/ui/Button';
import IconButton from '@/components/ui/IconButton';
import {dynamicBg} from '@/lib/utils/dynamicStyles';
interface SpellTagManagerProps {
tags: SpellTagProps[];
onBack: () => void;
onCreateTag: (name: string, color: string) => Promise<SpellTagProps | null>;
onUpdateTag: (tagId: string, name: string, color: string) => Promise<boolean>;
onDeleteTag: (tagId: string) => Promise<boolean>;
@@ -21,13 +24,12 @@ interface SpellTagManagerProps {
export default function SpellTagManager(
{
tags,
onBack,
onCreateTag,
onUpdateTag,
onDeleteTag,
}: SpellTagManagerProps) {
const t = useTranslations();
const {successMessage} = useContext(AlertContext);
const {successMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
const [newTagName, setNewTagName] = useState<string>('');
const [newTagColor, setNewTagColor] = useState<string>(defaultTagColors[0]);
@@ -87,25 +89,9 @@ export default function SpellTagManager(
}
return (
<div className="space-y-4">
<div
className="flex justify-between items-center p-4 border-b border-secondary/50 bg-tertiary/50 backdrop-blur-sm">
<button
onClick={onBack}
className="flex items-center gap-2 bg-secondary/50 py-2 px-4 rounded-xl border border-secondary/50 hover:bg-secondary hover:border-secondary hover:shadow-md hover:scale-105 transition-all duration-200"
>
<FontAwesomeIcon icon={faArrowLeft} className="text-primary w-4 h-4"/>
<span className="text-text-primary font-medium">{t("spellTagManager.back")}</span>
</button>
<span className="text-text-primary font-semibold text-lg flex items-center gap-2">
<FontAwesomeIcon icon={faTags} className="text-primary w-5 h-5"/>
{t("spellTagManager.title")}
</span>
<div className="w-24"/>
</div>
<div className="px-4 space-y-4">
<div className="bg-secondary/20 rounded-xl p-4 border border-secondary/30">
<div className="space-y-4 p-4">
<div className="space-y-4">
<div className="bg-tertiary rounded-xl p-4">
<h3 className="text-text-primary font-semibold mb-3">{t("spellTagManager.addTag")}</h3>
<div className="space-y-3">
<InputField
@@ -126,29 +112,22 @@ export default function SpellTagManager(
<button
key={color}
onClick={() => setNewTagColor(color)}
className={`w-10 h-10 rounded-full transition-all duration-200 ${newTagColor === color ? 'ring-2 ring-offset-2 ring-primary scale-110' : 'hover:scale-110'}`}
style={{backgroundColor: color}}
className={`w-10 h-10 rounded-full transition-all duration-200 ${newTagColor === color ? 'ring-2 ring-offset-2 ring-primary' : 'hover:ring-1 hover:ring-primary/50'} ${dynamicBg(color)}`}
/>
))}
</div>
</div>
<button
onClick={handleAddTag}
disabled={!newTagName.trim()}
className="w-full flex items-center justify-center gap-2 py-2.5 bg-primary text-text-primary rounded-xl hover:bg-primary-dark transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
<FontAwesomeIcon icon={faPlus} className="w-4 h-4"/>
<Button variant="primary" icon={Plus} onClick={handleAddTag} disabled={!newTagName.trim()}
fullWidth>
{t("spellTagManager.addTag")}
</button>
</Button>
</div>
</div>
<div className="overflow-y-auto max-h-[calc(100vh-500px)]">
{tags.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mb-4">
<FontAwesomeIcon icon={faTags} className="text-primary w-8 h-8"/>
</div>
<IconContainer icon={Tag} size="lg" shape="circle"/>
<p className="text-muted text-sm">{t("spellTagManager.noTags")}</p>
</div>
) : (
@@ -156,22 +135,14 @@ export default function SpellTagManager(
{tags.map((tag: SpellTagProps) => (
<div
key={tag.id}
className="flex items-center justify-between p-3 bg-secondary/30 rounded-xl border border-secondary/50"
className="flex items-center justify-between p-3 bg-secondary rounded-xl"
>
<SpellTagChip tag={tag}/>
<div className="flex items-center gap-2">
<button
onClick={() => handleEditClick(tag)}
className="p-2 bg-secondary/50 rounded-lg hover:bg-secondary hover:scale-110 transition-all duration-200"
>
<FontAwesomeIcon icon={faEdit} className="text-primary w-4 h-4"/>
</button>
<button
onClick={() => handleDeleteClick(tag)}
className="p-2 bg-error/10 rounded-lg hover:bg-error/20 hover:scale-110 transition-all duration-200"
>
<FontAwesomeIcon icon={faTrash} className="text-error w-4 h-4"/>
</button>
<IconButton icon={Pencil} variant="ghost" size="sm"
onClick={() => handleEditClick(tag)}/>
<IconButton icon={Trash2} variant="danger" size="sm"
onClick={() => handleDeleteClick(tag)}/>
</div>
</div>
))}
@@ -183,7 +154,7 @@ export default function SpellTagManager(
{showEditModal && editingTag && (
<Modal
title={t("spellTagManager.editTag")}
size="small"
size="sm"
onClose={() => setShowEditModal(false)}
onConfirm={handleUpdateTag}
confirmText={t("common.confirm")}
@@ -208,8 +179,7 @@ export default function SpellTagManager(
<button
key={color}
onClick={() => setEditTagColor(color)}
className={`w-10 h-10 rounded-full transition-all duration-200 ${editTagColor === color ? 'ring-2 ring-offset-2 ring-primary scale-110' : 'hover:scale-110'}`}
style={{backgroundColor: color}}
className={`w-10 h-10 rounded-full transition-all duration-200 ${editTagColor === color ? 'ring-2 ring-offset-2 ring-primary' : 'hover:ring-1 hover:ring-primary/50'} ${dynamicBg(color)}`}
/>
))}
</div>
@@ -231,7 +201,7 @@ export default function SpellTagManager(
{showDeleteConfirm && tagToDelete && (
<Modal
title={t("spellTagManager.deleteTagTitle")}
size="small"
size="sm"
onClose={() => setShowDeleteConfirm(false)}
onConfirm={handleDeleteTag}
confirmText={t("spellTagManager.delete")}

View File

@@ -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<BookContextProps>(BookContext);
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean>(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<void> {
await exitEditMode(true);
}
function handleCancel(): void {
exitEditMode(false);
}
async function handleDelete(): Promise<void> {
if (selectedSpell?.id) {
await deleteSpell(selectedSpell.id);
@@ -92,82 +89,75 @@ export default function SpellEditor(): React.JSX.Element {
backToList();
}
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<FontAwesomeIcon icon={faSpinner} className="w-6 h-6 text-primary animate-spin"/>
</div>
);
return <PulseLoader size="sm"/>;
}
const isNew: boolean = selectedSpell?.id === null;
const canExport: boolean = Boolean(bookSeriesId && selectedSpell?.id && !selectedSpell.seriesSpellId);
return (
<div className="flex flex-col h-full">
<ToolDetailHeader
title={selectedSpell?.name || ''}
defaultTitle={t('spellDetail.newSpell')}
viewMode={viewMode}
title={showTagManager ? t('spellTagManager.title') : (selectedSpell?.name || '')}
defaultTitle={showTagManager ? t('spellTagManager.title') : t('spellDetail.newSpell')}
viewMode={showTagManager ? 'detail' : viewMode}
isNew={isNew}
onBack={backToList}
onEdit={enterEditMode}
onBack={showTagManager ? function (): void {
setShowTagManager(false);
} : backToList}
onEdit={showTagManager ? undefined : enterEditMode}
onSave={handleSave}
onCancel={handleCancel}
onDelete={function (): void { setShowDeleteConfirm(true); }}
onExport={canExport ? exportToSeries : undefined}
showExport={canExport}
showDelete={Boolean(selectedSpell?.id)}
onDelete={showTagManager ? undefined : function (): void {
setShowDeleteConfirm(true);
}}
onExport={canExport && !showTagManager ? exportToSeries : undefined}
showExport={canExport && !showTagManager}
showDelete={!showTagManager && Boolean(selectedSpell?.id)}
/>
<div className="flex-1 overflow-y-auto">
{viewMode === 'list' && (
{showTagManager && (
<SpellTagManager
tags={tags}
onCreateTag={createTag}
onUpdateTag={updateTag}
onDeleteTag={deleteTag}
/>
)}
{!showTagManager && viewMode === 'list' && (
<div className="space-y-3 p-2">
{/* Toggle tool */}
<div className="bg-secondary/20 rounded-lg p-3 border border-secondary/30">
<InputField
icon={faToggleOn}
fieldName={t('spellComponent.enableTool')}
input={
<ToggleSwitch
checked={toolEnabled}
onChange={toggleTool}
/>
}
{/* Import from series */}
{bookSeriesId && availableSeriesSpells.length > 0 && (
<SeriesImportSelector
availableItems={availableSeriesSpells.map(function (ss: SeriesSpellListItem) {
return {
id: ss.id,
name: ss.name
};
})}
onImport={importFromSeries}
placeholder={t('seriesImport.selectElement')}
label={t('seriesImport.importFromSeries')}
/>
</div>
{toolEnabled && (
<>
{/* Import from series */}
{bookSeriesId && availableSeriesSpells.length > 0 && (
<SeriesImportSelector
availableItems={availableSeriesSpells.map(function (ss: SeriesSpellListItem) {
return {
id: ss.id,
name: ss.name
};
})}
onImport={importFromSeries}
placeholder={t('seriesImport.selectElement')}
label={t('seriesImport.importFromSeries')}
/>
)}
<SpellEditorList
spells={spells}
tags={tags}
onSpellClick={enterDetailMode}
onAddSpell={addNewSpell}
onManageTags={function (): void { setShowTagManager(true); }}
/>
</>
)}
<SpellEditorList
spells={spells}
tags={tags}
onSpellClick={enterDetailMode}
onAddSpell={addNewSpell}
onManageTags={function (): void {
setShowTagManager(true);
}}
/>
</div>
)}
{viewMode === 'detail' && selectedSpell && (
{!showTagManager && viewMode === 'detail' && selectedSpell && (
<div className="p-4">
<SpellEditorDetail
spell={selectedSpell}
@@ -176,8 +166,8 @@ export default function SpellEditor(): React.JSX.Element {
/>
</div>
)}
{viewMode === 'edit' && selectedSpell && (
{!showTagManager && viewMode === 'edit' && selectedSpell && (
<div className="p-4">
<SpellEditorEdit
spell={selectedSpell}
@@ -190,7 +180,7 @@ export default function SpellEditor(): React.JSX.Element {
</div>
)}
</div>
{showDeleteConfirm && selectedSpell?.id && (
<AlertBox
title={t('spellDetail.deleteTitle')}
@@ -199,19 +189,12 @@ export default function SpellEditor(): React.JSX.Element {
confirmText={t('common.delete')}
cancelText={t('common.cancel')}
onConfirm={handleDelete}
onCancel={function (): void { setShowDeleteConfirm(false); }}
/>
)}
{showTagManager && (
<SpellTagManager
tags={tags}
onBack={function (): void { setShowTagManager(false); }}
onCreateTag={createTag}
onUpdateTag={updateTag}
onDeleteTag={deleteTag}
onCancel={function (): void {
setShowDeleteConfirm(false);
}}
/>
)}
</div>
);
}

View File

@@ -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 (
<div className="mb-3">
<span className="text-text-secondary text-xs block mb-1">{label}</span>
<p className="text-text-primary text-sm whitespace-pre-wrap">{value}</p>
</div>
);
}
const selectedTags: SpellTagProps[] = getSelectedTags();
const powerLevelText: string = getLocalizedPowerLevel();
return (
<div>
<h3 className="text-text-primary font-semibold text-base mb-4">{spell.name}</h3>
{renderField(t('spellDetail.description'), spell.description)}
{renderField(t('spellDetail.appearance'), spell.appearance)}
{powerLevelText && renderField(t('spellDetail.powerLevel'), powerLevelText)}
{renderField(t('spellDetail.components'), spell.components)}
{renderField(t('spellDetail.limitations'), spell.limitations)}
{renderField(t('spellDetail.notes'), spell.notes)}
<DetailField variant="compact" label={t('spellDetail.description')} value={spell.description}/>
<DetailField variant="compact" label={t('spellDetail.appearance')} value={spell.appearance}/>
{powerLevelText &&
<DetailField variant="compact" label={t('spellDetail.powerLevel')} value={powerLevelText}/>}
<DetailField variant="compact" label={t('spellDetail.components')} value={spell.components}/>
<DetailField variant="compact" label={t('spellDetail.limitations')} value={spell.limitations}/>
<DetailField variant="compact" label={t('spellDetail.notes')} value={spell.notes}/>
{selectedTags.length > 0 && (
<div className="mb-3">
<span className="text-text-secondary text-xs block mb-1">{t('spellDetail.tags')}</span>

View File

@@ -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<string>('');
const [isCreatingTag, setIsCreatingTag] = useState<boolean>(false);
const [newTagColor, setNewTagColor] = useState<string>(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<void> {
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 (
<div className="space-y-4">
{/* Informations de base */}
<div className="border-b border-secondary/30 pb-3">
<div className="border-b border-secondary pb-3">
<h4 className="text-text-primary font-medium text-sm mb-3">{t('spellDetail.basicInfo')}</h4>
<div className="space-y-3">
<InputField
@@ -112,7 +113,9 @@ export default function SpellEditorEdit({
bookElementId={spell.id || ''}
field="name"
elementType="spell"
onDownload={function (): void { onSpellChange('name', seriesSpell?.name || ''); }}
onDownload={function (): void {
onSpellChange('name', seriesSpell?.name || '');
}}
onSyncComplete={onSyncComplete}
>
<TextInput
@@ -125,7 +128,7 @@ export default function SpellEditorEdit({
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('spellDetail.description')}
input={
@@ -136,10 +139,12 @@ export default function SpellEditorEdit({
bookElementId={spell.id || ''}
field="description"
elementType="spell"
onDownload={function (): void { onSpellChange('description', seriesSpell?.description || ''); }}
onDownload={function (): void {
onSpellChange('description', seriesSpell?.description || '');
}}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
<TextAreaInput
value={spell.description}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onSpellChange('description', e.target.value);
@@ -149,7 +154,7 @@ export default function SpellEditorEdit({
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('spellDetail.appearance')}
input={
@@ -160,10 +165,12 @@ export default function SpellEditorEdit({
bookElementId={spell.id || ''}
field="appearance"
elementType="spell"
onDownload={function (): void { onSpellChange('appearance', seriesSpell?.appearance || ''); }}
onDownload={function (): void {
onSpellChange('appearance', seriesSpell?.appearance || '');
}}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
<TextAreaInput
value={spell.appearance}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onSpellChange('appearance', e.target.value);
@@ -175,9 +182,9 @@ export default function SpellEditorEdit({
/>
</div>
</div>
{/* Tags */}
<div className="border-b border-secondary/30 pb-3">
<div className="border-b border-secondary pb-3">
<h4 className="text-text-primary font-medium text-sm mb-3">{t('spellDetail.tags')}</h4>
<div className="space-y-3">
{selectedTags.length > 0 && (
@@ -187,13 +194,15 @@ export default function SpellEditorEdit({
<SpellTagChip
key={tag.id}
tag={tag}
onRemove={function (): void { handleRemoveTag(tag.id); }}
onRemove={function (): void {
handleRemoveTag(tag.id);
}}
/>
);
})}
</div>
)}
<TextInput
value={tagSearchQuery}
setValue={function (e: ChangeEvent<HTMLInputElement>): void {
@@ -201,43 +210,42 @@ export default function SpellEditorEdit({
}}
placeholder={t('spellDetail.addTag')}
/>
{filteredTags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{filteredTags.map(function (tag: SpellTagProps): React.JSX.Element {
return (
<button
key={tag.id}
onClick={function (): void { handleAddTag(tag.id); }}
className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs transition-all duration-200 hover:scale-105 border"
style={{
backgroundColor: `${tag.color || '#3B82F6'}20`,
borderColor: `${tag.color || '#3B82F6'}50`,
color: tag.color || '#3B82F6'
onClick={function (): void {
handleAddTag(tag.id);
}}
className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs transition-colors duration-200 hover:brightness-110 border ${dynamicBgWithOpacity(tag.color || '#51AE84', '20')} ${dynamicBorderWithOpacity(tag.color || '#51AE84', '50')} ${dynamicText(tag.color || 'var(--color-primary)')}`}
>
<FontAwesomeIcon icon={faPlus} className="w-2.5 h-2.5"/>
<Plus className="w-2.5 h-2.5" strokeWidth={1.75}/>
{tag.name}
</button>
);
})}
</div>
)}
{showCreateOption && !isCreatingTag && (
<button
onClick={function (): void { setIsCreatingTag(true); }}
className="w-full flex items-center gap-2 px-3 py-2 bg-tertiary hover:bg-secondary/50 transition-colors text-left rounded-lg border border-secondary/50"
onClick={function (): void {
setIsCreatingTag(true);
}}
className="w-full flex items-center gap-2 px-3 py-2 bg-tertiary hover:bg-secondary transition-colors text-left rounded-lg border border-secondary"
>
<FontAwesomeIcon icon={faPlus} className="text-primary w-3 h-3"/>
<Plus className="text-primary w-3 h-3" strokeWidth={1.75}/>
<span className="text-primary font-medium text-sm">
{t('spellDetail.createTag', {name: tagSearchQuery})}
</span>
</button>
)}
{isCreatingTag && (
<div className="p-3 bg-tertiary rounded-lg border border-secondary/50">
<div className="p-3 bg-tertiary rounded-lg border border-secondary">
<p className="text-text-secondary text-xs mb-2">
{t('spellDetail.createTag', {name: tagSearchQuery})}
</p>
@@ -246,34 +254,31 @@ export default function SpellEditorEdit({
return (
<button
key={color}
onClick={function (): void { setNewTagColor(color); }}
className={`w-6 h-6 rounded-full transition-all duration-200 ${newTagColor === color ? 'ring-2 ring-offset-1 ring-primary scale-110' : 'hover:scale-110'}`}
style={{backgroundColor: color}}
onClick={function (): void {
setNewTagColor(color);
}}
className={`w-6 h-6 rounded-full transition-all duration-200 ${newTagColor === color ? 'ring-2 ring-offset-1 ring-primary' : 'hover:ring-1 hover:ring-primary/50'} ${dynamicBg(color)}`}
/>
);
})}
</div>
<div className="flex gap-2">
<button
onClick={function (): void { setIsCreatingTag(false); }}
className="flex-1 py-1.5 px-2 bg-secondary/50 text-text-primary rounded-lg hover:bg-secondary transition-colors text-sm"
>
<Button variant="secondary" size="sm" onClick={function (): void {
setIsCreatingTag(false);
}}>
{t('common.cancel')}
</button>
<button
onClick={handleCreateTag}
className="flex-1 py-1.5 px-2 bg-primary text-text-primary rounded-lg hover:bg-primary-dark transition-colors text-sm"
>
</Button>
<Button variant="primary" size="sm" onClick={handleCreateTag}>
{t('common.confirm')}
</button>
</Button>
</div>
</div>
)}
</div>
</div>
{/* Niveau de puissance */}
<div className="border-b border-secondary/30 pb-3">
<div className="border-b border-secondary pb-3">
<h4 className="text-text-primary font-medium text-sm mb-3">{t('spellDetail.powerLevel')}</h4>
<SyncFieldWrapper
seriesElementId={spell.seriesSpellId}
@@ -282,7 +287,9 @@ export default function SpellEditorEdit({
bookElementId={spell.id || ''}
field="powerLevel"
elementType="spell"
onDownload={function (): void { onSpellChange('powerLevel', seriesSpell?.powerLevel || null); }}
onDownload={function (): void {
onSpellChange('powerLevel', seriesSpell?.powerLevel || null);
}}
onSyncComplete={onSyncComplete}
>
<SelectBox
@@ -294,9 +301,9 @@ export default function SpellEditorEdit({
/>
</SyncFieldWrapper>
</div>
{/* Composants */}
<div className="border-b border-secondary/30 pb-3">
<div className="border-b border-secondary pb-3">
<h4 className="text-text-primary font-medium text-sm mb-3">{t('spellDetail.components')}</h4>
<SyncFieldWrapper
seriesElementId={spell.seriesSpellId}
@@ -305,10 +312,12 @@ export default function SpellEditorEdit({
bookElementId={spell.id || ''}
field="components"
elementType="spell"
onDownload={function (): void { onSpellChange('components', seriesSpell?.components || null); }}
onDownload={function (): void {
onSpellChange('components', seriesSpell?.components || null);
}}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
<TextAreaInput
value={spell.components || ''}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onSpellChange('components', e.target.value || null);
@@ -317,9 +326,9 @@ export default function SpellEditorEdit({
/>
</SyncFieldWrapper>
</div>
{/* Limitations */}
<div className="border-b border-secondary/30 pb-3">
<div className="border-b border-secondary pb-3">
<h4 className="text-text-primary font-medium text-sm mb-3">{t('spellDetail.limitations')}</h4>
<SyncFieldWrapper
seriesElementId={spell.seriesSpellId}
@@ -328,10 +337,12 @@ export default function SpellEditorEdit({
bookElementId={spell.id || ''}
field="limitations"
elementType="spell"
onDownload={function (): void { onSpellChange('limitations', seriesSpell?.limitations || null); }}
onDownload={function (): void {
onSpellChange('limitations', seriesSpell?.limitations || null);
}}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
<TextAreaInput
value={spell.limitations || ''}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onSpellChange('limitations', e.target.value || null);
@@ -340,7 +351,7 @@ export default function SpellEditorEdit({
/>
</SyncFieldWrapper>
</div>
{/* Notes */}
<div className="pb-3">
<h4 className="text-text-primary font-medium text-sm mb-3">{t('spellDetail.notes')}</h4>
@@ -351,10 +362,12 @@ export default function SpellEditorEdit({
bookElementId={spell.id || ''}
field="notes"
elementType="spell"
onDownload={function (): void { onSpellChange('notes', seriesSpell?.notes || null); }}
onDownload={function (): void {
onSpellChange('notes', seriesSpell?.notes || null);
}}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
<TextAreaInput
value={spell.notes || ''}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onSpellChange('notes', e.target.value || null);

View File

@@ -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<string>('');
const [selectedTagId, setSelectedTagId] = useState<string | null>(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 (
<div className="space-y-3">
<div className="px-2">
@@ -56,22 +59,24 @@ export default function SpellEditorList({
placeholder={t('spellList.search')}
/>
}
actionIcon={faPlus}
actionIcon={Plus}
actionLabel={t('spellList.add')}
addButtonCallBack={async function (): Promise<void> {
onAddSpell();
}}
/>
</div>
{/* Tag filter + manage button */}
<div className="px-2 flex items-center gap-2 flex-wrap">
<button
onClick={function (): void { setSelectedTagId(null); }}
onClick={function (): void {
setSelectedTagId(null);
}}
className={`px-2 py-1 text-xs rounded-full transition-colors ${
selectedTagId === null
? 'bg-primary text-white'
: 'bg-secondary/50 text-text-secondary hover:bg-secondary'
? 'bg-primary text-text-primary'
: 'bg-secondary text-text-secondary hover:bg-secondary'
}`}
>
{t('spellList.allTags')}
@@ -80,13 +85,14 @@ export default function SpellEditorList({
return (
<button
key={tag.id}
onClick={function (): void { setSelectedTagId(tag.id === selectedTagId ? null : tag.id); }}
onClick={function (): void {
setSelectedTagId(tag.id === selectedTagId ? null : tag.id);
}}
className={`px-2 py-1 text-xs rounded-full transition-colors ${
selectedTagId === tag.id
? 'bg-primary text-white'
: 'bg-secondary/50 text-text-secondary hover:bg-secondary'
}`}
style={selectedTagId === tag.id ? {} : {borderLeft: `3px solid ${tag.color}`}}
? 'bg-primary text-text-primary'
: 'bg-secondary text-text-secondary hover:bg-secondary'
} ${selectedTagId !== tag.id && tag.color ? dynamicBorderLeft(tag.color) : ''}`}
>
{tag.name}
</button>
@@ -94,19 +100,17 @@ export default function SpellEditorList({
})}
<button
onClick={onManageTags}
className="px-2 py-1 text-xs rounded-full bg-secondary/30 text-text-secondary hover:bg-secondary transition-colors flex items-center gap-1"
className="px-2 py-1 text-xs rounded-full bg-secondary text-text-secondary hover:bg-secondary transition-colors flex items-center gap-1"
>
<FontAwesomeIcon icon={faTags} className="w-3 h-3"/>
<Tag className="w-3 h-3" strokeWidth={1.75}/>
{t('spellList.manageTags')}
</button>
</div>
<div className="px-2 space-y-2">
{filteredSpells.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mb-3">
<FontAwesomeIcon icon={faHatWizard} className="text-primary w-8 h-8"/>
</div>
<IconContainer icon={Wand2} size="lg" shape="circle"/>
<h3 className="text-text-primary font-semibold text-base mb-1">
{t('spellList.noSpells')}
</h3>
@@ -117,43 +121,29 @@ export default function SpellEditorList({
) : (
filteredSpells.map(function (spell: SpellListItem): React.JSX.Element {
return (
<div
<EntityListItem
key={spell.id}
onClick={function (): void { onSpellClick(spell); }}
className="group flex items-center p-3 bg-secondary/30 rounded-lg border-l-4 border-primary border border-secondary/50 cursor-pointer hover:bg-secondary hover:shadow-md transition-all duration-200 hover:border-primary/50"
>
<div className="w-10 h-10 rounded-full border-2 border-primary overflow-hidden bg-secondary shadow-sm group-hover:scale-110 transition-transform flex items-center justify-center">
<FontAwesomeIcon icon={faHatWizard} className="text-primary w-5 h-5"/>
</div>
<div className="ml-3 flex-1 min-w-0">
<div className="text-text-primary font-semibold text-sm group-hover:text-primary transition-colors truncate">
{spell.name}
size="sm"
onClick={function (): void {
onSpellClick(spell);
}}
avatar={<AvatarIcon size="sm" icon={Wand2}/>}
title={spell.name}
subtitle={spell.description}
extra={spell.tags.length > 0 ? (
<div className="flex flex-wrap gap-1">
{spell.tags.slice(0, 2).map(function (tag: SpellTagProps): React.JSX.Element {
return <SpellTagChip key={tag.id} tag={tag} size="sm"/>;
})}
{spell.tags.length > 2 && (
<span
className="text-muted text-xs px-1.5 py-0.5 bg-secondary rounded-full">
+{spell.tags.length - 2}
</span>
)}
</div>
<div className="text-muted text-xs truncate">
{spell.description}
</div>
{spell.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{spell.tags.slice(0, 2).map(function (tag: SpellTagProps): React.JSX.Element {
return <SpellTagChip key={tag.id} tag={tag} size="sm"/>;
})}
{spell.tags.length > 2 && (
<span className="text-muted text-xs px-1.5 py-0.5 bg-secondary/50 rounded-full">
+{spell.tags.length - 2}
</span>
)}
</div>
)}
</div>
<div className="w-6 flex justify-center">
<FontAwesomeIcon
icon={faChevronRight}
className="text-muted group-hover:text-primary group-hover:translate-x-1 transition-all w-3 h-3"
/>
</div>
</div>
) : undefined}
/>
);
})
)}

View File

@@ -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<BookContextProps>(BookContext);
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean>(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<void> {
await exitEditMode(true);
}
function handleCancel(): void {
exitEditMode(false);
}
async function handleDelete(): Promise<void> {
if (selectedSpell?.id) {
await deleteSpell(selectedSpell.id);
@@ -107,61 +104,50 @@ export default function SpellSettings({
backToList();
}
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<FontAwesomeIcon icon={faSpinner} className="w-8 h-8 text-primary animate-spin"/>
</div>
);
return <PulseLoader/>;
}
const isNew: boolean = selectedSpell?.id === null;
const canExport: boolean = Boolean(bookSeriesId && selectedSpell?.id && !selectedSpell.seriesSpellId);
return (
<div className="flex flex-col h-full">
{/* Header - uniquement pour detail/edit */}
{/* Header */}
<ToolDetailHeader
title={selectedSpell?.name || ''}
defaultTitle={t('spellDetail.newSpell')}
viewMode={viewMode}
title={showTagManager ? t('spellTagManager.title') : (selectedSpell?.name || '')}
defaultTitle={showTagManager ? t('spellTagManager.title') : t('spellDetail.newSpell')}
viewMode={showTagManager ? 'detail' : viewMode}
isNew={isNew}
onBack={backToList}
onEdit={enterEditMode}
onBack={showTagManager ? function (): void {
setShowTagManager(false);
} : backToList}
onEdit={showTagManager ? undefined : enterEditMode}
onSave={handleSave}
onCancel={handleCancel}
onDelete={function (): void { setShowDeleteConfirm(true); }}
onExport={canExport ? exportToSeries : undefined}
showExport={canExport}
showDelete={Boolean(selectedSpell?.id)}
onDelete={showTagManager ? undefined : function (): void {
setShowDeleteConfirm(true);
}}
onExport={canExport && !showTagManager ? exportToSeries : undefined}
showExport={canExport && !showTagManager}
showDelete={!showTagManager && Boolean(selectedSpell?.id)}
/>
{/* Contenu principal */}
<div className="flex-1 overflow-y-auto">
{viewMode === 'list' && (
{showTagManager && (
<SpellTagManager
tags={tags}
onCreateTag={createTag}
onUpdateTag={updateTag}
onDeleteTag={deleteTag}
/>
)}
{!showTagManager && viewMode === 'list' && (
<div className="space-y-5 p-4">
{/* Toggle tool */}
{showToggle && !isSeriesMode && (
<div className="bg-secondary/20 rounded-xl p-5 shadow-inner border border-secondary/30">
<InputField
icon={faToggleOn}
fieldName={t('spellComponent.enableTool')}
input={
<ToggleSwitch
checked={toolEnabled}
onChange={toggleTool}
/>
}
/>
<p className="text-muted text-sm mt-2">
{t('spellComponent.enableToolDescription')}
</p>
</div>
)}
{/* 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 */}
<SpellSettingsList
spells={spells}
tags={tags}
onSpellClick={enterDetailMode}
onAddSpell={addNewSpell}
onManageTags={function (): void { setShowTagManager(true); }}
onManageTags={function (): void {
setShowTagManager(true);
}}
/>
</>
)}
</div>
)}
{viewMode === 'detail' && selectedSpell && (
{!showTagManager && viewMode === 'detail' && selectedSpell && (
<div className="p-4">
<SpellSettingsDetail
spell={selectedSpell}
@@ -200,8 +188,8 @@ export default function SpellSettings({
/>
</div>
)}
{viewMode === 'edit' && selectedSpell && (
{!showTagManager && viewMode === 'edit' && selectedSpell && (
<div className="p-4">
<SpellSettingsEdit
spell={selectedSpell}
@@ -214,7 +202,7 @@ export default function SpellSettings({
</div>
)}
</div>
{/* Modal de confirmation de suppression */}
{showDeleteConfirm && selectedSpell?.id && (
<AlertBox
@@ -224,20 +212,12 @@ export default function SpellSettings({
confirmText={t('common.delete')}
cancelText={t('common.cancel')}
onConfirm={handleDelete}
onCancel={function (): void { setShowDeleteConfirm(false); }}
/>
)}
{/* Tag Manager Modal */}
{showTagManager && (
<SpellTagManager
tags={tags}
onBack={function (): void { setShowTagManager(false); }}
onCreateTag={createTag}
onUpdateTag={updateTag}
onDeleteTag={deleteTag}
onCancel={function (): void {
setShowDeleteConfirm(false);
}}
/>
)}
</div>
);
}

View File

@@ -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 (
<div className="space-y-6 px-2 pb-4">
{/* Hero Section */}
<div className="p-6 bg-gradient-to-r from-primary/10 via-secondary/20 to-transparent rounded-2xl border border-secondary/30">
<div className="flex items-start gap-4">
<div className="w-16 h-16 rounded-xl bg-primary/20 flex items-center justify-center shrink-0">
<FontAwesomeIcon icon={faWandMagicSparkles} className="w-8 h-8 text-primary"/>
</div>
<div className="flex-1 min-w-0">
<h2 className="text-2xl font-bold text-text-primary">{spell.name || '—'}</h2>
{/* Power Level Badge */}
<div className="flex items-center gap-3 mt-3">
<span className={`inline-flex items-center gap-2 px-3 py-1 rounded-lg text-sm border ${getPowerLevelColor()}`}>
<FontAwesomeIcon icon={faBolt} className="w-3 h-3"/>
{getLocalizedPowerLevel()}
</span>
</div>
{/* Tags */}
{selectedTags.length > 0 && (
<div className="flex flex-wrap gap-2 mt-4">
{selectedTags.map(function (tag: SpellTagProps): React.JSX.Element {
return <SpellTagChip key={tag.id} tag={tag}/>;
})}
</div>
)}
</div>
<DetailHeroSection icon={Wand2} title={spell.name || '—'}>
<div className="flex items-center gap-3 mt-3">
<span
className={`inline-flex items-center gap-2 px-3 py-1 rounded-lg text-sm border ${getPowerLevelColor()}`}>
<Zap className="w-3 h-3" strokeWidth={1.75}/>
{getLocalizedPowerLevel()}
</span>
</div>
</div>
{selectedTags.length > 0 && (
<div className="flex flex-wrap gap-2 mt-4">
{selectedTags.map(function (tag: SpellTagProps): React.JSX.Element {
return <SpellTagChip key={tag.id} tag={tag}/>;
})}
</div>
)}
</DetailHeroSection>
{/* Description & Appearance - Side by side */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="p-5 bg-secondary/20 rounded-xl border border-secondary/30">
<div className="flex items-center gap-2 mb-3">
<FontAwesomeIcon icon={faHatWizard} className="w-4 h-4 text-primary"/>
<h3 className="text-text-primary font-semibold">{t('spellDetail.description')}</h3>
</div>
<p className={`whitespace-pre-wrap ${spell.description ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
{spell.description || '—'}
</p>
</div>
<div className="p-5 bg-secondary/20 rounded-xl border border-secondary/30">
<div className="flex items-center gap-2 mb-3">
<FontAwesomeIcon icon={faEye} className="w-4 h-4 text-primary"/>
<h3 className="text-text-primary font-semibold">{t('spellDetail.appearance')}</h3>
</div>
<p className={`whitespace-pre-wrap ${spell.appearance ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
{spell.appearance || '—'}
</p>
</div>
<DetailField icon={Wand2} label={t('spellDetail.description')} value={spell.description}/>
<DetailField icon={Eye} label={t('spellDetail.appearance')} value={spell.appearance}/>
</div>
{/* Components & Limitations - Side by side */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="p-5 bg-secondary/20 rounded-xl border border-secondary/30">
<div className="flex items-center gap-2 mb-3">
<FontAwesomeIcon icon={faPuzzlePiece} className="w-4 h-4 text-primary"/>
<h3 className="text-text-primary font-semibold">{t('spellDetail.components')}</h3>
</div>
<p className={`whitespace-pre-wrap ${spell.components ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
{spell.components || '—'}
</p>
</div>
<DetailField icon={Puzzle} label={t('spellDetail.components')} value={spell.components}/>
<div className="p-5 bg-error/10 rounded-xl border border-error/30">
<div className="flex items-center gap-2 mb-3">
<FontAwesomeIcon icon={faTriangleExclamation} className="w-4 h-4 text-error"/>
<AlertTriangle className="w-4 h-4 text-error" strokeWidth={1.75}/>
<h3 className="text-text-primary font-semibold">{t('spellDetail.limitations')}</h3>
</div>
<p className={`whitespace-pre-wrap ${spell.limitations ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
@@ -134,17 +96,9 @@ export default function SpellSettingsDetail({
</p>
</div>
</div>
{/* Notes - Full width */}
<div className="p-5 bg-secondary/20 rounded-xl border border-secondary/30">
<div className="flex items-center gap-2 mb-3">
<FontAwesomeIcon icon={faStickyNote} className="w-4 h-4 text-primary"/>
<h3 className="text-text-primary font-semibold">{t('spellDetail.notes')}</h3>
</div>
<p className={`whitespace-pre-wrap ${spell.notes ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
{spell.notes || '—'}
</p>
</div>
<DetailField icon={StickyNote} label={t('spellDetail.notes')} value={spell.notes}/>
</div>
);
}

View File

@@ -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<string>('');
const [isCreatingTag, setIsCreatingTag] = useState<boolean>(false);
const [newTagColor, setNewTagColor] = useState<string>(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<void> {
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 (
<div className="space-y-4 px-2 pb-4">
{/* Informations de base */}
<CollapsableArea title={t('spellDetail.basicInfo')} icon={faHatWizard}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<Collapse variant="card" title={t('spellDetail.basicInfo')} icon={Wand2}>
<InputField
fieldName={t('spellDetail.name')}
input={
@@ -123,7 +113,9 @@ export default function SpellSettingsEdit({
bookElementId={spell.id || ''}
field="name"
elementType="spell"
onDownload={function (): void { onSpellChange('name', seriesSpell?.name || ''); }}
onDownload={function (): void {
onSpellChange('name', seriesSpell?.name || '');
}}
onSyncComplete={onSyncComplete}
>
<TextInput
@@ -136,10 +128,10 @@ export default function SpellSettingsEdit({
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('spellDetail.description')}
icon={faBook}
icon={Book}
input={
<SyncFieldWrapper
seriesElementId={spell.seriesSpellId}
@@ -148,10 +140,12 @@ export default function SpellSettingsEdit({
bookElementId={spell.id || ''}
field="description"
elementType="spell"
onDownload={function (): void { onSpellChange('description', seriesSpell?.description || ''); }}
onDownload={function (): void {
onSpellChange('description', seriesSpell?.description || '');
}}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
<TextAreaInput
value={spell.description}
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
onSpellChange('description', e.target.value);
@@ -161,10 +155,10 @@ export default function SpellSettingsEdit({
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('spellDetail.appearance')}
icon={faEye}
icon={Eye}
input={
<SyncFieldWrapper
seriesElementId={spell.seriesSpellId}
@@ -173,10 +167,12 @@ export default function SpellSettingsEdit({
bookElementId={spell.id || ''}
field="appearance"
elementType="spell"
onDownload={function (): void { onSpellChange('appearance', seriesSpell?.appearance || ''); }}
onDownload={function (): void {
onSpellChange('appearance', seriesSpell?.appearance || '');
}}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
<TextAreaInput
value={spell.appearance}
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
onSpellChange('appearance', e.target.value);
@@ -186,12 +182,10 @@ export default function SpellSettingsEdit({
</SyncFieldWrapper>
}
/>
</div>
</CollapsableArea>
</Collapse>
{/* Tags */}
<CollapsableArea title={t('spellDetail.tags')} icon={faTags}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<Collapse variant="card" title={t('spellDetail.tags')} icon={Tag}>
{selectedTags.length > 0 && (
<div className="flex flex-wrap gap-2 mb-3">
{selectedTags.map(function (tag: SpellTagProps): React.JSX.Element {
@@ -199,13 +193,15 @@ export default function SpellSettingsEdit({
<SpellTagChip
key={tag.id}
tag={tag}
onRemove={function (): void { handleRemoveTag(tag.id); }}
onRemove={function (): void {
handleRemoveTag(tag.id);
}}
/>
);
})}
</div>
)}
<TextInput
value={tagSearchQuery}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
@@ -213,43 +209,42 @@ export default function SpellSettingsEdit({
}}
placeholder={t('spellDetail.addTag')}
/>
{filteredTags.length > 0 && (
<div className="flex flex-wrap gap-2">
{filteredTags.map(function (tag: SpellTagProps): React.JSX.Element {
return (
<button
key={tag.id}
onClick={function (): void { handleAddTag(tag.id); }}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm transition-all duration-200 hover:scale-105 hover:shadow-md border"
style={{
backgroundColor: `${tag.color || '#3B82F6'}20`,
borderColor: `${tag.color || '#3B82F6'}50`,
color: tag.color || '#3B82F6'
onClick={function (): void {
handleAddTag(tag.id);
}}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm transition-colors duration-200 hover:brightness-110 border ${dynamicBgWithOpacity(tag.color || '#51AE84', '20')} ${dynamicBorderWithOpacity(tag.color || '#51AE84', '50')} ${dynamicText(tag.color || 'var(--color-primary)')}`}
>
<FontAwesomeIcon icon={faPlus} className="w-3 h-3"/>
<Plus className="w-3 h-3" strokeWidth={1.75}/>
{tag.name}
</button>
);
})}
</div>
)}
{showCreateOption && !isCreatingTag && (
<button
onClick={function (): void { setIsCreatingTag(true); }}
className="w-full flex items-center gap-2 px-4 py-2.5 bg-tertiary hover:bg-secondary/50 transition-colors text-left rounded-xl border border-secondary/50"
onClick={function (): void {
setIsCreatingTag(true);
}}
className="w-full flex items-center gap-2 px-4 py-2.5 bg-tertiary hover:bg-secondary transition-colors text-left rounded-xl border border-secondary"
>
<FontAwesomeIcon icon={faPlus} className="text-primary w-3 h-3"/>
<Plus className="text-primary w-3 h-3" strokeWidth={1.75}/>
<span className="text-primary font-medium">
{t('spellDetail.createTag', {name: tagSearchQuery})}
</span>
</button>
)}
{isCreatingTag && (
<div className="p-4 bg-tertiary rounded-xl border border-secondary/50">
<div className="p-4 bg-tertiary rounded-xl border border-secondary">
<p className="text-text-secondary text-sm mb-3">
{t('spellDetail.createTag', {name: tagSearchQuery})}
</p>
@@ -258,35 +253,30 @@ export default function SpellSettingsEdit({
return (
<button
key={color}
onClick={function (): void { setNewTagColor(color); }}
className={`w-8 h-8 rounded-full transition-all duration-200 ${newTagColor === color ? 'ring-2 ring-offset-2 ring-primary scale-110' : 'hover:scale-110'}`}
style={{backgroundColor: color}}
onClick={function (): void {
setNewTagColor(color);
}}
className={`w-8 h-8 rounded-full transition-all duration-200 ${newTagColor === color ? 'ring-2 ring-offset-2 ring-primary' : 'hover:ring-1 hover:ring-primary/50'} ${dynamicBg(color)}`}
/>
);
})}
</div>
<div className="flex gap-2">
<button
onClick={function (): void { setIsCreatingTag(false); }}
className="flex-1 py-2 px-3 bg-secondary/50 text-text-primary rounded-lg hover:bg-secondary transition-colors"
>
<Button variant="secondary" size="sm" onClick={function (): void {
setIsCreatingTag(false);
}}>
{t('common.cancel')}
</button>
<button
onClick={handleCreateTag}
className="flex-1 py-2 px-3 bg-primary text-text-primary rounded-lg hover:bg-primary-dark transition-colors"
>
</Button>
<Button variant="primary" size="sm" onClick={handleCreateTag}>
{t('common.confirm')}
</button>
</Button>
</div>
</div>
)}
</div>
</CollapsableArea>
</Collapse>
{/* Niveau de puissance */}
<CollapsableArea title={t('spellDetail.powerLevel')} icon={faBolt}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<Collapse variant="card" title={t('spellDetail.powerLevel')} icon={Zap}>
<SyncFieldWrapper
seriesElementId={spell.seriesSpellId}
seriesValue={seriesSpell?.powerLevel || 'none'}
@@ -294,7 +284,9 @@ export default function SpellSettingsEdit({
bookElementId={spell.id || ''}
field="powerLevel"
elementType="spell"
onDownload={function (): void { onSpellChange('powerLevel', seriesSpell?.powerLevel || null); }}
onDownload={function (): void {
onSpellChange('powerLevel', seriesSpell?.powerLevel || null);
}}
onSyncComplete={onSyncComplete}
>
<SelectBox
@@ -305,12 +297,10 @@ export default function SpellSettingsEdit({
data={getLocalizedPowerLevels()}
/>
</SyncFieldWrapper>
</div>
</CollapsableArea>
</Collapse>
{/* Composants */}
<CollapsableArea title={t('spellDetail.components')} icon={faPuzzlePiece}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<Collapse variant="card" title={t('spellDetail.components')} icon={Puzzle}>
<SyncFieldWrapper
seriesElementId={spell.seriesSpellId}
seriesValue={seriesSpell?.components || ''}
@@ -318,10 +308,12 @@ export default function SpellSettingsEdit({
bookElementId={spell.id || ''}
field="components"
elementType="spell"
onDownload={function (): void { onSpellChange('components', seriesSpell?.components || null); }}
onDownload={function (): void {
onSpellChange('components', seriesSpell?.components || null);
}}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
<TextAreaInput
value={spell.components || ''}
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
onSpellChange('components', e.target.value || null);
@@ -329,12 +321,10 @@ export default function SpellSettingsEdit({
placeholder={t('spellDetail.componentsPlaceholder')}
/>
</SyncFieldWrapper>
</div>
</CollapsableArea>
</Collapse>
{/* Limitations */}
<CollapsableArea title={t('spellDetail.limitations')} icon={faTriangleExclamation}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<Collapse variant="card" title={t('spellDetail.limitations')} icon={AlertTriangle}>
<SyncFieldWrapper
seriesElementId={spell.seriesSpellId}
seriesValue={seriesSpell?.limitations || ''}
@@ -342,10 +332,12 @@ export default function SpellSettingsEdit({
bookElementId={spell.id || ''}
field="limitations"
elementType="spell"
onDownload={function (): void { onSpellChange('limitations', seriesSpell?.limitations || null); }}
onDownload={function (): void {
onSpellChange('limitations', seriesSpell?.limitations || null);
}}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
<TextAreaInput
value={spell.limitations || ''}
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
onSpellChange('limitations', e.target.value || null);
@@ -353,12 +345,10 @@ export default function SpellSettingsEdit({
placeholder={t('spellDetail.limitationsPlaceholder')}
/>
</SyncFieldWrapper>
</div>
</CollapsableArea>
</Collapse>
{/* Notes */}
<CollapsableArea title={t('spellDetail.notes')} icon={faStickyNote}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<Collapse variant="card" title={t('spellDetail.notes')} icon={StickyNote}>
<SyncFieldWrapper
seriesElementId={spell.seriesSpellId}
seriesValue={seriesSpell?.notes || ''}
@@ -366,10 +356,12 @@ export default function SpellSettingsEdit({
bookElementId={spell.id || ''}
field="notes"
elementType="spell"
onDownload={function (): void { onSpellChange('notes', seriesSpell?.notes || null); }}
onDownload={function (): void {
onSpellChange('notes', seriesSpell?.notes || null);
}}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
<TextAreaInput
value={spell.notes || ''}
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
onSpellChange('notes', e.target.value || null);
@@ -377,8 +369,7 @@ export default function SpellSettingsEdit({
placeholder={t('spellDetail.notesPlaceholder')}
/>
</SyncFieldWrapper>
</div>
</CollapsableArea>
</Collapse>
</div>
);
}

View File

@@ -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<string>('');
const [filterTag, setFilterTag] = useState<string>('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 (
<div className="space-y-4">
<div className="px-4 space-y-3">
@@ -57,13 +61,13 @@ export default function SpellSettingsList({
placeholder={t('spellList.search')}
/>
}
actionIcon={faPlus}
actionIcon={Plus}
actionLabel={t('spellList.add')}
addButtonCallBack={async function (): Promise<void> {
onAddSpell();
}}
/>
<div className="flex flex-wrap gap-3 items-center">
<div className="flex-1 min-w-[150px]">
<select
@@ -71,7 +75,7 @@ export default function SpellSettingsList({
onChange={function (e: React.ChangeEvent<HTMLSelectElement>): void {
setFilterTag(e.target.value);
}}
className="w-full text-text-primary bg-secondary/50 hover:bg-secondary px-4 py-2.5 rounded-xl border border-secondary/50 focus:border-primary focus:ring-4 focus:ring-primary/20 focus:bg-secondary hover:border-secondary outline-none transition-all duration-200 cursor-pointer"
className="input-base cursor-pointer"
>
<option value="all" className="bg-tertiary text-text-primary">
{t('spellList.allTags')}
@@ -85,70 +89,43 @@ export default function SpellSettingsList({
})}
</select>
</div>
<button
onClick={onManageTags}
className="flex items-center gap-2 px-4 py-2.5 bg-secondary/50 rounded-xl border border-secondary/50 hover:bg-secondary hover:border-secondary hover:shadow-md hover:scale-105 transition-all duration-200"
>
<FontAwesomeIcon icon={faCog} className="text-primary w-4 h-4"/>
<span className="text-text-primary text-sm font-medium">{t('spellList.manageTags')}</span>
</button>
<Button variant="secondary" size="sm" icon={Settings} onClick={onManageTags}>
{t('spellList.manageTags')}
</Button>
</div>
</div>
<div className="px-2">
{filteredSpells.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="w-20 h-20 rounded-full bg-primary/10 flex items-center justify-center mb-4">
<FontAwesomeIcon icon={faHatWizard} className="text-primary w-10 h-10"/>
</div>
<h3 className="text-text-primary font-semibold text-lg mb-2">
{t('spellList.noSpells')}
</h3>
<p className="text-muted text-sm max-w-xs">
{t('spellList.noSpellsDescription')}
</p>
</div>
<EmptyState icon={Wand2} title={t('spellList.noSpells')}
description={t('spellList.noSpellsDescription')}/>
) : (
<div className="space-y-2 p-2">
{filteredSpells.map(function (spell: SpellListItem): React.JSX.Element {
return (
<div
<EntityListItem
key={spell.id}
onClick={function (): void { onSpellClick(spell); }}
className="group flex items-center p-4 bg-secondary/30 rounded-xl border-l-4 border-primary border border-secondary/50 cursor-pointer hover:bg-secondary hover:shadow-md hover:scale-102 transition-all duration-200 hover:border-primary/50"
>
<div className="w-12 h-12 rounded-full border-2 border-primary overflow-hidden bg-secondary shadow-md group-hover:scale-110 transition-transform flex items-center justify-center">
<FontAwesomeIcon icon={faHatWizard} className="text-primary w-6 h-6"/>
</div>
<div className="ml-4 flex-1 min-w-0">
<div className="text-text-primary font-bold text-base group-hover:text-primary transition-colors">
{spell.name}
</div>
<div className="text-text-secondary text-sm mt-0.5 truncate">
{spell.description}
</div>
{spell.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
onClick={function (): void {
onSpellClick(spell);
}}
avatar={<AvatarIcon icon={Wand2}/>}
title={spell.name}
subtitle={spell.description}
extra={
spell.tags.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{spell.tags.slice(0, 3).map(function (tag: SpellTagProps): React.JSX.Element {
return <SpellTagChip key={tag.id} tag={tag} size="sm"/>;
})}
{spell.tags.length > 3 && (
<span className="text-muted text-xs px-2 py-0.5 bg-secondary/50 rounded-full">
<Badge variant="muted" size="sm">
+{spell.tags.length - 3}
</span>
</Badge>
)}
</div>
)}
</div>
<div className="w-8 flex justify-center">
<FontAwesomeIcon
icon={faChevronRight}
className="text-muted group-hover:text-primary group-hover:translate-x-1 transition-all w-4 h-4"
/>
</div>
</div>
) : undefined
}
/>
);
})}
</div>

View File

@@ -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<LangContextProps>(LangContext);
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
const {addToQueue} = useContext<LocalSyncQueueContextProps>(LocalSyncQueueContext);
const {localSyncedBooks} = useContext<BooksSyncContextProps>(BooksSyncContext);
const {book} = useContext(BookContext);
const {session} = useContext(SessionContext);
const {errorMessage, successMessage} = useContext(AlertContext);
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext);
const {book}: BookContextProps = useContext<BookContextProps>(BookContext);
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
const {errorMessage, successMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
const {isCurrentlyOffline}: OfflineContextType = useContext<OfflineContextType>(OfflineContext);
const bookId: string | undefined = book?.bookId;
const token: string = session.accessToken;
@@ -77,24 +66,16 @@ export default function Act({acts, setActs, mainChapters}: ActProps) {
async function addIncident(actId: number): Promise<void> {
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<string>('book/incident/new', {
incidentId = await apiPost<string>('book/incident/new', {
bookId,
name: newIncidentTitle,
}, token, lang);
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) {
addToQueue('add_incident', {data: {
bookId,
incidentId,
name: newIncidentTitle,
}});
}
}
if (!incidentId) {
errorMessage(t('errorAddIncident'));
@@ -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<void> {
try {
let response: boolean;
const deleteData = { bookId, incidentId, deletedAt: System.timeStampInSeconds() };
if (isCurrentlyOffline() || book?.localBook) {
response = await tauri.removeIncident(deleteData.bookId!, deleteData.incidentId, deleteData.deletedAt);
if (isDesktop && (isCurrentlyOffline() || book?.localBook)) {
response = await tauri.removeIncident(bookId ?? '', incidentId, Date.now());
} else {
response = await System.authDeleteToServer<boolean>('book/incident/remove', deleteData, token, lang);
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) {
addToQueue('remove_incident', {data: deleteData});
}
response = await apiDelete<boolean>('book/incident/remove', {
bookId,
incidentId,
}, token, lang);
}
if (!response) {
errorMessage(t('errorDeleteIncident'));
@@ -169,22 +148,14 @@ export default function Act({acts, setActs, mainChapters}: ActProps) {
if (newPlotPointTitle.trim() === '') return;
try {
let plotId: string;
const plotData = {
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<string>('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<string>('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<void> {
try {
let response: boolean;
const deleteData = { plotId: plotPointId, bookId, deletedAt: System.timeStampInSeconds() };
if (isCurrentlyOffline() || book?.localBook) {
response = await tauri.removePlotPoint(deleteData.plotId, deleteData.bookId!, deleteData.deletedAt);
if (isDesktop && (isCurrentlyOffline() || book?.localBook)) {
response = await tauri.removePlotPoint(plotPointId, bookId ?? '', Date.now());
} else {
response = await System.authDeleteToServer<boolean>('book/plot/remove', deleteData, token, lang);
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) {
addToQueue('remove_plot_point', {data: deleteData});
}
response = await apiDelete<boolean>('book/plot/remove', {
plotId: plotPointId,
}, token, lang);
}
if (!response) {
errorMessage(t('errorDeletePlotPoint'));
@@ -269,24 +237,22 @@ export default function Act({acts, setActs, mainChapters}: ActProps) {
}
try {
let linkId: string;
const linkData = {
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<string>('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<string>('chapter/resume/add', {
bookId,
chapterId: chapterId,
actId: actId,
plotId: destination === 'plotPoint' ? itemId : null,
incidentId: destination === 'incident' ? itemId : null,
}, token, lang);
}
if (!linkId) {
errorMessage(t('errorLinkChapter'));
@@ -363,15 +329,12 @@ export default function Act({acts, setActs, mainChapters}: ActProps) {
): Promise<void> {
try {
let response: boolean;
const unlinkData = { chapterInfoId, bookId, deletedAt: System.timeStampInSeconds() };
if (isCurrentlyOffline() || book?.localBook) {
response = await tauri.removeChapterInformation(unlinkData.chapterInfoId, unlinkData.bookId!, unlinkData.deletedAt);
if (isDesktop && (isCurrentlyOffline() || book?.localBook)) {
response = await tauri.removeChapterInformation(chapterInfoId, bookId ?? '', Date.now());
} else {
response = await System.authDeleteToServer<boolean>('chapter/resume/remove', unlinkData, token, lang);
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) {
addToQueue('remove_chapter_information', {data: unlinkData});
}
response = await apiDelete<boolean>('chapter/resume/remove', {
chapterInfoId,
}, token, lang);
}
if (!response) {
errorMessage(t('errorUnlinkChapter'));
@@ -621,20 +584,20 @@ export default function Act({acts, setActs, mainChapters}: ActProps) {
);
}
function renderActIcon(actId: number): IconDefinition {
function renderActIcon(actId: number): LucideIcon {
switch (actId) {
case 1:
return faFlag;
return Flag;
case 2:
return faFire;
return Flame;
case 3:
return faPuzzlePiece;
return Puzzle;
case 4:
return faScaleBalanced;
return Scale;
case 5:
return faTrophy;
return Trophy;
default:
return faFlag;
return Flag;
}
}
@@ -658,7 +621,7 @@ export default function Act({acts, setActs, mainChapters}: ActProps) {
return (
<div className="space-y-6">
{acts.map((act: ActType) => (
<CollapsableArea key={`act-${act.id}`}
<Collapse variant="card" key={`act-${act.id}`}
title={renderActTitle(act.id)}
icon={renderActIcon(act.id)}
children={

View File

@@ -1,19 +1,19 @@
import {ChangeEvent, useContext, useState} from 'react';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faPlus, faTrash, faWarning,} from '@fortawesome/free-solid-svg-icons';
import {Issue} from '@/lib/models/Book';
import System from '@/lib/models/System';
import {BookContext} from '@/context/BookContext';
import {SessionContext} from '@/context/SessionContext';
import {AlertContext} from '@/context/AlertContext';
import CollapsableArea from "@/components/CollapsableArea";
import {useTranslations} from "next-intl";
import {LangContext, LangContextProps} from "@/context/LangContext";
import OfflineContext, {OfflineContextType} from "@/context/OfflineContext";
import {LocalSyncQueueContext, LocalSyncQueueContextProps} from "@/context/SyncQueueContext";
import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext";
import {SyncedBook} from "@/lib/models/SyncedBook";
import React, {ChangeEvent, useContext, useState} from 'react';
import {AlertTriangle, Plus, Trash2} from 'lucide-react';
import IconButton from "@/components/ui/IconButton";
import {Issue} from '@/lib/types/book';
import {apiDelete, apiPost} from '@/lib/api/client';
import {isDesktop} from '@/lib/configs';
import * as tauri from '@/lib/tauri';
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
import {BookContext, BookContextProps} from '@/context/BookContext';
import {SessionContext, SessionContextProps} from '@/context/SessionContext';
import {AlertContext, AlertContextProps} from '@/context/AlertContext';
import Collapse from "@/components/ui/Collapse";
import TextInput from "@/components/form/TextInput";
import InputField from "@/components/form/InputField";
import {useTranslations} from '@/lib/i18n';
import {LangContext, LangContextProps} from "@/context/LangContext";
interface IssuesProps {
issues: Issue[];
@@ -22,14 +22,12 @@ interface IssuesProps {
export default function Issues({issues, setIssues}: IssuesProps) {
const t = useTranslations();
const {lang} = useContext<LangContextProps>(LangContext);
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
const {addToQueue} = useContext<LocalSyncQueueContextProps>(LocalSyncQueueContext);
const {localSyncedBooks} = useContext<BooksSyncContextProps>(BooksSyncContext);
const {book} = useContext(BookContext);
const {session} = useContext(SessionContext);
const {errorMessage} = useContext(AlertContext);
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext);
const {book}: BookContextProps = useContext<BookContextProps>(BookContext);
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
const {errorMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
const {isCurrentlyOffline}: OfflineContextType = useContext<OfflineContextType>(OfflineContext);
const bookId: string | undefined = book?.bookId;
const token: string = session.accessToken;
@@ -42,21 +40,13 @@ export default function Issues({issues, setIssues}: IssuesProps) {
}
try {
let issueId: string;
if (isCurrentlyOffline() || book?.localBook) {
issueId = await tauri.addIssue(bookId!, newIssueName);
if (isDesktop && (isCurrentlyOffline() || book?.localBook)) {
issueId = await tauri.addIssue(bookId ?? '', newIssueName);
} else {
issueId = await System.authPostToServer<string>('book/issue/add', {
issueId = await apiPost<string>('book/issue/add', {
bookId,
name: newIssueName,
}, token, lang);
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) {
addToQueue('add_issue', {data: {
bookId,
issueId,
name: newIssueName,
}});
}
}
if (!issueId) {
errorMessage(t("issues.errorAdd"));
@@ -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<void> {
if (issueId === undefined) {
errorMessage(t("issues.errorInvalidId"));
return;
}
try {
let response: boolean;
const deletedAt: number = System.timeStampInSeconds();
if (isCurrentlyOffline() || book?.localBook) {
response = await tauri.removeIssue(bookId!, issueId, deletedAt);
if (isDesktop && (isCurrentlyOffline() || book?.localBook)) {
response = await tauri.removeIssue(bookId ?? '', issueId, Date.now());
} else {
response = await System.authDeleteToServer<boolean>(
response = await apiDelete<boolean>(
'book/issue/remove',
{
bookId,
issueId,
deletedAt,
},
token,
lang
);
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) {
addToQueue('remove_issue', {data: {
bookId,
issueId,
deletedAt,
}});
}
}
if (response) {
const updatedIssues: Issue[] = issues.filter((issue: Issue): boolean => issue.id !== issueId,);
@@ -135,51 +115,48 @@ export default function Issues({issues, setIssues}: IssuesProps) {
}
return (
<CollapsableArea title={t("issues.title")} children={
<div className="p-1">
<Collapse variant="card" title={t("issues.title")} icon={AlertTriangle}>
<div className="space-y-2">
{issues && issues.length > 0 ? (
issues.map((item: Issue) => (
<div
className="mb-2 bg-secondary/30 rounded-xl p-3 shadow-sm hover:shadow-md transition-all duration-200"
key={`issue-${item.id}`}
>
<div className="flex justify-between items-center">
<input
className="flex-1 text-text-primary text-base px-2 py-1 bg-transparent border-b border-secondary/50 focus:outline-none focus:border-primary transition-colors duration-200 placeholder:text-muted/60"
value={item.name}
onChange={(e) => updateIssueName(item.id, e.target.value)}
placeholder={t("issues.issueNamePlaceholder")}
/>
<button
className="p-1.5 ml-2 rounded-lg text-error hover:bg-error/20 hover:scale-110 transition-all duration-200"
onClick={() => deleteIssue(item.id)}
>
<FontAwesomeIcon icon={faTrash} size="sm"/>
</button>
issues.map(function (item: Issue): React.JSX.Element {
return (
<div key={`issue-${item.id}`} className="flex items-center gap-2">
<div className="flex-1">
<TextInput
value={item.name}
setValue={function (e: ChangeEvent<HTMLInputElement>): void {
updateIssueName(item.id, e.target.value);
}}
placeholder={t("issues.issueNamePlaceholder")}
/>
</div>
<IconButton icon={Trash2} variant="danger" size="sm"
onClick={function (): Promise<void> {
return deleteIssue(item.id);
}}/>
</div>
</div>
))
);
})
) : (
<p className="text-text-secondary text-center py-2 text-sm">
{t("issues.noIssue")}
</p>
)}
<div className="flex items-center mt-3 bg-secondary/30 p-3 rounded-xl shadow-sm">
<input
className="flex-1 text-text-primary text-base px-2 py-1 bg-transparent border-b border-secondary/50 focus:outline-none focus:border-primary transition-colors duration-200 placeholder:text-muted/60"
value={newIssueName}
onChange={(e: ChangeEvent<HTMLInputElement>) => setNewIssueName(e.target.value)}
placeholder={t("issues.newIssuePlaceholder")}
/>
<button
className="bg-primary w-9 h-9 rounded-full flex justify-center items-center ml-2 text-text-primary shadow-md hover:shadow-lg hover:scale-110 hover:bg-primary-dark transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100"
onClick={addNewIssue}
disabled={newIssueName.trim() === ''}
>
<FontAwesomeIcon icon={faPlus}/>
</button>
</div>
<InputField
input={
<TextInput
value={newIssueName}
setValue={function (e: ChangeEvent<HTMLInputElement>): void {
setNewIssueName(e.target.value);
}}
placeholder={t("issues.newIssuePlaceholder")}
/>
}
actionIcon={Plus}
addButtonCallBack={addNewIssue}
isAddButtonDisabled={newIssueName.trim() === ''}
/>
</div>
} icon={faWarning}/>
</Collapse>
);
}

View File

@@ -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<OfflineContextType>(OfflineContext);
const {addToQueue} = useContext<LocalSyncQueueContextProps>(LocalSyncQueueContext);
const {localSyncedBooks} = useContext<BooksSyncContextProps>(BooksSyncContext);
const {book} = useContext(BookContext);
const {session} = useContext(SessionContext);
const {errorMessage, successMessage} = useContext(AlertContext);
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext)
const {book}: BookContextProps = useContext<BookContextProps>(BookContext);
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
const {errorMessage, successMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
const {isCurrentlyOffline}: OfflineContextType = useContext<OfflineContextType>(OfflineContext);
const bookId: string | undefined = book?.bookId;
const token: string = session.accessToken;
@@ -87,20 +85,18 @@ export default function MainChapter({chapters, setChapters}: MainChapterProps) {
try {
setDeleteConfirmMessage(false);
let response: boolean;
const deletedAt: number = System.timeStampInSeconds();
const deleteData = {
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<boolean>('chapter/remove', deleteData, token, lang);
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) {
addToQueue('remove_chapter', {data: deleteData});
}
response = await apiDelete<boolean>(
'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<string>('chapter/add', chapterData, token);
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) {
addToQueue('add_chapter', {data: {
...chapterData,
chapterId: responseId,
}});
}
responseId = await apiPost<string>(
'chapter/add',
{
bookId: bookId,
wordsCount: 0,
chapterOrder: newChapterOrder ? newChapterOrder : 0,
title: newChapterTitle,
},
token,
);
}
if (!responseId) {
errorMessage(t("mainChapter.errorAdd"));
return;
}
const newChapter: ChapterListProps = {
chapterId: responseId as string,
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 (
<div>
<CollapsableArea icon={faBookmark} title={t("mainChapter.chapters")} children={
<Collapse variant="card" icon={Bookmark} title={t("mainChapter.chapters")} children={
<div className="space-y-4">
{visibleChapters.length > 0 ? (
<div>
<div className="space-y-2">
{visibleChapters.map((item: ChapterListProps, index: number) => (
<div key={item.chapterId}
className="mb-2 bg-secondary/30 rounded-xl p-3 shadow-sm hover:shadow-md transition-all duration-200">
<div className="flex items-center">
<span
className="bg-primary-dark text-white text-sm w-6 h-6 rounded-full text-center leading-6 mr-2">
{item.chapterOrder !== undefined ? item.chapterOrder : index}
</span>
<input
className="flex-1 text-text-primary text-base px-2 py-1 bg-transparent border-b border-secondary/50 focus:outline-none focus:border-primary transition-colors duration-200"
<div key={item.chapterId} className="flex items-center gap-2">
<span
className="text-muted text-xs w-5 h-5 rounded-md bg-tertiary text-center leading-5 shrink-0">
{item.chapterOrder !== undefined ? item.chapterOrder : index}
</span>
<div className="flex-1">
<TextInput
value={item.title}
onChange={(e: ChangeEvent<HTMLInputElement>) => handleChapterTitleChange(item.chapterId, e.target.value)}
setValue={function (e: ChangeEvent<HTMLInputElement>): void {
handleChapterTitleChange(item.chapterId, e.target.value);
}}
placeholder={t("mainChapter.chapterTitlePlaceholder")}
/>
<div className="flex ml-1">
<button
className={`p-1.5 mx-0.5 rounded-lg hover:bg-secondary/50 transition-all duration-200 ${
item.chapterOrder === 0 ? 'text-muted cursor-not-allowed' : 'text-primary hover:scale-110'
}`}
onClick={(): void => moveChapterUp(index)}
disabled={item.chapterOrder === 0}
>
<FontAwesomeIcon icon={faArrowUp} size="sm"/>
</button>
<button
className="p-1.5 mx-0.5 rounded-lg text-primary hover:bg-secondary/50 hover:scale-110 transition-all duration-200"
onClick={(): void => moveChapterDown(index)}
>
<FontAwesomeIcon icon={faArrowDown} size="sm"/>
</button>
<button
className="p-1.5 mx-0.5 rounded-lg text-error hover:bg-error/20 hover:scale-110 transition-all duration-200"
onClick={(): void => {
setChapterIdToRemove(item.chapterId);
setDeleteConfirmMessage(true);
}}
>
<FontAwesomeIcon icon={faTrash} size="sm"/>
</button>
</div>
</div>
<div className="flex shrink-0">
<IconButton
icon={ArrowUp}
variant="ghost"
size="sm"
onClick={(): void => moveChapterUp(index)}
disabled={item.chapterOrder === 0}
/>
<IconButton
icon={ArrowDown}
variant="ghost"
size="sm"
onClick={(): void => moveChapterDown(index)}
/>
<IconButton
icon={Trash2}
variant="danger"
size="sm"
onClick={(): void => {
setChapterIdToRemove(item.chapterId);
setDeleteConfirmMessage(true);
}}
/>
</div>
</div>
))}
@@ -250,44 +234,18 @@ export default function MainChapter({chapters, setChapters}: MainChapterProps) {
</p>
)}
<div className="bg-secondary/30 rounded-xl p-3 mt-3 shadow-sm">
<div className="flex items-center">
<div
className="flex items-center gap-1 bg-secondary/50 rounded-lg mr-2 px-1 py-0.5 shadow-inner">
<button
className={`w-6 h-6 rounded-full bg-secondary flex justify-center items-center hover:scale-110 transition-all duration-200 ${
newChapterOrder <= 0 ? 'text-muted cursor-not-allowed opacity-50' : 'text-primary hover:bg-secondary-dark'
}`}
onClick={decrementNewChapterOrder}
disabled={newChapterOrder <= 0}
>
<FontAwesomeIcon icon={faMinus} size="xs"/>
</button>
<span
className="bg-primary-dark text-white text-sm w-6 h-6 rounded-full text-center leading-6 mx-0.5">
{newChapterOrder}
</span>
<button
className="w-6 h-6 rounded-full bg-secondary flex justify-center items-center text-primary hover:bg-secondary-dark hover:scale-110 transition-all duration-200"
onClick={incrementNewChapterOrder}
>
<FontAwesomeIcon icon={faPlus} size="xs"/>
</button>
</div>
<input
className="flex-1 text-text-primary text-base px-2 py-1 bg-transparent border-b border-secondary/50 focus:outline-none focus:border-primary transition-colors duration-200 placeholder:text-muted/60"
value={newChapterTitle}
onChange={e => setNewChapterTitle(e.target.value)}
placeholder={t("mainChapter.newChapterPlaceholder")}
/>
<button
className="bg-primary w-9 h-9 rounded-full flex justify-center items-center ml-2 text-text-primary shadow-md hover:shadow-lg hover:scale-110 hover:bg-primary-dark transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100"
onClick={addNewChapter}
disabled={newChapterTitle.trim() === ''}
>
<FontAwesomeIcon icon={faPlus} className={'w-5 h-5'}/>
</button>
</div>
<div className="mt-3">
<OrderInput
value={newChapterTitle}
setValue={function (e: ChangeEvent<HTMLInputElement>): void {
setNewChapterTitle(e.target.value);
}}
order={newChapterOrder}
setOrder={setNewChapterOrder}
placeholder={t("mainChapter.newChapterPlaceholder")}
onAdd={addNewChapter}
isAddDisabled={newChapterTitle.trim() === ''}
/>
</div>
</div>
}/>

View File

@@ -1,22 +1,21 @@
'use client'
import {createContext, forwardRef, useContext, useEffect, useImperativeHandle, useState} from 'react';
import {BookContext} from '@/context/BookContext';
import {SessionContext} from '@/context/SessionContext';
import {AlertContext} from '@/context/AlertContext';
import System from '@/lib/models/System';
import {Act as ActType, Issue} from '@/lib/models/Book';
import {ActChapter, ChapterListProps} from '@/lib/models/Chapter';
import React, {createContext, forwardRef, useContext, useEffect, useImperativeHandle, useState} from 'react';
import {BookContext, BookContextProps} from '@/context/BookContext';
import {SessionContext, SessionContextProps} from '@/context/SessionContext';
import {AlertContext, AlertContextProps} from '@/context/AlertContext';
import {apiGet, apiPost} from '@/lib/api/client';
import {isDesktop} from '@/lib/configs';
import * as tauri from '@/lib/tauri';
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
import {Act as ActType, Incident, Issue, PlotPoint} from '@/lib/types/book';
import {ActChapter, ChapterListProps} from '@/lib/types/chapter';
import MainChapter from "@/components/book/settings/story/MainChapter";
import Issues from "@/components/book/settings/story/Issue";
import Act from "@/components/book/settings/story/Act";
import {useTranslations} from "next-intl";
import {useTranslations} from '@/lib/i18n';
import {LangContext, LangContextProps} from "@/context/LangContext";
import OfflineContext, {OfflineContextType} from "@/context/OfflineContext";
import {LocalSyncQueueContext, LocalSyncQueueContextProps} from "@/context/SyncQueueContext";
import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext";
import {SyncedBook} from "@/lib/models/SyncedBook";
import * as tauri from '@/lib/tauri';
import {SettingRef} from "@/lib/types/settings";
export const StoryContext = createContext<{
acts: ActType[];
@@ -43,24 +42,22 @@ interface StoryFetchData {
issues: Issue[];
}
export function Story(props: any, ref: any) {
export function Story(_props: object, ref: React.ForwardedRef<SettingRef>): React.JSX.Element {
const t = useTranslations();
const {lang} = useContext<LangContextProps>(LangContext);
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
const {addToQueue} = useContext<LocalSyncQueueContextProps>(LocalSyncQueueContext);
const {localSyncedBooks} = useContext<BooksSyncContextProps>(BooksSyncContext);
const {book} = useContext(BookContext);
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext);
const {book}: BookContextProps = useContext<BookContextProps>(BookContext);
const bookId: string = book?.bookId ? book.bookId.toString() : '';
const {session} = useContext(SessionContext);
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
const userToken: string = session.accessToken;
const {errorMessage, successMessage} = useContext(AlertContext);
const {errorMessage, successMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
const {isCurrentlyOffline}: OfflineContextType = useContext<OfflineContextType>(OfflineContext);
const [acts, setActs] = useState<ActType[]>([]);
const [issues, setIssues] = useState<Issue[]>([]);
const [mainChapters, setMainChapters] = useState<ChapterListProps[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
useImperativeHandle(ref, function () {
useImperativeHandle(ref, function (): SettingRef {
return {
handleSave: handleSave
};
@@ -77,10 +74,10 @@ export function Story(props: any, ref: any) {
async function getStoryData(): Promise<void> {
try {
let response: StoryFetchData;
if (isCurrentlyOffline() || book?.localBook) {
if (isDesktop && (isCurrentlyOffline() || book?.localBook)) {
response = await tauri.getBookStory(bookId) as StoryFetchData;
} else {
response = await System.authGetQueryToServer<StoryFetchData>(`book/story`, userToken, lang, {
response = await apiGet<StoryFetchData>(`book/story`, userToken, lang, {
bookid: bookId,
});
}
@@ -101,22 +98,22 @@ export function Story(props: any, ref: any) {
}
function cleanupDeletedChapters(): void {
const existingChapterIds: string[] = mainChapters.map(ch => ch.chapterId);
const existingChapterIds: string[] = mainChapters.map((ch: ChapterListProps): string => ch.chapterId);
const updatedActs = acts.map((act: ActType) => {
const updatedActs: ActType[] = acts.map((act: ActType): ActType => {
const filteredChapters: ActChapter[] = act.chapters?.filter((chapter: ActChapter): boolean =>
existingChapterIds.includes(chapter.chapterId)) || [];
const updatedIncidents = act.incidents?.map(incident => {
const updatedIncidents: Incident[] = act.incidents?.map((incident: Incident): Incident => {
return {
...incident,
chapters: incident.chapters?.filter(chapter =>
chapters: incident.chapters?.filter((chapter: ActChapter): boolean =>
existingChapterIds.includes(chapter.chapterId)) || []
};
}) || [];
const updatedPlotPoints = act.plotPoints?.map(plotPoint => {
const updatedPlotPoints: PlotPoint[] = act.plotPoints?.map((plotPoint: PlotPoint): PlotPoint => {
return {
...plotPoint,
chapters: plotPoint.chapters?.filter(chapter =>
chapters: plotPoint.chapters?.filter((chapter: ActChapter): boolean =>
existingChapterIds.includes(chapter.chapterId)) || []
};
}) || [];
@@ -133,20 +130,20 @@ export function Story(props: any, ref: any) {
async function handleSave(): Promise<void> {
try {
let response: boolean;
const storyData = {
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<boolean>('book/story', storyData, userToken, lang);
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) {
addToQueue('update_book_story', {data: storyData});
}
response = await apiPost<boolean>('book/story', {
bookId,
acts,
mainChapters,
issues,
}, userToken, lang);
}
if (!response) {
errorMessage(t("story.errorSave"))
@@ -186,4 +183,4 @@ export function Story(props: any, ref: any) {
);
}
export default forwardRef(Story);
export default forwardRef<SettingRef, object>(Story);

View File

@@ -1,9 +1,9 @@
import {ChangeEvent} from 'react';
import {faTrash} from '@fortawesome/free-solid-svg-icons';
import {ActChapter} from '@/lib/models/Chapter';
import React, {ChangeEvent} from 'react';
import {Trash2} from 'lucide-react';
import {ActChapter} from '@/lib/types/chapter';
import InputField from '@/components/form/InputField';
import TexteAreaInput from '@/components/form/TexteAreaInput';
import {useTranslations} from 'next-intl';
import TextAreaInput from '@/components/form/TextAreaInput';
import {useTranslations} from '@/lib/i18n';
interface ActChapterItemProps {
chapter: ActChapter;
@@ -15,11 +15,10 @@ export default function ActChapterItem({chapter, onUpdateSummary, onUnlink}: Act
const t = useTranslations('actComponent');
return (
<div
className="bg-secondary/20 p-4 rounded-xl mb-3 border border-secondary/30 shadow-sm hover:shadow-md transition-all duration-200">
<div className="mb-3">
<InputField
input={
<TexteAreaInput
<TextAreaInput
value={chapter.summary || ''}
setValue={(e: ChangeEvent<HTMLTextAreaElement>) =>
onUpdateSummary(chapter.chapterId, e.target.value)
@@ -27,7 +26,7 @@ export default function ActChapterItem({chapter, onUpdateSummary, onUnlink}: Act
placeholder={t('chapterSummaryPlaceholder')}
/>
}
actionIcon={faTrash}
actionIcon={Trash2}
fieldName={chapter.title}
action={(): Promise<void> => onUnlink(chapter.chapterInfoId, chapter.chapterId)}
actionLabel={t('remove')}

View File

@@ -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 (
<div className="mb-4">
<button
className="flex justify-between items-center w-full bg-secondary/50 p-3 rounded-xl text-left hover:bg-secondary transition-all duration-200 shadow-sm"
onClick={(): void => onToggleSection(sectionKey)}
>
<span className="font-bold text-text-primary">{t('chapters')}</span>
<FontAwesomeIcon
icon={isExpanded ? faChevronUp : faChevronDown}
className="text-text-primary w-3.5 h-3.5"
/>
</button>
{isExpanded && (
<div className="p-2">
{chapters && chapters.length > 0 ? (
chapters.map((chapter: ActChapter) => (
<Collapse title={t('chapters')} defaultOpen={isExpanded}>
<div className="space-y-2">
{chapters && chapters.length > 0 ? (
chapters.map(function (chapter: ActChapter): React.JSX.Element {
return (
<ActChapterItem
key={`chapter-${chapter.chapterInfoId}`}
chapter={chapter}
onUpdateSummary={(chapterId, summary) =>
onUpdateChapterSummary(chapterId, summary)
}
onUnlink={(chapterInfoId, chapterId) =>
onUnlinkChapter(chapterInfoId, chapterId)
}
onUpdateSummary={function (chapterId: string, summary: string): void {
onUpdateChapterSummary(chapterId, summary);
}}
onUnlink={function (chapterInfoId: string, chapterId: string): Promise<void> {
return onUnlinkChapter(chapterInfoId, chapterId);
}}
/>
))
) : (
<p className="text-text-secondary text-center text-sm p-2">
{t('noLinkedChapter')}
</p>
)}
<InputField
addButtonCallBack={(): Promise<void> => onLinkChapter(actId, selectedChapterId)}
input={
<SelectBox
defaultValue={null}
onChangeCallBack={(e) => setSelectedChapterId(e.target.value)}
data={mainChaptersData()}
placeholder={t('selectChapterPlaceholder')}
/>
}
isAddButtonDisabled={!selectedChapterId}
/>
</div>
)}
</div>
);
})
) : (
<p className="text-text-secondary text-center text-sm p-2">
{t('noLinkedChapter')}
</p>
)}
<InputField
addButtonCallBack={function (): Promise<void> {
return onLinkChapter(actId, selectedChapterId);
}}
input={
<SelectBox
defaultValue={null}
onChangeCallBack={function (e: React.ChangeEvent<HTMLSelectElement>): void {
setSelectedChapterId(e.target.value);
}}
data={mainChaptersData()}
placeholder={t('selectChapterPlaceholder')}
/>
}
isAddButtonDisabled={!selectedChapterId}
/>
</div>
</Collapse>
);
}

View File

@@ -1,8 +1,8 @@
import {ChangeEvent} from 'react';
import {faTrash} from '@fortawesome/free-solid-svg-icons';
import React, {ChangeEvent} from 'react';
import {Trash2} from 'lucide-react';
import InputField from '@/components/form/InputField';
import TexteAreaInput from '@/components/form/TexteAreaInput';
import {useTranslations} from 'next-intl';
import TextAreaInput from '@/components/form/TextAreaInput';
import {useTranslations} from '@/lib/i18n';
interface ActDescriptionProps {
actId: number;
@@ -44,7 +44,7 @@ export default function ActDescription({actId, summary, onUpdateSummary}: ActDes
<InputField
fieldName={getActSummaryTitle(actId)}
input={
<TexteAreaInput
<TextAreaInput
value={summary || ''}
setValue={(e: ChangeEvent<HTMLTextAreaElement>) =>
onUpdateSummary(actId, e.target.value)
@@ -52,7 +52,7 @@ export default function ActDescription({actId, summary, onUpdateSummary}: ActDes
placeholder={getActSummaryPlaceholder(actId)}
/>
}
actionIcon={faTrash}
actionIcon={Trash2}
actionLabel={t('delete')}
/>
</div>

View File

@@ -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 (
<div className="mb-4">
<button
className="flex justify-between items-center w-full bg-secondary/50 p-3 rounded-xl text-left hover:bg-secondary transition-all duration-200 shadow-sm"
onClick={(): void => onToggleSection(sectionKey)}
>
<span className="font-bold text-text-primary">{t('incidentsTitle')}</span>
<FontAwesomeIcon
icon={isExpanded ? faChevronUp : faChevronDown}
className="text-text-primary w-3.5 h-3.5"
/>
</button>
{isExpanded && (
<div className="p-2">
{incidents && incidents.length > 0 ? (
<>
{incidents.map((item: Incident) => {
const itemKey = `incident_${item.incidentId}`;
const isItemExpanded: boolean = expandedItems[itemKey];
return (
<div
key={`incident-${item.incidentId}`}
className="bg-secondary/30 rounded-xl mb-3 overflow-hidden border border-secondary/40 shadow-sm hover:shadow-md transition-all duration-200"
>
<button
className="flex justify-between items-center w-full p-2 text-left"
onClick={(): void => toggleItem(itemKey)}
>
<span className="font-bold text-text-primary">{item.title}</span>
<div className="flex items-center">
<FontAwesomeIcon
icon={isItemExpanded ? faChevronUp : faChevronDown}
className="text-text-primary w-3.5 h-3.5 mr-2"
/>
<button
onClick={async (e) => {
e.stopPropagation();
await onDeleteIncident(actId, item.incidentId);
}}
className="text-error hover:bg-error/20 p-1.5 rounded-lg transition-all duration-200 hover:scale-110"
>
<FontAwesomeIcon icon={faTrash} className="w-3.5 h-3.5"/>
</button>
</div>
</button>
{isItemExpanded && (
<div className="p-3 bg-secondary/20">
{item.chapters && item.chapters.length > 0 ? (
<>
{item.chapters.map((chapter: ActChapter) => (
<ActChapterItem
key={`inc-chapter-${chapter.chapterId}-${chapter.chapterInfoId}`}
chapter={chapter}
onUpdateSummary={(chapterId, summary) =>
onUpdateChapterSummary(chapterId, summary, item.incidentId)
}
onUnlink={(chapterInfoId, chapterId) =>
onUnlinkChapter(chapterInfoId, chapterId, item.incidentId)
}
/>
))}
</>
) : (
<p className="text-text-secondary text-center text-sm p-2">
{t('noLinkedChapter')}
</p>
)}
<div className="flex items-center mt-2">
<select
onChange={(e) => setSelectedChapterId(e.target.value)}
className="flex-1 bg-secondary/50 text-text-primary rounded-xl px-4 py-2.5 mr-2 border border-secondary/50 focus:outline-none focus:ring-4 focus:ring-primary/20 focus:border-primary hover:bg-secondary hover:border-secondary transition-all duration-200"
>
<option value="">{t('selectChapterPlaceholder')}</option>
{mainChapters.map((chapter: ChapterListProps) => (
<option key={chapter.chapterId} value={chapter.chapterId}>
{`${chapter.chapterOrder}. ${chapter.title}`}
</option>
))}
</select>
<button
className="bg-primary text-text-primary w-9 h-9 rounded-full flex items-center justify-center disabled:opacity-50 disabled:cursor-not-allowed shadow-md hover:shadow-lg hover:scale-110 hover:bg-primary-dark transition-all duration-200"
onClick={(): Promise<void> =>
onLinkChapter(actId, selectedChapterId, item.incidentId)
}
disabled={selectedChapterId.length === 0}
>
<FontAwesomeIcon icon={faPlus} className="w-3.5 h-3.5"/>
</button>
</div>
</div>
)}
<Collapse title={t('incidentsTitle')} defaultOpen={isExpanded}>
<div className="space-y-3">
{incidents && incidents.length > 0 ? (
incidents.map(function (item: Incident): React.JSX.Element {
const itemKey: string = `incident_${item.incidentId}`;
const isItemExpanded: boolean = expandedItems[itemKey];
return (
<div key={`incident-${item.incidentId}`}
className="bg-secondary rounded-xl overflow-hidden">
<button
className="flex justify-between items-center w-full p-3 text-left"
onClick={function (): void {
toggleItem(itemKey);
}}
>
<span className="font-bold text-text-primary">{item.title}</span>
<div className="flex items-center gap-1">
{isItemExpanded
? <ChevronUp className="text-primary w-3.5 h-3.5" strokeWidth={1.75}/>
: <ChevronDown className="text-primary w-3.5 h-3.5" strokeWidth={1.75}/>
}
<div onClick={function (e: React.MouseEvent): void {
e.stopPropagation();
}}>
<IconButton
icon={Trash2}
variant="danger"
size="sm"
onClick={async function (): Promise<void> {
await onDeleteIncident(actId, item.incidentId);
}}
/>
</div>
</div>
);
})}
</>
) : (
<p className="text-text-secondary text-center text-sm p-2">
{t('noIncidentAdded')}
</p>
)}
<div className="flex items-center mt-2">
<input
type="text"
className="flex-1 bg-secondary/50 text-text-primary rounded-xl px-4 py-2.5 mr-2 border border-secondary/50 focus:outline-none focus:ring-4 focus:ring-primary/20 focus:border-primary hover:bg-secondary hover:border-secondary transition-all duration-200 placeholder:text-muted/60"
</button>
{isItemExpanded && (
<div className="p-3">
{item.chapters && item.chapters.length > 0 ? (
item.chapters.map(function (chapter: ActChapter): React.JSX.Element {
return (
<ActChapterItem
key={`inc-chapter-${chapter.chapterId}-${chapter.chapterInfoId}`}
chapter={chapter}
onUpdateSummary={function (chapterId: string, summary: string): void {
onUpdateChapterSummary(chapterId, summary, item.incidentId);
}}
onUnlink={function (chapterInfoId: string, chapterId: string): Promise<void> {
return onUnlinkChapter(chapterInfoId, chapterId, item.incidentId);
}}
/>
);
})
) : (
<p className="text-text-secondary text-center text-sm p-2">
{t('noLinkedChapter')}
</p>
)}
<InputField
input={
<SelectBox
defaultValue={null}
onChangeCallBack={function (e: React.ChangeEvent<HTMLSelectElement>): void {
setSelectedChapterId(e.target.value);
}}
data={mainChaptersData()}
placeholder={t('selectChapterPlaceholder')}
/>
}
addButtonCallBack={function (): Promise<void> {
return onLinkChapter(actId, selectedChapterId, item.incidentId);
}}
isAddButtonDisabled={selectedChapterId.length === 0}
/>
</div>
)}
</div>
);
})
) : (
<p className="text-text-secondary text-center text-sm p-2">
{t('noIncidentAdded')}
</p>
)}
<InputField
input={
<TextInput
value={newIncidentTitle}
onChange={(e) => setNewIncidentTitle(e.target.value)}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
setNewIncidentTitle(e.target.value);
}}
placeholder={t('newIncidentPlaceholder')}
/>
<button
className="bg-primary text-text-primary w-9 h-9 rounded-full flex items-center justify-center disabled:opacity-50 disabled:cursor-not-allowed shadow-md hover:shadow-lg hover:scale-110 hover:bg-primary-dark transition-all duration-200"
onClick={(): Promise<void> => onAddIncident(actId)}
disabled={newIncidentTitle.trim() === ''}
>
<FontAwesomeIcon icon={faPlus} className="w-3.5 h-3.5"/>
</button>
</div>
</div>
)}
</div>
}
actionIcon={Plus}
addButtonCallBack={function (): Promise<void> {
return onAddIncident(actId);
}}
isAddButtonDisabled={newIncidentTitle.trim() === ''}
/>
</div>
</Collapse>
);
}

View File

@@ -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 (
<div className="mb-4">
<button
className="flex justify-between items-center w-full bg-secondary/50 p-3 rounded-xl text-left hover:bg-secondary transition-all duration-200 shadow-sm"
onClick={(): void => onToggleSection(sectionKey)}
>
<span className="font-bold text-text-primary">{t('plotPointsTitle')}</span>
<FontAwesomeIcon
icon={isExpanded ? faChevronUp : faChevronDown}
className="text-text-primary w-3.5 h-3.5"
/>
</button>
{isExpanded && (
<div className="p-2">
{plotPoints && plotPoints.length > 0 ? (
plotPoints.map((item: PlotPoint) => {
const itemKey = `plotpoint_${item.plotPointId}`;
const isItemExpanded: boolean = expandedItems[itemKey];
const linkedIncident: Incident | undefined = incidents.find(
(inc: Incident): boolean => inc.incidentId === item.linkedIncidentId
);
return (
<div
key={`plot-point-${item.plotPointId}`}
className="bg-secondary/30 rounded-xl mb-3 overflow-hidden border border-secondary/40 shadow-sm hover:shadow-md transition-all duration-200"
<Collapse title={t('plotPointsTitle')} defaultOpen={isExpanded}>
<div className="space-y-3">
{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 (
<div key={`plot-point-${item.plotPointId}`}
className="bg-secondary rounded-xl overflow-hidden">
<button
className="flex justify-between items-center w-full p-3 text-left"
onClick={function (): void {
toggleItem(itemKey);
}}
>
<button
className="flex justify-between items-center w-full p-2 text-left"
onClick={(): void => toggleItem(itemKey)}
>
<div>
<p className="font-bold text-text-primary">{item.title}</p>
{linkedIncident && (
<p className="text-text-secondary text-sm italic">
{t('linkedTo')}: {linkedIncident.title}
</p>
)}
</div>
<div className="flex items-center">
<FontAwesomeIcon
icon={isItemExpanded ? faChevronUp : faChevronDown}
className="text-text-primary w-3.5 h-3.5 mr-2"
/>
<button
onClick={async (e): Promise<void> => {
e.stopPropagation();
<div>
<p className="font-bold text-text-primary">{item.title}</p>
{linkedIncident && (
<p className="text-text-secondary text-sm italic">
{t('linkedTo')}: {linkedIncident.title}
</p>
)}
</div>
<div className="flex items-center gap-1">
{isItemExpanded
? <ChevronUp className="text-primary w-3.5 h-3.5" strokeWidth={1.75}/>
: <ChevronDown className="text-primary w-3.5 h-3.5" strokeWidth={1.75}/>
}
<div onClick={function (e: React.MouseEvent): void {
e.stopPropagation();
}}>
<IconButton
icon={Trash2}
variant="danger"
size="sm"
onClick={async function (): Promise<void> {
await onDeletePlotPoint(actId, item.plotPointId);
}}
className="text-error hover:bg-error/20 p-1.5 rounded-lg transition-all duration-200 hover:scale-110"
>
<FontAwesomeIcon icon={faTrash} className="w-3.5 h-3.5"/>
</button>
/>
</div>
</button>
{isItemExpanded && (
<div className="p-3 bg-secondary/20">
{item.chapters && item.chapters.length > 0 ? (
item.chapters.map((chapter: ActChapter) => (
</div>
</button>
{isItemExpanded && (
<div className="p-3">
{item.chapters && item.chapters.length > 0 ? (
item.chapters.map(function (chapter: ActChapter): React.JSX.Element {
return (
<ActChapterItem
key={`plot-chapter-${chapter.chapterId}-${chapter.chapterInfoId}`}
chapter={chapter}
onUpdateSummary={(chapterId, summary) =>
onUpdateChapterSummary(chapterId, summary, item.plotPointId)
}
onUnlink={(chapterInfoId, chapterId) =>
onUnlinkChapter(chapterInfoId, chapterId, item.plotPointId)
}
onUpdateSummary={function (chapterId: string, summary: string): void {
onUpdateChapterSummary(chapterId, summary, item.plotPointId);
}}
onUnlink={function (chapterInfoId: string, chapterId: string): Promise<void> {
return onUnlinkChapter(chapterInfoId, chapterId, item.plotPointId);
}}
/>
))
) : (
<p className="text-text-secondary text-center text-sm p-2">
{t('noLinkedChapter')}
</p>
)}
<div className="flex items-center mt-2">
<select
onChange={(e) => setSelectedChapterId(e.target.value)}
className="flex-1 bg-secondary/50 text-text-primary rounded-xl px-4 py-2.5 mr-2 border border-secondary/50 focus:outline-none focus:ring-4 focus:ring-primary/20 focus:border-primary hover:bg-secondary hover:border-secondary transition-all duration-200"
>
<option value="">{t('selectChapterPlaceholder')}</option>
{mainChapters.map((chapter: ChapterListProps) => (
<option key={chapter.chapterId} value={chapter.chapterId}>
{`${chapter.chapterOrder}. ${chapter.title}`}
</option>
))}
</select>
<button
className="bg-primary text-text-primary w-9 h-9 rounded-full flex items-center justify-center disabled:opacity-50 disabled:cursor-not-allowed shadow-md hover:shadow-lg hover:scale-110 hover:bg-primary-dark transition-all duration-200"
onClick={() => onLinkChapter(actId, selectedChapterId, item.plotPointId)}
disabled={!selectedChapterId}
>
<FontAwesomeIcon icon={faPlus} className="w-3.5 h-3.5"/>
</button>
</div>
</div>
)}
</div>
);
})
) : (
<p className="text-text-secondary text-center text-sm p-2">
{t('noPlotPointAdded')}
</p>
)}
<div className="mt-2 space-y-2">
<div className="flex items-center">
<input
type="text"
className="flex-1 bg-secondary/50 text-text-primary rounded-xl px-4 py-2.5 border border-secondary/50 focus:outline-none focus:ring-4 focus:ring-primary/20 focus:border-primary hover:bg-secondary hover:border-secondary transition-all duration-200 placeholder:text-muted/60"
value={newPlotPointTitle}
onChange={(e) => setNewPlotPointTitle(e.target.value)}
placeholder={t('newPlotPointPlaceholder')}
);
})
) : (
<p className="text-text-secondary text-center text-sm p-2">
{t('noLinkedChapter')}
</p>
)}
<InputField
input={
<SelectBox
onChangeCallBack={function (e: React.ChangeEvent<HTMLSelectElement>): void {
setSelectedChapterId(e.target.value);
}}
data={mainChapters.map(function (chapter: ChapterListProps): SelectBoxProps {
return {
label: `${chapter.chapterOrder}. ${chapter.title}`,
value: chapter.chapterId,
};
})}
defaultValue={selectedChapterId}
placeholder={t('selectChapterPlaceholder')}
/>
}
addButtonCallBack={function (): Promise<void> {
return onLinkChapter(actId, selectedChapterId, item.plotPointId);
}}
isAddButtonDisabled={!selectedChapterId}
/>
</div>
)}
</div>
);
})
) : (
<p className="text-text-secondary text-center text-sm p-2">
{t('noPlotPointAdded')}
</p>
)}
<div className="space-y-2">
<TextInput
value={newPlotPointTitle}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
setNewPlotPointTitle(e.target.value);
}}
placeholder={t('newPlotPointPlaceholder')}
/>
<InputField
input={
<SelectBox
defaultValue=""
onChangeCallBack={function (e: React.ChangeEvent<HTMLSelectElement>): void {
setSelectedIncidentId(e.target.value);
}}
data={getIncidentData()}
/>
</div>
<InputField
input={
<SelectBox
defaultValue={``}
onChangeCallBack={(e) => setSelectedIncidentId(e.target.value)}
data={getIncidentData()}
/>
}
addButtonCallBack={(): Promise<void> => onAddPlotPoint(actId)}
isAddButtonDisabled={newPlotPointTitle.trim() === ''}
/>
</div>
}
actionIcon={Plus}
addButtonCallBack={function (): Promise<void> {
return onAddPlotPoint(actId);
}}
isAddButtonDisabled={newPlotPointTitle.trim() === ''}
/>
</div>
)}
</div>
</div>
</Collapse>
);
}

View File

@@ -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<LangContextProps>(LangContext);
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
const {addToQueue} = useContext<LocalSyncQueueContextProps>(LocalSyncQueueContext);
const {localSyncedBooks} = useContext<BooksSyncContextProps>(BooksSyncContext);
const {book} = useContext(BookContext);
const {worlds, setWorlds, selectedWorldIndex, isSeriesMode} = useContext(WorldContext);
const {errorMessage} = useContext(AlertContext);
const {session} = useContext(SessionContext);
const {seriesId, localSeries} = useContext<SeriesContextProps>(SeriesContext);
const {localSyncedSeries} = useContext<SeriesSyncContextProps>(SeriesSyncContext);
const [newElementName, setNewElementName] = useState<string>('');
async function handleRemoveElement(
section: keyof WorldProps,
index: number,
): Promise<void> {
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<boolean>('series/world/element/delete', deleteData, session.accessToken, lang);
if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) {
addToQueue('delete_series_world_element', {data: deleteData});
}
}
} else if (isCurrentlyOffline() || book?.localBook) {
response = await tauri.removeWorldElement(elementId, book?.bookId || '', deletedAt);
} else {
response = await System.authDeleteToServer<boolean>('book/world/element/delete', {
elementId, bookId: book?.bookId, deletedAt,
}, 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<void> {
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<string>(
'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 (
<div className="space-y-4">
{Array.isArray(worlds[selectedWorldIndex][sectionType as keyof WorldProps]) &&
(worlds[selectedWorldIndex][sectionType as keyof WorldProps] as WorldElement[]).map(
(element: WorldElement, index: number) => (
<div key={element.id}
className="bg-secondary/30 rounded-xl p-4 border-l-4 border-primary shadow-sm hover:shadow-md transition-all duration-200">
<div className="mb-2">
<InputField input={<TextInput
value={element.name}
setValue={(e: ChangeEvent<HTMLInputElement>) => handleElementChange(sectionType as keyof WorldProps, index, 'name', e.target.value)}
placeholder={t("worldElementComponent.namePlaceholder", {section: sectionLabel.toLowerCase()})}
/>}
removeButtonCallBack={(): Promise<void> => handleRemoveElement(sectionType as keyof WorldProps, index)}/>
</div>
<TexteAreaInput
value={element.description}
setValue={(e) => handleElementChange(sectionType as keyof WorldProps, index, 'description', e.target.value)}
placeholder={t("worldElementComponent.descriptionPlaceholder", {section: sectionLabel.toLowerCase()})}
/>
</div>
)
)
}
<InputField input={<TextInput
value={newElementName}
setValue={(e: ChangeEvent<HTMLInputElement>): void => setNewElementName(e.target.value)}
placeholder={t("worldElementComponent.newPlaceholder", {section: sectionLabel.toLowerCase()})}
/>} addButtonCallBack={(): Promise<void> => handleAddElement(sectionType as keyof WorldProps)}/>
</div>
);
}
'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<WorldElementSection, 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): React.JSX.Element {
const t = useTranslations();
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext);
const {
worlds,
setWorlds,
selectedWorldIndex,
isSeriesMode
}: WorldContextProps = useContext<WorldContextProps>(WorldContext);
const {errorMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
const {book}: BookContextProps = useContext<BookContextProps>(BookContext);
const {isCurrentlyOffline}: OfflineContextType = useContext<OfflineContextType>(OfflineContext);
const [newElementName, setNewElementName] = useState<string>('');
async function handleRemoveElement(
section: WorldElementSection,
index: number,
): Promise<void> {
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<boolean>(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<void> {
if (newElementName.trim() === '') {
errorMessage(t("worldElementComponent.emptyField", {section: sectionLabel}));
return;
}
try {
let elementId: string;
if (isSeriesMode) {
elementId = await apiPost<string>(
'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<string>('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 (
<div className="space-y-4">
{worlds[selectedWorldIndex][sectionType].map(
(element: WorldElement, index: number): React.JSX.Element => (
<div key={element.id}
className="bg-secondary rounded-xl p-4 border-l-4 border-primary transition-colors duration-200">
<div className="mb-2">
<InputField input={<TextInput
value={element.name}
setValue={(e: ChangeEvent<HTMLInputElement>): void => handleElementChange(sectionType, index, 'name', e.target.value)}
placeholder={t("worldElementComponent.namePlaceholder", {section: sectionLabel.toLowerCase()})}
/>}
removeButtonCallBack={(): Promise<void> => handleRemoveElement(sectionType, index)}/>
</div>
<TextAreaInput
value={element.description}
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => handleElementChange(sectionType, index, 'description', e.target.value)}
placeholder={t("worldElementComponent.descriptionPlaceholder", {section: sectionLabel.toLowerCase()})}
/>
</div>
)
)}
<InputField input={<TextInput
value={newElementName}
setValue={(e: ChangeEvent<HTMLInputElement>): void => setNewElementName(e.target.value)}
placeholder={t("worldElementComponent.newPlaceholder", {section: sectionLabel.toLowerCase()})}
/>} addButtonCallBack={(): Promise<void> => handleAddElement(sectionType)}/>
</div>
);
}

View File

@@ -1,36 +1,29 @@
'use client'
import React, {ChangeEvent, forwardRef, useContext, useEffect, useImperativeHandle, useState} from 'react';
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {faPlus, faShare, faToggleOn, IconDefinition} from "@fortawesome/free-solid-svg-icons";
import {LucideIcon, Plus, Share2, ToggleRight} from 'lucide-react';
import {WorldContext} from '@/context/WorldContext';
import {BookContext} from "@/context/BookContext";
import {AlertContext} from "@/context/AlertContext";
import {SelectBoxProps} from "@/shared/interface";
import System from "@/lib/models/System";
import {elementSections, WorldListResponse, WorldProps} from "@/lib/models/World";
import {SessionContext} from "@/context/SessionContext";
import {BookContext, BookContextProps} from "@/context/BookContext";
import {AlertContext, AlertContextProps} from "@/context/AlertContext";
import SelectBox, {SelectBoxProps} from "@/components/form/SelectBox";
import {apiGet, apiPatch, apiPost} from "@/lib/api/client";
import {isDesktop} from '@/lib/configs';
import * as tauri from '@/lib/tauri';
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
import {ElementSection, WorldListResponse, WorldProps, WorldTextField} from "@/lib/types/world";
import {SettingRef} from "@/lib/types/settings";
import {elementSections} from "@/lib/constants/world";
import {SessionContext, SessionContextProps} from "@/context/SessionContext";
import InputField from "@/components/form/InputField";
import TextInput from '@/components/form/TextInput';
import TexteAreaInput from "@/components/form/TexteAreaInput";
import TextAreaInput from "@/components/form/TextAreaInput";
import WorldElementComponent from './WorldElement';
import SelectBox from "@/components/form/SelectBox";
import {useTranslations} from "next-intl";
import {useTranslations} from '@/lib/i18n';
import {LangContext, LangContextProps} from "@/context/LangContext";
import OfflineContext, {OfflineContextType} from "@/context/OfflineContext";
import {LocalSyncQueueContext, LocalSyncQueueContextProps} from "@/context/SyncQueueContext";
import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext";
import {SyncedBook} from "@/lib/models/SyncedBook";
import ToggleSwitch from "@/components/form/ToggleSwitch";
import {SeriesWorldProps, SeriesWorldListItem} from "@/lib/models/Series";
import {SeriesWorldListItem, SeriesWorldProps} from "@/lib/types/series";
import SeriesImportSelector from "@/components/form/SeriesImportSelector";
import SyncFieldWrapper from "@/components/form/SyncFieldWrapper";
import * as tauri from '@/lib/tauri';
export interface ElementSection {
title: string;
section: keyof WorldProps;
icon: IconDefinition;
}
import Button from "@/components/ui/Button";
interface WorldSettingProps {
showToggle?: boolean;
@@ -38,16 +31,14 @@ interface WorldSettingProps {
entityId?: string;
}
export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSave: () => Promise<void> }>) {
export function WorldSetting(props: WorldSettingProps, ref: React.ForwardedRef<SettingRef>): React.JSX.Element {
const {showToggle = true, entityType = 'book', entityId} = props;
const t = useTranslations();
const {lang} = useContext<LangContextProps>(LangContext);
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
const {addToQueue} = useContext<LocalSyncQueueContextProps>(LocalSyncQueueContext);
const {localSyncedBooks} = useContext<BooksSyncContextProps>(BooksSyncContext);
const {errorMessage, successMessage} = useContext(AlertContext);
const {session} = useContext(SessionContext);
const {book, setBook} = useContext(BookContext);
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext);
const {errorMessage, successMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
const {book, setBook}: BookContextProps = useContext<BookContextProps>(BookContext);
const {isCurrentlyOffline}: OfflineContextType = useContext<OfflineContextType>(OfflineContext);
const currentEntityId: string = entityId || book?.bookId || '';
const isSeriesMode: boolean = entityType === 'series';
@@ -60,29 +51,27 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa
const [showAddNewWorld, setShowAddNewWorld] = useState<boolean>(false);
const [toolEnabled, setToolEnabled] = useState<boolean>(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<void> {
if (!bookSeriesId || isCurrentlyOffline()) return;
if (!bookSeriesId) return;
try {
const response: SeriesWorldProps[] = await System.authGetQueryToServer<SeriesWorldProps[]>('series/world/list', session.accessToken, lang, {
const response: SeriesWorldProps[] = await apiGet<SeriesWorldProps[]>('series/world/list', session.accessToken, lang, {
seriesid: bookSeriesId
});
if (response) {
@@ -90,7 +79,7 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa
}
} catch (e: unknown) {
if (e instanceof Error) {
console.error('Error loading series worlds:', e.message);
errorMessage(e.message);
}
}
}
@@ -99,21 +88,14 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa
if (isSeriesMode) return;
try {
let response: boolean;
if (isCurrentlyOffline() || book?.localBook) {
if (isDesktop && (isCurrentlyOffline() || book?.localBook)) {
response = await tauri.updateBookToolSetting(currentEntityId, 'worlds', enabled);
} else {
response = await System.authPatchToServer<boolean>('book/tool-setting', {
response = await apiPatch<boolean>('book/tool-setting', {
bookId: currentEntityId,
toolName: 'worlds',
enabled: enabled
}, session.accessToken, lang);
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) {
addToQueue('update_book_tool_setting', {data: {
bookId: currentEntityId,
toolName: 'worlds',
enabled: enabled
}});
}
}
if (response && setBook && book) {
setToolEnabled(enabled);
@@ -132,12 +114,11 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa
}
}
}
async function getWorlds(): Promise<void> {
try {
if (isSeriesMode) {
// Series mode: server only (series are not local)
const response: SeriesWorldProps[] = await System.authGetQueryToServer<SeriesWorldProps[]>('series/world/list', session.accessToken, lang, {
const response: SeriesWorldProps[] = await apiGet<SeriesWorldProps[]>('series/world/list', session.accessToken, lang, {
seriesid: currentEntityId
});
if (response) {
@@ -169,16 +150,31 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa
}));
setWorldsSelector(formattedWorlds);
}
} else {
// Book mode: dual offline/online logic
let response: WorldListResponse;
if (isCurrentlyOffline() || book?.localBook) {
response = await tauri.getWorlds(currentEntityId, true);
} else {
response = await System.authGetQueryToServer<WorldListResponse>('book/worlds', session.accessToken, lang, {
bookid: currentEntityId,
});
} else if (isDesktop && (isCurrentlyOffline() || book?.localBook)) {
const response: WorldListResponse = await tauri.getWorlds(currentEntityId, toolEnabled) as WorldListResponse;
if (response) {
setWorlds(response.worlds);
setToolEnabled(response.enabled);
if (setBook && book) {
setBook({
...book, tools: {
characters: book.tools?.characters ?? false,
worlds: response.enabled,
locations: book.tools?.locations ?? false,
spells: book.tools?.spells ?? false
}
});
}
const formattedWorlds: SelectBoxProps[] = response.worlds.map(
(world: WorldProps): SelectBoxProps => ({
label: world.name,
value: world.id.toString(),
}),
);
setWorldsSelector(formattedWorlds);
}
} else {
const response: WorldListResponse = await apiGet<WorldListResponse>('book/worlds', session.accessToken, lang, {bookid: currentEntityId});
if (response) {
setWorlds(response.worlds);
setToolEnabled(response.enabled);
@@ -209,7 +205,7 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa
}
}
}
async function handleAddNewWorld(): Promise<void> {
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<string>(
newWorldId = await apiPost<string>(
'series/world/add',
{
seriesId: currentEntityId,
@@ -232,30 +227,22 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa
errorMessage(t("worldSetting.addWorldError"));
return;
}
} else if (isCurrentlyOffline() || book?.localBook) {
// Book mode: offline/local
} else if (isDesktop && (isCurrentlyOffline() || book?.localBook)) {
newWorldId = await tauri.addWorld(currentEntityId, newWorldName);
if (!newWorldId) {
errorMessage(t("worldSetting.addWorldError"));
return;
}
} else {
// Book mode: online
newWorldId = await System.authPostToServer<string>('book/world/add', {
const worldId: string = await apiPost<string>('book/world/add', {
worldName: newWorldName,
bookId: currentEntityId,
}, session.accessToken, lang);
if (!newWorldId) {
if (!worldId) {
errorMessage(t("worldSetting.addWorldError"));
return;
}
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) {
addToQueue('add_world', {data: {
worldName: newWorldName,
worldId: newWorldId,
bookId: currentEntityId,
}});
}
newWorldId = worldId;
}
const newWorld: WorldProps = {
id: newWorldId,
@@ -298,11 +285,8 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa
if (worlds.length === 0) return;
try {
const currentWorld: WorldProps = worlds[selectedWorldIndex];
let response: boolean;
if (isSeriesMode) {
// Series mode: server only
response = await System.authPatchToServer<boolean>('series/world/update', {
const response: boolean = await apiPatch<boolean>('series/world/update', {
worldId: currentWorld.id,
name: currentWorld.name,
history: currentWorld.history,
@@ -311,27 +295,26 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa
religion: currentWorld.religion,
languages: currentWorld.languages,
}, session.accessToken, lang);
} else if (isCurrentlyOffline() || book?.localBook) {
// Book mode: offline/local
response = await tauri.updateWorld(currentWorld);
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<boolean>('book/world/update', {
const response: boolean = await apiPatch<boolean>('book/world/update', {
world: currentWorld,
bookId: currentEntityId,
}, session.accessToken, lang);
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) {
addToQueue('update_world', {data: {
world: currentWorld,
bookId: currentEntityId,
}});
if (!response) {
errorMessage(t("worldSetting.updateWorldError"));
return;
}
}
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<void> {
if (isCurrentlyOffline()) return;
const selectedWorld: WorldProps | undefined = worlds[selectedWorldIndex];
if (!selectedWorld || !bookSeriesId) return;
try {
const seriesWorldId: string = await System.authPostToServer<string>('series/world/add', {
const seriesWorldId: string = await apiPost<string>('series/world/add', {
seriesId: bookSeriesId,
world: {
name: selectedWorld.name,
@@ -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<boolean>('book/world/update', {
const updateResponse: boolean = await apiPost<boolean>('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<void> {
if (isCurrentlyOffline()) return;
const seriesWorld: SeriesWorldListItem | undefined = seriesWorlds.find((w) => w.id === seriesWorldId);
const seriesWorld: SeriesWorldListItem | undefined = seriesWorlds.find((w: SeriesWorldListItem): boolean => w.id === seriesWorldId);
if (!seriesWorld) return;
try {
const worldId: string = await System.authPostToServer<string>('book/world/add', {
const worldId: string = await apiPost<string>('book/world/add', {
worldName: seriesWorld.name,
bookId: currentEntityId,
seriesWorldId: seriesWorldId,
}, 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 (
<div className="space-y-6">
{showToggle && !isSeriesMode && (
<div className="bg-secondary/20 rounded-xl p-5 shadow-inner border border-secondary/30">
<div className="bg-secondary rounded-xl p-4">
<InputField
icon={faToggleOn}
icon={ToggleRight}
fieldName={t('worldSetting.enableTool')}
input={
<ToggleSwitch
@@ -483,25 +455,26 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa
)}
{(toolEnabled || isSeriesMode) && (
<>
{!isSeriesMode && bookSeriesId && !isCurrentlyOffline() &&
{!isSeriesMode && bookSeriesId &&
seriesWorlds.filter((seriesWorld: SeriesWorldProps): boolean => !worlds.some((world: WorldProps): boolean => world.seriesWorldId === seriesWorld.id)).length > 0 && (
<SeriesImportSelector
availableItems={seriesWorlds
.filter((seriesWorld: SeriesWorldProps): boolean => !worlds.some((world: WorldProps): boolean => world.seriesWorldId === seriesWorld.id))
.map((seriesWorld: SeriesWorldProps) => ({id: seriesWorld.id, name: seriesWorld.name}))}
.map((seriesWorld: SeriesWorldProps): { id: string; name: string } => ({
id: seriesWorld.id,
name: seriesWorld.name
}))}
onImport={handleImportFromSeries}
placeholder={t("seriesImport.selectElement")}
label={t("seriesImport.importFromSeries")}
/>
)}
<div
className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-lg">
<div className="grid grid-cols-1 gap-4 mb-4">
<InputField
fieldName={t("worldSetting.selectWorld")}
input={
<SelectBox
onChangeCallBack={(e) => handleWorldSelect(e.target.value)}
onChangeCallBack={(e: ChangeEvent<HTMLSelectElement>): void => handleWorldSelect(e.target.value)}
data={worldsSelector.length > 0 ? worldsSelector : [{
label: t("worldSetting.noWorldAvailable"),
value: '0'
@@ -510,9 +483,9 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa
placeholder={t("worldSetting.selectWorldPlaceholder")}
/>
}
actionIcon={faPlus}
actionIcon={Plus}
actionLabel={t("worldSetting.addWorldLabel")}
action={async () => setShowAddNewWorld(!showAddNewWorld)}
action={async (): Promise<void> => setShowAddNewWorld(!showAddNewWorld)}
/>
{showAddNewWorld && (
@@ -520,31 +493,25 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa
input={
<TextInput
value={newWorldName}
setValue={(e: ChangeEvent<HTMLInputElement>) => setNewWorldName(e.target.value)}
setValue={(e: ChangeEvent<HTMLInputElement>): void => setNewWorldName(e.target.value)}
placeholder={t("worldSetting.newWorldPlaceholder")}
/>
}
actionIcon={faPlus}
actionIcon={Plus}
actionLabel={t("worldSetting.createWorldLabel")}
addButtonCallBack={handleAddNewWorld}
/>
)}
{!isSeriesMode && bookSeriesId && !isCurrentlyOffline() && worlds[selectedWorldIndex] && !worlds[selectedWorldIndex].seriesWorldId && (
<button
onClick={handleExportToSeries}
className="flex items-center gap-2 px-4 py-2 bg-blue-500/90 hover:bg-blue-500 rounded-xl border border-blue-600 shadow-md hover:shadow-lg transition-all duration-200"
>
<FontAwesomeIcon icon={faShare} className="w-4 h-4 text-text-primary"/>
<span className="text-text-primary font-medium">{t("worldSetting.exportToSeries")}</span>
</button>
{!isSeriesMode && bookSeriesId && worlds[selectedWorldIndex] && !worlds[selectedWorldIndex].seriesWorldId && (
<Button variant="secondary" size="sm" icon={Share2} onClick={handleExportToSeries}>
{t("worldSetting.exportToSeries")}
</Button>
)}
</div>
</div>
{worlds.length > 0 && worlds[selectedWorldIndex] ? (
<WorldContext.Provider value={{worlds, setWorlds, selectedWorldIndex, isSeriesMode}}>
<div
className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-lg">
<div className="space-y-6">
<div className="mb-4">
<InputField
fieldName={t("worldSetting.worldName")}
@@ -556,8 +523,8 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa
bookElementId={worlds[selectedWorldIndex].id}
field="name"
elementType="world"
onDownload={() => {
const seriesWorld = getSeriesWorldForCurrentWorld();
onDownload={(): void => {
const seriesWorld: SeriesWorldProps | null = getSeriesWorldForCurrentWorld();
if (seriesWorld) {
const updatedWorlds: WorldProps[] = [...worlds];
updatedWorlds[selectedWorldIndex].name = seriesWorld.name;
@@ -568,7 +535,7 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa
>
<TextInput
value={worlds[selectedWorldIndex].name}
setValue={(e: ChangeEvent<HTMLInputElement>) => {
setValue={(e: ChangeEvent<HTMLInputElement>): void => {
const updatedWorlds: WorldProps[] = [...worlds];
updatedWorlds[selectedWorldIndex].name = e.target.value
setWorlds(updatedWorlds);
@@ -589,24 +556,21 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa
bookElementId={worlds[selectedWorldIndex].id}
field="history"
elementType="world"
onDownload={() => {
const seriesWorld = getSeriesWorldForCurrentWorld();
onDownload={(): void => {
const seriesWorld: SeriesWorldProps | null = getSeriesWorldForCurrentWorld();
if (seriesWorld) handleInputChange(seriesWorld.history || '', 'history');
}}
onSyncComplete={getSeriesWorlds}
>
<TexteAreaInput
<TextAreaInput
value={worlds[selectedWorldIndex].history || ''}
setValue={(e) => handleInputChange(e.target.value, 'history')}
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => handleInputChange(e.target.value, 'history')}
placeholder={t("worldSetting.worldHistoryPlaceholder")}
/>
</SyncFieldWrapper>
}
/>
</div>
<div
className="bg-tertiary/90 backdrop-blur-sm rounded-xl shadow-lg p-4 border border-secondary/50">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<InputField
fieldName={t("worldSetting.politics")}
@@ -618,15 +582,15 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa
bookElementId={worlds[selectedWorldIndex].id}
field="politics"
elementType="world"
onDownload={() => {
const seriesWorld = getSeriesWorldForCurrentWorld();
onDownload={(): void => {
const seriesWorld: SeriesWorldProps | null = getSeriesWorldForCurrentWorld();
if (seriesWorld) handleInputChange(seriesWorld.politics || '', 'politics');
}}
onSyncComplete={getSeriesWorlds}
>
<TexteAreaInput
<TextAreaInput
value={worlds[selectedWorldIndex].politics || ''}
setValue={(e) => handleInputChange(e.target.value, 'politics')}
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => handleInputChange(e.target.value, 'politics')}
placeholder={t("worldSetting.politicsPlaceholder")}
/>
</SyncFieldWrapper>
@@ -642,25 +606,22 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa
bookElementId={worlds[selectedWorldIndex].id}
field="economy"
elementType="world"
onDownload={() => {
const seriesWorld = getSeriesWorldForCurrentWorld();
onDownload={(): void => {
const seriesWorld: SeriesWorldProps | null = getSeriesWorldForCurrentWorld();
if (seriesWorld) handleInputChange(seriesWorld.economy || '', 'economy');
}}
onSyncComplete={getSeriesWorlds}
>
<TexteAreaInput
<TextAreaInput
value={worlds[selectedWorldIndex].economy || ''}
setValue={(e) => handleInputChange(e.target.value, 'economy')}
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => handleInputChange(e.target.value, 'economy')}
placeholder={t("worldSetting.economyPlaceholder")}
/>
</SyncFieldWrapper>
}
/>
</div>
</div>
<div
className="bg-tertiary/90 backdrop-blur-sm rounded-xl shadow-lg p-4 border border-secondary/50">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<InputField
fieldName={t("worldSetting.religion")}
@@ -672,15 +633,15 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa
bookElementId={worlds[selectedWorldIndex].id}
field="religion"
elementType="world"
onDownload={() => {
const seriesWorld = getSeriesWorldForCurrentWorld();
onDownload={(): void => {
const seriesWorld: SeriesWorldProps | null = getSeriesWorldForCurrentWorld();
if (seriesWorld) handleInputChange(seriesWorld.religion || '', 'religion');
}}
onSyncComplete={getSeriesWorlds}
>
<TexteAreaInput
<TextAreaInput
value={worlds[selectedWorldIndex].religion || ''}
setValue={(e) => handleInputChange(e.target.value, 'religion')}
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => handleInputChange(e.target.value, 'religion')}
placeholder={t("worldSetting.religionPlaceholder")}
/>
</SyncFieldWrapper>
@@ -696,31 +657,31 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa
bookElementId={worlds[selectedWorldIndex].id}
field="languages"
elementType="world"
onDownload={() => {
const seriesWorld = getSeriesWorldForCurrentWorld();
onDownload={(): void => {
const seriesWorld: SeriesWorldProps | null = getSeriesWorldForCurrentWorld();
if (seriesWorld) handleInputChange(seriesWorld.languages || '', 'languages');
}}
onSyncComplete={getSeriesWorlds}
>
<TexteAreaInput
<TextAreaInput
value={worlds[selectedWorldIndex].languages || ''}
setValue={(e) => handleInputChange(e.target.value, 'languages')}
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => handleInputChange(e.target.value, 'languages')}
placeholder={t("worldSetting.languagesPlaceholder")}
/>
</SyncFieldWrapper>
}
/>
</div>
</div>
{elementSections.map((section, index) => (
<div key={index}
className="bg-tertiary/90 backdrop-blur-sm rounded-xl shadow-lg p-4 border border-secondary/50">
{elementSections.map((section: ElementSection, index: number): React.JSX.Element => {
const SectionIcon: LucideIcon = section.icon;
return (
<div key={index}>
<h3 className="text-lg font-semibold text-text-primary mb-4 flex items-center">
<FontAwesomeIcon icon={section.icon} className="mr-2 w-5 h-5"/>
<SectionIcon className="mr-2 w-5 h-5" strokeWidth={1.75}/>
{section.title}
<span
className="ml-2 text-sm bg-dark-background text-text-secondary py-0.5 px-2 rounded-full">
className="ml-2 text-sm bg-secondary text-text-secondary py-0.5 px-2 rounded-full">
{worlds[selectedWorldIndex][section.section]?.length || 0}
</span>
</h3>
@@ -728,13 +689,14 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa
sectionLabel={section.title}
sectionType={section.section}
/>
</div>
))}
</div>
);
})}
</div>
</WorldContext.Provider>
) : (
<div
className="bg-tertiary/90 backdrop-blur-sm rounded-xl shadow-lg p-8 border border-secondary/50 text-center">
<p className="text-text-secondary mb-4">{t("worldSetting.noWorldAvailable")}</p>
<div className="text-center py-8">
<p className="text-text-secondary">{t("worldSetting.noWorldAvailable")}</p>
</div>
)}
</>
@@ -743,4 +705,4 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa
);
}
export default forwardRef(WorldSetting);
export default forwardRef<SettingRef, WorldSettingProps>(WorldSetting);

View File

@@ -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<BookContextProps>(BookContext);
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean>(false);
const [showAddForm, setShowAddForm] = useState<boolean>(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<void> {
if (newWorldName.trim()) {
@@ -87,26 +86,22 @@ export default function WorldEditor(): React.JSX.Element {
setShowAddForm(true);
}
}
async function handleSave(): Promise<void> {
await exitEditMode(true);
}
function handleCancel(): void {
exitEditMode(false);
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<FontAwesomeIcon icon={faSpinner} className="w-6 h-6 text-primary animate-spin"/>
</div>
);
return <PulseLoader size="sm"/>;
}
const selectedWorld: WorldProps | undefined = worlds[selectedWorldIndex];
const canExport: boolean = Boolean(bookSeriesId && selectedWorld && !selectedWorld.seriesWorldId);
return (
<div className="flex flex-col h-full">
<ToolDetailHeader
@@ -122,70 +117,52 @@ export default function WorldEditor(): React.JSX.Element {
showExport={canExport}
showDelete={false}
/>
<div className="flex-1 overflow-y-auto">
{viewMode === 'list' && (
<div className="space-y-3 p-2">
{/* Toggle tool */}
<div className="bg-secondary/20 rounded-lg p-3 border border-secondary/30">
<InputField
icon={faToggleOn}
fieldName={t('worldSetting.enableTool')}
input={
<ToggleSwitch
checked={toolEnabled}
onChange={toggleTool}
/>
}
{/* Import from series */}
{bookSeriesId && availableSeriesWorlds.length > 0 && (
<SeriesImportSelector
availableItems={availableSeriesWorlds.map(function (sw: SeriesWorldProps) {
return {id: sw.id, name: sw.name};
})}
onImport={importFromSeries}
placeholder={t('seriesImport.selectElement')}
label={t('seriesImport.importFromSeries')}
/>
</div>
{toolEnabled && (
<>
{/* Import from series */}
{bookSeriesId && availableSeriesWorlds.length > 0 && (
<SeriesImportSelector
availableItems={availableSeriesWorlds.map(function (sw: SeriesWorldProps) {
return {id: sw.id, name: sw.name};
})}
onImport={importFromSeries}
placeholder={t('seriesImport.selectElement')}
label={t('seriesImport.importFromSeries')}
/>
)}
{showAddForm && (
<div className="px-2">
<InputField
input={
<TextInput
value={newWorldName}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
setNewWorldName(e.target.value);
}}
placeholder={t('worldSetting.newWorldPlaceholder')}
/>
}
actionIcon={faPlus}
actionLabel={t('worldSetting.createWorldLabel')}
addButtonCallBack={async function (): Promise<void> {
await addNewWorld();
setShowAddForm(false);
}}
/>
</div>
)}
<WorldEditorList
worlds={worlds}
onWorldClick={handleWorldClick}
onAddWorld={handleAddWorld}
/>
</>
)}
{showAddForm && (
<div className="px-2">
<InputField
input={
<TextInput
value={newWorldName}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
setNewWorldName(e.target.value);
}}
placeholder={t('worldSetting.newWorldPlaceholder')}
/>
}
actionIcon={Plus}
actionLabel={t('worldSetting.createWorldLabel')}
addButtonCallBack={async function (): Promise<void> {
await addNewWorld();
setShowAddForm(false);
}}
/>
</div>
)}
<WorldEditorList
worlds={worlds}
onWorldClick={handleWorldClick}
onAddWorld={handleAddWorld}
/>
</div>
)}
{viewMode === 'detail' && selectedWorld && (
<div className="p-4">
<WorldEditorDetail
@@ -194,7 +171,7 @@ export default function WorldEditor(): React.JSX.Element {
/>
</div>
)}
{viewMode === 'edit' && selectedWorld && (
<div className="p-4">
<WorldEditorEdit
@@ -209,7 +186,7 @@ export default function WorldEditor(): React.JSX.Element {
</div>
)}
</div>
{showDeleteConfirm && selectedWorld && (
<AlertBox
title={t('worldSetting.deleteTitle')}
@@ -217,8 +194,12 @@ export default function WorldEditor(): React.JSX.Element {
type="danger"
confirmText={t('common.delete')}
cancelText={t('common.cancel')}
onConfirm={async function (): Promise<void> { setShowDeleteConfirm(false); }}
onCancel={function (): void { setShowDeleteConfirm(false); }}
onConfirm={async function (): Promise<void> {
setShowDeleteConfirm(false);
}}
onCancel={function (): void {
setShowDeleteConfirm(false);
}}
/>
)}
</div>

View File

@@ -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 (
<div className="mb-3">
<span className="text-text-secondary text-xs block mb-1">{label}</span>
<p className="text-text-primary text-sm whitespace-pre-wrap">{value}</p>
</div>
);
}
function renderElementSection(section: ElementSection): React.JSX.Element | null {
const elements: WorldElement[] = world[section.section] as WorldElement[];
const elements: WorldElement[] = world[section.section];
if (!elements || elements.length === 0) return null;
return (
<div key={section.section} className="border-b border-secondary/30 pb-3">
<div key={section.section} className="border-b border-secondary pb-3">
<h4 className="text-text-primary font-medium text-sm mb-2">{section.title}</h4>
<div className="space-y-2">
{elements.map(function (element: WorldElement): React.JSX.Element {
return (
<div key={element.id} className="bg-secondary/20 rounded-lg p-2">
<div key={element.id} className="bg-tertiary rounded-lg p-2">
<p className="text-text-primary font-medium text-sm">{element.name}</p>
{element.description && (
<p className="text-text-secondary text-xs mt-1">{element.description}</p>
@@ -51,33 +43,33 @@ export default function WorldEditorDetail({
</div>
);
}
return (
<div className="space-y-4">
{/* Informations de base */}
<div className="border-b border-secondary/30 pb-3">
<div className="border-b border-secondary pb-3">
<h3 className="text-text-primary font-semibold text-base mb-3">{world.name}</h3>
{renderField(t('worldSetting.worldHistory'), world.history)}
<DetailField variant="compact" label={t('worldSetting.worldHistory')} value={world.history}/>
</div>
{/* Politique et économie */}
{(world.politics || world.economy) && (
<div className="border-b border-secondary/30 pb-3">
<div className="border-b border-secondary pb-3">
<h4 className="text-text-primary font-medium text-sm mb-2">{t('worldSetting.politicsEconomy')}</h4>
{renderField(t('worldSetting.politics'), world.politics)}
{renderField(t('worldSetting.economy'), world.economy)}
<DetailField variant="compact" label={t('worldSetting.politics')} value={world.politics}/>
<DetailField variant="compact" label={t('worldSetting.economy')} value={world.economy}/>
</div>
)}
{/* Religion et langues */}
{(world.religion || world.languages) && (
<div className="border-b border-secondary/30 pb-3">
<div className="border-b border-secondary pb-3">
<h4 className="text-text-primary font-medium text-sm mb-2">{t('worldSetting.cultureLanguages')}</h4>
{renderField(t('worldSetting.religion'), world.religion)}
{renderField(t('worldSetting.languages'), world.languages)}
<DetailField variant="compact" label={t('worldSetting.religion')} value={world.religion}/>
<DetailField variant="compact" label={t('worldSetting.languages')} value={world.languages}/>
</div>
)}
{/* Sections d'éléments */}
{elementSections.map(function (section: ElementSection): React.JSX.Element | null {
return renderElementSection(section);

View File

@@ -1,21 +1,22 @@
'use client';
import React, {ChangeEvent, Dispatch, SetStateAction} from 'react';
import {WorldProps, elementSections, ElementSection} from '@/lib/models/World';
import {SeriesWorldProps} from '@/lib/models/Series';
import {ElementSection, WorldProps, WorldTextField} from '@/lib/types/world';
import {elementSections} from '@/lib/constants/world';
import {SeriesWorldProps} from '@/lib/types/series';
import {WorldContext} from '@/context/WorldContext';
import InputField from '@/components/form/InputField';
import TextInput from '@/components/form/TextInput';
import TexteAreaInput from '@/components/form/TexteAreaInput';
import TextAreaInput from '@/components/form/TextAreaInput';
import SyncFieldWrapper from '@/components/form/SyncFieldWrapper';
import WorldElementComponent from '@/components/book/settings/world/WorldElement';
import {useTranslations} from 'next-intl';
import {useTranslations} from '@/lib/i18n';
interface WorldEditorEditProps {
world: WorldProps;
worlds: WorldProps[];
selectedWorldIndex: number;
setWorlds: Dispatch<SetStateAction<WorldProps[]>>;
onWorldFieldChange: (field: keyof WorldProps, value: string) => void;
onWorldFieldChange: (field: WorldTextField, value: string) => void;
seriesWorld?: SeriesWorldProps | null;
onSyncComplete?: () => void;
}
@@ -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 (
<WorldContext.Provider value={{worlds, setWorlds, selectedWorldIndex, isSeriesMode: false}}>
<div className="space-y-4">
{/* Informations de base */}
<div className="border-b border-secondary/30 pb-3">
<div className="border-b border-secondary pb-3">
<h4 className="text-text-primary font-medium text-sm mb-3">{t('worldSetting.basicInfo')}</h4>
<div className="space-y-3">
<InputField
@@ -74,7 +75,7 @@ export default function WorldEditorEdit({
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t("worldSetting.worldHistory")}
input={
@@ -90,7 +91,7 @@ export default function WorldEditorEdit({
}}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
<TextAreaInput
value={world.history || ''}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onWorldFieldChange('history', e.target.value);
@@ -102,9 +103,9 @@ export default function WorldEditorEdit({
/>
</div>
</div>
{/* Politique et économie */}
<div className="border-b border-secondary/30 pb-3">
<div className="border-b border-secondary pb-3">
<h4 className="text-text-primary font-medium text-sm mb-3">{t('worldSetting.politicsEconomy')}</h4>
<div className="space-y-3">
<InputField
@@ -122,7 +123,7 @@ export default function WorldEditorEdit({
}}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
<TextAreaInput
value={world.politics || ''}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onWorldFieldChange('politics', e.target.value);
@@ -132,7 +133,7 @@ export default function WorldEditorEdit({
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t("worldSetting.economy")}
input={
@@ -148,7 +149,7 @@ export default function WorldEditorEdit({
}}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
<TextAreaInput
value={world.economy || ''}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onWorldFieldChange('economy', e.target.value);
@@ -160,9 +161,9 @@ export default function WorldEditorEdit({
/>
</div>
</div>
{/* Religion et langues */}
<div className="border-b border-secondary/30 pb-3">
<div className="border-b border-secondary pb-3">
<h4 className="text-text-primary font-medium text-sm mb-3">{t('worldSetting.cultureLanguages')}</h4>
<div className="space-y-3">
<InputField
@@ -180,7 +181,7 @@ export default function WorldEditorEdit({
}}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
<TextAreaInput
value={world.religion || ''}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onWorldFieldChange('religion', e.target.value);
@@ -190,7 +191,7 @@ export default function WorldEditorEdit({
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t("worldSetting.languages")}
input={
@@ -206,7 +207,7 @@ export default function WorldEditorEdit({
}}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
<TextAreaInput
value={world.languages || ''}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onWorldFieldChange('languages', e.target.value);
@@ -218,11 +219,11 @@ export default function WorldEditorEdit({
/>
</div>
</div>
{/* Sections d'éléments */}
{elementSections.map(function (section: ElementSection): React.JSX.Element {
return (
<div key={section.section} className="border-b border-secondary/30 pb-3 last:border-b-0">
<div key={section.section} className="border-b border-secondary pb-3 last:border-b-0">
<h4 className="text-text-primary font-medium text-sm mb-3">{section.title}</h4>
<WorldElementComponent
sectionLabel={section.title}

View File

@@ -1,11 +1,13 @@
'use client';
import React, {useState} from 'react';
import {WorldProps} from '@/lib/models/World';
import {WorldProps} from '@/lib/types/world';
import InputField from '@/components/form/InputField';
import TextInput from '@/components/form/TextInput';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faChevronRight, faGlobe, faPlus} from '@fortawesome/free-solid-svg-icons';
import {useTranslations} from 'next-intl';
import {Globe, Plus} from 'lucide-react';
import EmptyState from '@/components/ui/EmptyState';
import EntityListItem from '@/components/ui/EntityListItem';
import AvatarIcon from '@/components/ui/AvatarIcon';
import {useTranslations} from '@/lib/i18n';
interface WorldEditorListProps {
worlds: WorldProps[];
@@ -19,21 +21,21 @@ interface WorldEditorListProps {
* PAS de scroll interne (géré par parent ComposerRightBar)
*/
export default function WorldEditorList({
worlds,
onWorldClick,
onAddWorld,
}: WorldEditorListProps): React.JSX.Element {
worlds,
onWorldClick,
onAddWorld,
}: WorldEditorListProps): React.JSX.Element {
const t = useTranslations();
const [searchQuery, setSearchQuery] = useState<string>('');
function getFilteredWorlds(): WorldProps[] {
return worlds.filter(function (world: WorldProps): boolean {
return world.name.toLowerCase().includes(searchQuery.toLowerCase());
});
}
const filteredWorlds: WorldProps[] = getFilteredWorlds();
return (
<div className="space-y-3">
<div className="px-2">
@@ -47,57 +49,31 @@ export default function WorldEditorList({
placeholder={t('worldSetting.search')}
/>
}
actionIcon={faPlus}
actionIcon={Plus}
actionLabel={t('worldSetting.addWorldLabel')}
addButtonCallBack={async function (): Promise<void> {
onAddWorld();
}}
/>
</div>
<div className="px-2 space-y-2">
{filteredWorlds.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mb-3">
<FontAwesomeIcon icon={faGlobe} className="text-primary w-8 h-8"/>
</div>
<h3 className="text-text-primary font-semibold text-base mb-1">
{t('worldSetting.noWorldAvailable')}
</h3>
<p className="text-muted text-sm max-w-xs">
{t('worldSetting.noWorldDescription')}
</p>
</div>
<EmptyState icon={Globe} title={t('worldSetting.noWorldAvailable')}
description={t('worldSetting.noWorldDescription')}/>
) : (
filteredWorlds.map(function (world: WorldProps): React.JSX.Element {
return (
<div
<EntityListItem
key={world.id}
onClick={function (): void { onWorldClick(world); }}
className="group flex items-center p-3 bg-secondary/30 rounded-lg border-l-4 border-primary border border-secondary/50 cursor-pointer hover:bg-secondary hover:shadow-md transition-all duration-200 hover:border-primary/50"
>
<div className="w-10 h-10 rounded-full border-2 border-primary overflow-hidden bg-secondary shadow-sm group-hover:scale-110 transition-transform flex items-center justify-center">
<FontAwesomeIcon icon={faGlobe} className="text-primary w-5 h-5"/>
</div>
<div className="ml-3 flex-1 min-w-0">
<div className="text-text-primary font-semibold text-sm group-hover:text-primary transition-colors truncate">
{world.name}
</div>
{world.history && (
<div className="text-muted text-xs truncate">
{world.history.substring(0, 50)}{world.history.length > 50 ? '...' : ''}
</div>
)}
</div>
<div className="w-6 flex justify-center">
<FontAwesomeIcon
icon={faChevronRight}
className="text-muted group-hover:text-primary group-hover:translate-x-1 transition-all w-3 h-3"
/>
</div>
</div>
size="sm"
onClick={function (): void {
onWorldClick(world);
}}
avatar={<AvatarIcon size="sm" icon={Globe}/>}
title={world.name}
subtitle={world.history ? world.history.substring(0, 50) + (world.history.length > 50 ? '...' : '') : null}
/>
);
})
)}

View File

@@ -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<BookContextProps>(BookContext);
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean>(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<void> {
await exitEditMode(true);
}
function handleCancel(): void {
exitEditMode(false);
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<FontAwesomeIcon icon={faSpinner} className="w-8 h-8 text-primary animate-spin"/>
</div>
);
return <PulseLoader/>;
}
const selectedWorld: WorldProps | undefined = worlds[selectedWorldIndex];
const canExport: boolean = Boolean(bookSeriesId && selectedWorld && !selectedWorld.seriesWorldId);
return (
<div className="flex flex-col h-full">
{/* Header - uniquement pour detail/edit */}
@@ -119,32 +111,12 @@ export default function WorldSettings({
showExport={canExport}
showDelete={false}
/>
{/* Contenu principal */}
<div className="flex-1 overflow-y-auto">
{viewMode === 'list' && (
<div className="space-y-5 p-4">
{/* Toggle tool */}
{showToggle && !isSeriesMode && (
<div className="bg-secondary/20 rounded-xl p-5 shadow-inner border border-secondary/30">
<InputField
icon={faToggleOn}
fieldName={t('worldSetting.enableTool')}
input={
<ToggleSwitch
checked={toolEnabled}
onChange={toggleTool}
/>
}
/>
<p className="text-muted text-sm mt-2">
{t('worldSetting.enableToolDescription')}
</p>
</div>
)}
{/* Contenu si outil activé */}
{(toolEnabled || isSeriesMode) && (
{((parentToolEnabled !== undefined ? parentToolEnabled : toolEnabled) || isSeriesMode) && (
<>
{/* Import from series */}
{!isSeriesMode && bookSeriesId && availableSeriesWorlds.length > 0 && (
@@ -157,7 +129,7 @@ export default function WorldSettings({
label={t("seriesImport.importFromSeries")}
/>
)}
{/* Liste des mondes */}
<WorldSettingsList
worlds={worlds}
@@ -170,7 +142,7 @@ export default function WorldSettings({
)}
</div>
)}
{viewMode === 'detail' && selectedWorld && (
<div className="p-4">
<WorldSettingsDetail
@@ -179,7 +151,7 @@ export default function WorldSettings({
/>
</div>
)}
{viewMode === 'edit' && selectedWorld && (
<div className="p-4">
<WorldSettingsEdit

View File

@@ -1,19 +1,13 @@
'use client';
import React from 'react';
import {WorldProps, elementSections, ElementSection, WorldElement} from '@/lib/models/World';
import {SeriesWorldProps} from '@/lib/models/Series';
import CollapsableArea from '@/components/CollapsableArea';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {
faGlobe,
faLandmark,
faBook,
faCoins,
faChurch,
faLanguage,
faScroll
} from '@fortawesome/free-solid-svg-icons';
import {useTranslations} from 'next-intl';
import {ElementSection, WorldElement, WorldProps} from '@/lib/types/world';
import {elementSections} from '@/lib/constants/world';
import {SeriesWorldProps} from '@/lib/types/series';
import Collapse from '@/components/ui/Collapse';
import {Church, Coins, Globe, Landmark, Languages, ScrollText} from 'lucide-react';
import {useTranslations} from '@/lib/i18n';
import DetailHeroSection from '@/components/ui/DetailHeroSection';
import DetailField from '@/components/ui/DetailField';
interface WorldSettingsDetailProps {
world: WorldProps;
@@ -21,104 +15,54 @@ interface WorldSettingsDetailProps {
}
export default function WorldSettingsDetail({
world,
seriesWorld,
}: WorldSettingsDetailProps): React.JSX.Element {
world,
seriesWorld,
}: WorldSettingsDetailProps): React.JSX.Element {
const t = useTranslations();
function renderElementSection(section: ElementSection): React.JSX.Element | null {
const elements: WorldElement[] = world[section.section] as WorldElement[];
const elements: WorldElement[] = world[section.section];
if (!elements || elements.length === 0) return null;
return (
<CollapsableArea key={section.section} title={section.title} icon={section.icon}>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
{elements.map(function (element: WorldElement): React.JSX.Element {
return (
<div key={element.id} className="p-4 bg-dark-background/30 rounded-lg border border-secondary/20 hover:border-primary/30 transition-colors">
<h4 className="text-text-primary font-semibold">{element.name}</h4>
{element.description && (
<p className="text-text-secondary text-sm mt-2 line-clamp-3">{element.description}</p>
)}
</div>
);
})}
</div>
</CollapsableArea>
<Collapse variant="card" key={section.section} title={section.title} icon={section.icon}>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{elements.map(function (element: WorldElement): React.JSX.Element {
return (
<div key={element.id}
className="p-4 rounded-lg">
<h4 className="text-text-primary font-semibold">{element.name}</h4>
{element.description && (
<p className="text-text-secondary text-sm mt-2 line-clamp-3">{element.description}</p>
)}
</div>
);
})}
</div>
</Collapse>
);
}
return (
<div className="space-y-6 px-2 pb-4">
{/* Hero Section */}
<div className="p-6 bg-gradient-to-r from-primary/10 via-secondary/20 to-transparent rounded-2xl border border-secondary/30">
<div className="flex items-start gap-4">
<div className="w-16 h-16 rounded-xl bg-primary/20 flex items-center justify-center shrink-0">
<FontAwesomeIcon icon={faGlobe} className="w-8 h-8 text-primary"/>
</div>
<div className="flex-1 min-w-0">
<h2 className="text-2xl font-bold text-text-primary">{world.name || '—'}</h2>
</div>
</div>
</div>
<DetailHeroSection icon={Globe} title={world.name || '—'}/>
{/* Histoire du monde - Full width */}
<div className="p-5 bg-secondary/20 rounded-xl border border-secondary/30">
<div className="flex items-center gap-2 mb-3">
<FontAwesomeIcon icon={faScroll} className="w-4 h-4 text-primary"/>
<h3 className="text-text-primary font-semibold">{t('worldSetting.worldHistory')}</h3>
</div>
<p className={`whitespace-pre-wrap ${world.history ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
{world.history || '—'}
</p>
</div>
<DetailField icon={ScrollText} label={t('worldSetting.worldHistory')} value={world.history}/>
{/* Politique & Économie - Side by side */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="p-5 bg-secondary/20 rounded-xl border border-secondary/30">
<div className="flex items-center gap-2 mb-3">
<FontAwesomeIcon icon={faLandmark} className="w-4 h-4 text-primary"/>
<h3 className="text-text-primary font-semibold">{t('worldSetting.politics')}</h3>
</div>
<p className={`whitespace-pre-wrap ${world.politics ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
{world.politics || '—'}
</p>
</div>
<div className="p-5 bg-secondary/20 rounded-xl border border-secondary/30">
<div className="flex items-center gap-2 mb-3">
<FontAwesomeIcon icon={faCoins} className="w-4 h-4 text-primary"/>
<h3 className="text-text-primary font-semibold">{t('worldSetting.economy')}</h3>
</div>
<p className={`whitespace-pre-wrap ${world.economy ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
{world.economy || '—'}
</p>
</div>
<DetailField icon={Landmark} label={t('worldSetting.politics')} value={world.politics}/>
<DetailField icon={Coins} label={t('worldSetting.economy')} value={world.economy}/>
</div>
{/* Religion & Langues - Side by side */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="p-5 bg-secondary/20 rounded-xl border border-secondary/30">
<div className="flex items-center gap-2 mb-3">
<FontAwesomeIcon icon={faChurch} className="w-4 h-4 text-primary"/>
<h3 className="text-text-primary font-semibold">{t('worldSetting.religion')}</h3>
</div>
<p className={`whitespace-pre-wrap ${world.religion ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
{world.religion || '—'}
</p>
</div>
<div className="p-5 bg-secondary/20 rounded-xl border border-secondary/30">
<div className="flex items-center gap-2 mb-3">
<FontAwesomeIcon icon={faLanguage} className="w-4 h-4 text-primary"/>
<h3 className="text-text-primary font-semibold">{t('worldSetting.languages')}</h3>
</div>
<p className={`whitespace-pre-wrap ${world.languages ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
{world.languages || '—'}
</p>
</div>
<DetailField icon={Church} label={t('worldSetting.religion')} value={world.religion}/>
<DetailField icon={Languages} label={t('worldSetting.languages')} value={world.languages}/>
</div>
{/* Sections d'éléments - Grille de cards */}
{elementSections.map(function (section: ElementSection): React.JSX.Element | null {
return renderElementSection(section);

View File

@@ -1,24 +1,24 @@
'use client';
import React, {ChangeEvent, Dispatch, SetStateAction} from 'react';
import {WorldProps, elementSections, ElementSection} from '@/lib/models/World';
import {SeriesWorldProps} from '@/lib/models/Series';
import {ElementSection, WorldProps, WorldTextField} from '@/lib/types/world';
import {elementSections} from '@/lib/constants/world';
import {SeriesWorldProps} from '@/lib/types/series';
import {WorldContext} from '@/context/WorldContext';
import CollapsableArea from '@/components/CollapsableArea';
import Collapse from '@/components/ui/Collapse';
import InputField from '@/components/form/InputField';
import TextInput from '@/components/form/TextInput';
import TexteAreaInput from '@/components/form/TexteAreaInput';
import TextAreaInput from '@/components/form/TextAreaInput';
import SyncFieldWrapper from '@/components/form/SyncFieldWrapper';
import WorldElementComponent from '@/components/book/settings/world/WorldElement';
import {faBook, faGlobe, faLandmark} from '@fortawesome/free-solid-svg-icons';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {useTranslations} from 'next-intl';
import {Book, Globe, Landmark} from 'lucide-react';
import {useTranslations} from '@/lib/i18n';
interface WorldSettingsEditProps {
world: WorldProps;
worlds: WorldProps[];
selectedWorldIndex: number;
setWorlds: Dispatch<SetStateAction<WorldProps[]>>;
onWorldFieldChange: (field: keyof WorldProps, value: string) => void;
onWorldFieldChange: (field: WorldTextField, value: string) => void;
seriesWorld?: SeriesWorldProps | null;
isSeriesMode: boolean;
onSyncComplete?: () => void;
@@ -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 (
<WorldContext.Provider value={{worlds, setWorlds, selectedWorldIndex, isSeriesMode}}>
<div className="space-y-4 px-2 pb-4">
{/* Informations de base */}
<CollapsableArea title={t('worldSetting.basicInfo')} icon={faGlobe}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<Collapse variant="card" title={t('worldSetting.basicInfo')} icon={Globe}>
<InputField
fieldName={t("worldSetting.worldName")}
input={
@@ -78,7 +77,7 @@ export default function WorldSettingsEdit({
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t("worldSetting.worldHistory")}
input={
@@ -94,7 +93,7 @@ export default function WorldSettingsEdit({
}}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
<TextAreaInput
value={world.history || ''}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onWorldFieldChange('history', e.target.value);
@@ -104,12 +103,10 @@ export default function WorldSettingsEdit({
</SyncFieldWrapper>
}
/>
</div>
</CollapsableArea>
</Collapse>
{/* Politique et économie */}
<CollapsableArea title={t('worldSetting.politicsEconomy')} icon={faLandmark}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<Collapse variant="card" title={t('worldSetting.politicsEconomy')} icon={Landmark}>
<InputField
fieldName={t("worldSetting.politics")}
input={
@@ -125,7 +122,7 @@ export default function WorldSettingsEdit({
}}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
<TextAreaInput
value={world.politics || ''}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onWorldFieldChange('politics', e.target.value);
@@ -135,7 +132,7 @@ export default function WorldSettingsEdit({
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t("worldSetting.economy")}
input={
@@ -151,7 +148,7 @@ export default function WorldSettingsEdit({
}}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
<TextAreaInput
value={world.economy || ''}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onWorldFieldChange('economy', e.target.value);
@@ -161,12 +158,10 @@ export default function WorldSettingsEdit({
</SyncFieldWrapper>
}
/>
</div>
</CollapsableArea>
</Collapse>
{/* Religion et langues */}
<CollapsableArea title={t('worldSetting.cultureLanguages')} icon={faBook}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<Collapse variant="card" title={t('worldSetting.cultureLanguages')} icon={Book}>
<InputField
fieldName={t("worldSetting.religion")}
input={
@@ -182,7 +177,7 @@ export default function WorldSettingsEdit({
}}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
<TextAreaInput
value={world.religion || ''}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onWorldFieldChange('religion', e.target.value);
@@ -192,7 +187,7 @@ export default function WorldSettingsEdit({
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t("worldSetting.languages")}
input={
@@ -208,7 +203,7 @@ export default function WorldSettingsEdit({
}}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
<TextAreaInput
value={world.languages || ''}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onWorldFieldChange('languages', e.target.value);
@@ -218,20 +213,17 @@ export default function WorldSettingsEdit({
</SyncFieldWrapper>
}
/>
</div>
</CollapsableArea>
</Collapse>
{/* Sections d'éléments */}
{elementSections.map(function (section: ElementSection): React.JSX.Element {
return (
<CollapsableArea key={section.section} title={section.title} icon={section.icon}>
<div className="p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<Collapse variant="card" key={section.section} title={section.title} icon={section.icon}>
<WorldElementComponent
sectionLabel={section.title}
sectionType={section.section}
/>
</div>
</CollapsableArea>
</Collapse>
);
})}
</div>

View File

@@ -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<string>('');
function getFilteredWorlds(): WorldProps[] {
return worlds.filter(function (world: WorldProps): boolean {
return world.name.toLowerCase().includes(searchQuery.toLowerCase());
});
}
const filteredWorlds: WorldProps[] = getFilteredWorlds();
return (
<div className="space-y-4">
{/* Recherche et ajout */}
<div className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-lg">
<div className="grid grid-cols-1 gap-4">
<div className="grid grid-cols-1 gap-4">
<InputField
input={
<TextInput
@@ -67,57 +68,29 @@ export default function WorldSettingsList({
placeholder={t('worldSetting.newWorldPlaceholder')}
/>
}
actionIcon={faPlus}
actionIcon={Plus}
actionLabel={t('worldSetting.createWorldLabel')}
addButtonCallBack={onAddWorld}
/>
</div>
</div>
{/* Liste des mondes cliquables */}
<div className="space-y-2 px-2">
{filteredWorlds.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="w-20 h-20 rounded-full bg-primary/10 flex items-center justify-center mb-4">
<FontAwesomeIcon icon={faGlobe} className="text-primary w-10 h-10"/>
</div>
<h3 className="text-text-primary font-semibold text-lg mb-2">
{t('worldSetting.noWorldAvailable')}
</h3>
<p className="text-muted text-sm max-w-xs">
{t('worldSetting.noWorldDescription')}
</p>
</div>
<EmptyState icon={Globe} title={t('worldSetting.noWorldAvailable')}
description={t('worldSetting.noWorldDescription')}/>
) : (
filteredWorlds.map(function (world: WorldProps): React.JSX.Element {
return (
<div
<EntityListItem
key={world.id}
onClick={function (): void { onWorldClick(world.id); }}
className="group flex items-center p-4 bg-secondary/30 rounded-xl border-l-4 border-primary border border-secondary/50 cursor-pointer hover:bg-secondary hover:shadow-md hover:scale-102 transition-all duration-200 hover:border-primary/50"
>
<div className="w-12 h-12 rounded-full border-2 border-primary overflow-hidden bg-secondary shadow-md group-hover:scale-110 transition-transform flex items-center justify-center">
<FontAwesomeIcon icon={faGlobe} className="text-primary w-6 h-6"/>
</div>
<div className="ml-4 flex-1 min-w-0">
<div className="text-text-primary font-bold text-base group-hover:text-primary transition-colors">
{world.name}
</div>
{world.history && (
<div className="text-text-secondary text-sm mt-0.5 truncate">
{world.history.substring(0, 60)}{world.history.length > 60 ? '...' : ''}
</div>
)}
</div>
<div className="w-8 flex justify-center">
<FontAwesomeIcon
icon={faChevronRight}
className="text-muted group-hover:text-primary group-hover:translate-x-1 transition-all w-4 h-4"
/>
</div>
</div>
onClick={function (): void {
onWorldClick(world.id);
}}
avatar={<AvatarIcon icon={Globe}/>}
title={world.name}
subtitle={world.history ? world.history.substring(0, 60) + (world.history.length > 60 ? '...' : '') : null}
/>
);
})
)}