Files
ERitors-Scribe-Desktop/components/series/AddNewSeriesForm.tsx
natreex cfd08e3261 Bump app version to 0.5.0 and implement offline mode support across components
- Added offline detection logic with `OfflineContext` to improve app functionality in offline scenarios.
- Integrated Tauri IPC functions to handle local tool settings and character attributes when offline.
- Refined indentation logic in `TextEditor` for better compatibility with WebKit engines.
- Removed unused `indent` property and related settings in editor components to simplify configuration.
- Updated locale files with improved translation consistency and parameterized placeholders.
2026-03-24 22:45:10 -04:00

199 lines
9.1 KiB
TypeScript

'use client';
import React, {ChangeEvent, Dispatch, SetStateAction, useContext, useState} from "react";
import {AlertContext, AlertContextProps} from "@/context/AlertContext";
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
import {isDesktop} from '@/lib/configs';
import {apiPost} from '@/lib/api/client';
import {createSeries} from '@/lib/tauri';
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 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/types/synced-book";
interface AddNewSeriesFormProps {
setCloseForm: Dispatch<SetStateAction<boolean>>;
onSeriesCreated?: (seriesId: string, name: string) => void;
}
export default function AddNewSeriesForm({setCloseForm, onSeriesCreated}: AddNewSeriesFormProps) {
const t = useTranslations();
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext);
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
const {errorMessage, successMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
const {isCurrentlyOffline} = useContext(OfflineContext);
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 ?? '';
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 useLocal: boolean = isDesktop && isCurrentlyOffline();
const response: string = useLocal
? await createSeries({name, description: description || null, bookIds: selectedBookIds})
: await apiPost<string>('series/add', {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) {
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 (
<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>
{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
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>
</Modal>
);
}