- Added support for creating books locally when the cloud limit is reached. - Enhanced error handling in `AddNewBookForm` with `ApiError` and fallback logic for local book creation. - Implemented `BookTypeLimit` to manage type-specific book limits with visual indicators in `BookList`. - Refactored `TombstoneRecord` to standardize naming conventions for better API compatibility. - Updated `useSyncSeries` and `useSyncBooks` to handle empty tombstones gracefully. - Updated locales with new translations for fallback and error messaging.
539 lines
28 KiB
TypeScript
539 lines
28 KiB
TypeScript
import React, {useContext, useEffect, useRef, useState} from "react";
|
|
import {apiGet, apiPost} from "@/lib/api/client";
|
|
import {isDesktop} from '@/lib/configs';
|
|
import * as tauri from '@/lib/tauri';
|
|
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
|
|
import {AlertContext, AlertContextProps} from "@/context/AlertContext";
|
|
import SearchBook from "./SearchBook";
|
|
import {useRouter} from "@/lib/navigation";
|
|
import {Book, ChevronLeft, ChevronRight, Download, Settings, Trash2} from 'lucide-react';
|
|
import Badge from "@/components/ui/Badge";
|
|
import {SessionContext, SessionContextProps} from "@/context/SessionContext";
|
|
import {BookProps, BookTypeLimit} from "@/lib/types/book";
|
|
import {getBookTypeLabel} from "@/lib/utils/book";
|
|
import BookCard from "@/components/book/BookCard";
|
|
import BookCardSkeleton from "@/components/book/BookCardSkeleton";
|
|
import GuideTour, {GuideStep} from "@/components/GuideTour";
|
|
import {guideTourDone, setNewGuideTour} from "@/lib/utils/user";
|
|
import {useTranslations} from '@/lib/i18n';
|
|
import {LangContext, LangContextProps} from "@/context/LangContext";
|
|
import {BooksSyncContext, BooksSyncContextProps, SyncType} from "@/context/BooksSyncContext";
|
|
import {BookSyncCompare, SyncedBook} from "@/lib/types/synced-book";
|
|
import {SeriesListItemProps} from "@/lib/types/series";
|
|
import SeriesCard, {SeriesCardProps} from "@/components/series/SeriesCard";
|
|
import SeriesSetting from "@/components/series/SeriesSetting";
|
|
|
|
interface CategoryItem {
|
|
type: 'book' | 'series';
|
|
book?: BookProps;
|
|
series?: SeriesCardProps;
|
|
}
|
|
|
|
export default function BookList() {
|
|
const {session, setSession}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
|
|
const accessToken: string = session?.accessToken || '';
|
|
const {errorMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
|
|
const router = useRouter();
|
|
const t = useTranslations();
|
|
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext)
|
|
const {
|
|
serverSyncedBooks, booksToSyncFromServer, booksToSyncToServer,
|
|
serverOnlyBooks, localOnlyBooks
|
|
}: BooksSyncContextProps = useContext<BooksSyncContextProps>(BooksSyncContext);
|
|
const {isCurrentlyOffline, offlineMode}: OfflineContextType = useContext<OfflineContextType>(OfflineContext);
|
|
|
|
const [searchQuery, setSearchQuery] = useState<string>('');
|
|
const [groupedItems, setGroupedItems] = useState<Record<string, CategoryItem[]>>({});
|
|
const [isLoadingBooks, setIsLoadingBooks] = useState<boolean>(true);
|
|
const [showSeriesSettingId, setShowSeriesSettingId] = useState<string | null>(null);
|
|
const [bookLimits, setBookLimits] = useState<Record<string, BookTypeLimit> | null>(null);
|
|
const carouselRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
|
|
|
const [bookGuide, setBookGuide] = useState<boolean>(false);
|
|
|
|
const bookGuideSteps: GuideStep[] = [
|
|
{
|
|
id: 0,
|
|
targetSelector: '[data-guide="book-category"]',
|
|
position: 'left',
|
|
highlightRadius: -200,
|
|
title: `${t("bookList.guideStep0Title")} ${session.user?.name}`,
|
|
content: (
|
|
<div>
|
|
<p>{t("bookList.guideStep0Content")}</p>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
id: 1,
|
|
targetSelector: '[data-guide="book-card"]',
|
|
position: 'left',
|
|
title: t("bookList.guideStep1Title"),
|
|
content: (
|
|
<div>
|
|
<p>{t("bookList.guideStep1Content")}</p>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
id: 2,
|
|
targetSelector: '[data-guide="bottom-book-card"]',
|
|
position: 'left',
|
|
title: t("bookList.guideStep2Title"),
|
|
content: (
|
|
<div>
|
|
<p>
|
|
<Settings strokeWidth={1.75} className="mr-2 text-primary w-5 h-5 inline"/>
|
|
{t("bookList.guideStep2ContentGear")}
|
|
</p>
|
|
<p>
|
|
<Download strokeWidth={1.75} className="mr-2 text-primary w-5 h-5 inline"/>
|
|
{t("bookList.guideStep2ContentDownload")}
|
|
</p>
|
|
<p>
|
|
<Trash2 strokeWidth={1.75} className="mr-2 text-primary w-5 h-5 inline"/>
|
|
{t("bookList.guideStep2ContentTrash")}
|
|
</p>
|
|
</div>
|
|
),
|
|
},
|
|
]
|
|
|
|
useEffect((): void => {
|
|
if (groupedItems && Object.keys(groupedItems).length > 0) {
|
|
const notDone: boolean = isCurrentlyOffline()
|
|
? localStorage.getItem('guide-tour-new-first-book') !== 'true'
|
|
: guideTourDone(session.user?.guideTour || [], 'new-first-book');
|
|
if (notDone) setBookGuide(true);
|
|
}
|
|
}, [groupedItems]);
|
|
|
|
useEffect((): void => {
|
|
const canLoad: boolean = !isDesktop ||
|
|
(!isCurrentlyOffline() || offlineMode.isDatabaseInitialized);
|
|
if (canLoad) loadBooksAndSeries().then();
|
|
}, [serverSyncedBooks, offlineMode.isDatabaseInitialized, booksToSyncFromServer, booksToSyncToServer, serverOnlyBooks, localOnlyBooks]);
|
|
|
|
useEffect((): void => {
|
|
if (accessToken) loadBooksAndSeries().then();
|
|
}, [accessToken]);
|
|
|
|
async function handleFirstBookGuide(): Promise<void> {
|
|
if (isCurrentlyOffline()) {
|
|
localStorage.setItem('guide-tour-new-first-book', 'true');
|
|
setBookGuide(false);
|
|
return;
|
|
}
|
|
try {
|
|
const response: boolean = await apiPost<boolean>(
|
|
'logs/tour',
|
|
{plateforme: 'desktop', tour: 'new-first-book'},
|
|
session.accessToken, lang
|
|
);
|
|
if (response) {
|
|
localStorage.setItem('guide-tour-new-first-book', 'true');
|
|
setSession(setNewGuideTour(session, 'new-first-book'));
|
|
setBookGuide(false);
|
|
}
|
|
} catch (error: unknown) {
|
|
if (error instanceof Error) {
|
|
errorMessage(error.message);
|
|
} else {
|
|
errorMessage(t("bookList.errorBookCreate"));
|
|
}
|
|
}
|
|
}
|
|
|
|
async function loadBooksAndSeries(): Promise<void> {
|
|
setIsLoadingBooks(true);
|
|
try {
|
|
let booksResponse: BookProps[];
|
|
let seriesResponse: SeriesListItemProps[];
|
|
|
|
if (isDesktop && isCurrentlyOffline()) {
|
|
if (!offlineMode.isDatabaseInitialized) {
|
|
setIsLoadingBooks(false);
|
|
return;
|
|
}
|
|
const [localBooks, localSeries] = await Promise.all([
|
|
tauri.getBooks() as Promise<BookProps[]>,
|
|
tauri.getSeriesList() as Promise<SeriesListItemProps[]>
|
|
]);
|
|
booksResponse = localBooks.map((b: BookProps): BookProps => ({...b, localBook: true}));
|
|
seriesResponse = localSeries;
|
|
} else {
|
|
const [onlineBooks, localBooks, onlineSeries, localSeries, limitsResponse] = await Promise.all([
|
|
apiGet<BookProps[]>('books', accessToken, lang),
|
|
isDesktop && offlineMode.isDatabaseInitialized
|
|
? tauri.getBooks() as Promise<BookProps[]>
|
|
: Promise.resolve([]),
|
|
apiGet<SeriesListItemProps[]>('series/list', accessToken, lang),
|
|
isDesktop && offlineMode.isDatabaseInitialized
|
|
? tauri.getSeriesList() as Promise<SeriesListItemProps[]>
|
|
: Promise.resolve([]),
|
|
apiGet<Record<string, BookTypeLimit> | null>('books/limits', accessToken, lang)
|
|
]);
|
|
setBookLimits(limitsResponse);
|
|
|
|
// Merge livres : serveur + locaux uniques
|
|
const onlineBookIds: Set<string> = new Set(onlineBooks.map((b: BookProps): string => b.bookId));
|
|
const uniqueLocalBooks: BookProps[] = localBooks
|
|
.filter((b: BookProps): boolean => !onlineBookIds.has(b.bookId))
|
|
.map((b: BookProps): BookProps => ({...b, localBook: true}));
|
|
booksResponse = [...onlineBooks, ...uniqueLocalBooks];
|
|
|
|
// Merge séries : serveur + bookIds locaux manquants + séries locales uniques
|
|
const localSeriesMap: Map<string, SeriesListItemProps> = new Map(
|
|
localSeries.map((s: SeriesListItemProps): [string, SeriesListItemProps] => [s.id, s])
|
|
);
|
|
const mergedOnlineSeries: SeriesListItemProps[] = onlineSeries.map((serverSeries: SeriesListItemProps): SeriesListItemProps => {
|
|
const localVersion: SeriesListItemProps | undefined = localSeriesMap.get(serverSeries.id);
|
|
if (localVersion) {
|
|
const serverBookIds: Set<string> = new Set(serverSeries.bookIds);
|
|
const localOnlyBookIds: string[] = localVersion.bookIds.filter((id: string): boolean => !serverBookIds.has(id));
|
|
return {
|
|
...serverSeries,
|
|
bookIds: [...serverSeries.bookIds, ...localOnlyBookIds]
|
|
};
|
|
}
|
|
return serverSeries;
|
|
});
|
|
const onlineSeriesIds: Set<string> = new Set(onlineSeries.map((s: SeriesListItemProps): string => s.id));
|
|
const uniqueLocalSeries: SeriesListItemProps[] = localSeries.filter((s: SeriesListItemProps): boolean => !onlineSeriesIds.has(s.id));
|
|
seriesResponse = [...mergedOnlineSeries, ...uniqueLocalSeries];
|
|
}
|
|
|
|
if (booksResponse) {
|
|
const seriesList: SeriesListItemProps[] = seriesResponse || [];
|
|
|
|
// Créer un mapping bookId -> seriesInfo
|
|
const bookToSeriesMap: Map<string, SeriesListItemProps> = new Map();
|
|
seriesList.forEach((series: SeriesListItemProps): void => {
|
|
series.bookIds.forEach((bookId: string): void => {
|
|
bookToSeriesMap.set(bookId, series);
|
|
});
|
|
});
|
|
|
|
// Transformer les livres avec leur image
|
|
const transformedBooks: BookProps[] = booksResponse.map((book: BookProps & { bookType?: string }): BookProps => {
|
|
const imageDataUrl: string = book.coverImage ? 'data:image/jpeg;base64,' + book.coverImage : '';
|
|
return {
|
|
bookId: book.bookId,
|
|
type: book.type || book.bookType || '',
|
|
title: book.title,
|
|
subTitle: book.subTitle,
|
|
summary: book.summary,
|
|
serie: book.serie,
|
|
publicationDate: book.publicationDate,
|
|
desiredWordCount: book.desiredWordCount,
|
|
totalWordCount: 0,
|
|
coverImage: imageDataUrl,
|
|
};
|
|
});
|
|
|
|
// Grouper par catégorie avec séries
|
|
const itemsByCategory: Record<string, CategoryItem[]> = {};
|
|
const processedSeriesIds: Set<string> = new Set();
|
|
|
|
transformedBooks.forEach((book: BookProps): void => {
|
|
if (!itemsByCategory[book.type]) {
|
|
itemsByCategory[book.type] = [];
|
|
}
|
|
|
|
const seriesInfo: SeriesListItemProps | undefined = bookToSeriesMap.get(book.bookId);
|
|
|
|
if (seriesInfo && !processedSeriesIds.has(seriesInfo.id)) {
|
|
// Ce livre fait partie d'une série non encore traitée
|
|
processedSeriesIds.add(seriesInfo.id);
|
|
|
|
// Récupérer tous les livres de cette série dans cette catégorie
|
|
const seriesBooks: BookProps[] = transformedBooks.filter(
|
|
(bookItem: BookProps): boolean => seriesInfo.bookIds.includes(bookItem.bookId)
|
|
);
|
|
|
|
const seriesCard: SeriesCardProps = {
|
|
id: seriesInfo.id,
|
|
name: seriesInfo.name,
|
|
coverImage: seriesInfo.coverImage,
|
|
books: seriesBooks
|
|
};
|
|
|
|
itemsByCategory[book.type].push({
|
|
type: 'series',
|
|
series: seriesCard
|
|
});
|
|
} else if (!seriesInfo) {
|
|
// Livre individuel (pas dans une série)
|
|
itemsByCategory[book.type].push({
|
|
type: 'book',
|
|
book: book
|
|
});
|
|
}
|
|
// Si le livre fait partie d'une série déjà traitée, on l'ignore car il est déjà dans le SeriesCard
|
|
});
|
|
|
|
// Ajouter les séries sans livres (orphelines)
|
|
seriesList.forEach((series: SeriesListItemProps): void => {
|
|
if (series.bookIds.length === 0) {
|
|
const emptySeriesCategory: string = t('bookList.emptySeries');
|
|
if (!itemsByCategory[emptySeriesCategory]) {
|
|
itemsByCategory[emptySeriesCategory] = [];
|
|
}
|
|
|
|
const emptySeriesCard: SeriesCardProps = {
|
|
id: series.id,
|
|
name: series.name,
|
|
coverImage: series.coverImage,
|
|
books: []
|
|
};
|
|
|
|
itemsByCategory[emptySeriesCategory].push({
|
|
type: 'series',
|
|
series: emptySeriesCard
|
|
});
|
|
}
|
|
});
|
|
|
|
setGroupedItems(itemsByCategory);
|
|
}
|
|
} catch (error: unknown) {
|
|
if (error instanceof Error) {
|
|
errorMessage(error.message);
|
|
} else {
|
|
errorMessage(t("bookList.errorBooksFetch"));
|
|
}
|
|
} finally {
|
|
setIsLoadingBooks(false);
|
|
}
|
|
}
|
|
|
|
function getFilteredGroupedItems(): Record<string, CategoryItem[]> {
|
|
if (!searchQuery) {
|
|
return groupedItems;
|
|
}
|
|
|
|
const filtered: Record<string, CategoryItem[]> = {};
|
|
|
|
Object.entries(groupedItems).forEach(([category, items]: [string, CategoryItem[]]): void => {
|
|
const filteredItems: CategoryItem[] = items.filter((item: CategoryItem): boolean => {
|
|
if (item.type === 'book' && item.book) {
|
|
return item.book.title.toLowerCase().includes(searchQuery.toLowerCase());
|
|
}
|
|
if (item.type === 'series' && item.series) {
|
|
// Recherche dans le nom de la série ou dans les titres des livres
|
|
const matchesSeriesName: boolean = item.series.name.toLowerCase().includes(searchQuery.toLowerCase());
|
|
const matchesBookTitle: boolean = item.series.books.some(
|
|
(book: BookProps): boolean => book.title.toLowerCase().includes(searchQuery.toLowerCase())
|
|
);
|
|
return matchesSeriesName || matchesBookTitle;
|
|
}
|
|
return false;
|
|
});
|
|
|
|
if (filteredItems.length > 0) {
|
|
filtered[category] = filteredItems;
|
|
}
|
|
});
|
|
|
|
return filtered;
|
|
}
|
|
|
|
function getTotalItemsCount(items: CategoryItem[]): number {
|
|
return items.reduce((count: number, item: CategoryItem): number => {
|
|
if (item.type === 'book') {
|
|
return count + 1;
|
|
}
|
|
if (item.type === 'series' && item.series) {
|
|
return count + item.series.books.length;
|
|
}
|
|
return count;
|
|
}, 0);
|
|
}
|
|
|
|
function detectBookSyncStatus(bookId: string): SyncType {
|
|
if (!isDesktop || isCurrentlyOffline()) return 'synced';
|
|
if (serverOnlyBooks.find((book: SyncedBook): boolean => book.id === bookId)) {
|
|
return 'server-only';
|
|
}
|
|
if (localOnlyBooks.find((book: SyncedBook): boolean => book.id === bookId)) {
|
|
return 'local-only';
|
|
}
|
|
if (booksToSyncFromServer.find((book: BookSyncCompare): boolean => book.id === bookId)) {
|
|
return 'to-sync-from-server';
|
|
}
|
|
if (booksToSyncToServer.find((book: BookSyncCompare): boolean => book.id === bookId)) {
|
|
return 'to-sync-to-server';
|
|
}
|
|
return 'synced';
|
|
}
|
|
|
|
function handleBookClick(bookId: string): void {
|
|
router.push(`/book/${bookId}`);
|
|
}
|
|
|
|
function handleSeriesSettingsClick(seriesId: string): void {
|
|
setShowSeriesSettingId(seriesId);
|
|
}
|
|
|
|
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'
|
|
});
|
|
}
|
|
|
|
const filteredItems: Record<string, CategoryItem[]> = getFilteredGroupedItems();
|
|
|
|
return (
|
|
<div
|
|
className="flex flex-col h-full overflow-hidden w-full text-text-primary font-['Lora']">
|
|
<div className="flex flex-col w-full overflow-y-auto overflow-x-hidden h-full min-h-0 flex-grow">
|
|
{session?.user && (
|
|
<div data-guide="search-bar" className="sticky top-0 z-10 w-full flex justify-center px-4 py-3">
|
|
<SearchBook searchQuery={searchQuery} setSearchQuery={setSearchQuery}/>
|
|
</div>
|
|
)}
|
|
{
|
|
isLoadingBooks ? (
|
|
<>
|
|
<div className="text-center mb-8 px-6">
|
|
<h1 className="font-['ADLaM_Display'] text-4xl mb-3 text-text-primary">{t("bookList.library")}</h1>
|
|
<p className="text-muted italic text-lg">{t("bookList.booksAreMirrors")}</p>
|
|
</div>
|
|
|
|
<div className="w-full mb-10">
|
|
<div className="flex justify-between items-center w-full max-w-5xl mx-auto mb-4 px-6">
|
|
<div className="h-8 bg-secondary rounded-xl w-32 animate-pulse"></div>
|
|
<div className="h-6 bg-tertiary rounded-lg w-24 animate-pulse"></div>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap justify-center items-start w-full px-4">
|
|
{Array.from({length: 6}).map((_, id: number) => (
|
|
<div key={id}
|
|
className="w-full sm:w-1/3 md:w-1/4 lg:w-1/5 xl:w-1/6 p-2 box-border">
|
|
<BookCardSkeleton/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : Object.entries(filteredItems).length > 0 ? (
|
|
<>
|
|
<div className="text-center mb-8 px-6">
|
|
<h1 className="font-['ADLaM_Display'] text-4xl mb-3 text-text-primary">{t("bookList.library")}</h1>
|
|
<p className="text-muted italic text-lg">{t("bookList.booksAreMirrors")}</p>
|
|
</div>
|
|
|
|
{Object.entries(filteredItems).map(([category, items]: [string, CategoryItem[]], index: number) => {
|
|
const itemCount: number = getTotalItemsCount(items);
|
|
const typeLimit: BookTypeLimit | undefined = bookLimits?.[category] ?? undefined;
|
|
const isLimitReached: boolean = typeLimit !== undefined && typeLimit.current >= typeLimit.max;
|
|
const categoryLabel: string = t(getBookTypeLabel(category));
|
|
return (
|
|
<div key={category} {...(index === 0 && {'data-guide': 'book-category'})}
|
|
className="w-full mb-10">
|
|
<div
|
|
className="flex justify-between items-center w-full max-w-5xl mx-auto mb-6 px-6">
|
|
<h2 className="text-3xl text-text-primary capitalize font-['ADLaM_Display'] flex items-center gap-3">
|
|
<span className="w-1 h-8 bg-primary rounded-full"></span>
|
|
{categoryLabel}
|
|
</h2>
|
|
<Badge variant={isLimitReached ? "error" : "muted"} size="md">
|
|
{typeLimit !== undefined
|
|
? `${typeLimit.current}/${typeLimit.max}`
|
|
: itemCount
|
|
} {t("bookList.works")}
|
|
</Badge>
|
|
</div>
|
|
|
|
<div className="group/carousel relative w-full">
|
|
<div
|
|
className="absolute left-3 top-1/2 -translate-y-1/2 z-10 opacity-0 group-hover/carousel:opacity-100">
|
|
<button
|
|
onClick={(): void => scrollCarousel(category, 'left')}
|
|
className="flex items-center justify-center w-11 h-11 rounded-xl bg-primary-dark hover:bg-primary text-text-primary transition-all duration-200"
|
|
>
|
|
<ChevronLeft strokeWidth={1.75} className="w-5 h-5"/>
|
|
</button>
|
|
</div>
|
|
|
|
<div
|
|
ref={(el: HTMLDivElement | null) => {
|
|
carouselRefs.current[category] = el;
|
|
}}
|
|
className="flex items-start w-full overflow-x-auto px-4 gap-2 scroll-smooth scrollbar-hide"
|
|
>
|
|
{items.map((item: CategoryItem, idx: number) => {
|
|
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 ${bookGuide && 'mb-[200px]'}`}>
|
|
<BookCard
|
|
book={item.book}
|
|
onClickCallback={handleBookClick}
|
|
index={idx}
|
|
syncStatus={detectBookSyncStatus(item.book.bookId)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
if (item.type === 'series' && item.series) {
|
|
return (
|
|
<SeriesCard
|
|
key={item.series.id}
|
|
series={item.series}
|
|
onBookClick={handleBookClick}
|
|
onSettingsClick={handleSeriesSettingsClick}
|
|
/>
|
|
);
|
|
}
|
|
return null;
|
|
})}
|
|
</div>
|
|
|
|
<div
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 z-10 opacity-0 group-hover/carousel:opacity-100">
|
|
<button
|
|
onClick={(): void => scrollCarousel(category, 'right')}
|
|
className="flex items-center justify-center w-11 h-11 rounded-xl bg-primary-dark hover:bg-primary text-text-primary transition-all duration-200"
|
|
>
|
|
<ChevronRight strokeWidth={1.75} className="w-5 h-5"/>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</>
|
|
) : (
|
|
<div className="flex items-center justify-center h-full">
|
|
<div className="text-center p-8 max-w-lg">
|
|
<div className="mx-auto mb-6 w-fit">
|
|
<div
|
|
className="bg-primary/10 flex items-center justify-center w-16 h-16 rounded-2xl">
|
|
<Book strokeWidth={1.75} className="text-primary w-8 h-8"/>
|
|
</div>
|
|
</div>
|
|
<h2 className="text-4xl font-['ADLaM_Display'] mb-4 text-text-primary">{t("bookList.welcomeWritingWorkshop")}</h2>
|
|
<p className="text-muted mb-6 text-lg leading-relaxed">
|
|
{t("bookList.whitePageText")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{
|
|
bookGuide && <GuideTour stepId={0} steps={bookGuideSteps} onComplete={handleFirstBookGuide}
|
|
onClose={() => setBookGuide(false)}/>
|
|
}
|
|
{showSeriesSettingId &&
|
|
<SeriesSetting seriesId={showSeriesSettingId} onClose={() => setShowSeriesSettingId(null)}/>}
|
|
</div>
|
|
);
|
|
}
|