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,25 +1,24 @@
|
||||
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 {useContext, useEffect, useRef, useState} from "react";
|
||||
import System from "@/lib/models/System";
|
||||
import {AlertContext} from "@/context/AlertContext";
|
||||
import {BookContext} from "@/context/BookContext";
|
||||
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
|
||||
import {AlertContext, AlertContextProps} from "@/context/AlertContext";
|
||||
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 {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} 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 User from "@/lib/models/User";
|
||||
import {useTranslations} from "next-intl";
|
||||
import {guideTourDone, setNewGuideTour} from "@/lib/utils/user";
|
||||
import {useTranslations} from '@/lib/i18n';
|
||||
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 {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext";
|
||||
import {SeriesListItemProps} from "@/lib/types/series";
|
||||
import SeriesCard, {SeriesCardProps} from "@/components/series/SeriesCard";
|
||||
import SeriesSetting from "@/components/series/SeriesSetting";
|
||||
|
||||
@@ -30,32 +29,19 @@ interface CategoryItem {
|
||||
}
|
||||
|
||||
export default function BookList() {
|
||||
const {session, setSession} = useContext(SessionContext);
|
||||
const {session, setSession}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
|
||||
const accessToken: string = session?.accessToken || '';
|
||||
const {errorMessage} = useContext(AlertContext);
|
||||
const {setBook} = useContext(BookContext);
|
||||
const {errorMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
const {lang} = useContext<LangContextProps>(LangContext);
|
||||
const {isCurrentlyOffline, offlineMode} = useContext<OfflineContextType>(OfflineContext);
|
||||
const {
|
||||
booksToSyncFromServer,
|
||||
booksToSyncToServer,
|
||||
serverOnlyBooks,
|
||||
localOnlyBooks,
|
||||
serverSyncedBooks
|
||||
} = useContext<BooksSyncContextProps>(BooksSyncContext);
|
||||
const {
|
||||
seriesToSyncFromServer,
|
||||
seriesToSyncToServer,
|
||||
serverOnlySeries,
|
||||
localOnlySeries
|
||||
} = useContext<SeriesSyncContextProps>(SeriesSyncContext);
|
||||
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext)
|
||||
const {serverSyncedBooks}: BooksSyncContextProps = useContext<BooksSyncContextProps>(BooksSyncContext)
|
||||
const {isCurrentlyOffline}: 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 [isLocalSeries, setIsLocalSeries] = useState<boolean>(false);
|
||||
const carouselRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
|
||||
const [bookGuide, setBookGuide] = useState<boolean>(false);
|
||||
@@ -92,240 +78,165 @@ export default function BookList() {
|
||||
content: (
|
||||
<div>
|
||||
<p>
|
||||
<FontAwesomeIcon icon={faGear} className="mr-2 text-primary w-5 h-5"/>
|
||||
<Settings strokeWidth={1.75} className="mr-2 text-primary w-5 h-5 inline"/>
|
||||
{t("bookList.guideStep2ContentGear")}
|
||||
</p>
|
||||
<p>
|
||||
<FontAwesomeIcon icon={faDownload} className="mr-2 text-primary w-5 h-5"/>
|
||||
<Download strokeWidth={1.75} className="mr-2 text-primary w-5 h-5 inline"/>
|
||||
{t("bookList.guideStep2ContentDownload")}
|
||||
</p>
|
||||
<p>
|
||||
<FontAwesomeIcon icon={faTrash} className="mr-2 text-primary w-5 h-5"/>
|
||||
<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 && User.guideTourDone(session.user?.guideTour || [], 'new-first-book')) {
|
||||
if (groupedItems && Object.keys(groupedItems).length > 0 && guideTourDone(session.user?.guideTour || [], 'new-first-book')) {
|
||||
setBookGuide(true);
|
||||
}
|
||||
}, [groupedItems]);
|
||||
|
||||
useEffect((): void => {
|
||||
const shouldFetch: boolean | "" =
|
||||
(session.isConnected || accessToken) &&
|
||||
(!isCurrentlyOffline() || offlineMode.isDatabaseInitialized);
|
||||
loadBooksAndSeries().then()
|
||||
}, [serverSyncedBooks]);
|
||||
|
||||
if (shouldFetch) {
|
||||
loadBooksAndSeries().then();
|
||||
}
|
||||
}, [
|
||||
session.isConnected,
|
||||
accessToken,
|
||||
offlineMode.isDatabaseInitialized,
|
||||
offlineMode.isOffline,
|
||||
booksToSyncFromServer,
|
||||
booksToSyncToServer,
|
||||
serverOnlyBooks,
|
||||
localOnlyBooks,
|
||||
serverSyncedBooks
|
||||
]);
|
||||
useEffect((): void => {
|
||||
if (accessToken) loadBooksAndSeries().then();
|
||||
}, [accessToken]);
|
||||
|
||||
async function handleFirstBookGuide(): Promise<void> {
|
||||
try {
|
||||
if (!isCurrentlyOffline()) {
|
||||
const response: boolean = await System.authPostToServer<boolean>(
|
||||
'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'));
|
||||
const response: boolean = await apiPost<boolean>(
|
||||
'logs/tour',
|
||||
{plateforme: 'web', tour: 'new-first-book'},
|
||||
session.accessToken, lang
|
||||
);
|
||||
if (response) {
|
||||
setSession(setNewGuideTour(session, 'new-first-book'));
|
||||
setBookGuide(false);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
} 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 & { 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<BookProps[]>('books', accessToken, lang),
|
||||
offlineMode.isDatabaseInitialized
|
||||
? tauri.getBooks()
|
||||
: Promise.resolve([]),
|
||||
System.authGetQueryToServer<SeriesListItemProps[]>('series/list', accessToken, lang),
|
||||
offlineMode.isDatabaseInitialized
|
||||
? tauri.getSeriesList() as Promise<SeriesListItemProps[]>
|
||||
: 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];
|
||||
|
||||
let booksResponse: BookProps[];
|
||||
let seriesResponse: SeriesListItemProps[];
|
||||
if (isDesktop && isCurrentlyOffline()) {
|
||||
booksResponse = await tauri.getBooks() as BookProps[];
|
||||
seriesResponse = await tauri.getSeriesList() as SeriesListItemProps[];
|
||||
} else {
|
||||
// OFFLINE : local seulement
|
||||
if (!offlineMode.isDatabaseInitialized) {
|
||||
setIsLoadingBooks(false);
|
||||
return;
|
||||
}
|
||||
const [localBooks, localSeries] = await Promise.all([
|
||||
tauri.getBooks(),
|
||||
tauri.getSeriesList() as Promise<SeriesListItemProps[]>
|
||||
[booksResponse, seriesResponse] = await Promise.all([
|
||||
apiGet<BookProps[]>('books', accessToken, lang),
|
||||
apiGet<SeriesListItemProps[]>('series/list', accessToken, lang)
|
||||
]);
|
||||
booksResponse = localBooks.map(b => ({...b, itIsLocal: true}));
|
||||
seriesResponse = localSeries;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// PARTIE 2 : CRÉATION DU MAPPING BOOK → SERIES
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const bookToSeriesMap: Map<string, SeriesListItemProps> = new Map();
|
||||
seriesResponse.forEach((series: SeriesListItemProps): void => {
|
||||
series.bookIds.forEach((bookId: string): void => {
|
||||
bookToSeriesMap.set(bookId, series);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 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<string, CategoryItem[]> = {};
|
||||
const processedSeriesIds: Set<string> = 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
|
||||
|
||||
// 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: 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,
|
||||
};
|
||||
|
||||
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] = [];
|
||||
});
|
||||
|
||||
// Grouper par catégorie avec séries
|
||||
const itemsByCategory: Record<string, CategoryItem[]> = {};
|
||||
const processedSeriesIds: Set<string> = new Set();
|
||||
|
||||
transformedBooks.forEach((book: BookProps): void => {
|
||||
const categoryLabel: string = t(book.type);
|
||||
if (!itemsByCategory[categoryLabel]) {
|
||||
itemsByCategory[categoryLabel] = [];
|
||||
}
|
||||
itemsByCategory[emptySeriesCategory].push({
|
||||
type: 'series',
|
||||
series: {
|
||||
|
||||
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[categoryLabel].push({
|
||||
type: 'series',
|
||||
series: seriesCard
|
||||
});
|
||||
} else if (!seriesInfo) {
|
||||
// Livre individuel (pas dans une série)
|
||||
itemsByCategory[categoryLabel].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: []
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
setGroupedItems(itemsByCategory);
|
||||
|
||||
};
|
||||
|
||||
itemsByCategory[emptySeriesCategory].push({
|
||||
type: 'series',
|
||||
series: emptySeriesCard
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
setGroupedItems(itemsByCategory);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
errorMessage(error.message);
|
||||
@@ -336,114 +247,58 @@ export default function BookList() {
|
||||
setIsLoadingBooks(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function getFilteredGroupedItems(): Record<string, CategoryItem[]> {
|
||||
if (!searchQuery) {
|
||||
return groupedItems;
|
||||
}
|
||||
|
||||
|
||||
const filtered: Record<string, CategoryItem[]> = {};
|
||||
|
||||
Object.entries(groupedItems).forEach(([category, items]) => {
|
||||
const filteredItems = items.filter((item): boolean => {
|
||||
|
||||
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) {
|
||||
const matchesSeriesName = item.series.name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesBookTitle = item.series.books.some(
|
||||
book => book.title.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
// 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, item) => {
|
||||
if (item.type === 'book') return count + 1;
|
||||
if (item.type === 'series' && item.series) return count + item.series.books.length;
|
||||
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 (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 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';
|
||||
|
||||
function handleSeriesSettingsClick(seriesId: string): void {
|
||||
setShowSeriesSettingId(seriesId);
|
||||
}
|
||||
|
||||
async function handleBookClick(bookId: string): Promise<void> {
|
||||
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<BookProps>(
|
||||
'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;
|
||||
@@ -454,25 +309,18 @@ export default function BookList() {
|
||||
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();
|
||||
|
||||
const filteredItems: Record<string, CategoryItem[]> = getFilteredGroupedItems();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center h-full overflow-hidden w-full text-text-primary font-['Lora']">
|
||||
{session?.user && (
|
||||
<div data-guide="search-bar" className="w-full max-w-3xl px-4 pt-6 pb-4">
|
||||
<SearchBook searchQuery={searchQuery} setSearchQuery={setSearchQuery}/>
|
||||
</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 ? (
|
||||
<>
|
||||
@@ -483,10 +331,10 @@ export default function BookList() {
|
||||
|
||||
<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/30 rounded-xl w-32 animate-pulse"></div>
|
||||
<div className="h-6 bg-secondary/20 rounded-lg w-24 animate-pulse"></div>
|
||||
<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}
|
||||
@@ -503,8 +351,8 @@ export default function BookList() {
|
||||
<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], index) => (
|
||||
|
||||
{Object.entries(filteredItems).map(([category, items]: [string, CategoryItem[]], index: number) => (
|
||||
<div key={category} {...(index === 0 && {'data-guide': 'book-category'})}
|
||||
className="w-full mb-10">
|
||||
<div
|
||||
@@ -513,33 +361,36 @@ export default function BookList() {
|
||||
<span className="w-1 h-8 bg-primary rounded-full"></span>
|
||||
{category}
|
||||
</h2>
|
||||
<span
|
||||
className="text-muted text-lg font-medium bg-secondary/30 px-4 py-1.5 rounded-full">
|
||||
<Badge variant="muted" size="md">
|
||||
{getTotalItemsCount(items)} {t("bookList.works")}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<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 className="group/carousel relative w-full">
|
||||
<div
|
||||
ref={(el: HTMLDivElement | null) => { carouselRefs.current[category] = el; }}
|
||||
className="flex items-start w-full overflow-hidden px-4 gap-2 scroll-smooth"
|
||||
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, idx) => {
|
||||
{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 ${User.guideTourDone(session.user?.guideTour || [], 'new-first-book') && 'mb-[200px]'}`}>
|
||||
className={`flex-shrink-0 w-64 sm:w-52 md:w-48 lg:w-56 xl:w-64 p-2 box-border ${guideTourDone(session.user?.guideTour || [], 'new-first-book') && 'mb-[200px]'}`}>
|
||||
<BookCard
|
||||
book={item.book}
|
||||
syncStatus={detectBookSyncStatus(item.book.bookId)}
|
||||
onClickCallback={handleBookClick}
|
||||
index={idx}
|
||||
/>
|
||||
@@ -553,21 +404,22 @@ export default function BookList() {
|
||||
series={item.series}
|
||||
onBookClick={handleBookClick}
|
||||
onSettingsClick={handleSeriesSettingsClick}
|
||||
getSyncStatus={detectBookSyncStatus}
|
||||
seriesSyncStatus={detectSeriesSyncStatus(item.series.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
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
|
||||
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>
|
||||
))}
|
||||
@@ -575,9 +427,11 @@ export default function BookList() {
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center p-8 max-w-lg">
|
||||
<div
|
||||
className="w-24 h-24 bg-primary/20 text-primary rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-lg animate-pulse">
|
||||
<FontAwesomeIcon icon={faBook} className={'w-12 h-12'}/>
|
||||
<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">
|
||||
@@ -591,16 +445,8 @@ export default function BookList() {
|
||||
bookGuide && <GuideTour stepId={0} steps={bookGuideSteps} onComplete={handleFirstBookGuide}
|
||||
onClose={() => setBookGuide(false)}/>
|
||||
}
|
||||
{showSeriesSettingId && (
|
||||
<SeriesSetting
|
||||
seriesId={showSeriesSettingId}
|
||||
localSeries={isLocalSeries}
|
||||
onClose={() => {
|
||||
setShowSeriesSettingId(null);
|
||||
setIsLocalSeries(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showSeriesSettingId &&
|
||||
<SeriesSetting seriesId={showSeriesSettingId} onClose={() => setShowSeriesSettingId(null)}/>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user