diff --git a/apps/backoffice/app/components/FolderPicker.tsx b/apps/backoffice/app/components/FolderPicker.tsx index 0843aae..429cbe5 100644 --- a/apps/backoffice/app/components/FolderPicker.tsx +++ b/apps/backoffice/app/components/FolderPicker.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { createPortal } from "react-dom"; import { FolderBrowser } from "./FolderBrowser"; import { FolderItem } from "../../lib/api"; import { Button } from "./ui"; @@ -64,14 +65,14 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP {/* Popup Modal */} - {isOpen && ( + {isOpen && createPortal( <> {/* Backdrop */} -
setIsOpen(false)} /> - + {/* Modal */}
@@ -121,7 +122,8 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP
- + , + document.body )} ); diff --git a/apps/backoffice/app/components/LibraryActions.tsx b/apps/backoffice/app/components/LibraryActions.tsx index dd4282b..e781e92 100644 --- a/apps/backoffice/app/components/LibraryActions.tsx +++ b/apps/backoffice/app/components/LibraryActions.tsx @@ -1,6 +1,7 @@ "use client"; -import { useState, useRef, useEffect, useTransition } from "react"; +import { useState, useTransition } from "react"; +import { createPortal } from "react-dom"; import { Button } from "../components/ui"; import { ProviderIcon } from "../components/ProviderIcon"; import { useTranslation } from "../../lib/i18n/context"; @@ -24,23 +25,11 @@ export function LibraryActions({ metadataProvider, fallbackMetadataProvider, metadataRefreshMode, - onUpdate }: LibraryActionsProps) { const { t } = useTranslation(); const [isOpen, setIsOpen] = useState(false); const [isPending, startTransition] = useTransition(); const [saveError, setSaveError] = useState(null); - const dropdownRef = useRef(null); - - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { - setIsOpen(false); - } - }; - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, []); const handleSubmit = (formData: FormData) => { setSaveError(null); @@ -89,11 +78,11 @@ export function LibraryActions({ }; return ( -
- - {isOpen && ( -
-
-
-
- -
+ {isOpen && createPortal( + <> + {/* Backdrop */} +
setIsOpen(false)} + /> -
- -
- -
- - + + + +
-
- - -
+ {/* Form */} + +
-
- - -
+ {/* Section: Indexation */} +
+

+ + + + {t("libraryActions.sectionIndexation")} +

-
- - -
+ {/* Auto scan */} +
+
+ +

{t("libraryActions.autoScanDesc")}

+
+ +
- {saveError && ( -

- {saveError} -

- )} + {/* File watcher */} +
+ +

{t("libraryActions.fileWatchDesc")}

+
+
- +
+ + {/* Section: Metadata */} +
+

+ + + + {t("libraryActions.sectionMetadata")} +

+ + {/* Provider */} +
+
+ + +
+

{t("libraryActions.providerDesc")}

+
+ + {/* Fallback */} +
+
+ + +
+

{t("libraryActions.fallbackDesc")}

+
+ + {/* Metadata refresh */} +
+
+ + +
+

{t("libraryActions.metadataRefreshDesc")}

+
+
+ + {saveError && ( +

+ {saveError} +

+ )} +
+ + {/* Footer */} +
+ + +
+
- -
+
+ , + document.body )} -
+ ); } diff --git a/apps/backoffice/app/libraries/page.tsx b/apps/backoffice/app/libraries/page.tsx index 64d60f3..1ab9186 100644 --- a/apps/backoffice/app/libraries/page.tsx +++ b/apps/backoffice/app/libraries/page.tsx @@ -1,9 +1,11 @@ import { revalidatePath } from "next/cache"; import Link from "next/link"; -import { listFolders, createLibrary, deleteLibrary, fetchLibraries, fetchSeries, scanLibrary, startMetadataBatch, LibraryDto, FolderItem } from "../../lib/api"; +import { listFolders, createLibrary, deleteLibrary, fetchLibraries, fetchSeries, getBookCoverUrl, LibraryDto, FolderItem } from "../../lib/api"; +import type { TranslationKey } from "../../lib/i18n/fr"; import { getServerTranslations } from "../../lib/i18n/server"; import { LibraryActions } from "../components/LibraryActions"; import { LibraryForm } from "../components/LibraryForm"; +import { ProviderIcon } from "../components/ProviderIcon"; import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, Badge @@ -31,18 +33,26 @@ export default async function LibrariesPage() { listFolders().catch(() => [] as FolderItem[]) ]); - const seriesCounts = await Promise.all( + const seriesData = await Promise.all( libraries.map(async (lib) => { try { - const seriesPage = await fetchSeries(lib.id); - return { id: lib.id, count: seriesPage.items.length }; + const seriesPage = await fetchSeries(lib.id, 1, 6); + return { + id: lib.id, + count: seriesPage.total, + thumbnails: seriesPage.items + .map(s => s.first_book_id) + .filter(Boolean) + .slice(0, 4) + .map(bookId => getBookCoverUrl(bookId)), + }; } catch { - return { id: lib.id, count: 0 }; + return { id: lib.id, count: 0, thumbnails: [] as string[] }; } }) ); - - const seriesCountMap = new Map(seriesCounts.map(s => [s.id, s.count])); + + const seriesMap = new Map(seriesData.map(s => [s.id, s])); async function addLibrary(formData: FormData) { "use server"; @@ -61,35 +71,6 @@ export default async function LibrariesPage() { revalidatePath("/libraries"); } - async function scanLibraryAction(formData: FormData) { - "use server"; - const id = formData.get("id") as string; - await scanLibrary(id); - revalidatePath("/libraries"); - revalidatePath("/jobs"); - } - - async function scanLibraryFullAction(formData: FormData) { - "use server"; - const id = formData.get("id") as string; - await scanLibrary(id, true); - revalidatePath("/libraries"); - revalidatePath("/jobs"); - } - - async function batchMetadataAction(formData: FormData) { - "use server"; - const id = formData.get("id") as string; - try { - await startMetadataBatch(id); - } catch { - // Library may have metadata disabled — ignore silently - return; - } - revalidatePath("/libraries"); - revalidatePath("/jobs"); - } - return ( <>
@@ -100,7 +81,7 @@ export default async function LibrariesPage() { {t("libraries.title")}
- + {/* Add Library Form */} @@ -115,106 +96,136 @@ export default async function LibrariesPage() { {/* Libraries Grid */}
{libraries.map((lib) => { - const seriesCount = seriesCountMap.get(lib.id) || 0; + const series = seriesMap.get(lib.id); + const seriesCount = series?.count || 0; + const thumbnails = series?.thumbnails || []; return ( - + + {/* Thumbnail fan */} + {thumbnails.length > 0 ? ( + + +
+ {thumbnails.map((url, i) => { + const count = thumbnails.length; + const mid = (count - 1) / 2; + const angle = (i - mid) * 12; + const radius = 220; + const rad = ((angle - 90) * Math.PI) / 180; + const cx = Math.cos(rad) * radius; + const cy = Math.sin(rad) * radius; + return ( + + ); + })} +
+ + ) : ( +
+ )} +
{lib.name} {!lib.enabled && {t("libraries.disabled")}}
- +
+ +
+ + +
+
+ {lib.root_path}
- {/* Path */} - {lib.root_path} - {/* Stats */} -
- + {lib.book_count} {t("libraries.books")} - {seriesCount} {t("libraries.series")}
- {/* Status */} -
- - {lib.monitor_enabled ? '●' : '○'} {lib.monitor_enabled ? t("libraries.auto") : t("libraries.manual")} + {/* Configuration tags */} +
+ + {lib.monitor_enabled ? '●' : '○'} + {t("libraries.scanLabel", { mode: t(`monitoring.${lib.scan_mode}` as TranslationKey) })} - {lib.watcher_enabled && ( - + + + {lib.watcher_enabled ? '⚡' : '○'} + {t("libraries.watcherLabel")} + + + {lib.metadata_provider && lib.metadata_provider !== "none" && ( + + + {lib.metadata_provider.replace('_', ' ')} + )} + + {lib.metadata_refresh_mode !== "manual" && ( + + {t("libraries.metaRefreshLabel", { mode: t(`monitoring.${lib.metadata_refresh_mode}` as TranslationKey) })} + + )} + {lib.monitor_enabled && lib.next_scan_at && ( - + {t("libraries.nextScan", { time: formatNextScan(lib.next_scan_at, t("libraries.imminent")) })} )} - {lib.metadata_refresh_mode !== "manual" && lib.next_metadata_refresh_at && ( - - {t("libraries.nextMetadataRefreshShort", { time: formatNextScan(lib.next_metadata_refresh_at, t("libraries.imminent")) })} - - )} -
- - {/* Actions */} -
-
- - -
-
- - -
- {lib.metadata_provider !== "none" && ( -
- - -
- )} -
- - -
diff --git a/apps/backoffice/lib/i18n/en.ts b/apps/backoffice/lib/i18n/en.ts index 16ae40f..6c7208b 100644 --- a/apps/backoffice/lib/i18n/en.ts +++ b/apps/backoffice/lib/i18n/en.ts @@ -140,6 +140,9 @@ const en: Record = { "libraries.imminent": "Imminent", "libraries.nextMetadataRefresh": "Next metadata refresh: {{time}}", "libraries.nextMetadataRefreshShort": "Meta.: {{time}}", + "libraries.scanLabel": "Scan: {{mode}}", + "libraries.watcherLabel": "File watch", + "libraries.metaRefreshLabel": "Meta refresh: {{mode}}", "libraries.index": "Index", "libraries.fullIndex": "Full", "libraries.batchMetadata": "Batch metadata", @@ -157,14 +160,22 @@ const en: Record = { "librarySeries.noBooksInSeries": "No books in this series", // Library actions - "libraryActions.autoScan": "Auto scan", - "libraryActions.fileWatch": "File watch ⚡", - "libraryActions.schedule": "📅 Schedule", + "libraryActions.settingsTitle": "Library settings", + "libraryActions.sectionIndexation": "Indexation", + "libraryActions.sectionMetadata": "Metadata", + "libraryActions.autoScan": "Scheduled scan", + "libraryActions.autoScanDesc": "Automatically scan for new and modified files", + "libraryActions.fileWatch": "Real-time file watch", + "libraryActions.fileWatchDesc": "Detect file changes instantly via filesystem events", + "libraryActions.schedule": "Frequency", "libraryActions.provider": "Provider", - "libraryActions.fallback": "Fallback", + "libraryActions.providerDesc": "Source used to fetch series and volume metadata", + "libraryActions.fallback": "Fallback provider", + "libraryActions.fallbackDesc": "Used when the primary provider returns no results", "libraryActions.default": "Default", "libraryActions.none": "None", - "libraryActions.metadataRefreshSchedule": "Refresh meta.", + "libraryActions.metadataRefreshSchedule": "Auto-refresh", + "libraryActions.metadataRefreshDesc": "Periodically re-fetch metadata for existing series", "libraryActions.saving": "Saving...", // Library sub-page header diff --git a/apps/backoffice/lib/i18n/fr.ts b/apps/backoffice/lib/i18n/fr.ts index 951610f..870ea19 100644 --- a/apps/backoffice/lib/i18n/fr.ts +++ b/apps/backoffice/lib/i18n/fr.ts @@ -138,6 +138,9 @@ const fr = { "libraries.imminent": "Imminent", "libraries.nextMetadataRefresh": "Prochain rafraîchissement méta. : {{time}}", "libraries.nextMetadataRefreshShort": "Méta. : {{time}}", + "libraries.scanLabel": "Scan : {{mode}}", + "libraries.watcherLabel": "Surveillance fichiers", + "libraries.metaRefreshLabel": "Rafraîch. méta. : {{mode}}", "libraries.index": "Indexer", "libraries.fullIndex": "Complet", "libraries.batchMetadata": "Métadonnées en lot", @@ -155,14 +158,22 @@ const fr = { "librarySeries.noBooksInSeries": "Aucun livre dans cette série", // Library actions - "libraryActions.autoScan": "Scan auto", - "libraryActions.fileWatch": "Surveillance fichiers ⚡", - "libraryActions.schedule": "📅 Planification", + "libraryActions.settingsTitle": "Paramètres de la bibliothèque", + "libraryActions.sectionIndexation": "Indexation", + "libraryActions.sectionMetadata": "Métadonnées", + "libraryActions.autoScan": "Scan planifié", + "libraryActions.autoScanDesc": "Scanner automatiquement les fichiers nouveaux et modifiés", + "libraryActions.fileWatch": "Surveillance en temps réel", + "libraryActions.fileWatchDesc": "Détecter les changements de fichiers instantanément", + "libraryActions.schedule": "Fréquence", "libraryActions.provider": "Fournisseur", - "libraryActions.fallback": "Secours", + "libraryActions.providerDesc": "Source utilisée pour récupérer les métadonnées des séries", + "libraryActions.fallback": "Fournisseur de secours", + "libraryActions.fallbackDesc": "Utilisé quand le fournisseur principal ne retourne aucun résultat", "libraryActions.default": "Par défaut", "libraryActions.none": "Aucun", - "libraryActions.metadataRefreshSchedule": "Rafraîchir méta.", + "libraryActions.metadataRefreshSchedule": "Rafraîchissement auto", + "libraryActions.metadataRefreshDesc": "Re-télécharger périodiquement les métadonnées existantes", "libraryActions.saving": "Enregistrement...", // Library sub-page header