import * as tauri from '@/lib/tauri'; 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, 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"; import BookCardSkeleton from "@/components/book/BookCardSkeleton"; import GuideTour, {GuideStep} from "@/components/GuideTour"; import User from "@/lib/models/User"; import {useTranslations} from "next-intl"; import {LangContext, LangContextProps} from "@/context/LangContext"; import OfflineContext, {OfflineContextType} from "@/context/OfflineContext"; import {BooksSyncContext, BooksSyncContextProps, SyncType} from "@/context/BooksSyncContext"; import {SeriesSyncContext, SeriesSyncContextProps, SeriesSyncType} from "@/context/SeriesSyncContext"; import {BookSyncCompare, SyncedBook} from "@/lib/models/SyncedBook"; import {SeriesSyncCompare, SyncedSeries} from "@/lib/models/SyncedSeries"; import {SeriesListItemProps} from "@/lib/models/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} = useContext(SessionContext); const accessToken: string = session?.accessToken || ''; const {errorMessage} = useContext(AlertContext); const {setBook} = useContext(BookContext); const t = useTranslations(); const {lang} = useContext(LangContext); const {isCurrentlyOffline, offlineMode} = useContext(OfflineContext); const { booksToSyncFromServer, booksToSyncToServer, serverOnlyBooks, localOnlyBooks, serverSyncedBooks } = useContext(BooksSyncContext); const { seriesToSyncFromServer, seriesToSyncToServer, serverOnlySeries, localOnlySeries } = useContext(SeriesSyncContext); const [searchQuery, setSearchQuery] = useState(''); const [groupedItems, setGroupedItems] = useState>({}); const [isLoadingBooks, setIsLoadingBooks] = useState(true); const [showSeriesSettingId, setShowSeriesSettingId] = useState(null); const [isLocalSeries, setIsLocalSeries] = useState(false); 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 && User.guideTourDone(session.user?.guideTour || [], 'new-first-book')) { setBookGuide(true); } }, [groupedItems]); useEffect((): void => { const shouldFetch: boolean | "" = (session.isConnected || accessToken) && (!isCurrentlyOffline() || offlineMode.isDatabaseInitialized); if (shouldFetch) { loadBooksAndSeries().then(); } }, [ session.isConnected, accessToken, offlineMode.isDatabaseInitialized, offlineMode.isOffline, booksToSyncFromServer, booksToSyncToServer, serverOnlyBooks, localOnlyBooks, serverSyncedBooks ]); async function handleFirstBookGuide(): Promise { try { if (!isCurrentlyOffline()) { const response: boolean = await System.authPostToServer( 'logs/tour', {plateforme: 'desktop', tour: 'new-first-book'}, session.accessToken, lang ); if (response) { setSession(User.setNewGuideTour(session, 'new-first-book')); setBookGuide(false); } } else { const completedGuides = JSON.parse(localStorage.getItem('completedGuides') || '[]'); if (!completedGuides.includes('new-first-book')) { completedGuides.push('new-first-book'); localStorage.setItem('completedGuides', JSON.stringify(completedGuides)); } setSession(User.setNewGuideTour(session, 'new-first-book')); setBookGuide(false); } } catch (e: unknown) { if (e instanceof Error) { errorMessage(e.message); } else { errorMessage(t("bookList.errorBookCreate")); } } } async function loadBooksAndSeries(): Promise { setIsLoadingBooks(true); try { let booksResponse: (BookProps & { itIsLocal?: boolean })[] = []; let seriesResponse: SeriesListItemProps[] = []; // ═══════════════════════════════════════════════════════════════ // PARTIE 1 : FETCH DES DONNÉES (dual logic) // ═══════════════════════════════════════════════════════════════ if (!isCurrentlyOffline()) { // ONLINE : fetch serveur + local en parallèle const [onlineBooks, localBooks, onlineSeries, localSeries] = await Promise.all([ System.authGetQueryToServer('books', accessToken, lang), offlineMode.isDatabaseInitialized ? tauri.getBooks() : Promise.resolve([]), System.authGetQueryToServer('series/list', accessToken, lang), offlineMode.isDatabaseInitialized ? tauri.getSeriesList() as Promise : Promise.resolve([]) ]); // Merge des livres (serveur + locaux uniques) const onlineBookIds = new Set(onlineBooks.map(b => b.bookId)); const uniqueLocalBooks = localBooks.filter(b => !onlineBookIds.has(b.bookId)); booksResponse = [ ...onlineBooks.map(b => ({...b, itIsLocal: false})), ...uniqueLocalBooks.map(b => ({...b, itIsLocal: true})) ]; // Merge des séries (serveur + locales uniques) // Pour les séries synced, on merge les bookIds (serveur + local-only) const localSeriesMap = new Map(localSeries.map(s => [s.id, s])); const mergedOnlineSeries = onlineSeries.map(serverSeries => { const localVersion = localSeriesMap.get(serverSeries.id); if (localVersion) { // Merger les bookIds : serveur + ceux du local qui ne sont pas sur le serveur const serverBookIds = new Set(serverSeries.bookIds); const localOnlyBookIds = localVersion.bookIds.filter(id => !serverBookIds.has(id)); return { ...serverSeries, bookIds: [...serverSeries.bookIds, ...localOnlyBookIds] }; } return serverSeries; }); const onlineSeriesIds = new Set(onlineSeries.map(s => s.id)); const uniqueLocalSeries = localSeries.filter(s => !onlineSeriesIds.has(s.id)); seriesResponse = [...mergedOnlineSeries, ...uniqueLocalSeries]; } else { // OFFLINE : local seulement if (!offlineMode.isDatabaseInitialized) { setIsLoadingBooks(false); return; } const [localBooks, localSeries] = await Promise.all([ tauri.getBooks(), tauri.getSeriesList() as Promise ]); booksResponse = localBooks.map(b => ({...b, itIsLocal: true})); seriesResponse = localSeries; } // ═══════════════════════════════════════════════════════════════ // PARTIE 2 : CRÉATION DU MAPPING BOOK → SERIES // ═══════════════════════════════════════════════════════════════ const bookToSeriesMap: Map = new Map(); seriesResponse.forEach((series: SeriesListItemProps): void => { series.bookIds.forEach((bookId: string): void => { bookToSeriesMap.set(bookId, series); }); }); // ═══════════════════════════════════════════════════════════════ // PARTIE 3 : TRANSFORMATION DES LIVRES // ═══════════════════════════════════════════════════════════════ const transformedBooks: (BookProps & { itIsLocal?: boolean })[] = booksResponse.map(book => { const imageDataUrl = book.coverImage ? 'data:image/jpeg;base64,' + book.coverImage : ''; return { bookId: book.bookId, type: Book.getBookTypeLabel(book.type), title: book.title, subTitle: book.subTitle, summary: book.summary, serie: book.serie, publicationDate: book.publicationDate, desiredWordCount: book.desiredWordCount, totalWordCount: 0, coverImage: imageDataUrl, itIsLocal: book.itIsLocal }; }); // ═══════════════════════════════════════════════════════════════ // PARTIE 4 : GROUPEMENT PAR CATÉGORIE AVEC SÉRIES // ═══════════════════════════════════════════════════════════════ const itemsByCategory: Record = {}; const processedSeriesIds: Set = new Set(); transformedBooks.forEach((book): void => { const categoryLabel: string = t(book.type); if (!itemsByCategory[categoryLabel]) { itemsByCategory[categoryLabel] = []; } const seriesInfo = bookToSeriesMap.get(book.bookId); if (seriesInfo && !processedSeriesIds.has(seriesInfo.id)) { // 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 const seriesBooks: BookProps[] = transformedBooks.filter( b => seriesInfo.bookIds.includes(b.bookId) ); const seriesCard: SeriesCardProps = { id: seriesInfo.id, name: seriesInfo.name, coverImage: seriesInfo.coverImage, books: seriesBooks }; itemsByCategory[categoryLabel].push({ type: 'series', series: seriesCard }); } else if (!seriesInfo) { // Livre individuel (pas dans une série) itemsByCategory[categoryLabel].push({ type: 'book', book: book }); } }); // Ajouter les séries vides (orphelines) seriesResponse.forEach((series): void => { if (series.bookIds.length === 0) { const emptySeriesCategory = t('bookList.emptySeries'); if (!itemsByCategory[emptySeriesCategory]) { itemsByCategory[emptySeriesCategory] = []; } itemsByCategory[emptySeriesCategory].push({ type: 'series', series: { id: series.id, name: series.name, coverImage: series.coverImage, books: [] } }); } }); 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]) => { const filteredItems = items.filter((item): boolean => { if (item.type === 'book' && item.book) { return item.book.title.toLowerCase().includes(searchQuery.toLowerCase()); } if (item.type === 'series' && item.series) { const matchesSeriesName = item.series.name.toLowerCase().includes(searchQuery.toLowerCase()); const matchesBookTitle = item.series.books.some( book => 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, item) => { 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 (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 detectSeriesSyncStatus(seriesId: string): SeriesSyncType { if (serverOnlySeries.find((series: SyncedSeries): boolean => series.id === seriesId)) return 'server-only'; if (localOnlySeries.find((series: SyncedSeries): boolean => series.id === seriesId)) return 'local-only'; if (seriesToSyncFromServer.find((series: SeriesSyncCompare): boolean => series.id === seriesId)) return 'to-sync-from-server'; if (seriesToSyncToServer.find((series: SeriesSyncCompare): boolean => series.id === seriesId)) return 'to-sync-to-server'; return 'synced'; } async function handleBookClick(bookId: string): Promise { try { let localBookOnly: boolean = false; let bookResponse: BookProps | null = null; // DUAL LOGIC const isOfflineBook = localOnlyBooks.find((book: SyncedBook): boolean => book.id === bookId); if (isCurrentlyOffline() || isOfflineBook) { if (isCurrentlyOffline() && !offlineMode.isDatabaseInitialized) { errorMessage(t("bookList.errorBookDetails")); return; } bookResponse = await tauri.getBookBasicInformation(bookId); if (bookResponse) localBookOnly = true; } if (!bookResponse) { bookResponse = await System.authGetQueryToServer( 'book/basic-information', accessToken, lang, {id: bookId} ); } if (!bookResponse) { errorMessage(t("bookList.errorBookDetails")); return; } if (setBook) { setBook({ bookId: bookId, title: bookResponse.title || '', subTitle: bookResponse.subTitle || '', summary: bookResponse.summary || '', type: bookResponse.type || '', serie: bookResponse.serie, seriesId: bookResponse.seriesId, publicationDate: bookResponse.publicationDate || '', desiredWordCount: bookResponse.desiredWordCount || 0, totalWordCount: 0, localBook: localBookOnly, coverImage: bookResponse.coverImage ? 'data:image/jpeg;base64,' + bookResponse.coverImage : '', quillsenseEnabled: bookResponse.quillsenseEnabled, tools: bookResponse.tools, }); } } catch (error: unknown) { if (error instanceof Error) { errorMessage(error.message); } else { errorMessage(t("bookList.errorUnknown")); } } } 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' }); } function handleSeriesSettingsClick(seriesId: string): void { const isLocal: boolean = isCurrentlyOffline() || Boolean(localOnlySeries.find((s: SyncedSeries): boolean => s.id === seriesId)); setIsLocalSeries(isLocal); setShowSeriesSettingId(seriesId); } const filteredItems = 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], index) => (

{category}

{getTotalItemsCount(items)} {t("bookList.works")}
{ 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 (
); } if (item.type === 'series' && item.series) { return ( ); } return null; })}
))} ) : (

{t("bookList.welcomeWritingWorkshop")}

{t("bookList.whitePageText")}

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