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:
@@ -1,228 +1,288 @@
|
||||
'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 React, {useCallback, useContext, useEffect, useState} from 'react';
|
||||
import {useTranslations} from 'next-intl';
|
||||
import {BookContext} from '@/context/BookContext';
|
||||
import {AlertContext} from '@/context/AlertContext';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {faDownload, faSpinner} from '@fortawesome/free-solid-svg-icons';
|
||||
import {
|
||||
ChapterExportInfo,
|
||||
ChapterExportSelection,
|
||||
chapterVersions,
|
||||
ExportFormat
|
||||
} from '@/lib/models/Chapter';
|
||||
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 exportFormats: {value: ExportFormat; label: string}[] = [
|
||||
{value: 'epub', label: 'EPUB'},
|
||||
{value: 'pdf', label: 'PDF'},
|
||||
{value: 'docx', label: 'DOCX'},
|
||||
];
|
||||
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} = useContext(BookContext);
|
||||
const {errorMessage, successMessage} = useContext(AlertContext);
|
||||
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 [format, setFormat] = useState<ExportFormat>('epub');
|
||||
const [chapters, setChapters] = useState<ChapterExportInfo[]>([]);
|
||||
const [selections, setSelections] = useState<ChapterExportSelection[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
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 loadChapters = useCallback(async (): Promise<void> => {
|
||||
if (!book) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const chaptersInfo: ChapterExportInfo[] = await tauri.getBookExportInfo(book.bookId) as ChapterExportInfo[];
|
||||
setChapters(chaptersInfo);
|
||||
const initialSelections: ChapterExportSelection[] = chaptersInfo.map(
|
||||
(ch: ChapterExportInfo): ChapterExportSelection => ({
|
||||
chapterId: ch.chapterId,
|
||||
version: ch.availableVersions[ch.availableVersions.length - 1],
|
||||
selected: true
|
||||
})
|
||||
);
|
||||
setSelections(initialSelections);
|
||||
} catch {
|
||||
errorMessage(t('exportOption.error'));
|
||||
} finally {
|
||||
setIsLoading(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;
|
||||
}
|
||||
}, [book]);
|
||||
|
||||
useEffect((): void => {
|
||||
loadChapters();
|
||||
}, [loadChapters]);
|
||||
|
||||
function toggleChapter(chapterId: string): void {
|
||||
setSelections((prev: ChapterExportSelection[]): ChapterExportSelection[] =>
|
||||
prev.map((s: ChapterExportSelection): ChapterExportSelection =>
|
||||
s.chapterId === chapterId ? {...s, selected: !s.selected} : s
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function toggleAll(selectAll: boolean): void {
|
||||
setSelections((prev: ChapterExportSelection[]): ChapterExportSelection[] =>
|
||||
prev.map((s: ChapterExportSelection): ChapterExportSelection => ({...s, selected: selectAll}))
|
||||
);
|
||||
}
|
||||
|
||||
function updateVersion(chapterId: string, version: number): void {
|
||||
setSelections((prev: ChapterExportSelection[]): ChapterExportSelection[] =>
|
||||
prev.map((s: ChapterExportSelection): ChapterExportSelection =>
|
||||
s.chapterId === chapterId ? {...s, version} : s
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function getVersionLabel(version: number): string {
|
||||
const found = chapterVersions.find((v) => v.value === String(version));
|
||||
return found ? t(found.label) : `v${version}`;
|
||||
}
|
||||
|
||||
async function handleExport(): Promise<void> {
|
||||
if (!book) return;
|
||||
setIsExporting(true);
|
||||
setIsLoadingChapters(true);
|
||||
try {
|
||||
const selectedChapters = selections
|
||||
.filter((s: ChapterExportSelection): boolean => s.selected)
|
||||
.map((s: ChapterExportSelection) => ({chapterId: s.chapterId, version: s.version}));
|
||||
|
||||
const result: boolean = await tauri.exportBook({
|
||||
bookId: book.bookId,
|
||||
format,
|
||||
selections: selectedChapters.length === chapters.length ? null : selectedChapters
|
||||
});
|
||||
|
||||
if (result) {
|
||||
successMessage(t('exportOption.success'));
|
||||
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.error'));
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
const allSelected: boolean = selections.every((s: ChapterExportSelection): boolean => s.selected);
|
||||
const hasSelection: boolean = selections.some((s: ChapterExportSelection): boolean => s.selected);
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-4">
|
||||
<div className="p-4 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-text-primary font-semibold mb-2">{t('exportOption.format')}</h3>
|
||||
<h3 className="text-sm font-semibold text-text-primary mb-3">
|
||||
{t('exportOption.formatLabel')}
|
||||
</h3>
|
||||
<div className="flex gap-3">
|
||||
{exportFormats.map(({value, label}) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={(): void => setFormat(value)}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-all duration-200 ${
|
||||
format === value
|
||||
? 'bg-primary text-white shadow-md'
|
||||
: 'bg-secondary/30 text-text-secondary hover:bg-secondary/50'
|
||||
}`}
|
||||
{formats.map((format: ExportFormat) => (
|
||||
<Button
|
||||
key={format}
|
||||
variant={selectedFormat === format ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={(): void => setSelectedFormat(format)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{format.toUpperCase()}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-text-primary font-semibold">{t('exportOption.chapters')}</h3>
|
||||
{chapters.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(): void => toggleAll(!allSelected)}
|
||||
className="text-sm text-primary hover:text-primary/80 transition-colors"
|
||||
>
|
||||
{allSelected ? t('exportOption.deselectAll') : t('exportOption.selectAll')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<FontAwesomeIcon icon={faSpinner} className="w-6 h-6 text-primary animate-spin mr-2"/>
|
||||
<span className="text-text-secondary">{t('exportOption.loadingChapters')}</span>
|
||||
</div>
|
||||
) : chapters.length === 0 ? (
|
||||
<p className="text-text-secondary text-center py-8">{t('exportOption.noChapters')}</p>
|
||||
<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="space-y-2 max-h-96 overflow-y-auto pr-1">
|
||||
{chapters.map((chapter: ChapterExportInfo) => {
|
||||
const selection: ChapterExportSelection | undefined = selections.find(
|
||||
(s: ChapterExportSelection): boolean => s.chapterId === chapter.chapterId
|
||||
);
|
||||
if (!selection) return null;
|
||||
<>
|
||||
<div className="mb-3">
|
||||
<Button variant="ghost" size="sm" onClick={toggleAllChapters}>
|
||||
{allSelected ? t('exportOption.deselectAll') : t('exportOption.selectAll')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
return (
|
||||
<div
|
||||
key={chapter.chapterId}
|
||||
className={`flex items-center justify-between p-3 rounded-lg transition-all duration-200 ${
|
||||
selection.selected
|
||||
? 'bg-primary/10 border border-primary/30'
|
||||
: 'bg-secondary/20 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<label className="flex items-center gap-3 cursor-pointer flex-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selection.selected}
|
||||
onChange={(): void => toggleChapter(chapter.chapterId)}
|
||||
className="w-4 h-4 rounded accent-primary cursor-pointer"
|
||||
/>
|
||||
<span className={`text-sm ${
|
||||
selection.selected ? 'text-text-primary' : 'text-text-secondary'
|
||||
}`}>
|
||||
{chapter.title}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{chapter.availableVersions.length > 1 && selection.selected && (
|
||||
<select
|
||||
value={selection.version}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>): void =>
|
||||
updateVersion(chapter.chapterId, parseInt(e.target.value, 10))
|
||||
}
|
||||
className="text-xs bg-secondary/50 text-text-primary rounded-md px-2 py-1 border border-secondary/30 focus:outline-none focus:border-primary"
|
||||
<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"
|
||||
>
|
||||
{chapter.availableVersions.map((v: number) => (
|
||||
<option key={v} value={v}>
|
||||
{getVersionLabel(v)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<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
|
||||
type="button"
|
||||
onClick={handleExport}
|
||||
disabled={isExporting || !hasSelection || isLoading}
|
||||
className="w-full flex items-center justify-center gap-2 bg-primary hover:bg-primary/90 disabled:bg-primary/50 text-white font-semibold py-3 px-6 rounded-xl transition-all duration-200 shadow-md hover:shadow-lg disabled:cursor-not-allowed"
|
||||
>
|
||||
{isExporting ? (
|
||||
<>
|
||||
<FontAwesomeIcon icon={faSpinner} className="w-4 h-4 animate-spin"/>
|
||||
{t('exportOption.exporting')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FontAwesomeIcon icon={faDownload} className="w-4 h-4"/>
|
||||
{t('exportOption.export')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user