- 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.
289 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|