Files
ERitors-Scribe-Desktop/components/book/settings/ExportSetting.tsx
natreex 64ed90d993 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.
2026-03-22 22:37:31 -04:00

289 lines
14 KiB
TypeScript

'use client'
import React, {useCallback, useContext, useEffect, useMemo, useState} from 'react';
import {Check} from 'lucide-react';
import Button from '@/components/ui/Button';
import PulseLoader from '@/components/ui/PulseLoader';
import {useTranslations} from '@/lib/i18n';
import {BookContext, BookContextProps} from '@/context/BookContext';
import {SessionContext, SessionContextProps} from '@/context/SessionContext';
import {AlertContext, AlertContextProps} from '@/context/AlertContext';
import {configs, isDesktop} from '@/lib/configs';
import {apiGet} from '@/lib/api/client';
import * as tauri from '@/lib/tauri';
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
import {LangContext, LangContextProps} from '@/context/LangContext';
import {ChapterExportInfo, ChapterExportSelection, ExportFormat,} from '@/lib/types/chapter';
import {chapterVersions} from '@/lib/constants/chapter';
import {SelectBoxProps} from '@/components/form/SelectBox';
const formats: ExportFormat[] = ['epub', 'pdf', 'docx'];
const formatExtensions: Record<ExportFormat, string> = {
epub: '.epub',
pdf: '.pdf',
docx: '.docx',
};
export default function ExportSetting(): React.JSX.Element {
const t = useTranslations();
const {book}: BookContextProps = useContext<BookContextProps>(BookContext);
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
const {successMessage, errorMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext);
const {isCurrentlyOffline}: OfflineContextType = useContext<OfflineContextType>(OfflineContext);
const [selectedFormat, setSelectedFormat] = useState<ExportFormat>('epub');
const [chaptersExportInfo, setChaptersExportInfo] = useState<ChapterExportInfo[]>([]);
const [chapterSelections, setChapterSelections] = useState<ChapterExportSelection[]>([]);
const [isLoadingChapters, setIsLoadingChapters] = useState<boolean>(true);
const [isExporting, setIsExporting] = useState<boolean>(false);
const selectedCount: number = useMemo(
() => chapterSelections.filter((selection: ChapterExportSelection): boolean => selection.selected).length,
[chapterSelections],
);
const allSelected: boolean = useMemo(
() => chapterSelections.length > 0 && chapterSelections.every((selection: ChapterExportSelection): boolean => selection.selected),
[chapterSelections],
);
const canExport: boolean = useMemo(
() => !isExporting && selectedCount > 0,
[isExporting, selectedCount],
);
const fetchChaptersExportInfo = useCallback(async (): Promise<void> => {
if (!book?.bookId) {
setIsLoadingChapters(false);
return;
}
setIsLoadingChapters(true);
try {
let data: ChapterExportInfo[];
if (isDesktop && (isCurrentlyOffline() || book?.localBook)) {
data = await tauri.getBookExportInfo(book.bookId) as ChapterExportInfo[];
} else {
data = await apiGet<ChapterExportInfo[]>(
'book/chapters/export-info', session.accessToken, lang, {id: book.bookId}
);
}
setChaptersExportInfo(data);
setChapterSelections(
data.map((chapter: ChapterExportInfo): ChapterExportSelection => ({
chapterId: chapter.chapterId,
version: chapter.availableVersions.length > 0
? chapter.availableVersions[chapter.availableVersions.length - 1]
: 1,
selected: true,
})),
);
} catch {
errorMessage(t('exportOption.serverError'));
} finally {
setIsLoadingChapters(false);
}
}, [book?.bookId, session.accessToken, lang, errorMessage, t]);
useEffect((): void => {
fetchChaptersExportInfo();
}, [fetchChaptersExportInfo]);
function toggleChapterSelection(chapterId: string): void {
setChapterSelections((previous: ChapterExportSelection[]): ChapterExportSelection[] =>
previous.map((selection: ChapterExportSelection): ChapterExportSelection =>
selection.chapterId === chapterId
? {...selection, selected: !selection.selected}
: selection,
),
);
}
function toggleAllChapters(): void {
const newSelected: boolean = !allSelected;
setChapterSelections((previous: ChapterExportSelection[]): ChapterExportSelection[] =>
previous.map((selection: ChapterExportSelection): ChapterExportSelection => ({
...selection,
selected: newSelected,
})),
);
}
function setChapterVersion(chapterId: string, version: number): void {
setChapterSelections((previous: ChapterExportSelection[]): ChapterExportSelection[] =>
previous.map((selection: ChapterExportSelection): ChapterExportSelection =>
selection.chapterId === chapterId
? {...selection, version}
: selection,
),
);
}
function getVersionLabel(version: number): string {
const match: SelectBoxProps | undefined = chapterVersions.find(
(chapterVersion: SelectBoxProps): boolean => chapterVersion.value === String(version),
);
return match ? t(match.label) : `V${version}`;
}
async function handleExport(): Promise<void> {
if (!book?.bookId) {
errorMessage(t('exportOption.noBookSelected'));
return;
}
if (selectedCount === 0) {
errorMessage(t('exportOption.noChaptersSelected'));
return;
}
setIsExporting(true);
const selectedChapters: { chapterId: string; version: number }[] = chapterSelections
.filter((selection: ChapterExportSelection): boolean => selection.selected)
.map((selection: ChapterExportSelection): { chapterId: string; version: number } => ({
chapterId: selection.chapterId,
version: selection.version,
}));
try {
const response: Response = await fetch(`${configs.apiUrl}book/export`, {
method: 'POST',
headers: {
Authorization: `Bearer ${session.accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
bookId: book.bookId,
format: selectedFormat,
chapters: selectedChapters,
}),
});
if (!response.ok) {
errorMessage(t('exportOption.downloadError'));
return;
}
const blob: Blob = await response.blob();
const bookName: string = book.subTitle ? `${book.title} - ${book.subTitle}` : book.title;
const fileName: string = `${bookName}${formatExtensions[selectedFormat]}`;
const virtualUrl: string = window.URL.createObjectURL(blob);
const downloadLink: HTMLAnchorElement = document.createElement('a');
downloadLink.href = virtualUrl;
downloadLink.download = fileName;
document.body.appendChild(downloadLink);
downloadLink.click();
downloadLink.remove();
window.URL.revokeObjectURL(virtualUrl);
successMessage(t('exportOption.downloadSuccess', {format: selectedFormat.toUpperCase()}));
} catch {
errorMessage(t('exportOption.unknownError'));
} finally {
setIsExporting(false);
}
}
return (
<div className="p-4 space-y-6">
<div>
<h3 className="text-sm font-semibold text-text-primary mb-3">
{t('exportOption.formatLabel')}
</h3>
<div className="flex gap-3">
{formats.map((format: ExportFormat) => (
<Button
key={format}
variant={selectedFormat === format ? 'primary' : 'secondary'}
size="sm"
onClick={(): void => setSelectedFormat(format)}
>
{format.toUpperCase()}
</Button>
))}
</div>
</div>
<div>
<h3 className="text-sm font-semibold text-text-primary mb-3">
{t('exportOption.chapters')}
</h3>
{isLoadingChapters ? (
<PulseLoader text={t('exportOption.loadingChapters')} size="sm"/>
) : chaptersExportInfo.length === 0 ? (
<p className="text-text-secondary text-sm text-center py-8">
{t('exportOption.noChaptersAvailable')}
</p>
) : (
<>
<div className="mb-3">
<Button variant="ghost" size="sm" onClick={toggleAllChapters}>
{allSelected ? t('exportOption.deselectAll') : t('exportOption.selectAll')}
</Button>
</div>
<div className="space-y-1">
{chaptersExportInfo.map((chapter: ChapterExportInfo, index: number) => {
const selection: ChapterExportSelection = chapterSelections[index];
return (
<div key={chapter.chapterId} className="border-b border-secondary pb-2">
<button
onClick={(): void => toggleChapterSelection(chapter.chapterId)}
className="flex items-center gap-3 w-full py-2 text-left hover:bg-tertiary rounded-lg px-2 transition-all"
>
<div
className={`w-5 h-5 rounded flex items-center justify-center border transition-colors ${
selection.selected
? 'bg-primary/20 border-primary'
: 'border-secondary bg-tertiary'
}`}>
{selection.selected && (
<Check className="w-3 h-3 text-text-primary" strokeWidth={1.75}/>
)}
</div>
<span className={`text-sm font-medium truncate ${
selection.selected ? 'text-text-primary' : 'text-text-secondary'
}`}>
{chapter.chapterOrder !== -1 ? `${chapter.chapterOrder}. ` : ''}
{chapter.title}
</span>
</button>
{selection.selected && chapter.availableVersions.length > 1 && (
<div className="flex flex-wrap gap-2 ml-10 mt-1 mb-1">
{chapter.availableVersions.map((version: number) => (
<button
key={version}
onClick={(): void => setChapterVersion(chapter.chapterId, version)}
className={`px-3 py-1 rounded-full text-xs font-medium transition-all border ${
selection.version === version
? 'bg-primary/20 text-primary border-primary/50'
: 'bg-tertiary text-text-secondary border-secondary hover:bg-secondary'
}`}
>
{getVersionLabel(version)}
</button>
))}
</div>
)}
</div>
);
})}
</div>
</>
)}
</div>
<Button variant="primary" size="lg" onClick={handleExport} disabled={!canExport}
isLoading={isExporting} loadingText={t('exportOption.exporting')}
fullWidth>
{t('exportOption.exportButton')} {selectedFormat.toUpperCase()}
{selectedCount > 0 ? ` (${selectedCount})` : ''}
</Button>
</div>
);
}