Files
ERitors-Scribe-Desktop/components/leftbar/ScribeChapterComponent.tsx

335 lines
17 KiB
TypeScript

import {ChapterListProps} from "@/lib/types/chapter";
import React, {useContext, useEffect, useRef, useState} from "react";
import {apiDelete, apiGet, apiPost} from '@/lib/api/client';
import {isDesktop} from '@/lib/configs';
import * as tauri from '@/lib/tauri';
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
import {BooksSyncContext, BooksSyncContextProps} from '@/context/BooksSyncContext';
import {SyncedBook} from '@/lib/types/synced-book';
import {LocalSyncQueueContext, LocalSyncQueueContextProps} from '@/context/SyncQueueContext';
import {BookContext, BookContextProps} from "@/context/BookContext";
import {AlertContext, AlertContextProps} from "@/context/AlertContext";
import {ChapterContext, ChapterContextProps} from "@/context/ChapterContext";
import {SessionContext, SessionContextProps} from "@/context/SessionContext";
import {useRouter} from "@/lib/navigation";
import {FileText} from "lucide-react";
import ListItem from "@/components/ui/ListItem";
import AlertBox from "@/components/ui/AlertBox";
import {useTranslations} from '@/lib/i18n';
import InlineAddInput from "@/components/form/InlineAddInput";
import {LangContext, LangContextProps} from "@/context/LangContext";
export default function ScribeChapterComponent() {
const t = useTranslations();
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext);
const {book}: BookContextProps = useContext<BookContextProps>(BookContext);
const {chapter}: ChapterContextProps = useContext<ChapterContextProps>(ChapterContext);
const {errorMessage, successMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
const userToken: string = session?.accessToken ? session?.accessToken : '';
const {isCurrentlyOffline}: OfflineContextType = useContext<OfflineContextType>(OfflineContext);
const {addToQueue}: LocalSyncQueueContextProps = useContext<LocalSyncQueueContextProps>(LocalSyncQueueContext);
const {localSyncedBooks}: BooksSyncContextProps = useContext<BooksSyncContextProps>(BooksSyncContext);
const router = useRouter();
const [chapters, setChapters] = useState<ChapterListProps[]>([])
const [newChapterName, setNewChapterName] = useState<string>('');
const [newChapterOrder, setNewChapterOrder] = useState<number>(1);
const [deleteConfirmationMessage, setDeleteConfirmationMessage] = useState<boolean>(false);
const [removeChapterId, setRemoveChapterId] = useState<string>('');
const chapterRefs: React.RefObject<Map<string, HTMLDivElement>> = useRef<Map<string, HTMLDivElement>>(new Map());
const scrollContainerRef: React.RefObject<HTMLUListElement | null> = useRef<HTMLUListElement>(null);
useEffect((): void => {
if (book) {
getChapterList().then();
}
}, [book]);
useEffect((): void => {
setNewChapterOrder(getNextChapterOrder());
}, [chapters]);
useEffect((): void => {
if (chapter?.chapterId && scrollContainerRef.current) {
setTimeout((): void => {
const element: HTMLDivElement | undefined = chapterRefs.current.get(chapter.chapterId);
const container: HTMLUListElement | null = scrollContainerRef.current;
if (element && container) {
const containerRect: DOMRect = container.getBoundingClientRect();
const elementRect: DOMRect = element.getBoundingClientRect();
const relativeTop: number = elementRect.top - containerRect.top + container.scrollTop;
const scrollPosition: number = relativeTop - (containerRect.height / 2) + (elementRect.height / 2);
container.scrollTo({
top: Math.max(0, scrollPosition),
behavior: 'smooth'
});
}
}, 100);
}
}, [chapter?.chapterId]);
function getNextChapterOrder(): number {
const maxOrder: number = Math.max(0, ...chapters.map((chap: ChapterListProps) => chap.chapterOrder ?? 0));
return maxOrder + 1;
}
async function getChapterList(): Promise<void> {
try {
let response: ChapterListProps[];
if (isDesktop && (isCurrentlyOffline() || book?.localBook)) {
response = await tauri.getChapters(book?.bookId ?? '') as ChapterListProps[];
} else {
response = await apiGet<ChapterListProps[]>(`book/chapters?id=${book?.bookId}`, userToken, lang);
}
if (response) {
setChapters(response);
}
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t("scribeChapterComponent.errorFetchChapters"));
}
}
}
function navigateToChapter(chapterId: string): void {
router.push(`/book/${book?.bookId}/chapter/${chapterId}`);
}
async function handleChapterUpdate(chapterId: string, title: string, chapterOrder: number): Promise<void> {
try {
let response: boolean;
if (isDesktop && (isCurrentlyOffline() || book?.localBook)) {
response = await tauri.updateChapter(chapterId, title, chapterOrder);
} else {
const updateData = {chapterId, chapterOrder, title};
response = await apiPost<boolean>('chapter/update', updateData, userToken, lang);
if (isDesktop && localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === book?.bookId)) {
addToQueue('update_chapter', updateData);
}
}
if (!response) {
errorMessage(t("scribeChapterComponent.errorChapterUpdate"));
return;
}
successMessage(t("scribeChapterComponent.successUpdate"));
setChapters((prevState: ChapterListProps[]): ChapterListProps[] => {
return prevState.map((chapter: ChapterListProps): ChapterListProps => {
if (chapter.chapterId === chapterId) {
chapter.chapterOrder = chapterOrder;
chapter.title = title;
}
return chapter;
});
});
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t("scribeChapterComponent.errorChapterUpdateEn"));
}
}
}
async function handleDeleteConfirmation(chapterId: string): Promise<void> {
setDeleteConfirmationMessage(true);
setRemoveChapterId(chapterId);
}
async function handleDeleteChapter(): Promise<void> {
try {
setDeleteConfirmationMessage(false);
let response: boolean;
if (isDesktop && (isCurrentlyOffline() || book?.localBook)) {
response = await tauri.removeChapter(removeChapterId, book?.bookId ?? '', Date.now());
} else {
const deleteData = {bookId: book?.bookId, chapterId: removeChapterId};
response = await apiDelete<boolean>('chapter/remove', deleteData, userToken, lang);
if (isDesktop && localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === book?.bookId)) {
addToQueue('remove_chapter', {...deleteData, deletedAt: Date.now()});
}
}
if (!response) {
errorMessage(t("scribeChapterComponent.errorChapterDelete"));
return;
}
const updatedChapters: ChapterListProps[] = chapters.filter(
(chapter: ChapterListProps): boolean => chapter.chapterId !== removeChapterId,
);
setChapters(updatedChapters);
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t("scribeChapterComponent.unknownErrorChapterDelete"));
}
}
}
async function handleAddChapter(chapterOrder: number): Promise<void> {
if (!newChapterName && chapterOrder >= 0) {
errorMessage(t("scribeChapterComponent.errorChapterNameRequired"));
return;
}
const chapterTitle: string = chapterOrder >= 0 ? newChapterName : (book?.title ?? '');
try {
let chapterId: string;
if (isDesktop && (isCurrentlyOffline() || book?.localBook)) {
chapterId = await tauri.addChapter({
bookId: book?.bookId ?? '',
title: chapterTitle,
chapterOrder: chapterOrder,
});
} else {
const addData = {bookId: book?.bookId, chapterOrder, title: chapterTitle};
chapterId = await apiPost<string>('chapter/add', addData, userToken, lang);
if (isDesktop && localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === book?.bookId)) {
addToQueue('add_chapter', addData);
}
}
if (!chapterId) {
errorMessage(t("scribeChapterComponent.errorChapterSubmit", {chapterName: newChapterName}));
return;
}
const newChapter: ChapterListProps = {
chapterId: chapterId,
title: chapterTitle,
chapterOrder: chapterOrder
}
setChapters((prevState: ChapterListProps[]): ChapterListProps[] => {
return [newChapter, ...prevState]
})
navigateToChapter(chapterId);
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t("scribeChapterComponent.errorChapterSubmit", {chapterName: newChapterName}));
}
}
}
return (
<div className="flex-1 flex flex-col p-4 min-h-0">
<div className="mb-4">
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-3">{t("scribeChapterComponent.sheetHeading")}</h3>
<ul className="space-y-2">
{
chapters.filter((chap: ChapterListProps): boolean => {
return chap.chapterOrder !== undefined && chap.chapterOrder < 0;
})
.sort((a: ChapterListProps, b: ChapterListProps): number => {
const aOrder: number = a.chapterOrder ?? 0;
const bOrder: number = b.chapterOrder ?? 0;
return aOrder - bOrder;
}).map((chap: ChapterListProps): React.JSX.Element => (
<div key={chap.chapterId}
ref={(el: HTMLDivElement | null): void => {
if (el) {
chapterRefs.current.set(chap.chapterId, el);
} else {
chapterRefs.current.delete(chap.chapterId);
}
}}>
<ListItem icon={FileText}
onClick={(): void => navigateToChapter(chap.chapterId)}
selectedId={chapter?.chapterId ?? ''}
id={chap.chapterId}
text={chap.title}/>
</div>
))
}
{
chapters.filter((chap: ChapterListProps): boolean => {
return chap.chapterOrder !== undefined && chap.chapterOrder < 0;
}).length === 0 &&
<li onClick={(): Promise<void> => handleAddChapter(-1)}
className="group p-3 rounded-xl hover:bg-tertiary cursor-pointer transition-colors duration-150">
<span
className="text-sm font-medium text-muted group-hover:text-text-primary transition-colors">
{t("scribeChapterComponent.createSheet")}
</span>
</li>
}
</ul>
</div>
<div className="flex-1 flex flex-col mt-6 min-h-0">
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-3">{t("scribeChapterComponent.chaptersHeading")}</h3>
<ul ref={scrollContainerRef} className="flex-1 space-y-2 overflow-y-auto pr-2 min-h-0">
{
chapters.filter((chap: ChapterListProps): boolean => {
return !(chap.chapterOrder && chap.chapterOrder < 0);
})
.sort((a: ChapterListProps, b: ChapterListProps): number => {
const aOrder: number = a.chapterOrder ?? 0;
const bOrder: number = b.chapterOrder ?? 0;
return aOrder - bOrder;
}).map((chap: ChapterListProps): React.JSX.Element => (
<div key={chap.chapterId}
ref={(el: HTMLDivElement | null): void => {
if (el) {
chapterRefs.current.set(chap.chapterId, el);
} else {
chapterRefs.current.delete(chap.chapterId);
}
}}>
<ListItem onClick={(): void => navigateToChapter(chap.chapterId)}
isEditable={true}
handleUpdate={handleChapterUpdate}
handleDelete={handleDeleteConfirmation}
selectedId={chapter?.chapterId ?? ''}
id={chap.chapterId} text={chap.title}
numericalIdentifier={chap.chapterOrder}
onReorder={(chapterId: string, newOrder: number): void => {
setChapters((previousChapters: ChapterListProps[]): ChapterListProps[] =>
previousChapters.map((chapter: ChapterListProps): ChapterListProps =>
chapter.chapterId === chapterId ? {
...chapter,
chapterOrder: newOrder
} : chapter
)
);
}}/>
</div>
))
}
</ul>
<div className="mt-2 shrink-0">
<InlineAddInput
value={newChapterName}
setValue={setNewChapterName}
numericalValue={newChapterOrder}
setNumericalValue={setNewChapterOrder}
placeholder={t("scribeChapterComponent.addChapterPlaceholder")}
onAdd={async (): Promise<void> => {
await handleAddChapter(newChapterOrder);
setNewChapterName("");
}}
showNumericalInput={true}
/>
</div>
</div>
{
deleteConfirmationMessage &&
<AlertBox title={t("scribeChapterComponent.deleteChapterTitle")}
message={t("scribeChapterComponent.deleteChapterMessage")}
type={"danger"} onConfirm={(): Promise<void> => handleDeleteChapter()}
onCancel={(): void => setDeleteConfirmationMessage(false)}/>
}
</div>
)
}