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:
@@ -1,12 +1,12 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import React, {ChangeEvent, Dispatch, SetStateAction, useContext, useState} from "react";
|
import React, {ChangeEvent, Dispatch, SetStateAction, useContext, useState} from "react";
|
||||||
import {AlertContext, AlertContextProps} from "@/context/AlertContext";
|
import {AlertContext, AlertContextProps} from "@/context/AlertContext";
|
||||||
import {apiPost} from "@/lib/api/client";
|
import {ApiError, apiPost} from "@/lib/api/client";
|
||||||
import {isDesktop} from '@/lib/configs';
|
import {isDesktop} from '@/lib/configs';
|
||||||
import * as tauri from '@/lib/tauri';
|
import * as tauri from '@/lib/tauri';
|
||||||
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
|
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
|
||||||
import {SessionContext, SessionContextProps} from "@/context/SessionContext";
|
import {SessionContext, SessionContextProps} from "@/context/SessionContext";
|
||||||
import {Book, BookOpen, Calendar, FileText, Info, Pencil} from "lucide-react";
|
import {AlertTriangle, Book, BookOpen, Calendar, FileText, HardDrive, Info, Pencil} from "lucide-react";
|
||||||
import SelectBox, {SelectBoxProps} from "@/components/form/SelectBox";
|
import SelectBox, {SelectBoxProps} from "@/components/form/SelectBox";
|
||||||
import {bookTypes} from "@/lib/constants/book";
|
import {bookTypes} from "@/lib/constants/book";
|
||||||
import InputField from "@/components/form/InputField";
|
import InputField from "@/components/form/InputField";
|
||||||
@@ -32,7 +32,7 @@ export default function AddNewBookForm({setCloseForm}: { setCloseForm: Dispatch<
|
|||||||
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext);
|
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext);
|
||||||
const {session, setSession}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
|
const {session, setSession}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
|
||||||
const {errorMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
|
const {errorMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
|
||||||
const {setServerSyncedBooks}: BooksSyncContextProps = useContext<BooksSyncContextProps>(BooksSyncContext)
|
const {setServerSyncedBooks, setLocalOnlyBooks}: BooksSyncContextProps = useContext<BooksSyncContextProps>(BooksSyncContext)
|
||||||
const {isCurrentlyOffline}: OfflineContextType = useContext<OfflineContextType>(OfflineContext);
|
const {isCurrentlyOffline}: OfflineContextType = useContext<OfflineContextType>(OfflineContext);
|
||||||
const [title, setTitle] = useState<string>('');
|
const [title, setTitle] = useState<string>('');
|
||||||
const [subtitle, setSubtitle] = useState<string>('');
|
const [subtitle, setSubtitle] = useState<string>('');
|
||||||
@@ -43,6 +43,7 @@ export default function AddNewBookForm({setCloseForm}: { setCloseForm: Dispatch<
|
|||||||
|
|
||||||
const [isAddingBook, setIsAddingBook] = useState<boolean>(false);
|
const [isAddingBook, setIsAddingBook] = useState<boolean>(false);
|
||||||
const [bookTypeHint, setBookTypeHint] = useState<boolean>(false);
|
const [bookTypeHint, setBookTypeHint] = useState<boolean>(false);
|
||||||
|
const [showLocalFallback, setShowLocalFallback] = useState<boolean>(false);
|
||||||
|
|
||||||
const token: string = session?.accessToken ?? '';
|
const token: string = session?.accessToken ?? '';
|
||||||
|
|
||||||
@@ -157,6 +158,58 @@ export default function AddNewBookForm({setCloseForm}: { setCloseForm: Dispatch<
|
|||||||
|
|
||||||
setIsAddingBook(false);
|
setIsAddingBook(false);
|
||||||
setCloseForm(false)
|
setCloseForm(false)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const apiError = e as ApiError;
|
||||||
|
if (e instanceof Error) {
|
||||||
|
errorMessage(e.message);
|
||||||
|
} else {
|
||||||
|
errorMessage(t('addNewBookForm.error.addingBook'));
|
||||||
|
}
|
||||||
|
if (apiError?.statusCode === 409 && isDesktop) {
|
||||||
|
setShowLocalFallback(true);
|
||||||
|
}
|
||||||
|
setIsAddingBook(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateLocalBook(): Promise<void> {
|
||||||
|
setIsAddingBook(true);
|
||||||
|
setShowLocalFallback(false);
|
||||||
|
try {
|
||||||
|
const bookId: string = await tauri.createBook({
|
||||||
|
title: title,
|
||||||
|
subTitle: subtitle,
|
||||||
|
type: selectedBookType,
|
||||||
|
summary: summary,
|
||||||
|
desiredReleaseDate: publicationDate,
|
||||||
|
desiredWordCount: wordCount,
|
||||||
|
});
|
||||||
|
if (!bookId) {
|
||||||
|
errorMessage(t('addNewBookForm.error.addingBook'));
|
||||||
|
setIsAddingBook(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const book: SyncedBook = {
|
||||||
|
id: bookId,
|
||||||
|
type: selectedBookType,
|
||||||
|
title: title,
|
||||||
|
subTitle: subtitle,
|
||||||
|
seriesId: null,
|
||||||
|
lastUpdate: new Date().getTime() / 1000,
|
||||||
|
chapters: [],
|
||||||
|
characters: [],
|
||||||
|
locations: [],
|
||||||
|
worlds: [],
|
||||||
|
incidents: [],
|
||||||
|
plotPoints: [],
|
||||||
|
issues: [],
|
||||||
|
actSummaries: [],
|
||||||
|
guideLine: null,
|
||||||
|
aiGuideLine: null
|
||||||
|
};
|
||||||
|
setLocalOnlyBooks((prev: SyncedBook[]): SyncedBook[] => [...prev, book]);
|
||||||
|
setIsAddingBook(false);
|
||||||
|
setCloseForm(false);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
if (e instanceof Error) {
|
if (e instanceof Error) {
|
||||||
errorMessage(e.message);
|
errorMessage(e.message);
|
||||||
@@ -212,6 +265,15 @@ export default function AddNewBookForm({setCloseForm}: { setCloseForm: Dispatch<
|
|||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="secondary" onClick={() => setCloseForm(false)}>{t("common.cancel")}</Button>
|
<Button variant="secondary" onClick={() => setCloseForm(false)}>{t("common.cancel")}</Button>
|
||||||
|
{showLocalFallback ? (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleCreateLocalBook}
|
||||||
|
isLoading={isAddingBook}
|
||||||
|
loadingText={t("addNewBookForm.adding")}
|
||||||
|
icon={HardDrive}
|
||||||
|
>{t("addNewBookForm.error.saveLocally")}</Button>
|
||||||
|
) : (
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={handleAddBook}
|
onClick={handleAddBook}
|
||||||
@@ -219,9 +281,19 @@ export default function AddNewBookForm({setCloseForm}: { setCloseForm: Dispatch<
|
|||||||
loadingText={t("addNewBookForm.adding")}
|
loadingText={t("addNewBookForm.adding")}
|
||||||
icon={Book}
|
icon={Book}
|
||||||
>{t("addNewBookForm.add")}</Button>
|
>{t("addNewBookForm.add")}</Button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{showLocalFallback && (
|
||||||
|
<div className="flex items-start gap-3 bg-warning/10 border border-warning/30 rounded-xl p-4 mb-4">
|
||||||
|
<AlertTriangle strokeWidth={1.75} className="w-5 h-5 text-warning shrink-0 mt-0.5"/>
|
||||||
|
<div>
|
||||||
|
<p className="text-text-primary font-medium text-sm">{t("addNewBookForm.error.limitReached")}</p>
|
||||||
|
<p className="text-muted text-sm mt-1">{t("addNewBookForm.error.localFallbackDescription")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<InputField icon={BookOpen} fieldName={t("addNewBookForm.type")} input={
|
<InputField icon={BookOpen} fieldName={t("addNewBookForm.type")} input={
|
||||||
<SelectBox
|
<SelectBox
|
||||||
onChangeCallBack={(e: ChangeEvent<HTMLSelectElement>): void => setSelectedBookType(e.target.value)}
|
onChangeCallBack={(e: ChangeEvent<HTMLSelectElement>): void => setSelectedBookType(e.target.value)}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {useRouter} from "@/lib/navigation";
|
|||||||
import {Book, ChevronLeft, ChevronRight, Download, Settings, Trash2} from 'lucide-react';
|
import {Book, ChevronLeft, ChevronRight, Download, Settings, Trash2} from 'lucide-react';
|
||||||
import Badge from "@/components/ui/Badge";
|
import Badge from "@/components/ui/Badge";
|
||||||
import {SessionContext, SessionContextProps} from "@/context/SessionContext";
|
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 {getBookTypeLabel} from "@/lib/utils/book";
|
||||||
import BookCard from "@/components/book/BookCard";
|
import BookCard from "@/components/book/BookCard";
|
||||||
import BookCardSkeleton from "@/components/book/BookCardSkeleton";
|
import BookCardSkeleton from "@/components/book/BookCardSkeleton";
|
||||||
@@ -46,6 +46,7 @@ export default function BookList() {
|
|||||||
const [groupedItems, setGroupedItems] = useState<Record<string, CategoryItem[]>>({});
|
const [groupedItems, setGroupedItems] = useState<Record<string, CategoryItem[]>>({});
|
||||||
const [isLoadingBooks, setIsLoadingBooks] = useState<boolean>(true);
|
const [isLoadingBooks, setIsLoadingBooks] = useState<boolean>(true);
|
||||||
const [showSeriesSettingId, setShowSeriesSettingId] = useState<string | null>(null);
|
const [showSeriesSettingId, setShowSeriesSettingId] = useState<string | null>(null);
|
||||||
|
const [bookLimits, setBookLimits] = useState<Record<string, BookTypeLimit> | null>(null);
|
||||||
const carouselRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
const carouselRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||||
|
|
||||||
const [bookGuide, setBookGuide] = useState<boolean>(false);
|
const [bookGuide, setBookGuide] = useState<boolean>(false);
|
||||||
@@ -148,14 +149,58 @@ export default function BookList() {
|
|||||||
try {
|
try {
|
||||||
let booksResponse: BookProps[];
|
let booksResponse: BookProps[];
|
||||||
let seriesResponse: SeriesListItemProps[];
|
let seriesResponse: SeriesListItemProps[];
|
||||||
|
|
||||||
if (isDesktop && isCurrentlyOffline()) {
|
if (isDesktop && isCurrentlyOffline()) {
|
||||||
booksResponse = await tauri.getBooks() as BookProps[];
|
if (!offlineMode.isDatabaseInitialized) {
|
||||||
seriesResponse = await tauri.getSeriesList() as SeriesListItemProps[];
|
setIsLoadingBooks(false);
|
||||||
} else {
|
return;
|
||||||
[booksResponse, seriesResponse] = await Promise.all([
|
}
|
||||||
apiGet<BookProps[]>('books', accessToken, lang),
|
const [localBooks, localSeries] = await Promise.all([
|
||||||
apiGet<SeriesListItemProps[]>('series/list', accessToken, lang)
|
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) {
|
if (booksResponse) {
|
||||||
@@ -170,11 +215,11 @@ export default function BookList() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Transformer les livres avec leur image
|
// 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 : '';
|
const imageDataUrl: string = book.coverImage ? 'data:image/jpeg;base64,' + book.coverImage : '';
|
||||||
return {
|
return {
|
||||||
bookId: book.bookId,
|
bookId: book.bookId,
|
||||||
type: getBookTypeLabel(book.type),
|
type: book.type || book.bookType || '',
|
||||||
title: book.title,
|
title: book.title,
|
||||||
subTitle: book.subTitle,
|
subTitle: book.subTitle,
|
||||||
summary: book.summary,
|
summary: book.summary,
|
||||||
@@ -191,9 +236,8 @@ export default function BookList() {
|
|||||||
const processedSeriesIds: Set<string> = new Set();
|
const processedSeriesIds: Set<string> = new Set();
|
||||||
|
|
||||||
transformedBooks.forEach((book: BookProps): void => {
|
transformedBooks.forEach((book: BookProps): void => {
|
||||||
const categoryLabel: string = t(book.type);
|
if (!itemsByCategory[book.type]) {
|
||||||
if (!itemsByCategory[categoryLabel]) {
|
itemsByCategory[book.type] = [];
|
||||||
itemsByCategory[categoryLabel] = [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const seriesInfo: SeriesListItemProps | undefined = bookToSeriesMap.get(book.bookId);
|
const seriesInfo: SeriesListItemProps | undefined = bookToSeriesMap.get(book.bookId);
|
||||||
@@ -214,13 +258,13 @@ export default function BookList() {
|
|||||||
books: seriesBooks
|
books: seriesBooks
|
||||||
};
|
};
|
||||||
|
|
||||||
itemsByCategory[categoryLabel].push({
|
itemsByCategory[book.type].push({
|
||||||
type: 'series',
|
type: 'series',
|
||||||
series: seriesCard
|
series: seriesCard
|
||||||
});
|
});
|
||||||
} else if (!seriesInfo) {
|
} else if (!seriesInfo) {
|
||||||
// Livre individuel (pas dans une série)
|
// Livre individuel (pas dans une série)
|
||||||
itemsByCategory[categoryLabel].push({
|
itemsByCategory[book.type].push({
|
||||||
type: 'book',
|
type: 'book',
|
||||||
book: book
|
book: book
|
||||||
});
|
});
|
||||||
@@ -384,17 +428,25 @@ export default function BookList() {
|
|||||||
<p className="text-muted italic text-lg">{t("bookList.booksAreMirrors")}</p>
|
<p className="text-muted italic text-lg">{t("bookList.booksAreMirrors")}</p>
|
||||||
</div>
|
</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'})}
|
<div key={category} {...(index === 0 && {'data-guide': 'book-category'})}
|
||||||
className="w-full mb-10">
|
className="w-full mb-10">
|
||||||
<div
|
<div
|
||||||
className="flex justify-between items-center w-full max-w-5xl mx-auto mb-6 px-6">
|
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">
|
<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>
|
<span className="w-1 h-8 bg-primary rounded-full"></span>
|
||||||
{category}
|
{categoryLabel}
|
||||||
</h2>
|
</h2>
|
||||||
<Badge variant="muted" size="md">
|
<Badge variant={isLimitReached ? "error" : "muted"} size="md">
|
||||||
{getTotalItemsCount(items)} {t("bookList.works")}
|
{typeLimit !== undefined
|
||||||
|
? `${typeLimit.current}/${typeLimit.max}`
|
||||||
|
: itemCount
|
||||||
|
} {t("bookList.works")}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -455,7 +507,8 @@ export default function BookList() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
|
|||||||
@@ -216,7 +216,7 @@ export default function useSyncBooks() {
|
|||||||
|
|
||||||
serverBooksResponse = serverResponse.books;
|
serverBooksResponse = serverResponse.books;
|
||||||
|
|
||||||
await tauri.applyBookTombstones(serverResponse.tombstones as tauri.TombstoneRecord[]);
|
await tauri.applyBookTombstones((serverResponse.tombstones ?? []) as tauri.TombstoneRecord[]);
|
||||||
} else {
|
} else {
|
||||||
// No local DB but online - just get server books without tombstones
|
// No local DB but online - just get server books without tombstones
|
||||||
const serverResponse: SyncedBooksResponse = await apiPost<SyncedBooksResponse>(
|
const serverResponse: SyncedBooksResponse = await apiPost<SyncedBooksResponse>(
|
||||||
|
|||||||
@@ -302,7 +302,7 @@ export default function useSyncSeries() {
|
|||||||
|
|
||||||
serverSeriesResponse = serverResponse.series;
|
serverSeriesResponse = serverResponse.series;
|
||||||
|
|
||||||
await tauri.applySeriesTombstones(serverResponse.tombstones as tauri.TombstoneRecord[]);
|
await tauri.applySeriesTombstones((serverResponse.tombstones ?? []) as tauri.TombstoneRecord[]);
|
||||||
} else {
|
} else {
|
||||||
// No local DB but online - just get server series without tombstones
|
// No local DB but online - just get server series without tombstones
|
||||||
const serverResponse: SyncedSeriesResponse = await apiPost<SyncedSeriesResponse>(
|
const serverResponse: SyncedSeriesResponse = await apiPost<SyncedSeriesResponse>(
|
||||||
|
|||||||
@@ -13,10 +13,20 @@ interface ApiRequestConfig {
|
|||||||
contentType?: ContentType;
|
contentType?: ContentType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
statusCode: number;
|
||||||
|
constructor(message: string, statusCode: number) {
|
||||||
|
super(message);
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
this.name = 'ApiError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleApiError(error: unknown): never {
|
function handleApiError(error: unknown): never {
|
||||||
if (axios.isAxiosError(error)) {
|
if (axios.isAxiosError(error)) {
|
||||||
const serverMessage: string = error.response?.data?.message || error.response?.data || error.message;
|
const serverMessage: string = error.response?.data?.message || error.response?.data || error.message;
|
||||||
throw new Error(serverMessage);
|
const statusCode: number = error.response?.status ?? 500;
|
||||||
|
throw new ApiError(serverMessage, statusCode);
|
||||||
} else if (error instanceof Error) {
|
} else if (error instanceof Error) {
|
||||||
throw new Error(error.message);
|
throw new Error(error.message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -718,7 +718,10 @@
|
|||||||
"titleTooShort": "Title is too short. Minimum 2 characters required.",
|
"titleTooShort": "Title is too short. Minimum 2 characters required.",
|
||||||
"titleTooLong": "Title is too long. Maximum 50 characters allowed.",
|
"titleTooLong": "Title is too long. Maximum 50 characters allowed.",
|
||||||
"typeMissing": "Select a genre.",
|
"typeMissing": "Select a genre.",
|
||||||
"addingBook": "An error occurred while adding the book."
|
"addingBook": "An error occurred while adding the book.",
|
||||||
|
"limitReached": "You have reached the book limit for this type on the cloud.",
|
||||||
|
"saveLocally": "Save locally",
|
||||||
|
"localFallbackDescription": "You can still save this book locally on your device."
|
||||||
},
|
},
|
||||||
"bookTypeHint": {
|
"bookTypeHint": {
|
||||||
"title": "Type of work",
|
"title": "Type of work",
|
||||||
|
|||||||
@@ -717,7 +717,10 @@
|
|||||||
"titleTooShort": "Le titre est trop court. Minimum 2 caractères requis",
|
"titleTooShort": "Le titre est trop court. Minimum 2 caractères requis",
|
||||||
"titleTooLong": "Le titre est trop long. Maximum 50 caractères autorisés",
|
"titleTooLong": "Le titre est trop long. Maximum 50 caractères autorisés",
|
||||||
"typeMissing": "Sélectionner un genre.",
|
"typeMissing": "Sélectionner un genre.",
|
||||||
"addingBook": "Une erreur est survenue lors de l'ajout du livre."
|
"addingBook": "Une erreur est survenue lors de l'ajout du livre.",
|
||||||
|
"limitReached": "Vous avez atteint la limite de livres pour ce type sur le cloud.",
|
||||||
|
"saveLocally": "Sauvegarder localement",
|
||||||
|
"localFallbackDescription": "Vous pouvez tout de même enregistrer ce livre localement sur votre appareil."
|
||||||
},
|
},
|
||||||
"bookTypeHint": {
|
"bookTypeHint": {
|
||||||
"title": "Type d'oeuvre",
|
"title": "Type d'oeuvre",
|
||||||
|
|||||||
@@ -44,10 +44,10 @@ export interface SyncCheckResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface TombstoneRecord {
|
export interface TombstoneRecord {
|
||||||
tableName: string;
|
table_name: string;
|
||||||
entityId: string;
|
entity_id: string;
|
||||||
bookId: string | null;
|
book_id: string | null;
|
||||||
deletedAt: number;
|
deleted_at: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── User & Auth ───────────────────────────────────────────
|
// ─── User & Auth ───────────────────────────────────────────
|
||||||
|
|||||||
@@ -106,3 +106,8 @@ export interface BookTags {
|
|||||||
objects: Tag[];
|
objects: Tag[];
|
||||||
worldElements: Tag[];
|
worldElements: Tag[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BookTypeLimit {
|
||||||
|
current: number;
|
||||||
|
max: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ fn get_session(session: &State<SessionState>) -> Result<(String, Lang), AppError
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct TombstoneRecord {
|
pub struct TombstoneRecord {
|
||||||
pub table_name: String,
|
pub table_name: String,
|
||||||
pub entity_id: String,
|
pub entity_id: String,
|
||||||
@@ -63,7 +62,6 @@ pub fn get_tombstones_since(since: i64, db: State<DbManager>, session: State<Ses
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct TombstoneInput {
|
pub struct TombstoneInput {
|
||||||
pub table_name: String,
|
pub table_name: String,
|
||||||
pub entity_id: String,
|
pub entity_id: String,
|
||||||
|
|||||||
Reference in New Issue
Block a user