Remove CharacterComponent and CharacterDetail components
- Deleted `CharacterComponent` and `CharacterDetail` files from the project. - Refactored related logic to improve code maintainability and reduce redundancy.
This commit is contained in:
258
components/series/AddNewSeriesForm.tsx
Normal file
258
components/series/AddNewSeriesForm.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
'use client';
|
||||
import React, {ChangeEvent, Dispatch, SetStateAction, useContext, useEffect, useRef, useState} from "react";
|
||||
import {AlertContext} from "@/context/AlertContext";
|
||||
import System from "@/lib/models/System";
|
||||
import {SessionContext} from "@/context/SessionContext";
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {faBook, faCheck, faLayerGroup, faPencilAlt, faX} from "@fortawesome/free-solid-svg-icons";
|
||||
import InputField from "@/components/form/InputField";
|
||||
import TextInput from "@/components/form/TextInput";
|
||||
import TexteAreaInput from "@/components/form/TexteAreaInput";
|
||||
import CancelButton from "@/components/form/CancelButton";
|
||||
import SubmitButtonWLoading from "@/components/form/SubmitButtonWLoading";
|
||||
import {useTranslations} from "next-intl";
|
||||
import {LangContext, LangContextProps} from "@/context/LangContext";
|
||||
import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext";
|
||||
import {SyncedBook} from "@/lib/models/SyncedBook";
|
||||
import OfflineContext, {OfflineContextType} from "@/context/OfflineContext";
|
||||
import {SeriesSyncContext, SeriesSyncContextProps} from "@/context/SeriesSyncContext";
|
||||
import {SyncedSeries, SyncedSeriesBook} from "@/lib/models/SyncedSeries";
|
||||
|
||||
interface AddNewSeriesFormProps {
|
||||
setCloseForm: Dispatch<SetStateAction<boolean>>;
|
||||
onSeriesCreated?: (seriesId: string, name: string) => void;
|
||||
}
|
||||
|
||||
export default function AddNewSeriesForm({setCloseForm, onSeriesCreated}: AddNewSeriesFormProps) {
|
||||
const t = useTranslations();
|
||||
const {lang} = useContext<LangContextProps>(LangContext);
|
||||
const {session} = useContext(SessionContext);
|
||||
const {errorMessage, successMessage} = useContext(AlertContext);
|
||||
const {serverSyncedBooks, setServerSyncedBooks, localSyncedBooks, setLocalSyncedBooks} = useContext<BooksSyncContextProps>(BooksSyncContext);
|
||||
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
|
||||
const {serverSyncedSeries, localSyncedSeries} = useContext<SeriesSyncContextProps>(SeriesSyncContext);
|
||||
|
||||
// Get all bookIds already in a series
|
||||
const booksAlreadyInSeries: Set<string> = new Set(
|
||||
(isCurrentlyOffline() ? localSyncedSeries : serverSyncedSeries)
|
||||
.flatMap((series: SyncedSeries): string[] =>
|
||||
series.books.map((book: SyncedSeriesBook): string => book.bookId)
|
||||
)
|
||||
);
|
||||
const modalRef: React.RefObject<HTMLDivElement | null> = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [name, setName] = useState<string>('');
|
||||
const [description, setDescription] = useState<string>('');
|
||||
const [selectedBookIds, setSelectedBookIds] = useState<string[]>([]);
|
||||
const [isAddingSeries, setIsAddingSeries] = useState<boolean>(false);
|
||||
|
||||
const token: string = session?.accessToken ?? '';
|
||||
|
||||
useEffect((): () => void => {
|
||||
document.body.style.overflow = 'hidden';
|
||||
return (): void => {
|
||||
document.body.style.overflow = 'auto';
|
||||
};
|
||||
}, []);
|
||||
|
||||
function toggleBookSelection(bookId: string): void {
|
||||
setSelectedBookIds((prev: string[]): string[] => {
|
||||
if (prev.includes(bookId)) {
|
||||
return prev.filter((id: string): boolean => id !== bookId);
|
||||
}
|
||||
return [...prev, bookId];
|
||||
});
|
||||
}
|
||||
|
||||
async function handleAddSeries(): Promise<void> {
|
||||
if (!name) {
|
||||
errorMessage(t('addNewSeriesForm.error.nameMissing'));
|
||||
return;
|
||||
}
|
||||
if (name.length < 2) {
|
||||
errorMessage(t('addNewSeriesForm.error.nameTooShort'));
|
||||
return;
|
||||
}
|
||||
if (name.length > 100) {
|
||||
errorMessage(t('addNewSeriesForm.error.nameTooLong'));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAddingSeries(true);
|
||||
try {
|
||||
const createData = {
|
||||
name: name,
|
||||
description: description || null,
|
||||
bookIds: selectedBookIds,
|
||||
};
|
||||
let response: string;
|
||||
|
||||
if (isCurrentlyOffline()) {
|
||||
response = await window.electron.invoke<string>('db:series:create', createData);
|
||||
} else {
|
||||
response = await System.authPostToServer<string>(
|
||||
'series/add',
|
||||
createData,
|
||||
token,
|
||||
lang
|
||||
);
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
errorMessage(t('addNewSeriesForm.error.addingSeries'));
|
||||
setIsAddingSeries(false);
|
||||
return;
|
||||
}
|
||||
successMessage(t('addNewSeriesForm.success'));
|
||||
|
||||
if (selectedBookIds.length > 0) {
|
||||
if (isCurrentlyOffline()) {
|
||||
setLocalSyncedBooks((prev: SyncedBook[]): SyncedBook[] =>
|
||||
prev.map((book: SyncedBook): SyncedBook =>
|
||||
selectedBookIds.includes(book.id)
|
||||
? {...book, seriesId: response}
|
||||
: book
|
||||
)
|
||||
);
|
||||
} else {
|
||||
setServerSyncedBooks((prev: SyncedBook[]): SyncedBook[] =>
|
||||
prev.map((book: SyncedBook): SyncedBook =>
|
||||
selectedBookIds.includes(book.id)
|
||||
? {...book, seriesId: response}
|
||||
: book
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (onSeriesCreated) {
|
||||
onSeriesCreated(response, name);
|
||||
}
|
||||
setIsAddingSeries(false);
|
||||
setCloseForm(false);
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
} else {
|
||||
errorMessage(t('addNewSeriesForm.error.addingSeries'));
|
||||
}
|
||||
setIsAddingSeries(false);
|
||||
}
|
||||
}
|
||||
|
||||
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-2/5 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={faLayerGroup} className="w-6 h-6"/>
|
||||
{t("addNewSeriesForm.title")}
|
||||
</h2>
|
||||
<button
|
||||
className="text-background hover:text-background w-10 h-10 rounded-xl hover:bg-white/20 transition-all duration-200 flex items-center justify-center hover:scale-110"
|
||||
onClick={(): void => setCloseForm(false)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faX} className={'w-5 h-5'}/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-5 overflow-y-auto flex-grow custom-scrollbar">
|
||||
<div className="space-y-6">
|
||||
<InputField icon={faPencilAlt} fieldName={t("addNewSeriesForm.name")} input={
|
||||
<TextInput
|
||||
value={name}
|
||||
setValue={(e: ChangeEvent<HTMLInputElement>): void => setName(e.target.value)}
|
||||
placeholder={t("addNewSeriesForm.namePlaceholder")}
|
||||
/>
|
||||
}/>
|
||||
|
||||
<InputField
|
||||
icon={faPencilAlt}
|
||||
fieldName={`${t("addNewSeriesForm.description")} (${t("addNewSeriesForm.optional")})`}
|
||||
input={
|
||||
<TexteAreaInput
|
||||
value={description}
|
||||
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setDescription(e.target.value)}
|
||||
placeholder={t("addNewSeriesForm.descriptionPlaceholder")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-text-primary">
|
||||
<FontAwesomeIcon icon={faBook} className="w-4 h-4 text-primary"/>
|
||||
<span className="font-medium">{t("addNewSeriesForm.selectBooks")}</span>
|
||||
{selectedBookIds.length > 0 && (
|
||||
<span className="text-sm text-muted">
|
||||
({selectedBookIds.length} {t("addNewSeriesForm.selected")})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(isCurrentlyOffline() ? localSyncedBooks : serverSyncedBooks)
|
||||
.filter((book: SyncedBook): boolean => !booksAlreadyInSeries.has(book.id)).length === 0 ? (
|
||||
<div className="text-center py-6 text-muted">
|
||||
<FontAwesomeIcon icon={faBook} className="w-8 h-8 mb-2 opacity-50"/>
|
||||
<p>{t("addNewSeriesForm.noBooks")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-48 overflow-y-auto rounded-xl border border-secondary/50 bg-secondary/20">
|
||||
{(isCurrentlyOffline() ? localSyncedBooks : serverSyncedBooks)
|
||||
.filter((book: SyncedBook): boolean => !booksAlreadyInSeries.has(book.id))
|
||||
.map((book: SyncedBook) => {
|
||||
const isSelected: boolean = selectedBookIds.includes(book.id);
|
||||
return (
|
||||
<button
|
||||
key={book.id}
|
||||
type="button"
|
||||
onClick={(): void => toggleBookSelection(book.id)}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 transition-all duration-200 border-b border-secondary/30 last:border-b-0 ${
|
||||
isSelected
|
||||
? 'bg-primary/20 hover:bg-primary/30'
|
||||
: 'hover:bg-secondary/50'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-all ${
|
||||
isSelected
|
||||
? 'bg-primary border-primary'
|
||||
: 'border-secondary/50'
|
||||
}`}>
|
||||
{isSelected && (
|
||||
<FontAwesomeIcon icon={faCheck} className="w-3 h-3 text-white"/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 text-left">
|
||||
<div className="text-text-primary font-medium">{book.title}</div>
|
||||
{book.subTitle && (
|
||||
<div className="text-sm text-muted">{book.subTitle}</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</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={handleAddSeries}
|
||||
isLoading={isAddingSeries}
|
||||
text={t("addNewSeriesForm.add")}
|
||||
loadingText={t("addNewSeriesForm.adding")}
|
||||
icon={faLayerGroup}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
components/series/SeriesCard.tsx
Normal file
112
components/series/SeriesCard.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
'use client';
|
||||
import React, {useState} from "react";
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {faCog, faLayerGroup} from "@fortawesome/free-solid-svg-icons";
|
||||
import {BookProps} from "@/lib/models/Book";
|
||||
import BookCard from "@/components/book/BookCard";
|
||||
import {useTranslations} from "next-intl";
|
||||
import {SyncType} from "@/context/BooksSyncContext";
|
||||
import {SeriesSyncType} from "@/context/SeriesSyncContext";
|
||||
import SyncSeries from "@/components/SyncSeries";
|
||||
|
||||
export interface SeriesCardProps {
|
||||
id: string;
|
||||
name: string;
|
||||
coverImage: string | null;
|
||||
books: BookProps[];
|
||||
}
|
||||
|
||||
interface SeriesCardComponentProps {
|
||||
series: SeriesCardProps;
|
||||
onBookClick: (bookId: string) => Promise<void>;
|
||||
onSettingsClick: (seriesId: string) => void;
|
||||
getSyncStatus?: (bookId: string) => SyncType;
|
||||
seriesSyncStatus?: SeriesSyncType;
|
||||
}
|
||||
|
||||
export default function SeriesCard({series, onBookClick, onSettingsClick, getSyncStatus, seriesSyncStatus = 'synced'}: SeriesCardComponentProps) {
|
||||
const t = useTranslations();
|
||||
const [isExpanded, setIsExpanded] = useState<boolean>(false);
|
||||
|
||||
return (
|
||||
<div className="flex flex-shrink-0 p-2">
|
||||
<div
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className={`group bg-tertiary/90 backdrop-blur-sm shadow-lg hover:shadow-2xl transition-all duration-300 border-2 border-primary/50 hover:border-primary flex flex-col cursor-pointer flex-shrink-0 w-64 sm:w-52 md:w-48 lg:w-56 xl:w-64 ${isExpanded ? 'rounded-l-2xl border-r-0' : 'rounded-2xl'}`}
|
||||
>
|
||||
<div className="relative w-full aspect-[2/3] flex-shrink-0 overflow-hidden rounded-t-2xl">
|
||||
{series.coverImage ? (
|
||||
<img
|
||||
src={series.coverImage}
|
||||
alt={series.name}
|
||||
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110 opacity-80"
|
||||
/>
|
||||
) : (
|
||||
<div className="relative w-full h-full bg-gradient-to-br from-primary/30 via-primary/20 to-secondary flex items-center justify-center overflow-hidden">
|
||||
<div className="absolute inset-0 bg-primary/10"></div>
|
||||
<FontAwesomeIcon icon={faLayerGroup} className="text-primary w-16 h-16 opacity-60"/>
|
||||
</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 className="absolute top-3 right-3 flex items-center gap-2">
|
||||
<SyncSeries seriesId={series.id} status={seriesSyncStatus} />
|
||||
<div className="bg-primary text-white text-xs px-2 py-1 rounded-full font-bold shadow-lg">
|
||||
{series.books.length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 flex-1 flex flex-col justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-text-primary text-center font-bold text-base mb-2 truncate group-hover:text-primary transition-colors tracking-wide">
|
||||
{series.name}
|
||||
</h3>
|
||||
<div className="flex items-center justify-center mb-3 h-5">
|
||||
<div className="h-px w-8 bg-primary/30"></div>
|
||||
<p className="text-muted text-center mx-2 text-xs italic truncate px-2">
|
||||
{series.books.length} {series.books.length > 1 ? t("seriesCard.books") : t("seriesCard.book")}
|
||||
</p>
|
||||
<div className="h-px w-8 bg-primary/30"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-center items-center pt-3 border-t border-primary/30">
|
||||
<span className="bg-primary/20 text-primary text-xs px-3 py-1 rounded-full font-medium border border-primary/30">
|
||||
{t("seriesCard.series")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`${isExpanded ? 'flex' : 'hidden'} items-center border-y-2 border-primary bg-primary/5`}>
|
||||
{series.books.map((book: BookProps, idx: number) => (
|
||||
<div key={book.bookId} className="flex-shrink-0 w-64 sm:w-52 md:w-48 lg:w-56 xl:w-64 p-2">
|
||||
<BookCard
|
||||
book={book}
|
||||
onClickCallback={onBookClick}
|
||||
index={idx}
|
||||
syncStatus={getSyncStatus ? getSyncStatus(book.bookId) : 'synced'}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Bouton Settings */}
|
||||
<div className="flex items-center px-4 flex-shrink-0">
|
||||
<button
|
||||
onClick={(event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
onSettingsClick(series.id);
|
||||
}}
|
||||
className="p-4 rounded-xl bg-primary/20 hover:bg-primary text-primary hover:text-white transition-all duration-200 hover:scale-110 shadow-lg"
|
||||
title={t("seriesCard.settings")}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCog} className="w-6 h-6"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bordure de fin */}
|
||||
<div className={`${isExpanded ? 'block' : 'hidden'} w-3 border-y-2 border-r-2 border-primary rounded-r-2xl bg-primary/5 flex-shrink-0`}></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
components/series/SeriesSetting.tsx
Normal file
37
components/series/SeriesSetting.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
'use client'
|
||||
import {useState} from "react";
|
||||
import SeriesSettingSidebar from "@/components/series/SeriesSettingSidebar";
|
||||
import SeriesSettingOption from "@/components/series/SeriesSettingOption";
|
||||
import {SeriesContext} from "@/context/SeriesContext";
|
||||
import {useTranslations} from "next-intl";
|
||||
import SettingsPanel from "@/components/SettingsPanel";
|
||||
|
||||
interface SeriesSettingProps {
|
||||
seriesId: string;
|
||||
localSeries: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function SeriesSetting({seriesId, localSeries, onClose}: SeriesSettingProps) {
|
||||
const t = useTranslations();
|
||||
const [currentSetting, setCurrentSetting] = useState<string>('basic-information');
|
||||
|
||||
return (
|
||||
<SeriesContext.Provider value={{seriesId, localSeries}}>
|
||||
<SettingsPanel
|
||||
title={t("bookList.seriesSettings")}
|
||||
sidebar={
|
||||
<SeriesSettingSidebar
|
||||
selectedSetting={currentSetting}
|
||||
setSelectedSetting={setCurrentSetting}
|
||||
seriesId={seriesId}
|
||||
onClose={onClose}
|
||||
/>
|
||||
}
|
||||
onClose={onClose}
|
||||
>
|
||||
<SeriesSettingOption setting={currentSetting}/>
|
||||
</SettingsPanel>
|
||||
</SeriesContext.Provider>
|
||||
);
|
||||
}
|
||||
121
components/series/SeriesSettingOption.tsx
Normal file
121
components/series/SeriesSettingOption.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
'use client'
|
||||
import React, {lazy, Suspense, useContext, 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 {SeriesContext} from '@/context/SeriesContext';
|
||||
|
||||
// Lazy loaded components - avec ref (anciens)
|
||||
const BasicSeriesInformation = lazy(function () {
|
||||
return import('./settings/BasicSeriesInformation');
|
||||
});
|
||||
const SeriesBooksManager = lazy(function () {
|
||||
return import('./settings/SeriesBooksManager');
|
||||
});
|
||||
|
||||
// Lazy loaded components - sans ref (nouveaux avec leur propre header)
|
||||
const WorldSettings = lazy(function () {
|
||||
return import('@/components/book/settings/world/settings/WorldSettings');
|
||||
});
|
||||
const LocationSettings = lazy(function () {
|
||||
return import('@/components/book/settings/locations/settings/LocationSettings');
|
||||
});
|
||||
const CharacterSettings = lazy(function () {
|
||||
return import('@/components/book/settings/characters/settings/CharacterSettings');
|
||||
});
|
||||
const SpellSettings = lazy(function () {
|
||||
return import('@/components/book/settings/spells/settings/SpellSettings');
|
||||
});
|
||||
|
||||
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 SeriesSettingOptionProps {
|
||||
setting: string;
|
||||
}
|
||||
|
||||
interface SettingRef {
|
||||
handleSave: () => Promise<void>;
|
||||
}
|
||||
|
||||
// Settings qui gèrent leur propre save (pas de bouton save parent)
|
||||
const selfManagedSettings: string[] = ['characters', 'spells', 'worlds', 'locations'];
|
||||
|
||||
export default function SeriesSettingOption({setting}: SeriesSettingOptionProps): React.JSX.Element {
|
||||
const t = useTranslations();
|
||||
const {seriesId} = useContext(SeriesContext);
|
||||
const settingRef = useRef<SettingRef>(null);
|
||||
|
||||
const showSaveButton: boolean = !selfManagedSettings.includes(setting);
|
||||
|
||||
function renderTitle(): string {
|
||||
switch (setting) {
|
||||
case 'basic-information':
|
||||
return t("seriesSettingOption.basicInformation");
|
||||
case 'books':
|
||||
return t("seriesSettingOption.books");
|
||||
case 'characters':
|
||||
return t("seriesSettingOption.characters");
|
||||
case 'worlds':
|
||||
return t("seriesSettingOption.worlds");
|
||||
case 'locations':
|
||||
return t("seriesSettingOption.locations");
|
||||
case 'spells':
|
||||
return t("seriesSettingOption.spells");
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveClick(): Promise<void> {
|
||||
if (settingRef.current?.handleSave) {
|
||||
await settingRef.current.handleSave();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-6">
|
||||
<div className="sticky top-0 z-10 bg-tertiary pt-6 pb-4">
|
||||
<PanelHeader
|
||||
icon={faPen}
|
||||
badge="SE"
|
||||
title={renderTitle()}
|
||||
description=""
|
||||
secondActionCallback={showSaveButton ? handleSaveClick : undefined}
|
||||
callBackAction={showSaveButton ? handleSaveClick : undefined}
|
||||
secondActionIcon={showSaveButton ? faSave : undefined}
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-secondary/10 rounded-xl p-1 mb-6">
|
||||
<Suspense fallback={<LoadingSpinner/>}>
|
||||
{setting === 'basic-information' && <BasicSeriesInformation ref={settingRef}/>}
|
||||
{setting === 'books' && <SeriesBooksManager ref={settingRef}/>}
|
||||
{setting === 'worlds' && (
|
||||
<WorldSettings entityType="series" entityId={seriesId} showToggle={false}/>
|
||||
)}
|
||||
{setting === 'locations' && (
|
||||
<LocationSettings entityType="series" entityId={seriesId} showToggle={false}/>
|
||||
)}
|
||||
{setting === 'characters' && (
|
||||
<CharacterSettings entityType="series" entityId={seriesId} showToggle={false}/>
|
||||
)}
|
||||
{setting === 'spells' && (
|
||||
<SpellSettings entityType="series" entityId={seriesId} showToggle={false}/>
|
||||
)}
|
||||
{!['basic-information', 'books', 'worlds', 'locations', 'characters', 'spells'].includes(setting) && (
|
||||
<div className="text-text-secondary py-4 text-center">
|
||||
{t("bookSettingOption.notAvailable")}
|
||||
</div>
|
||||
)}
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
177
components/series/SeriesSettingSidebar.tsx
Normal file
177
components/series/SeriesSettingSidebar.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
'use client'
|
||||
import Link from "next/link";
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faBook,
|
||||
faGlobe,
|
||||
faHatWizard,
|
||||
faMapMarkedAlt,
|
||||
faPencilAlt,
|
||||
faTrash,
|
||||
faUser
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import React, {Dispatch, SetStateAction, useContext, useState} from "react";
|
||||
import {IconDefinition} from "@fortawesome/fontawesome-svg-core";
|
||||
import {useTranslations} from "next-intl";
|
||||
import AlertBox from "@/components/AlertBox";
|
||||
import {SessionContext} from "@/context/SessionContext";
|
||||
import {LangContext, LangContextProps} from "@/context/LangContext";
|
||||
import {AlertContext} from "@/context/AlertContext";
|
||||
import System from "@/lib/models/System";
|
||||
import {SeriesContext, SeriesContextProps} from "@/context/SeriesContext";
|
||||
import OfflineContext, {OfflineContextType} from "@/context/OfflineContext";
|
||||
import {SeriesSyncContext, SeriesSyncContextProps} from "@/context/SeriesSyncContext";
|
||||
import {LocalSyncQueueContext, LocalSyncQueueContextProps} from "@/context/SyncQueueContext";
|
||||
import {SyncedSeries} from "@/lib/models/SyncedSeries";
|
||||
|
||||
interface SeriesSettingOption {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: IconDefinition;
|
||||
}
|
||||
|
||||
interface SeriesSettingSidebarProps {
|
||||
selectedSetting: string;
|
||||
setSelectedSetting: Dispatch<SetStateAction<string>>;
|
||||
seriesId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function SeriesSettingSidebar(
|
||||
{
|
||||
selectedSetting,
|
||||
setSelectedSetting,
|
||||
seriesId,
|
||||
onClose
|
||||
}: SeriesSettingSidebarProps) {
|
||||
const t = useTranslations();
|
||||
const {session} = useContext(SessionContext);
|
||||
const {lang} = useContext<LangContextProps>(LangContext);
|
||||
const {errorMessage, successMessage} = useContext(AlertContext);
|
||||
const userToken: string = session?.accessToken ? session?.accessToken : '';
|
||||
const {localSeries} = useContext<SeriesContextProps>(SeriesContext);
|
||||
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
|
||||
const {localSyncedSeries} = useContext<SeriesSyncContextProps>(SeriesSyncContext);
|
||||
const {addToQueue} = useContext<LocalSyncQueueContextProps>(LocalSyncQueueContext);
|
||||
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean>(false);
|
||||
|
||||
async function handleDeleteSeries(): Promise<void> {
|
||||
try {
|
||||
const deleteData = {seriesId: seriesId};
|
||||
let success: boolean;
|
||||
|
||||
if (isCurrentlyOffline() || localSeries) {
|
||||
success = await window.electron.invoke<boolean>('db:series:delete', deleteData);
|
||||
} else {
|
||||
success = await System.authDeleteToServer<boolean>(
|
||||
'series/delete',
|
||||
deleteData,
|
||||
userToken,
|
||||
lang
|
||||
);
|
||||
|
||||
if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) {
|
||||
addToQueue('db:series:delete', deleteData);
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
successMessage(t('seriesSetting.deleteSuccess'));
|
||||
onClose();
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
errorMessage(error.message);
|
||||
} else {
|
||||
errorMessage(t('seriesSetting.deleteError'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteConfirm(): Promise<void> {
|
||||
await handleDeleteSeries();
|
||||
setShowDeleteConfirm(false);
|
||||
}
|
||||
|
||||
const settings: SeriesSettingOption[] = [
|
||||
{
|
||||
id: 'basic-information',
|
||||
name: 'seriesSetting.basicInformation',
|
||||
icon: faPencilAlt
|
||||
},
|
||||
{
|
||||
id: 'books',
|
||||
name: 'seriesSetting.books',
|
||||
icon: faBook
|
||||
},
|
||||
{
|
||||
id: 'characters',
|
||||
name: 'seriesSetting.characters',
|
||||
icon: faUser
|
||||
},
|
||||
{
|
||||
id: 'worlds',
|
||||
name: 'seriesSetting.worlds',
|
||||
icon: faGlobe
|
||||
},
|
||||
{
|
||||
id: 'locations',
|
||||
name: 'seriesSetting.locations',
|
||||
icon: faMapMarkedAlt
|
||||
},
|
||||
{
|
||||
id: 'spells',
|
||||
name: 'seriesSetting.spells',
|
||||
icon: faHatWizard
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="py-6 px-3 flex flex-col h-full">
|
||||
<nav className="space-y-1 flex-1">
|
||||
{
|
||||
settings.map((setting: SeriesSettingOption) => (
|
||||
<Link
|
||||
key={setting.id}
|
||||
href={''}
|
||||
onClick={(): void => setSelectedSetting(setting.id)}
|
||||
className={`flex items-center text-base rounded-xl transition-all duration-200 ${
|
||||
selectedSetting === setting.id
|
||||
? 'bg-primary/20 text-text-primary border-l-4 border-primary font-semibold shadow-md scale-105'
|
||||
: 'text-text-secondary hover:bg-secondary/50 hover:text-text-primary hover:scale-102'
|
||||
} p-3 mb-1`}>
|
||||
<FontAwesomeIcon
|
||||
icon={setting.icon}
|
||||
className={`mr-3 ${selectedSetting === setting.id ? 'text-primary w-5 h-5' : 'text-text-secondary w-5 h-5'}`}/>
|
||||
{t(setting.name)}
|
||||
</Link>
|
||||
))
|
||||
}
|
||||
</nav>
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-secondary/50">
|
||||
<button
|
||||
onClick={(): void => setShowDeleteConfirm(true)}
|
||||
className="w-full flex items-center justify-center gap-2 p-3 text-red-400 hover:bg-red-500/20 rounded-xl transition-all duration-200"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} className="w-4 h-4"/>
|
||||
{t('seriesSetting.deleteSeries')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showDeleteConfirm && (
|
||||
<AlertBox
|
||||
title={t('seriesSetting.deleteSeries')}
|
||||
message={t('seriesSetting.deleteConfirmMessage')}
|
||||
type="danger"
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={(): void => setShowDeleteConfirm(false)}
|
||||
confirmText={t('common.delete')}
|
||||
cancelText={t('common.cancel')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
151
components/series/settings/BasicSeriesInformation.tsx
Normal file
151
components/series/settings/BasicSeriesInformation.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
'use client'
|
||||
import {ChangeEvent, forwardRef, useContext, useEffect, useImperativeHandle, useState} from "react";
|
||||
import System from "@/lib/models/System";
|
||||
import {AlertContext} from "@/context/AlertContext";
|
||||
import {SessionContext} from "@/context/SessionContext";
|
||||
import TextInput from "@/components/form/TextInput";
|
||||
import TexteAreaInput from "@/components/form/TexteAreaInput";
|
||||
import InputField from "@/components/form/InputField";
|
||||
import {useTranslations} from "next-intl";
|
||||
import {LangContext, LangContextProps} from "@/context/LangContext";
|
||||
import {SeriesContext, SeriesContextProps} from "@/context/SeriesContext";
|
||||
import {SeriesDetailResponse, SeriesUpdateResponse} from "@/lib/models/Series";
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {faSpinner} from "@fortawesome/free-solid-svg-icons";
|
||||
import OfflineContext, {OfflineContextType} from "@/context/OfflineContext";
|
||||
import {LocalSyncQueueContext, LocalSyncQueueContextProps} from "@/context/SyncQueueContext";
|
||||
import {SeriesSyncContext, SeriesSyncContextProps} from "@/context/SeriesSyncContext";
|
||||
import {SyncedSeries} from "@/lib/models/SyncedSeries";
|
||||
|
||||
function BasicSeriesInformation(props: object, ref: React.Ref<{ handleSave: () => Promise<void> }>) {
|
||||
const t = useTranslations();
|
||||
const {lang} = useContext<LangContextProps>(LangContext);
|
||||
|
||||
const {session} = useContext(SessionContext);
|
||||
const {seriesId, localSeries} = useContext<SeriesContextProps>(SeriesContext);
|
||||
const userToken: string = session?.accessToken ? session?.accessToken : '';
|
||||
const {errorMessage, successMessage} = useContext(AlertContext);
|
||||
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
|
||||
const {addToQueue} = useContext<LocalSyncQueueContextProps>(LocalSyncQueueContext);
|
||||
const {localSyncedSeries} = useContext<SeriesSyncContextProps>(SeriesSyncContext);
|
||||
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [name, setName] = useState<string>('');
|
||||
const [description, setDescription] = useState<string>('');
|
||||
|
||||
useEffect(function () {
|
||||
if (seriesId) {
|
||||
loadSeriesData();
|
||||
}
|
||||
}, [seriesId]);
|
||||
|
||||
async function loadSeriesData(): Promise<void> {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
let response: SeriesDetailResponse;
|
||||
|
||||
if (isCurrentlyOffline() || localSeries) {
|
||||
response = await window.electron.invoke<SeriesDetailResponse>('db:series:detail', {seriesId});
|
||||
} else {
|
||||
response = await System.authGetQueryToServer<SeriesDetailResponse>(
|
||||
'series/detail',
|
||||
userToken,
|
||||
lang,
|
||||
{seriesid: seriesId}
|
||||
);
|
||||
}
|
||||
|
||||
if (response) {
|
||||
setName(response.name);
|
||||
setDescription(response.description || '');
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
} else {
|
||||
errorMessage(t('seriesBasicInformation.error.unknown'));
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useImperativeHandle(ref, function () {
|
||||
return {
|
||||
handleSave: handleSave
|
||||
};
|
||||
});
|
||||
|
||||
async function handleSave(): Promise<void> {
|
||||
if (!name) {
|
||||
errorMessage(t('seriesBasicInformation.error.nameRequired'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const updateData = {
|
||||
seriesId: seriesId,
|
||||
name: name,
|
||||
description: description
|
||||
};
|
||||
let success: boolean;
|
||||
|
||||
if (isCurrentlyOffline() || localSeries) {
|
||||
success = await window.electron.invoke<boolean>('db:series:update', updateData);
|
||||
} else {
|
||||
const response: SeriesUpdateResponse = await System.authPutToServer<SeriesUpdateResponse>(
|
||||
'series/update',
|
||||
updateData,
|
||||
userToken,
|
||||
lang
|
||||
);
|
||||
success = response.success;
|
||||
|
||||
if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) {
|
||||
addToQueue('db:series:update', updateData);
|
||||
}
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
errorMessage(t('seriesBasicInformation.error.update'));
|
||||
return;
|
||||
}
|
||||
successMessage(t('seriesBasicInformation.success.update'));
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
} else {
|
||||
errorMessage(t('seriesBasicInformation.error.unknown'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
|
||||
<InputField fieldName={t('seriesBasicInformation.fields.name')} input={<TextInput
|
||||
value={name}
|
||||
setValue={(e: ChangeEvent<HTMLInputElement>) => setName(e.target.value)}
|
||||
placeholder={t('seriesBasicInformation.fields.namePlaceholder')}
|
||||
/>}/>
|
||||
</div>
|
||||
|
||||
<div className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
|
||||
<InputField fieldName={t('seriesBasicInformation.fields.description')} input={<TexteAreaInput
|
||||
value={description}
|
||||
setValue={(e: ChangeEvent<HTMLTextAreaElement>) => setDescription(e.target.value)}
|
||||
placeholder={t('seriesBasicInformation.fields.descriptionPlaceholder')}
|
||||
/>}/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef(BasicSeriesInformation);
|
||||
372
components/series/settings/SeriesBooksManager.tsx
Normal file
372
components/series/settings/SeriesBooksManager.tsx
Normal file
@@ -0,0 +1,372 @@
|
||||
'use client'
|
||||
import {forwardRef, useContext, useEffect, useImperativeHandle, useState} from "react";
|
||||
import System from "@/lib/models/System";
|
||||
import {AlertContext} from "@/context/AlertContext";
|
||||
import {SessionContext} from "@/context/SessionContext";
|
||||
import {useTranslations} from "next-intl";
|
||||
import {LangContext, LangContextProps} from "@/context/LangContext";
|
||||
import {SeriesContext, SeriesContextProps} from "@/context/SeriesContext";
|
||||
import {SeriesBookProps} from "@/lib/models/Series";
|
||||
import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext";
|
||||
import {SyncedBook} from "@/lib/models/SyncedBook";
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {faArrowDown, faArrowUp, faBook, faPlus, faSpinner, faTrash} from "@fortawesome/free-solid-svg-icons";
|
||||
import OfflineContext, {OfflineContextType} from "@/context/OfflineContext";
|
||||
import {LocalSyncQueueContext, LocalSyncQueueContextProps} from "@/context/SyncQueueContext";
|
||||
import {SeriesSyncContext, SeriesSyncContextProps} from "@/context/SeriesSyncContext";
|
||||
import {SyncedSeries, SyncedSeriesBook} from "@/lib/models/SyncedSeries";
|
||||
|
||||
function SeriesBooksManager(props: object, ref: React.Ref<{ handleSave: () => Promise<void> }>) {
|
||||
const t = useTranslations();
|
||||
const {lang} = useContext<LangContextProps>(LangContext);
|
||||
|
||||
const {session} = useContext(SessionContext);
|
||||
const {seriesId, localSeries} = useContext<SeriesContextProps>(SeriesContext);
|
||||
const {serverSyncedBooks, setServerSyncedBooks, localSyncedBooks} = useContext<BooksSyncContextProps>(BooksSyncContext);
|
||||
const userToken: string = session?.accessToken ? session?.accessToken : '';
|
||||
const {errorMessage, successMessage} = useContext(AlertContext);
|
||||
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
|
||||
const {addToQueue} = useContext<LocalSyncQueueContextProps>(LocalSyncQueueContext);
|
||||
const {localSyncedSeries, serverSyncedSeries} = useContext<SeriesSyncContextProps>(SeriesSyncContext);
|
||||
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [seriesBooks, setSeriesBooks] = useState<SeriesBookProps[]>([]);
|
||||
const [selectedBookToAdd, setSelectedBookToAdd] = useState<string>('');
|
||||
const [availableBooks, setAvailableBooks] = useState<SyncedBook[]>([]);
|
||||
|
||||
useEffect(function () {
|
||||
if (seriesId) {
|
||||
loadSeriesBooks();
|
||||
}
|
||||
}, [seriesId]);
|
||||
|
||||
useEffect(function () {
|
||||
const booksInThisSeries: string[] = seriesBooks.map((book: SeriesBookProps) => book.bookId);
|
||||
let allBooks: SyncedBook[];
|
||||
let allSeries: SyncedSeries[];
|
||||
|
||||
if (isCurrentlyOffline() || localSeries) {
|
||||
allBooks = localSyncedBooks;
|
||||
allSeries = localSyncedSeries;
|
||||
} else {
|
||||
allBooks = serverSyncedBooks;
|
||||
allSeries = serverSyncedSeries;
|
||||
}
|
||||
|
||||
// Get all bookIds in OTHER series (not this one)
|
||||
const booksInOtherSeries: Set<string> = new Set(
|
||||
allSeries
|
||||
.filter((series: SyncedSeries): boolean => series.id !== seriesId)
|
||||
.flatMap((series: SyncedSeries): string[] =>
|
||||
series.books.map((book: SyncedSeriesBook): string => book.bookId)
|
||||
)
|
||||
);
|
||||
|
||||
// Filter out books already in this series AND books already in another series
|
||||
const filteredBooks: SyncedBook[] = allBooks.filter(
|
||||
(book: SyncedBook) => !booksInThisSeries.includes(book.id) && !booksInOtherSeries.has(book.id)
|
||||
);
|
||||
setAvailableBooks(filteredBooks);
|
||||
}, [seriesBooks, serverSyncedBooks, localSyncedBooks, serverSyncedSeries, localSyncedSeries, isCurrentlyOffline, localSeries, seriesId]);
|
||||
|
||||
async function loadSeriesBooks(): Promise<void> {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
let response: SeriesBookProps[];
|
||||
|
||||
if (isCurrentlyOffline() || localSeries) {
|
||||
response = await window.electron.invoke<SeriesBookProps[]>('db:series:books', {seriesId});
|
||||
} else {
|
||||
response = await System.authGetQueryToServer<SeriesBookProps[]>(
|
||||
'series/book/list',
|
||||
userToken,
|
||||
lang,
|
||||
{seriesid: seriesId}
|
||||
);
|
||||
}
|
||||
|
||||
if (response) {
|
||||
setSeriesBooks(response);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
} else {
|
||||
errorMessage(t('seriesBooks.error.unknown'));
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useImperativeHandle(ref, function () {
|
||||
return {
|
||||
handleSave: handleSave
|
||||
};
|
||||
});
|
||||
|
||||
async function handleSave(): Promise<void> {
|
||||
successMessage(t('seriesBooks.success.saved'));
|
||||
}
|
||||
|
||||
async function handleAddBook(): Promise<void> {
|
||||
if (!selectedBookToAdd) {
|
||||
errorMessage(t('seriesBooks.error.selectBook'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const addData = {
|
||||
seriesId: seriesId,
|
||||
bookId: selectedBookToAdd
|
||||
};
|
||||
let response: boolean;
|
||||
|
||||
if (isCurrentlyOffline() || localSeries) {
|
||||
response = await window.electron.invoke<boolean>('db:series:book:add', addData);
|
||||
} else {
|
||||
response = await System.authPostToServer<boolean>(
|
||||
'series/book/add',
|
||||
addData,
|
||||
userToken,
|
||||
lang
|
||||
);
|
||||
|
||||
if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) {
|
||||
addToQueue('db:series:book:add', addData);
|
||||
}
|
||||
}
|
||||
|
||||
if (response) {
|
||||
const allBooks: SyncedBook[] = isCurrentlyOffline() || localSeries ? localSyncedBooks : serverSyncedBooks;
|
||||
const addedBook: SyncedBook | undefined = allBooks.find(
|
||||
(book: SyncedBook) => book.id === selectedBookToAdd
|
||||
);
|
||||
if (addedBook) {
|
||||
const newSeriesBook: SeriesBookProps = {
|
||||
bookId: addedBook.id,
|
||||
title: addedBook.title,
|
||||
order: seriesBooks.length + 1,
|
||||
coverImage: null
|
||||
};
|
||||
setSeriesBooks([...seriesBooks, newSeriesBook]);
|
||||
|
||||
setServerSyncedBooks((prev: SyncedBook[]): SyncedBook[] =>
|
||||
prev.map((book: SyncedBook): SyncedBook =>
|
||||
book.id === selectedBookToAdd
|
||||
? {...book, seriesId: seriesId}
|
||||
: book
|
||||
)
|
||||
);
|
||||
}
|
||||
setSelectedBookToAdd('');
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
} else {
|
||||
errorMessage(t('seriesBooks.error.unknown'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveBook(bookId: string): Promise<void> {
|
||||
try {
|
||||
const removeData = {
|
||||
seriesId: seriesId,
|
||||
bookId: bookId
|
||||
};
|
||||
let response: boolean;
|
||||
|
||||
if (isCurrentlyOffline() || localSeries) {
|
||||
response = await window.electron.invoke<boolean>('db:series:book:remove', removeData);
|
||||
} else {
|
||||
response = await System.authDeleteToServer<boolean>(
|
||||
'series/book/remove',
|
||||
removeData,
|
||||
userToken,
|
||||
lang
|
||||
);
|
||||
|
||||
if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) {
|
||||
addToQueue('db:series:book:remove', removeData);
|
||||
}
|
||||
}
|
||||
|
||||
if (response) {
|
||||
const updatedBooks: SeriesBookProps[] = seriesBooks
|
||||
.filter((book: SeriesBookProps) => book.bookId !== bookId)
|
||||
.map((book: SeriesBookProps, index: number) => ({
|
||||
...book,
|
||||
order: index + 1
|
||||
}));
|
||||
setSeriesBooks(updatedBooks);
|
||||
|
||||
setServerSyncedBooks((prev: SyncedBook[]): SyncedBook[] =>
|
||||
prev.map((book: SyncedBook): SyncedBook =>
|
||||
book.id === bookId
|
||||
? {...book, seriesId: null}
|
||||
: book
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
} else {
|
||||
errorMessage(t('seriesBooks.error.unknown'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMoveBook(bookId: string, direction: 'up' | 'down'): Promise<void> {
|
||||
const currentIndex: number = seriesBooks.findIndex((book: SeriesBookProps) => book.bookId === bookId);
|
||||
if (currentIndex === -1) return;
|
||||
|
||||
const newIndex: number = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
|
||||
if (newIndex < 0 || newIndex >= seriesBooks.length) return;
|
||||
|
||||
const reorderedBooks: SeriesBookProps[] = [...seriesBooks];
|
||||
const [movedBook] = reorderedBooks.splice(currentIndex, 1);
|
||||
reorderedBooks.splice(newIndex, 0, movedBook);
|
||||
|
||||
const updatedBooks: SeriesBookProps[] = reorderedBooks.map((book: SeriesBookProps, index: number) => ({
|
||||
...book,
|
||||
order: index + 1
|
||||
}));
|
||||
|
||||
try {
|
||||
const reorderData = {
|
||||
seriesId: seriesId,
|
||||
booksOrder: updatedBooks.map((book: SeriesBookProps) => ({
|
||||
bookId: book.bookId,
|
||||
order: book.order
|
||||
}))
|
||||
};
|
||||
let response: boolean;
|
||||
|
||||
if (isCurrentlyOffline() || localSeries) {
|
||||
response = await window.electron.invoke<boolean>('db:series:book:reorder', reorderData);
|
||||
} else {
|
||||
response = await System.authPutToServer<boolean>(
|
||||
'series/book/reorder',
|
||||
reorderData,
|
||||
userToken,
|
||||
lang
|
||||
);
|
||||
|
||||
if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) {
|
||||
addToQueue('db:series:book:reorder', reorderData);
|
||||
}
|
||||
}
|
||||
|
||||
if (response) {
|
||||
setSeriesBooks(updatedBooks);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
} else {
|
||||
errorMessage(t('seriesBooks.error.unknown'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
|
||||
<h3 className="text-lg font-semibold text-text-primary mb-4">
|
||||
{t('seriesBooks.addBook')}
|
||||
</h3>
|
||||
<div className="flex gap-3">
|
||||
<select
|
||||
value={selectedBookToAdd}
|
||||
onChange={(e) => setSelectedBookToAdd(e.target.value)}
|
||||
className="flex-1 bg-secondary/50 border border-secondary/50 rounded-lg px-4 py-2 text-text-primary focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value="">{t('seriesBooks.selectBookPlaceholder')}</option>
|
||||
{availableBooks.map((book: SyncedBook) => (
|
||||
<option key={book.id} value={book.id}>
|
||||
{book.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={handleAddBook}
|
||||
disabled={!selectedBookToAdd}
|
||||
className="bg-primary hover:bg-primary-dark disabled:bg-secondary disabled:cursor-not-allowed text-white px-4 py-2 rounded-lg transition-colors duration-200 flex items-center gap-2"
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} className="w-4 h-4"/>
|
||||
{t('seriesBooks.add')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
|
||||
<h3 className="text-lg font-semibold text-text-primary mb-4">
|
||||
{t('seriesBooks.booksInSeries')} ({seriesBooks.length})
|
||||
</h3>
|
||||
|
||||
{seriesBooks.length === 0 ? (
|
||||
<div className="text-center py-8 text-text-secondary">
|
||||
<FontAwesomeIcon icon={faBook} className="w-12 h-12 mb-4 opacity-50"/>
|
||||
<p>{t('seriesBooks.noBooks')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{seriesBooks
|
||||
.sort((a: SeriesBookProps, b: SeriesBookProps) => a.order - b.order)
|
||||
.map((book: SeriesBookProps, index: number) => (
|
||||
<div
|
||||
key={book.bookId}
|
||||
className="flex items-center justify-between bg-secondary/30 rounded-lg p-3 border border-secondary/30 hover:border-primary/30 transition-colors duration-200"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="bg-primary/20 text-primary font-bold w-8 h-8 rounded-full flex items-center justify-center text-sm">
|
||||
{book.order}
|
||||
</span>
|
||||
<span className="text-text-primary font-medium">{book.title}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleMoveBook(book.bookId, 'up')}
|
||||
disabled={index === 0}
|
||||
className="p-2 rounded-lg hover:bg-secondary/50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors duration-200"
|
||||
title={t('seriesBooks.moveUp')}
|
||||
>
|
||||
<FontAwesomeIcon icon={faArrowUp} className="w-4 h-4 text-text-secondary"/>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleMoveBook(book.bookId, 'down')}
|
||||
disabled={index === seriesBooks.length - 1}
|
||||
className="p-2 rounded-lg hover:bg-secondary/50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors duration-200"
|
||||
title={t('seriesBooks.moveDown')}
|
||||
>
|
||||
<FontAwesomeIcon icon={faArrowDown} className="w-4 h-4 text-text-secondary"/>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRemoveBook(book.bookId)}
|
||||
className="p-2 rounded-lg hover:bg-error/20 text-error transition-colors duration-200"
|
||||
title={t('seriesBooks.removeBook')}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} className="w-4 h-4"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef(SeriesBooksManager);
|
||||
Reference in New Issue
Block a user