Remove CharacterComponent and CharacterDetail components

- Deleted `CharacterComponent` and `CharacterDetail` files from the project.
- Refactored related logic to improve code maintainability and reduce redundancy.
This commit is contained in:
natreex
2026-02-05 14:12:08 -05:00
parent cec5830360
commit 209dc6f85a
133 changed files with 17673 additions and 3110 deletions

View File

@@ -15,7 +15,18 @@ 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);
@@ -23,13 +34,27 @@ export default function BookList() {
const {errorMessage} = useContext(AlertContext);
const {setBook} = useContext(BookContext);
const t = useTranslations();
const {lang} = useContext<LangContextProps>(LangContext)
const {isCurrentlyOffline, offlineMode} = useContext<OfflineContextType>(OfflineContext)
const {booksToSyncFromServer, booksToSyncToServer, serverOnlyBooks, localOnlyBooks} = useContext<BooksSyncContextProps>(BooksSyncContext)
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 [searchQuery, setSearchQuery] = useState<string>('');
const [groupedBooks, setGroupedBooks] = useState<Record<string, BookProps[]>>({});
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 [bookGuide, setBookGuide] = useState<boolean>(false);
@@ -79,21 +104,21 @@ export default function BookList() {
</div>
),
},
]
];
useEffect((): void => {
if (groupedBooks && Object.keys(groupedBooks).length > 0 && User.guideTourDone(session.user?.guideTour || [], 'new-first-book')) {
if (groupedItems && Object.keys(groupedItems).length > 0 && User.guideTourDone(session.user?.guideTour || [], 'new-first-book')) {
setBookGuide(true);
}
}, [groupedBooks]);
}, [groupedItems]);
useEffect((): void => {
const shouldFetchBooks:boolean|"" =
const shouldFetch: boolean | "" =
(session.isConnected || accessToken) &&
(!isCurrentlyOffline() || offlineMode.isDatabaseInitialized);
if (shouldFetchBooks) {
getBooks().then();
if (shouldFetch) {
loadBooksAndSeries().then();
}
}, [
session.isConnected,
@@ -103,9 +128,10 @@ export default function BookList() {
booksToSyncFromServer,
booksToSyncToServer,
serverOnlyBooks,
localOnlyBooks
localOnlyBooks,
serverSyncedBooks
]);
async function handleFirstBookGuide(): Promise<void> {
try {
if (!isCurrentlyOffline()) {
@@ -135,60 +161,172 @@ export default function BookList() {
}
}
}
async function getBooks(): Promise<void> {
async function loadBooksAndSeries(): Promise<void> {
setIsLoadingBooks(true);
try {
let bookResponse: (BookProps & { itIsLocal: boolean })[] = [];
let booksResponse: (BookProps & { itIsLocal?: boolean })[] = [];
let seriesResponse: SeriesListItemProps[] = [];
// ═══════════════════════════════════════════════════════════════
// PARTIE 1 : FETCH DES DONNÉES (dual logic)
// ═══════════════════════════════════════════════════════════════
if (!isCurrentlyOffline()) {
const [onlineBooks, localBooks]: [BookProps[], BookProps[]] = await Promise.all([
// ONLINE : fetch serveur + local en parallèle
const [onlineBooks, localBooks, onlineSeries, localSeries] = await Promise.all([
System.authGetQueryToServer<BookProps[]>('books', accessToken, lang),
offlineMode.isDatabaseInitialized
? window.electron.invoke<BookProps[]>('db:book:books')
: Promise.resolve([]),
System.authGetQueryToServer<SeriesListItemProps[]>('series/list', accessToken, lang),
offlineMode.isDatabaseInitialized
? window.electron.invoke<SeriesListItemProps[]>('db:series:list')
: Promise.resolve([])
]);
const onlineBookIds: Set<string> = new Set(onlineBooks.map((book: BookProps): string => book.bookId));
const uniqueLocalBooks: BookProps[] = localBooks.filter((book: BookProps): boolean => !onlineBookIds.has(book.bookId));
bookResponse = [
...onlineBooks.map((book: BookProps): BookProps & { itIsLocal: boolean } => ({ ...book, itIsLocal: false })),
...uniqueLocalBooks.map((book: BookProps): BookProps & { itIsLocal: boolean } => ({ ...book, itIsLocal: true }))
// 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: BookProps[] = await window.electron.invoke<BookProps[]>('db:book:books');
bookResponse = localBooks.map((book: BookProps): BookProps & { itIsLocal: boolean } => ({ ...book, itIsLocal: true }));
const [localBooks, localSeries] = await Promise.all([
window.electron.invoke<BookProps[]>('db:book:books'),
window.electron.invoke<SeriesListItemProps[]>('db:series:list')
]);
booksResponse = localBooks.map(b => ({...b, itIsLocal: true}));
seriesResponse = localSeries;
}
console.log(bookResponse);
if (bookResponse) {
const booksByType: Record<string, BookProps[]> = bookResponse.reduce((groups: Record<string, BookProps[]>, book: BookProps): Record<string, BookProps[]> => {
const imageDataUrl: string = book.coverImage ? 'data:image/jpeg;base64,' + book.coverImage : '';
const categoryLabel: string = Book.getBookTypeLabel(book.type);
const transformedBook: BookProps = {
bookId: book.bookId,
type: categoryLabel,
title: book.title,
subTitle: book.subTitle,
summary: book.summary,
serie: book.serie,
publicationDate: book.publicationDate,
desiredWordCount: book.desiredWordCount,
totalWordCount: 0,
coverImage: imageDataUrl,
// ═══════════════════════════════════════════════════════════════
// 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);
});
});
// ═══════════════════════════════════════════════════════════════
// 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
};
if (!groups[t(categoryLabel)]) {
groups[t(categoryLabel)] = [];
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] = [];
}
groups[t(categoryLabel)].push(transformedBook);
return groups;
}, {});
setGroupedBooks(booksByType);
}
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
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"));
}
@@ -196,90 +334,128 @@ export default function BookList() {
setIsLoadingBooks(false);
}
}
const filteredGroupedBooks: Record<string, BookProps[]> = Object.entries(groupedBooks).reduce(
(acc: Record<string, BookProps[]>, [category, books]: [string, BookProps[]]): Record<string, BookProps[]> => {
const filteredBooks: BookProps[] = books.filter((book: BookProps): boolean =>
book.title.toLowerCase().includes(searchQuery.toLowerCase())
);
if (filteredBooks.length > 0) {
acc[category] = filteredBooks;
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 => {
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 acc;
},
{}
);
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 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';
}
async function getBook(bookId: string): Promise<void> {
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<void> {
try {
let localBookOnly: boolean = false;
let bookResponse: BookProps|null = null;
if (isCurrentlyOffline()){
let bookResponse: BookProps | null = null;
// DUAL LOGIC
if (isCurrentlyOffline()) {
if (!offlineMode.isDatabaseInitialized) {
errorMessage(t("bookList.errorBookDetails"));
return;
}
bookResponse = await window.electron.invoke('db:book:bookBasicInformation', bookId)
if (bookResponse) {
localBookOnly = true;
}
bookResponse = await window.electron.invoke('db:book:bookBasicInformation', bookId);
if (bookResponse) localBookOnly = true;
} else {
const isOfflineBook: SyncedBook | undefined = localOnlyBooks.find((book: SyncedBook):boolean => book.id === bookId);
const isOfflineBook = localOnlyBooks.find((book: SyncedBook): boolean => book.id === bookId);
if (isOfflineBook) {
bookResponse = await window.electron.invoke('db:book:bookBasicInformation', bookId)
bookResponse = await window.electron.invoke('db:book:bookBasicInformation', bookId);
localBookOnly = true;
}
if (!bookResponse) {
bookResponse = await System.authGetQueryToServer<BookProps>(`book/basic-information`, accessToken, lang, {id: bookId});
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,
publicationDate: bookResponse?.publicationDate || '',
desiredWordCount: bookResponse?.desiredWordCount || 0,
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,
coverImage: bookResponse.coverImage ? 'data:image/jpeg;base64,' + bookResponse.coverImage : '',
quillsenseEnabled: bookResponse.quillsenseEnabled,
tools: bookResponse.tools,
});
}
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} catch (error: unknown) {
if (error instanceof Error) {
errorMessage(error.message);
} else {
errorMessage(t("bookList.errorUnknown"));
}
}
}
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 (
<div
className="flex flex-col items-center h-full overflow-hidden w-full text-text-primary font-['Lora']">
@@ -302,7 +478,7 @@ export default function BookList() {
<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>
<div className="flex flex-wrap justify-center items-start w-full px-4">
{Array.from({length: 6}).map((_, id: number) => (
<div key={id}
@@ -313,40 +489,58 @@ export default function BookList() {
</div>
</div>
</>
) : Object.entries(filteredGroupedBooks).length > 0 ? (
) : Object.entries(filteredItems).length > 0 ? (
<>
<div className="text-center mb-8 px-6">
<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(filteredGroupedBooks).map(([category, books], index) => (
<div {...(index === 0 && {'data-guide': 'book-category'})} key={category}
{Object.entries(filteredItems).map(([category, items], index) => (
<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>
<span>{category}</span>
{category}
</h2>
<span
className="text-muted text-lg font-medium bg-secondary/30 px-4 py-1.5 rounded-full">{books.length} {t("bookList.works")}</span>
className="text-muted text-lg font-medium bg-secondary/30 px-4 py-1.5 rounded-full">
{getTotalItemsCount(items)} {t("bookList.works")}
</span>
</div>
<div className="flex flex-wrap justify-center items-start w-full px-4">
{
books.map((book: BookProps, idx) => (
<div key={book.bookId || `book-${idx}`}
{...(idx === 0 && {'data-guide': 'book-card'})}
className={`w-full sm:w-1/3 md:w-1/4 lg:w-1/5 xl:w-1/6 p-2 box-border ${User.guideTourDone(session.user?.guideTour || [], 'new-first-book') && 'mb-[200px]'}`}>
<BookCard book={book}
syncStatus={detectBookSyncStatus(book.bookId)}
onClickCallback={getBook}
index={idx}
<div className="flex items-start justify-center w-full px-4 overflow-x-auto pb-4">
{items.map((item, idx) => {
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]'}`}>
<BookCard
book={item.book}
syncStatus={detectBookSyncStatus(item.book.bookId)}
onClickCallback={handleBookClick}
index={idx}
/>
</div>
);
}
if (item.type === 'series' && item.series) {
return (
<SeriesCard
key={item.series.id}
series={item.series}
onBookClick={handleBookClick}
onSettingsClick={handleSeriesSettingsClick}
getSyncStatus={detectBookSyncStatus}
seriesSyncStatus={detectSeriesSyncStatus(item.series.id)}
/>
</div>
))
}
);
}
return null;
})}
</div>
</div>
))}
@@ -354,8 +548,9 @@ 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">
<FontAwesomeIcon icon={faBook} size={'3x'}/>
<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>
<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">
@@ -369,6 +564,16 @@ 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);
}}
/>
)}
</div>
);
}
}