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,23 +1,18 @@
'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 React, {ChangeEvent, Dispatch, SetStateAction, useContext, useState} from "react";
import {AlertContext, AlertContextProps} from "@/context/AlertContext";
import {apiPost} from '@/lib/api/client';
import {SessionContext, SessionContextProps} from "@/context/SessionContext";
import {Book, Check, Layers, Pencil} from 'lucide-react';
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 TextAreaInput from "@/components/form/TextAreaInput";
import Button from "@/components/ui/Button";
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/models/SyncedBook";
import OfflineContext, {OfflineContextType} from "@/context/OfflineContext";
import {SeriesSyncContext, SeriesSyncContextProps} from "@/context/SeriesSyncContext";
import {SyncedSeries, SyncedSeriesBook} from "@/lib/models/SyncedSeries";
import * as tauri from '@/lib/tauri';
import {SyncedBook} from "@/lib/types/synced-book";
interface AddNewSeriesFormProps {
setCloseForm: Dispatch<SetStateAction<boolean>>;
@@ -26,36 +21,20 @@ interface AddNewSeriesFormProps {
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 {lang}: LangContextProps = useContext<LangContextProps>(LangContext);
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
const {errorMessage, successMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
const {
serverSyncedBooks,
setServerSyncedBooks
}: BooksSyncContextProps = useContext<BooksSyncContextProps>(BooksSyncContext);
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)) {
@@ -64,7 +43,7 @@ export default function AddNewSeriesForm({setCloseForm, onSeriesCreated}: AddNew
return [...prev, bookId];
});
}
async function handleAddSeries(): Promise<void> {
if (!name) {
errorMessage(t('addNewSeriesForm.error.nameMissing'));
@@ -78,54 +57,36 @@ export default function AddNewSeriesForm({setCloseForm, onSeriesCreated}: AddNew
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 tauri.createSeries(createData);
} else {
response = await System.authPostToServer<string>(
'series/add',
createData,
token,
lang
);
}
const response: string = await apiPost<string>(
'series/add',
{
name: name,
description: description || null,
bookIds: selectedBookIds,
},
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
)
);
}
setServerSyncedBooks((prev: SyncedBook[]): SyncedBook[] =>
prev.map((book: SyncedBook): SyncedBook =>
selectedBookIds.includes(book.id)
? {...book, seriesId: response}
: book
)
);
}
if (onSeriesCreated) {
onSeriesCreated(response, name);
}
@@ -140,120 +101,100 @@ export default function AddNewSeriesForm({setCloseForm, onSeriesCreated}: AddNew
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>
<Modal
icon={Layers}
title={t("addNewSeriesForm.title")}
onClose={(): void => setCloseForm(false)}
size="md"
footer={
<>
<Button variant="secondary" onClick={() => setCloseForm(false)}>{t("common.cancel")}</Button>
<Button
variant="primary"
onClick={handleAddSeries}
isLoading={isAddingSeries}
loadingText={t("addNewSeriesForm.adding")}
icon={Layers}
>{t("addNewSeriesForm.add")}</Button>
</>
}
>
<InputField icon={Pencil} fieldName={t("addNewSeriesForm.name")} input={
<TextInput
value={name}
setValue={(e: ChangeEvent<HTMLInputElement>): void => setName(e.target.value)}
placeholder={t("addNewSeriesForm.namePlaceholder")}
/>
}/>
<InputField
icon={Pencil}
fieldName={`${t("addNewSeriesForm.description")} (${t("addNewSeriesForm.optional")})`}
input={
<TextAreaInput
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">
<Book className="w-4 h-4 text-primary" strokeWidth={1.75}/>
<span className="font-medium">{t("addNewSeriesForm.selectBooks")}</span>
{selectedBookIds.length > 0 && (
<span className="text-sm text-muted">
({selectedBookIds.length} {t("addNewSeriesForm.selected")})
</span>
)}
</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>
{serverSyncedBooks.length === 0 ? (
<div className="text-center py-6 text-muted">
<Book className="w-8 h-8 mb-2 opacity-50" strokeWidth={1.75}/>
<p>{t("addNewSeriesForm.noBooks")}</p>
</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
className="max-h-48 overflow-y-auto rounded-xl border border-secondary bg-tertiary">
{serverSyncedBooks.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-colors duration-150 border-b border-secondary last:border-b-0 ${
isSelected
? 'bg-secondary'
: 'hover:bg-secondary/50'
}`}
>
<div
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
isSelected
? 'bg-primary/20 border-primary'
: 'border-secondary'
}`}>
{isSelected && (
<Check className="w-3 h-3 text-text-primary" strokeWidth={1.75}/>
)}
</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>
</Modal>
);
}

View File

@@ -1,13 +1,11 @@
'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 {Layers, Settings} from 'lucide-react';
import IconButton from "@/components/ui/IconButton";
import {BookProps} from "@/lib/types/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";
import Badge from "@/components/ui/Badge";
import {useTranslations} from '@/lib/i18n';
export interface SeriesCardProps {
id: string;
@@ -18,13 +16,11 @@ export interface SeriesCardProps {
interface SeriesCardComponentProps {
series: SeriesCardProps;
onBookClick: (bookId: string) => Promise<void>;
onBookClick: (bookId: string) => void;
onSettingsClick: (seriesId: string) => void;
getSyncStatus?: (bookId: string) => SyncType;
seriesSyncStatus?: SeriesSyncType;
}
export default function SeriesCard({series, onBookClick, onSettingsClick, getSyncStatus, seriesSyncStatus = 'synced'}: SeriesCardComponentProps) {
export default function SeriesCard({series, onBookClick, onSettingsClick}: SeriesCardComponentProps) {
const t = useTranslations();
const [isExpanded, setIsExpanded] = useState<boolean>(false);
@@ -32,81 +28,58 @@ export default function SeriesCard({series, onBookClick, onSettingsClick, getSyn
<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'}`}
className={`group relative aspect-[2/3] overflow-hidden cursor-pointer transition-all duration-300 hover:ring-1 hover:ring-text-primary/20 flex-shrink-0 w-64 sm:w-52 md:w-48 lg:w-56 xl:w-64 ${isExpanded ? 'rounded-l-xl' : 'rounded-xl'}`}
>
<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>
{series.coverImage ? (
<img
src={series.coverImage}
alt={series.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full bg-secondary flex items-center justify-center">
<Layers className="text-muted w-12 h-12" strokeWidth={1.75}/>
</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">
{series.name}
</h3>
<p className="text-text-secondary text-xs mt-0.5">
{series.books.length} {series.books.length > 1 ? t("seriesCard.books") : t("seriesCard.book")}
</p>
</div>
<Badge size="sm" floating>
{series.books.length}
</Badge>
</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'}
/>
{isExpanded && (
<>
<div className="flex items-center border-y border-secondary bg-tertiary">
{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}
/>
</div>
))}
<div className="flex items-center px-4 flex-shrink-0">
<IconButton icon={Settings} variant="ghost" size="lg" shape="square"
tooltip={t("seriesCard.settings")}
onClick={(): void => onSettingsClick(series.id)}/>
</div>
</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
className="w-3 border-y border-r border-secondary rounded-r-xl bg-tertiary flex-shrink-0"></div>
</>
)}
</div>
);
}

View File

@@ -3,21 +3,20 @@ 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";
import {useTranslations} from '@/lib/i18n';
import SettingsPanel from "@/components/ui/SettingsPanel";
interface SeriesSettingProps {
seriesId: string;
localSeries: boolean;
onClose: () => void;
}
export default function SeriesSetting({seriesId, localSeries, onClose}: SeriesSettingProps) {
export default function SeriesSetting({seriesId, onClose}: SeriesSettingProps) {
const t = useTranslations();
const [currentSetting, setCurrentSetting] = useState<string>('basic-information');
return (
<SeriesContext.Provider value={{seriesId, localSeries}}>
<SeriesContext.Provider value={{seriesId}}>
<SettingsPanel
title={t("bookList.seriesSettings")}
sidebar={

View File

@@ -1,11 +1,12 @@
'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';
import {useTranslations} from '@/lib/i18n';
import SectionHeader from "@/components/ui/SectionHeader";
import IconButton from "@/components/ui/IconButton";
import {Save} from 'lucide-react';
import PulseLoader from '@/components/ui/PulseLoader';
import {SeriesContext, SeriesContextProps} from '@/context/SeriesContext';
import {SettingRef} from "@/lib/types/settings";
// Lazy loaded components - avec ref (anciens)
const BasicSeriesInformation = lazy(function () {
@@ -29,32 +30,20 @@ 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 {seriesId}: SeriesContextProps = useContext<SeriesContextProps>(SeriesContext);
const settingRef: React.RefObject<SettingRef | null> = useRef<SettingRef>(null);
const showSaveButton: boolean = !selfManagedSettings.includes(setting);
function renderTitle(): string {
switch (setting) {
case 'basic-information':
@@ -73,41 +62,38 @@ export default function SeriesSettingOption({setting}: SeriesSettingOptionProps)
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"
<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={showSaveButton ? (
<IconButton icon={Save} variant="primary" onClick={handleSaveClick}/>
) : undefined}
/>
</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' && <BasicSeriesInformation ref={settingRef}/>}
{setting === 'books' && <SeriesBooksManager ref={settingRef}/>}
{setting === 'worlds' && (
<WorldSettings entityType="series" entityId={seriesId} showToggle={false}/>
<WorldSettings entityType="series" entityId={seriesId}/>
)}
{setting === 'locations' && (
<LocationSettings entityType="series" entityId={seriesId} showToggle={false}/>
<LocationSettings entityType="series" entityId={seriesId}/>
)}
{setting === 'characters' && (
<CharacterSettings entityType="series" entityId={seriesId} showToggle={false}/>
<CharacterSettings entityType="series" entityId={seriesId}/>
)}
{setting === 'spells' && (
<SpellSettings entityType="series" entityId={seriesId} showToggle={false}/>
<SpellSettings entityType="series" entityId={seriesId}/>
)}
{!['basic-information', 'books', 'worlds', 'locations', 'characters', 'spells'].includes(setting) && (
<div className="text-text-secondary py-4 text-center">

View File

@@ -1,34 +1,17 @@
'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 {Book, Globe, LucideIcon, Map, Pencil, Trash2, User, Wand2} from 'lucide-react';
import {useTranslations} from '@/lib/i18n';
import AlertBox from "@/components/ui/AlertBox";
import {SessionContext, SessionContextProps} 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";
import * as tauri from '@/lib/tauri';
import {AlertContext, AlertContextProps} from "@/context/AlertContext";
import {apiDelete} from '@/lib/api/client';
interface SeriesSettingOption {
id: string;
name: string;
icon: IconDefinition;
icon: LucideIcon;
}
interface SeriesSettingSidebarProps {
@@ -46,37 +29,21 @@ export default function SeriesSettingSidebar(
onClose
}: SeriesSettingSidebarProps) {
const t = useTranslations();
const {session} = useContext(SessionContext);
const {lang} = useContext<LangContextProps>(LangContext);
const {errorMessage, successMessage} = useContext(AlertContext);
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext);
const {errorMessage, successMessage}: AlertContextProps = useContext<AlertContextProps>(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, deletedAt: System.timeStampInSeconds()};
let success: boolean;
if (isCurrentlyOffline() || localSeries) {
success = await tauri.deleteSeries(deleteData.seriesId, deleteData.deletedAt);
} else {
success = await System.authDeleteToServer<boolean>(
'series/delete',
deleteData,
userToken,
lang
);
if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) {
addToQueue('delete_series', {data: deleteData});
}
}
const success: boolean = await apiDelete<boolean>(
'series/delete',
{seriesId: seriesId},
userToken,
lang
);
if (success) {
successMessage(t('seriesSetting.deleteSuccess'));
onClose();
@@ -97,71 +64,50 @@ export default function SeriesSettingSidebar(
}
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
}
{id: 'basic-information', name: 'seriesSetting.basicInformation', icon: Pencil},
{id: 'books', name: 'seriesSetting.books', icon: Book},
{id: 'characters', name: 'seriesSetting.characters', icon: User},
{id: 'worlds', name: 'seriesSetting.worlds', icon: Globe},
{id: 'locations', name: 'seriesSetting.locations', icon: Map},
{id: 'spells', name: 'seriesSetting.spells', icon: Wand2},
];
return (
<div className="py-6 px-3 flex flex-col h-full">
<nav className="space-y-1 flex-1">
{
settings.map((setting: SeriesSettingOption) => (
<Link
<div className="py-4 px-2 flex flex-col h-full">
<nav className="space-y-0.5 flex-1">
{settings.map((setting: SeriesSettingOption) => {
const Icon: LucideIcon = setting.icon;
const isActive: boolean = selectedSetting === setting.id;
return (
<button
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'}`}/>
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)}
</Link>
))
}
</button>
);
})}
</nav>
<div className="mt-6 pt-4 border-t border-secondary/50">
<div className="mt-4 pt-3 border-t border-secondary">
<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"
className="w-full flex items-center justify-center gap-2 px-3 py-2 text-sm text-accent-red hover:bg-accent-red/20 rounded-lg transition-colors duration-150"
>
<FontAwesomeIcon icon={faTrash} className="w-4 h-4"/>
<Trash2 className="w-4 h-4" strokeWidth={1.75}/>
{t('seriesSetting.deleteSeries')}
</button>
</div>
{showDeleteConfirm && (
<AlertBox
title={t('seriesSetting.deleteSeries')}

View File

@@ -1,61 +1,45 @@
'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 {apiGet, apiPut} from '@/lib/api/client';
import {AlertContext, AlertContextProps} from "@/context/AlertContext";
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 {useTranslations} from "next-intl";
import {useTranslations} from '@/lib/i18n';
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";
import * as tauri from '@/lib/tauri';
import {SeriesDetailResponse, SeriesUpdateResponse} from "@/lib/types/series";
import PulseLoader from '@/components/ui/PulseLoader';
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 {lang}: LangContextProps = useContext<LangContextProps>(LangContext);
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
const {seriesId}: SeriesContextProps = 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 {errorMessage, successMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
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 tauri.getSeriesDetail(seriesId);
} else {
response = await System.authGetQueryToServer<SeriesDetailResponse>(
'series/detail',
userToken,
lang,
{seriesid: seriesId}
);
}
const response: SeriesDetailResponse = await apiGet<SeriesDetailResponse>(
'series/detail',
userToken,
lang,
{seriesid: seriesId}
);
if (response) {
setName(response.name);
setDescription(response.description || '');
@@ -70,43 +54,25 @@ function BasicSeriesInformation(props: object, ref: React.Ref<{ handleSave: () =
setIsLoading(false);
}
}
useImperativeHandle(ref, function () {
return {
handleSave: handleSave
};
});
async function handleSave(): Promise<void> {
if (!name) {
errorMessage(t('seriesBasicInformation.error.nameRequired'));
return;
}
try {
const updateData = {
const response: SeriesUpdateResponse = await apiPut<SeriesUpdateResponse>('series/update', {
seriesId: seriesId,
name: name,
description: description
};
let success: boolean;
if (isCurrentlyOffline() || localSeries) {
success = await tauri.updateSeries(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('update_series', {data: updateData});
}
}
if (!success) {
}, userToken, lang);
if (!response.success) {
errorMessage(t('seriesBasicInformation.error.update'));
return;
}
@@ -119,32 +85,24 @@ function BasicSeriesInformation(props: object, ref: React.Ref<{ handleSave: () =
}
}
}
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/>;
}
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>
<InputField fieldName={t('seriesBasicInformation.fields.name')} input={<TextInput
value={name}
setValue={(e: ChangeEvent<HTMLInputElement>) => setName(e.target.value)}
placeholder={t('seriesBasicInformation.fields.namePlaceholder')}
/>}/>
<InputField fieldName={t('seriesBasicInformation.fields.description')} input={<TextAreaInput
value={description}
setValue={(e: ChangeEvent<HTMLTextAreaElement>) => setDescription(e.target.value)}
placeholder={t('seriesBasicInformation.fields.descriptionPlaceholder')}
/>}/>
</div>
);
}

View File

@@ -1,91 +1,64 @@
'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 {apiDelete, apiGet, apiPost, apiPut} from '@/lib/api/client';
import {AlertContext, AlertContextProps} from "@/context/AlertContext";
import {SessionContext, SessionContextProps} from "@/context/SessionContext";
import {useTranslations} from '@/lib/i18n';
import {LangContext, LangContextProps} from "@/context/LangContext";
import {SeriesContext, SeriesContextProps} from "@/context/SeriesContext";
import {SeriesBookProps} from "@/lib/models/Series";
import {SeriesBookProps} from "@/lib/types/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";
import * as tauri from '@/lib/tauri';
import {SyncedBook} from "@/lib/types/synced-book";
import {ArrowDown, ArrowUp, Book, Trash2} from 'lucide-react';
import PulseLoader from '@/components/ui/PulseLoader';
import InputField from '@/components/form/InputField';
import SelectBox from '@/components/form/SelectBox';
import IconButton from "@/components/ui/IconButton";
import EmptyState from "@/components/ui/EmptyState";
import Badge from "@/components/ui/Badge";
import EntityListItem from "@/components/ui/EntityListItem";
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 {lang}: LangContextProps = useContext<LangContextProps>(LangContext);
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
const {seriesId}: SeriesContextProps = useContext<SeriesContextProps>(SeriesContext);
const {
serverSyncedBooks,
setServerSyncedBooks
}: BooksSyncContextProps = 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 {errorMessage, successMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
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)
const booksInSeries: string[] = seriesBooks.map((book: SeriesBookProps) => book.bookId);
const filteredBooks: SyncedBook[] = serverSyncedBooks.filter(
(book: SyncedBook) => !booksInSeries.includes(book.id)
);
setAvailableBooks(filteredBooks);
}, [seriesBooks, serverSyncedBooks, localSyncedBooks, serverSyncedSeries, localSyncedSeries, isCurrentlyOffline, localSeries, seriesId]);
}, [seriesBooks, serverSyncedBooks]);
async function loadSeriesBooks(): Promise<void> {
setIsLoading(true);
try {
let response: SeriesBookProps[];
if (isCurrentlyOffline() || localSeries) {
response = await tauri.getSeriesBooks(seriesId);
} else {
response = await System.authGetQueryToServer<SeriesBookProps[]>(
'series/book/list',
userToken,
lang,
{seriesid: seriesId}
);
}
const response: SeriesBookProps[] = await apiGet<SeriesBookProps[]>(
'series/book/list',
userToken,
lang,
{seriesid: seriesId}
);
if (response) {
setSeriesBooks(response);
}
@@ -99,48 +72,36 @@ function SeriesBooksManager(props: object, ref: React.Ref<{ handleSave: () => Pr
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 tauri.addBookToSeries(addData.seriesId, addData.bookId);
} else {
response = await System.authPostToServer<boolean>(
'series/book/add',
addData,
userToken,
lang
);
if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) {
addToQueue('add_book_to_series', {data: addData});
}
}
const response: boolean = await apiPost<boolean>(
'series/book/add',
{
seriesId: seriesId,
bookId: selectedBookToAdd
},
userToken,
lang
);
if (response) {
const allBooks: SyncedBook[] = isCurrentlyOffline() || localSeries ? localSyncedBooks : serverSyncedBooks;
const addedBook: SyncedBook | undefined = allBooks.find(
const addedBook: SyncedBook | undefined = serverSyncedBooks.find(
(book: SyncedBook) => book.id === selectedBookToAdd
);
if (addedBook) {
@@ -151,7 +112,7 @@ function SeriesBooksManager(props: object, ref: React.Ref<{ handleSave: () => Pr
coverImage: null
};
setSeriesBooks([...seriesBooks, newSeriesBook]);
setServerSyncedBooks((prev: SyncedBook[]): SyncedBook[] =>
prev.map((book: SyncedBook): SyncedBook =>
book.id === selectedBookToAdd
@@ -170,31 +131,19 @@ function SeriesBooksManager(props: object, ref: React.Ref<{ handleSave: () => Pr
}
}
}
async function handleRemoveBook(bookId: string): Promise<void> {
try {
const removeData = {
seriesId: seriesId,
bookId: bookId,
deletedAt: System.timeStampInSeconds(),
};
let response: boolean;
if (isCurrentlyOffline() || localSeries) {
response = await tauri.removeBookFromSeries(removeData.seriesId, removeData.bookId, removeData.deletedAt);
} else {
response = await System.authDeleteToServer<boolean>(
'series/book/remove',
removeData,
userToken,
lang
);
if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) {
addToQueue('remove_book_from_series', {data: removeData});
}
}
const response: boolean = await apiDelete<boolean>(
'series/book/remove',
{
seriesId: seriesId,
bookId: bookId
},
userToken,
lang
);
if (response) {
const updatedBooks: SeriesBookProps[] = seriesBooks
.filter((book: SeriesBookProps) => book.bookId !== bookId)
@@ -203,7 +152,7 @@ function SeriesBooksManager(props: object, ref: React.Ref<{ handleSave: () => Pr
order: index + 1
}));
setSeriesBooks(updatedBooks);
setServerSyncedBooks((prev: SyncedBook[]): SyncedBook[] =>
prev.map((book: SyncedBook): SyncedBook =>
book.id === bookId
@@ -220,48 +169,37 @@ function SeriesBooksManager(props: object, ref: React.Ref<{ handleSave: () => Pr
}
}
}
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 tauri.reorderSeriesBooks(reorderData.seriesId, reorderData.booksOrder);
} else {
response = await System.authPutToServer<boolean>(
'series/book/reorder',
reorderData,
userToken,
lang
);
if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) {
addToQueue('reorder_series_books', {data: reorderData});
}
}
const response: boolean = await apiPut<boolean>(
'series/book/reorder',
{
seriesId: seriesId,
booksOrder: updatedBooks.map((book: SeriesBookProps) => ({
bookId: book.bookId,
order: book.order
}))
},
userToken,
lang
);
if (response) {
setSeriesBooks(updatedBooks);
}
@@ -273,96 +211,74 @@ function SeriesBooksManager(props: object, ref: React.Ref<{ handleSave: () => Pr
}
}
}
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/>;
}
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">
<InputField
fieldName={t('seriesBooks.addBook')}
input={
<SelectBox
onChangeCallBack={(e) => setSelectedBookToAdd(e.target.value)}
data={availableBooks.map((book: SyncedBook) => ({label: book.title, value: book.id}))}
defaultValue={selectedBookToAdd}
placeholder={t('seriesBooks.selectBookPlaceholder')}
/>
}
addButtonCallBack={handleAddBook}
isAddButtonDisabled={!selectedBookToAdd}
/>
<div>
<p className="text-text-secondary text-sm font-medium mb-3">
{t('seriesBooks.booksInSeries')} ({seriesBooks.length})
</h3>
</p>
{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>
<EmptyState icon={Book} title={t('seriesBooks.noBooks')}/>
) : (
<div className="space-y-2">
{seriesBooks
.sort((a: SeriesBookProps, b: SeriesBookProps) => a.order - b.order)
.map((book: SeriesBookProps, index: number) => (
<div
<EntityListItem
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>
onClick={function (): void {
}}
size="sm"
avatar={<Badge variant="primary" size="sm">{book.order}</Badge>}
title={book.title}
extra={
<div className="flex items-center gap-1">
<IconButton
icon={ArrowUp}
variant="ghost"
size="sm"
onClick={() => handleMoveBook(book.bookId, 'up')}
disabled={index === 0}
tooltip={t('seriesBooks.moveUp')}
/>
<IconButton
icon={ArrowDown}
variant="ghost"
size="sm"
onClick={() => handleMoveBook(book.bookId, 'down')}
disabled={index === seriesBooks.length - 1}
tooltip={t('seriesBooks.moveDown')}
/>
<IconButton
icon={Trash2}
variant="danger"
size="sm"
onClick={() => handleRemoveBook(book.bookId)}
tooltip={t('seriesBooks.removeBook')}
/>
</div>
}
/>
))}
</div>
)}