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, Cloud, Download, HardDrive, Settings, Trash2} from 'lucide-react'; import Badge from "@/components/ui/Badge"; import {SessionContext, SessionContextProps} from "@/context/SessionContext"; import {BookContext, BookContextProps} from "@/context/BookContext"; 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(SessionContext); const accessToken: string = session?.accessToken || ''; const {errorMessage}: AlertContextProps = useContext(AlertContext); const router = useRouter(); const t = useTranslations(); const {lang}: LangContextProps = useContext(LangContext) const { serverSyncedBooks, booksToSyncFromServer, booksToSyncToServer, serverOnlyBooks, localOnlyBooks }: BooksSyncContextProps = useContext(BooksSyncContext); const {isCurrentlyOffline, offlineMode}: OfflineContextType = useContext(OfflineContext); const {setBook}: BookContextProps = useContext(BookContext); const [searchQuery, setSearchQuery] = useState(''); const [groupedItems, setGroupedItems] = useState>({}); const [isLoadingBooks, setIsLoadingBooks] = useState(true); const [showSeriesSettingId, setShowSeriesSettingId] = useState(null); const [bookLimits, setBookLimits] = useState | null>(null); const carouselRefs = useRef>({}); const [bookGuide, setBookGuide] = useState(false); const bookGuideSteps: GuideStep[] = [ { id: 0, targetSelector: '[data-guide="book-category"]', position: 'left', highlightRadius: -200, title: `${t("bookList.guideStep0Title")} ${session.user?.name}`, content: (

{t("bookList.guideStep0Content")}

), }, { id: 1, targetSelector: '[data-guide="book-card"]', position: 'left', title: t("bookList.guideStep1Title"), content: (

{t("bookList.guideStep1Content")}

), }, { id: 2, targetSelector: '[data-guide="bottom-book-card"]', position: 'left', title: t("bookList.guideStep2Title"), content: (

{t("bookList.guideStep2ContentGear")}

{t("bookList.guideStep2ContentDownload")}

{t("bookList.guideStep2ContentTrash")}

), }, ] 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 { if (isCurrentlyOffline()) { localStorage.setItem('guide-tour-new-first-book', 'true'); setBookGuide(false); return; } try { const response: boolean = await apiPost( '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 { 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, tauri.getSeriesList() as Promise ]); booksResponse = localBooks.map((b: BookProps): BookProps => ({...b, localBook: true})); seriesResponse = localSeries; } else { const [onlineBooks, localBooks, onlineSeries, localSeries, limitsResponse] = await Promise.all([ apiGet('books', accessToken, lang), isDesktop && offlineMode.isDatabaseInitialized ? tauri.getBooks() as Promise : Promise.resolve([]), apiGet('series/list', accessToken, lang), isDesktop && offlineMode.isDatabaseInitialized ? tauri.getSeriesList() as Promise : Promise.resolve([]), apiGet | null>('books/limits', accessToken, lang) ]); setBookLimits(limitsResponse); // Merge livres : serveur + locaux uniques const onlineBookIds: Set = 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 = 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 = 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 = 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 = 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): BookProps => { const imageDataUrl: string = book.coverImage ? 'data:image/jpeg;base64,' + book.coverImage : ''; return { bookId: book.bookId, type: book.type, title: book.title, subTitle: book.subTitle, summary: book.summary, serie: book.serie, publicationDate: book.publicationDate, desiredWordCount: book.desiredWordCount, totalWordCount: 0, coverImage: imageDataUrl, localBook: book.localBook, }; }); // Grouper par catégorie avec séries const itemsByCategory: Record = {}; const processedSeriesIds: Set = 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 { if (!searchQuery) { return groupedItems; } const filtered: Record = {}; 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 getOnlineItemsCount(items: CategoryItem[]): number { return items.reduce((count: number, item: CategoryItem): number => { if (item.type === 'book' && !item.book?.localBook) return count + 1; if (item.type === 'series' && item.series) { return count + item.series.books.filter((b: BookProps): boolean => !b.localBook).length; } return count; }, 0); } function getLocalItemsCount(items: CategoryItem[]): number { return items.reduce((count: number, item: CategoryItem): number => { if (item.type === 'book' && item.book?.localBook) return count + 1; if (item.type === 'series' && item.series) { return count + item.series.books.filter((b: BookProps): boolean => !!b.localBook).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'; } async function handleBookClick(bookId: string): Promise { try { let localBookOnly: boolean = false; let bookResponse: BookProps | null = null; if (isCurrentlyOffline()) { if (!offlineMode.isDatabaseInitialized) { errorMessage(t("bookList.errorBookDetails")); return; } bookResponse = await tauri.getBookBasicInformation(bookId); if (bookResponse) localBookOnly = true; } else { const isOfflineBook = localOnlyBooks.find((book: SyncedBook): boolean => book.id === bookId); if (isOfflineBook) { bookResponse = await tauri.getBookBasicInformation(bookId); localBookOnly = true; } if (!bookResponse) { bookResponse = await apiGet( 'book/basic-information', accessToken, lang, {id: bookId} ); } } if (!bookResponse) { errorMessage(t("bookList.errorBookDetails")); return; } setBook({ bookId: bookId, type: bookResponse.type, title: bookResponse.title || '', subTitle: bookResponse.subTitle || '', summary: bookResponse.summary || '', serie: bookResponse.serie, seriesId: bookResponse.seriesId, publicationDate: bookResponse.publicationDate || '', desiredWordCount: bookResponse.desiredWordCount || 0, totalWordCount: bookResponse.totalWordCount ?? 0, localBook: localBookOnly, coverImage: bookResponse.coverImage ? 'data:image/jpeg;base64,' + bookResponse.coverImage : '', quillsenseEnabled: bookResponse.quillsenseEnabled, tools: bookResponse.tools, }); router.push(`/book/${bookId}`); } catch (e: unknown) { if (e instanceof Error) { errorMessage(e.message); } else { errorMessage(t("bookList.errorBookDetails")); } } } 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(':scope > div')?.offsetWidth || 250; const scrollAmount: number = cardWidth * 2; container.scrollBy({ left: direction === 'left' ? -scrollAmount : scrollAmount, behavior: 'smooth' }); } const filteredItems: Record = getFilteredGroupedItems(); return (
{session?.user && (
)} { isLoadingBooks ? ( <>

{t("bookList.library")}

{t("bookList.booksAreMirrors")}

{Array.from({length: 6}).map((_, id: number) => (
))}
) : Object.entries(filteredItems).length > 0 ? ( <>

{t("bookList.library")}

{t("bookList.booksAreMirrors")}

{Object.entries(filteredItems).map(([category, items]: [string, CategoryItem[]], index: number) => { const onlineCount: number = getOnlineItemsCount(items); const localCount: number = getLocalItemsCount(items); const typeLimit: BookTypeLimit | undefined = bookLimits?.[category] ?? undefined; const isLimitReached: boolean = typeLimit !== undefined && typeLimit.current >= typeLimit.max; const categoryLabel: string = t(getBookTypeLabel(category)); return (

{categoryLabel}

{!isCurrentlyOffline() && ( {typeLimit !== undefined ? `${typeLimit.current}/${typeLimit.max}` : onlineCount } )} {isDesktop && localCount > 0 && ( {localCount} )}
{ 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 (
); } if (item.type === 'series' && item.series) { return ( ); } return null; })}
); })} ) : (

{t("bookList.welcomeWritingWorkshop")}

{t("bookList.whitePageText")}

)}
{ bookGuide && setBookGuide(false)}/> } {showSeriesSettingId && setShowSeriesSettingId(null)}/>}
); }