Remove ExportBook component and integrate new export workflows

- Deleted `ExportBook` component and its usage in `BookCard.tsx`.
- Integrated improved book export workflows in `BookSettingOption` for better user experience.
- Updated database models and repositories to support export options with chapter/version selection.
- Added localization support for export-related messages and tooltips.
- Upgraded dependencies to include libraries required for export formats (e.g., DOCX, PDF, EPUB).
- Bumped app version to 0.4.1.
This commit is contained in:
natreex
2026-03-05 16:31:56 -05:00
parent 94cac463fb
commit ceaecb19fc
16 changed files with 780 additions and 245 deletions

View File

@@ -1,190 +0,0 @@
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {faDownload} from "@fortawesome/free-solid-svg-icons";
import {useContext, useRef, useState} from "react";
import {SessionContext} from "@/context/SessionContext";
import {AlertContext} from "@/context/AlertContext";
import {configs} from "@/lib/configs";
interface CreateEpubProps {
bookId: string;
bookTitle: string;
}
export default function ExportBook({bookId, bookTitle}: CreateEpubProps) {
const {session} = useContext(SessionContext);
const {successMessage, errorMessage} = useContext(AlertContext);
const [showMenu, setShowMenu] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
function handleClickOutside(event: MouseEvent): void {
if (
menuRef.current &&
buttonRef.current &&
!menuRef.current.contains(event.target as Node) &&
!buttonRef.current.contains(event.target as Node)
) {
setShowMenu(false);
document.removeEventListener("mousedown", handleClickOutside);
}
}
function toggleMenu(): void {
if (!showMenu) {
setTimeout((): void => {
document.addEventListener("mousedown", handleClickOutside);
}, 0);
} else {
document.removeEventListener("mousedown", handleClickOutside);
}
setShowMenu(!showMenu);
}
async function handleDownloadEpub() {
try {
const response = await fetch(
`${configs.apiUrl}book/transform/epub?id=${bookId}`,
{
method: "GET",
headers: {
Authorization: `Bearer ${session.accessToken}`,
},
}
);
if (!response.ok) {
errorMessage(`Échec du téléchargement du EPUB.`);
return;
}
const blob = await response.blob();
const virtualUrl = window.URL.createObjectURL(blob);
const aLink = document.createElement("a");
aLink.href = virtualUrl;
aLink.download = `${bookTitle}.epub`;
document.body.appendChild(aLink);
aLink.click();
aLink.remove();
window.URL.revokeObjectURL(virtualUrl);
setShowMenu(false);
successMessage(`Votre fichier EPUB a été téléchargé.`);
} catch (error) {
console.error(`Error downloading EPUB:`, error);
errorMessage(`Une erreur est survenue lors du téléchargement.`);
}
}
async function handleDownloadPdf() {
try {
const response = await fetch(
`${configs.apiUrl}book/transform/pdf?id=${bookId}`,
{
method: "GET",
headers: {
Authorization: `Bearer ${session.accessToken}`,
},
}
);
if (!response.ok) {
errorMessage(`Échec du téléchargement du PDF.`);
return;
}
const blob = await response.blob();
const virtualUrl = window.URL.createObjectURL(blob);
const aLink = document.createElement("a");
aLink.href = virtualUrl;
aLink.download = `${bookTitle}.pdf`;
document.body.appendChild(aLink);
aLink.click();
aLink.remove();
window.URL.revokeObjectURL(virtualUrl);
setShowMenu(false);
successMessage(`Votre fichier PDF a été téléchargé.`);
} catch (error) {
console.error(`Error downloading PDF:`, error);
errorMessage(`Une erreur est survenue lors du téléchargement.`);
}
}
async function handleDownloadDocx() {
try {
const response = await fetch(
`${configs.apiUrl}book/transform/docx?id=${bookId}`,
{
method: "GET",
headers: {
Authorization: `Bearer ${session.accessToken}`,
},
}
);
if (!response.ok) {
errorMessage(`Échec du téléchargement du DOCX.`);
return;
}
const blob = await response.blob();
const virtualUrl = window.URL.createObjectURL(blob);
const aLink = document.createElement("a");
aLink.href = virtualUrl;
aLink.download = `${bookTitle}.docx`;
document.body.appendChild(aLink);
aLink.click();
aLink.remove();
window.URL.revokeObjectURL(virtualUrl);
setShowMenu(false);
successMessage(`Votre fichier DOCX a été téléchargé.`);
} catch (error) {
console.error(`Error downloading DOCX:`, error);
errorMessage(`Une erreur est survenue lors du téléchargement.`);
}
}
return (
<div className="relative">
<button
ref={buttonRef}
onClick={toggleMenu}
className="text-muted hover:text-primary transition-all duration-200 p-1.5 rounded-lg hover:bg-secondary/50 hover:scale-110"
>
<FontAwesomeIcon icon={faDownload} className={'w-4 h-4'}/>
</button>
{showMenu && (
<div
ref={menuRef}
className="absolute z-50 bg-tertiary/90 backdrop-blur-sm shadow-2xl rounded-xl border border-secondary/50"
style={{
width: '110px',
right: '-30px',
top: '100%',
marginTop: '8px',
}}
>
<ul className="py-2">
<li
className="px-3 py-2 hover:bg-secondary cursor-pointer text-sm text-muted hover:text-text-primary transition-all duration-200 hover:scale-105 font-medium"
onClick={handleDownloadEpub}
>
EPUB
</li>
<li
className="px-3 py-2 hover:bg-secondary cursor-pointer text-sm text-muted hover:text-text-primary transition-all duration-200 hover:scale-105 font-medium"
onClick={handleDownloadPdf}
>
PDF
</li>
<li
className="px-3 py-2 hover:bg-secondary cursor-pointer text-sm text-muted hover:text-text-primary transition-all duration-200 hover:scale-105 font-medium"
onClick={handleDownloadDocx}
>
DOCX
</li>
</ul>
</div>
)}
</div>
);
}

View File

@@ -1,11 +1,9 @@
// Removed Next.js Link import for Electron
import {BookProps} from "@/lib/models/Book";
import DeleteBook from "@/components/book/settings/DeleteBook";
import ExportBook from "@/components/ExportBook";
import {useTranslations} from "next-intl";
import SyncBook from "@/components/SyncBook";
import {SyncType} from "@/context/BooksSyncContext";
import {useEffect} from "react";
interface BookCardProps {
book: BookProps;
@@ -68,7 +66,6 @@ export default function BookCard({book, onClickCallback, index, syncStatus}: Boo
<div className="flex justify-between items-center pt-3 border-t border-secondary/30">
<SyncBook status={syncStatus} bookId={book.bookId}/>
<div className="flex items-center gap-1" {...index === 0 && {'data-guide': 'bottom-book-card'}}>
<ExportBook bookTitle={book.title} bookId={book.bookId}/>
<DeleteBook bookId={book.bookId}/>
</div>
</div>

View File

@@ -1,10 +1,10 @@
import {useContext, useEffect, useState} from "react";
import {useContext, useEffect, useRef, useState} from "react";
import System from "@/lib/models/System";
import {AlertContext} from "@/context/AlertContext";
import {BookContext} from "@/context/BookContext";
import SearchBook from "./SearchBook";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {faBook, faDownload, faGear, faTrash} from "@fortawesome/free-solid-svg-icons";
import {faBook, faChevronLeft, faChevronRight, faDownload, faGear, faTrash} from "@fortawesome/free-solid-svg-icons";
import {SessionContext} from "@/context/SessionContext";
import Book, {BookProps} from "@/lib/models/Book";
import BookCard from "@/components/book/BookCard";
@@ -55,6 +55,7 @@ export default function BookList() {
const [isLoadingBooks, setIsLoadingBooks] = useState<boolean>(true);
const [showSeriesSettingId, setShowSeriesSettingId] = useState<string | null>(null);
const [isLocalSeries, setIsLocalSeries] = useState<boolean>(false);
const carouselRefs = useRef<Record<string, HTMLDivElement | null>>({});
const [bookGuide, setBookGuide] = useState<boolean>(false);
@@ -447,6 +448,17 @@ export default function BookList() {
}
}
function scrollCarousel(category: string, direction: 'left' | 'right'): void {
const container: HTMLDivElement | null = carouselRefs.current[category];
if (!container) return;
const cardWidth: number = container.querySelector<HTMLDivElement>(':scope > div')?.offsetWidth || 250;
const scrollAmount: number = cardWidth * 2;
container.scrollBy({
left: direction === 'left' ? -scrollAmount : scrollAmount,
behavior: 'smooth'
});
}
function handleSeriesSettingsClick(seriesId: string): void {
const isLocal: boolean = isCurrentlyOffline() ||
Boolean(localOnlySeries.find((s: SyncedSeries): boolean => s.id === seriesId));
@@ -464,7 +476,7 @@ export default function BookList() {
<SearchBook searchQuery={searchQuery} setSearchQuery={setSearchQuery}/>
</div>
)}
<div className="flex flex-col w-full overflow-y-auto h-full min-h-0 flex-grow">
<div className="flex flex-col w-full overflow-y-auto overflow-x-hidden h-full min-h-0 flex-grow">
{
isLoadingBooks ? (
<>
@@ -511,36 +523,55 @@ export default function BookList() {
</span>
</div>
<div className="flex items-start justify-center w-full px-4 overflow-x-auto pb-4">
{items.map((item, idx) => {
if (item.type === 'book' && item.book) {
return (
<div key={item.book.bookId}
{...(idx === 0 && {'data-guide': 'book-card'})}
className={`flex-shrink-0 w-64 sm:w-52 md:w-48 lg:w-56 xl:w-64 p-2 box-border ${User.guideTourDone(session.user?.guideTour || [], 'new-first-book') && 'mb-[200px]'}`}>
<BookCard
book={item.book}
syncStatus={detectBookSyncStatus(item.book.bookId)}
onClickCallback={handleBookClick}
index={idx}
<div className="group relative w-full">
<button
onClick={() => scrollCarousel(category, 'left')}
className="absolute left-3 top-1/2 -translate-y-1/2 z-10 bg-primary/80 backdrop-blur-sm hover:bg-primary text-white rounded-2xl w-12 h-12 flex items-center justify-center shadow-xl border border-primary-light/30 transition-all duration-200 opacity-0 group-hover:opacity-100 hover:scale-110"
>
<FontAwesomeIcon icon={faChevronLeft} className="w-5 h-5"/>
</button>
<div
ref={(el: HTMLDivElement | null) => { carouselRefs.current[category] = el; }}
className="flex items-start w-full overflow-hidden px-4 gap-2 scroll-smooth"
>
{items.map((item, idx) => {
if (item.type === 'book' && item.book) {
return (
<div key={item.book.bookId}
{...(idx === 0 && {'data-guide': 'book-card'})}
className={`flex-shrink-0 w-64 sm:w-52 md:w-48 lg:w-56 xl:w-64 p-2 box-border ${User.guideTourDone(session.user?.guideTour || [], 'new-first-book') && 'mb-[200px]'}`}>
<BookCard
book={item.book}
syncStatus={detectBookSyncStatus(item.book.bookId)}
onClickCallback={handleBookClick}
index={idx}
/>
</div>
);
}
if (item.type === 'series' && item.series) {
return (
<SeriesCard
key={item.series.id}
series={item.series}
onBookClick={handleBookClick}
onSettingsClick={handleSeriesSettingsClick}
getSyncStatus={detectBookSyncStatus}
seriesSyncStatus={detectSeriesSyncStatus(item.series.id)}
/>
</div>
);
}
if (item.type === 'series' && item.series) {
return (
<SeriesCard
key={item.series.id}
series={item.series}
onBookClick={handleBookClick}
onSettingsClick={handleSeriesSettingsClick}
getSyncStatus={detectBookSyncStatus}
seriesSyncStatus={detectSeriesSyncStatus(item.series.id)}
/>
);
}
return null;
})}
);
}
return null;
})}
</div>
<button
onClick={() => scrollCarousel(category, 'right')}
className="absolute right-3 top-1/2 -translate-y-1/2 z-10 bg-primary/80 backdrop-blur-sm hover:bg-primary text-white rounded-2xl w-12 h-12 flex items-center justify-center shadow-xl border border-primary-light/30 transition-all duration-200 opacity-0 group-hover:opacity-100 hover:scale-110"
>
<FontAwesomeIcon icon={faChevronRight} className="w-5 h-5"/>
</button>
</div>
</div>
))}

View File

@@ -33,6 +33,9 @@ const CharacterSettings = lazy(function () {
const SpellSettings = lazy(function () {
return import('./spells/settings/SpellSettings');
});
const ExportSetting = lazy(function () {
return import('./ExportSetting');
});
function LoadingSpinner(): React.JSX.Element {
return (
@@ -51,7 +54,7 @@ interface SettingRef {
}
// Settings qui gèrent leur propre save (pas de bouton save parent)
const selfManagedSettings: string[] = ['characters', 'spells', 'world', 'worlds', 'locations'];
const selfManagedSettings: string[] = ['characters', 'spells', 'world', 'worlds', 'locations', 'export'];
export default function BookSettingOption({setting}: BookSettingOptionProps): React.JSX.Element {
const t = useTranslations();
@@ -77,6 +80,8 @@ export default function BookSettingOption({setting}: BookSettingOptionProps): Re
return t("bookSettingOption.characters");
case 'spells':
return t("bookSettingOption.spells");
case 'export':
return t("bookSettingOption.export");
default:
return "";
}
@@ -119,7 +124,10 @@ export default function BookSettingOption({setting}: BookSettingOptionProps): Re
{setting === 'spells' && (
<SpellSettings entityType="book" showToggle={true}/>
)}
{!['basic-information', 'guide-line', 'story', 'world', 'worlds', 'locations', 'characters', 'spells', 'quillsense'].includes(setting) && (
{setting === 'export' && (
<ExportSetting/>
)}
{!['basic-information', 'guide-line', 'story', 'world', 'worlds', 'locations', 'characters', 'spells', 'quillsense', 'export'].includes(setting) && (
<div className="text-text-secondary py-4 text-center">
{t("bookSettingOption.notAvailable")}
</div>

View File

@@ -3,6 +3,7 @@
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {
faBook,
faDownload,
faGlobe,
faHatWizard,
faListAlt,
@@ -74,6 +75,11 @@ export default function BookSettingSidebar(
name: 'bookSetting.quillsense',
icon: faWandMagicSparkles
},
{
id: 'export',
name: 'bookSetting.export',
icon: faDownload
},
// {
// id: 'objects',
// name: t('bookSetting.objects'),

View File

@@ -15,7 +15,7 @@ export default function ScribeEditor() {
return (
<SettingBookContext.Provider value={{bookSettingId, setBookSettingId}}>
<div className="flex-1 bg-darkest-background">
<div className="flex-1 min-w-0 bg-darkest-background">
{
chapter ? (
<TextEditor/>

View File

@@ -1,5 +1,5 @@
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {faBookMedical, faBookOpen, faFeather, faLayerGroup} from "@fortawesome/free-solid-svg-icons";
import {faBookMedical, faBookOpen, faFeather, faFileImport, faLayerGroup} from "@fortawesome/free-solid-svg-icons";
import React, {useContext, useEffect, useState} from "react";
import {BookContext} from "@/context/BookContext";
import ScribeChapterComponent from "@/components/leftbar/ScribeChapterComponent";
@@ -8,6 +8,7 @@ import {PanelComponent} from "@/lib/models/Editor";
import AddNewBookForm from "@/components/book/AddNewBookForm";
import AddNewSeriesForm from "@/components/series/AddNewSeriesForm";
import ShortStoryGenerator from "@/components/ShortStoryGenerator";
import ImportBookForm from "@/components/book/ImportBookForm";
import {SessionContext} from "@/context/SessionContext";
import {useTranslations} from "next-intl";
import OfflineContext from "@/context/OfflineContext";
@@ -24,6 +25,7 @@ export default function ScribeLeftBar() {
const [showAddNewBook, setShowAddNewBook] = useState<boolean>(false);
const [showAddNewSeries, setShowAddNewSeries] = useState<boolean>(false);
const [showGenerateShortModal, setShowGenerateShortModal] = useState<boolean>(false)
const [showImportBook, setShowImportBook] = useState<boolean>(false)
const {isCurrentlyOffline} = useContext(OfflineContext)
const editorComponents: PanelComponent[] = [
@@ -69,6 +71,12 @@ export default function ScribeLeftBar() {
icon: faLayerGroup,
badge: t("scribeLeftBar.homeComponents.addSeries.badge"),
description: t("scribeLeftBar.homeComponents.addSeries.description")
}, {
id: 4,
title: t("scribeLeftBar.homeComponents.importBook.title"),
icon: faFileImport,
badge: t("scribeLeftBar.homeComponents.importBook.badge"),
description: t("scribeLeftBar.homeComponents.importBook.description")
},
]
@@ -116,9 +124,9 @@ export default function ScribeLeftBar() {
)) : (
homeComponents
.filter((component: PanelComponent): boolean => {
// Hide generate story (id: 2) in offline mode (requires AI server)
// Hide generate story (id: 2) and import book (id: 4) in offline mode (requires server)
// Series (id: 3) now has dual logic and works offline
if (isCurrentlyOffline() && component.id === 2) {
if (isCurrentlyOffline() && (component.id === 2 || component.id === 4)) {
return false;
}
return true;
@@ -126,7 +134,7 @@ export default function ScribeLeftBar() {
.map((component: PanelComponent) => (
<button
key={component.id}
onClick={() => component.id === 1 ? setShowAddNewBook(true) : component.id === 2 ? setShowGenerateShortModal(true) : component.id === 3 ? setShowAddNewSeries(true) : null}
onClick={() => component.id === 1 ? setShowAddNewBook(true) : component.id === 2 ? setShowGenerateShortModal(true) : component.id === 3 ? setShowAddNewSeries(true) : component.id === 4 ? setShowImportBook(true) : null}
title={component.title}
className={`group relative p-3 rounded-xl transition-all duration-200 ${panelHidden && currentPanel?.id === component.id
? 'bg-primary text-text-primary shadow-lg shadow-primary/30'
@@ -162,6 +170,10 @@ export default function ScribeLeftBar() {
showGenerateShortModal &&
<ShortStoryGenerator onClose={() => setShowGenerateShortModal(false)}/>
}
{
showImportBook &&
<ImportBookForm setCloseForm={setShowImportBook}/>
}
</div>
)
}