Introduce local fallback for book creation and improve error handling
- 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.
This commit is contained in:
@@ -9,7 +9,7 @@ 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 {BookProps, BookTypeLimit} from "@/lib/types/book";
|
||||
import {getBookTypeLabel} from "@/lib/utils/book";
|
||||
import BookCard from "@/components/book/BookCard";
|
||||
import BookCardSkeleton from "@/components/book/BookCardSkeleton";
|
||||
@@ -46,6 +46,7 @@ export default function BookList() {
|
||||
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);
|
||||
@@ -148,14 +149,58 @@ export default function BookList() {
|
||||
try {
|
||||
let booksResponse: BookProps[];
|
||||
let seriesResponse: SeriesListItemProps[];
|
||||
|
||||
if (isDesktop && isCurrentlyOffline()) {
|
||||
booksResponse = await tauri.getBooks() as BookProps[];
|
||||
seriesResponse = await tauri.getSeriesList() as SeriesListItemProps[];
|
||||
} else {
|
||||
[booksResponse, seriesResponse] = await Promise.all([
|
||||
apiGet<BookProps[]>('books', accessToken, lang),
|
||||
apiGet<SeriesListItemProps[]>('series/list', accessToken, lang)
|
||||
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) {
|
||||
@@ -170,11 +215,11 @@ export default function BookList() {
|
||||
});
|
||||
|
||||
// Transformer les livres avec leur image
|
||||
const transformedBooks: BookProps[] = booksResponse.map((book: BookProps): BookProps => {
|
||||
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: getBookTypeLabel(book.type),
|
||||
type: book.type || book.bookType || '',
|
||||
title: book.title,
|
||||
subTitle: book.subTitle,
|
||||
summary: book.summary,
|
||||
@@ -191,36 +236,35 @@ export default function BookList() {
|
||||
const processedSeriesIds: Set<string> = new Set();
|
||||
|
||||
transformedBooks.forEach((book: BookProps): void => {
|
||||
const categoryLabel: string = t(book.type);
|
||||
if (!itemsByCategory[categoryLabel]) {
|
||||
itemsByCategory[categoryLabel] = [];
|
||||
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[categoryLabel].push({
|
||||
|
||||
itemsByCategory[book.type].push({
|
||||
type: 'series',
|
||||
series: seriesCard
|
||||
});
|
||||
} else if (!seriesInfo) {
|
||||
// Livre individuel (pas dans une série)
|
||||
itemsByCategory[categoryLabel].push({
|
||||
itemsByCategory[book.type].push({
|
||||
type: 'book',
|
||||
book: book
|
||||
});
|
||||
@@ -384,17 +428,25 @@ export default function BookList() {
|
||||
<p className="text-muted italic text-lg">{t("bookList.booksAreMirrors")}</p>
|
||||
</div>
|
||||
|
||||
{Object.entries(filteredItems).map(([category, items]: [string, CategoryItem[]], index: number) => (
|
||||
{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>
|
||||
{category}
|
||||
{categoryLabel}
|
||||
</h2>
|
||||
<Badge variant="muted" size="md">
|
||||
{getTotalItemsCount(items)} {t("bookList.works")}
|
||||
<Badge variant={isLimitReached ? "error" : "muted"} size="md">
|
||||
{typeLimit !== undefined
|
||||
? `${typeLimit.current}/${typeLimit.max}`
|
||||
: itemCount
|
||||
} {t("bookList.works")}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -455,7 +507,8 @@ export default function BookList() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
|
||||
Reference in New Issue
Block a user